Compare commits

...

13 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
James Wampler
cae55e5737 Add coverage reporting/badges and split CI between Gitea and GitHub (#5)
All checks were successful
CI / build-and-push (push) Successful in 46s
CI / deploy-qa (push) Successful in 13s
CI / smoke-qa (push) Successful in 35s
Merge dotnet+jest coverage via reportgenerator, publish a self-hosted
coverage badge and build-status badges on the readme. Gitea remains the
full pipeline (build/test/docker push/deploy-qa/smoke); GitHub only
builds and tests since it has no registry secrets or qa runner.

Reviewed-on: #5
2026-07-05 10:14:30 -07:00
71 changed files with 6695 additions and 67 deletions

View File

@@ -2,15 +2,25 @@
# (both look under .github/workflows/). Every non-checkout step just invokes a # (both look under .github/workflows/). Every non-checkout step just invokes a
# bash script under scripts/ci/, so the entire pipeline is reproducible by # bash script under scripts/ci/, so the entire pipeline is reproducible by
# running the same scripts locally - no marketplace build/test/push actions. # running the same scripts locally - no marketplace build/test/push actions.
#
# Gitea (origin) is the internal/testing remote and runs the full pipeline:
# build, test, docker push, deploy-to-qa, smoke test. GitHub is the public
# mirror and only needs to prove the code builds and tests pass - it has no
# registry secrets and no [self-hosted, qa] runner, so the docker push and
# deploy/smoke jobs are skipped there via the `github.server_url` check
# below (identical on both engines: https://github.com on GitHub, the Gitea
# instance URL on Gitea).
name: CI name: CI
on: on:
push: push:
branches: [main, build-runner-fix] paths-ignore: [badges/**]
jobs: jobs:
build-and-push: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
env: env:
REGISTRY: ${{ secrets.REGISTRY }} REGISTRY: ${{ secrets.REGISTRY }}
REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }} REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }}
@@ -25,14 +35,25 @@ jobs:
- name: Test - name: Test
run: ./scripts/ci/test.sh run: ./scripts/ci/test.sh
- name: Coverage report
run: ./scripts/ci/coverage.sh
- name: Publish coverage badge
env:
GITHUB_TOKEN: ${{ github.token }}
run: ./scripts/ci/publish-coverage-badge.sh
- name: Build Docker images - name: Build Docker images
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
run: ./scripts/ci/docker-build.sh run: ./scripts/ci/docker-build.sh
- name: Push Docker images - name: Push Docker images
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
run: ./scripts/ci/docker-push.sh run: ./scripts/ci/docker-push.sh
deploy-qa: deploy-qa:
needs: build-and-push needs: build-and-push
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
runs-on: [self-hosted, qa] runs-on: [self-hosted, qa]
env: env:
REGISTRY: ${{ secrets.REGISTRY }} REGISTRY: ${{ secrets.REGISTRY }}
@@ -56,6 +77,7 @@ jobs:
smoke-qa: smoke-qa:
needs: deploy-qa needs: deploy-qa
if: github.server_url != 'https://github.com'
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 }}

3
.gitignore vendored
View File

@@ -476,3 +476,6 @@ ehthumbs.db
# Self-installed .NET SDK (scripts/ci/lib.sh ensure_dotnet, used when a CI runner lacks the SDK) # Self-installed .NET SDK (scripts/ci/lib.sh ensure_dotnet, used when a CI runner lacks the SDK)
/.dotnet/ /.dotnet/
# Self-installed reportgenerator CLI (scripts/ci/lib.sh ensure_reportgenerator)
/.dotnet-tools/

View File

@@ -1,10 +1,10 @@
# CLAUDE.md # CLAUDE.md
Guidance for Claude Code (claude.ai/code) when working in this repo. Guidance for Claude Code (claude.ai/code) in this repo.
## Project ## Project
MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, projects, environments. MicCheck: open source. asp.net, c#, typescript, VueJS. Manage feature flags, projects, environments.
## Planned Structure ## Planned Structure
@@ -18,27 +18,30 @@ MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, pr
- Use latest LTS .NET + latest supported nuget packages for that version - Use latest LTS .NET + latest supported nuget packages for that version
- Set `langVersion` to latest in all csproj files; enable nullable - Set `langVersion` to latest in all csproj files; enable nullable
- Organize code by feature/area, not type (e.g. `features` namespace) - Organize code by feature/area, not type (e.g. `features` namespace)
- New features need unit tests covering as much logic as possible (both nunit and jest) - New features need unit tests covering logic as much as possible (both nunit and jest)
- Any modified file: evaluate for missing test coverage and that all tests pass - Modified file: check missing test coverage, all tests pass
# Coding # Coding
- Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper - Descriptive names all classes/methods. No generic: Provider, Manager, Helper
- Match formatting/style from `.editorconfig` - Match formatting/style from `.editorconfig`
- Wrap lines at 220 characters, leave single line if fewer - Wrap lines at 220 chars, single line if fewer
- Place interfaces that are implemented by a single class at the bottom of the class file. An interface with multiple implementations of an interface should be in a seperate file. - Interfaces implemented by single class bottom of class file. Interface w/ multiple implementations separate file.
- Do not use tuples for return types. Prefer records or classes for multiple values - No tuples for return types. Prefer records or classes for multiple values
- Do not use `sealed` - No `sealed`
- Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible. - Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible.
## Testing ## Testing
- Min 70% code coverage, target 90%. Unit tests focus end-user scenarios first.
- Don't write tests just for coverage. Call out missing coverage rather than cover stuff not valuable to end user.
- Code not cleanly unit-testable → mark `[ExcludeFromCodeCoverage]` or exclude namespace from coverage in .runsettings file
- BDD-style unit tests, end-to-end as possible, no external resources (DB, filesystem). e.g. `WhenAUserDoesSomething_ThenAThingAppears` - BDD-style unit tests, end-to-end as possible, no external resources (DB, filesystem). e.g. `WhenAUserDoesSomething_ThenAThingAppears`
- Mock external deps with Moq - Mock external deps w/ Moq
- Mock EntityFramework DBContexts with an extracted interface and Moq. Do not rely on InMemory provider. - Mock EntityFramework DBContexts via extracted interface + Moq. Don't rely on InMemory provider.
- New features need unit tests covering as much logic as possible - New features need unit tests covering logic as much as possible
- Any modified file: evaluate for missing test coverage- - Modified file: check missing test coverage
- No "Mock" in mocked object names - No "Mock" in mocked object names
- No Arrange/Act/Assert comments - No Arrange/Act/Assert comments
- All tests should pass before commit - All tests pass before commit
## Claude ## Claude
- Plans = `.md` files in `docs/plans/`. Admin → `docs/plans/admin/`, API → `docs/plans/api/` - Plans = `.md` files in `docs/plans/`. Admin → `docs/plans/admin/`, API → `docs/plans/api/`
@@ -47,4 +50,4 @@ MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, pr
- Plan implemented from `docs/plans/<name>.md` → save summary as `docs/plans/<name>_output.md` - Plan implemented from `docs/plans/<name>.md` → save summary as `docs/plans/<name>_output.md`
## Stack ## Stack
`.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes. `.gitignore` set for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack change.

55
CLAUDE.original.md Executable file → Normal file
View File

@@ -1,44 +1,53 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Guidance for Claude Code (claude.ai/code) when working in this repo.
## Project ## Project
MicCheck is an open source project written in asp.net, c#, typescript and VueJS that manages feature flags, their projects, and their environments. MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, projects, environments.
## Planned Structure ## Planned Structure
- `src/admin/`administrative components in VueJS - `src/admin/`VueJS admin components
- `src/api/` - api and restful endpoints in .NET - `src/api/` - .NET API + REST endpoints
- `tests/` — test suite - `tests/` — test suite
- `docs/` — documentation - `docs/` — documentation
## Best Practices ## Best Practices
- Ensure all projects are using the latest LTS version of .NET as well as the latest supported nuget packages for that .NET version - Use latest LTS .NET + latest supported nuget packages for that version
- Ensure that langVersion is set to latest in all csproj files and nullable is enabled - Set `langVersion` to latest in all csproj files; enable nullable
- Code in all projects should be organized by feature or area instead of type. (e.g. a features namespace with all feature related code in it or in child namespaces of it) - Organize code by feature/area, not type (e.g. `features` namespace)
- All new feature requests should include corresponding unit tests that cover as much of the logic as possible. - New features need unit tests covering as much logic as possible (both nunit and jest)
- Any file modified should be evaluated for potential test cases and missing coverage areas. - Any modified file: evaluate for missing test coverage and that all tests pass
# Coding # Coding
- Use descriptive names for all classes and method created. Avoid generic names like Provider, Manager, Helper - Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper
- Coding should match formating and style rules in the .editorconfig file - Match formatting/style from `.editorconfig`
- Wrap lines at 220 characters, leave single line if fewer
- Place interfaces that are implemented by a single class at the bottom of the class file. An interface with multiple implementations of an interface should be in a seperate file.
- Do not use tuples for return types. Prefer records or classes for multiple values
- Do not use `sealed`
- Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible.
## Testing ## Testing
- Write unit tests in a BDD style, testing as much code end-to-end as possible without without touching external resources like databases or file systems (e.g. WhenAUserDoesSomething_ThenAThingAppears) - Require a minimum of 70% code coverage with a target of 90%. Unit tests should focus on end-user scenarios first.
- Mock any external dependencies using Moq. - Do not write tests for just to increase code coverage. Call out lack of test coverage rather than covering something that isn't valuable to the end user.
- Do not name any mocked objects with the word Mock in them - Code that can not be cleanly unit tested should be marked with [ExcludeFromCodeCoverage] or have it's namespace excluded from code coverage.
- Do not include any Arrange / Act / Assert comments in the code - BDD-style unit tests, end-to-end as possible, no external resources (DB, filesystem). e.g. `WhenAUserDoesSomething_ThenAThingAppears`
- Mock external deps with Moq
- Mock EntityFramework DBContexts with an extracted interface and Moq. Do not rely on InMemory provider.
- New features need unit tests covering as much logic as possible
- Any modified file: evaluate for missing test coverage-
- No "Mock" in mocked object names
- No Arrange/Act/Assert comments
- All tests should pass before commit
## Claude ## Claude
- Create all plans as .md file located in the `docs/` folder. Admin in `docs/admin/` and API in `docs/api/` - Plans = `.md` files in `docs/plans/`. Admin `docs/plans/admin/`, API `docs/plans/api/`
- Divide up large plans into discrete chucks of functionality so each can be built and committed independently. - Split large plans into discrete chunks — each buildable + committable independently
- When generating a plan from a .md file in /docs/ save the plan in the same folder with the same filename minus the .md extention, but with a _plan.md at the end - Plan generated from `docs/plans/<name>.md` save as `docs/plans/<name>_plan.md`
- When implementing a plan from a .md file in /docs/ save the summary of the plan in the same folder with the same filename minus the .md extention, but with a _output.md at the end - Plan implemented from `docs/plans/<name>.md` save summary as `docs/plans/<name>_output.md`
## Stack ## Stack
`.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes.
The `.gitignore` is configured for a .NET/Visual Studio project (C#, NuGet, MSBuild). If this changes, update this file accordingly.

138
badges/coverage.svg Normal file
View File

@@ -0,0 +1,138 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="155" height="20">
<style type="text/css">
<![CDATA[
@keyframes fade1 {
0% { visibility: visible; opacity: 1; }
23% { visibility: visible; opacity: 1; }
25% { visibility: hidden; opacity: 0; }
48% { visibility: hidden; opacity: 0; }
50% { visibility: hidden; opacity: 0; }
73% { visibility: hidden; opacity: 0; }
75% { visibility: hidden; opacity: 0; }
98% { visibility: hidden; opacity: 0; }
100% { visibility: visible; opacity: 1; }
}
@keyframes fade2 {
0% { visibility: hidden; opacity: 0; }
23% { visibility: hidden; opacity: 0; }
25% { visibility: visible; opacity: 1; }
48% { visibility: visible; opacity: 1; }
50% { visibility: hidden; opacity: 0; }
73% { visibility: hidden; opacity: 0; }
75% { visibility: hidden; opacity: 0; }
98% { visibility: hidden; opacity: 0; }
100% { visibility: hidden; opacity: 0; }
}
@keyframes fade3 {
0% { visibility: hidden; opacity: 0; }
23% { visibility: hidden; opacity: 0; }
25% { visibility: hidden; opacity: 0; }
48% { visibility: hidden; opacity: 0; }
50% { visibility: visible; opacity: 1; }
73% { visibility: visible; opacity: 1; }
75% { visibility: hidden; opacity: 0; }
98% { visibility: hidden; opacity: 0; }
100% { visibility: hidden; opacity: 0; }
}
@keyframes fade4 {
0% { visibility: hidden; opacity: 0; }
23% { visibility: hidden; opacity: 0; }
25% { visibility: hidden; opacity: 0; }
48% { visibility: hidden; opacity: 0; }
50% { visibility: hidden; opacity: 0; }
73% { visibility: hidden; opacity: 0; }
75% { visibility: visible; opacity: 1; }
98% { visibility: visible; opacity: 1; }
100% { visibility: hidden; opacity: 0; }
}
.linecoverage {
animation-duration: 15s;
animation-name: fade1;
animation-iteration-count: infinite;
}
.branchcoverage {
animation-duration: 15s;
animation-name: fade2;
animation-iteration-count: infinite;
}
.methodcoverage {
animation-duration: 15s;
animation-name: fade3;
animation-iteration-count: infinite;
}
.fullmethodcoverage {
animation-duration: 15s;
animation-name: fade4;
animation-iteration-count: infinite;
}
]]>
</style>
<title>Code coverage</title>
<defs>
<linearGradient id="gradient" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<linearGradient id="c">
<stop offset="0" stop-color="#d40000"/>
<stop offset="1" stop-color="#ff2a2a"/>
</linearGradient>
<linearGradient id="a">
<stop offset="0" stop-color="#e0e0de"/>
<stop offset="1" stop-color="#fff"/>
</linearGradient>
<linearGradient id="b">
<stop offset="0" stop-color="#37c837"/>
<stop offset="1" stop-color="#217821"/>
</linearGradient>
<linearGradient xlink:href="#a" id="e" x1="106.44" x2="69.96" y1="-11.96" y2="-46.84" gradientTransform="matrix(-.8426 -.00045 -.00045 -.8426 -94.27 -75.82)" gradientUnits="userSpaceOnUse"/>
<linearGradient xlink:href="#b" id="f" x1="56.19" x2="77.97" y1="-23.45" y2="10.62" gradientTransform="matrix(.8426 .00045 .00045 .8426 94.27 75.82)" gradientUnits="userSpaceOnUse"/>
<linearGradient xlink:href="#c" id="g" x1="79.98" x2="132.9" y1="10.79" y2="10.79" gradientTransform="matrix(.8426 .00045 .00045 .8426 94.27 75.82)" gradientUnits="userSpaceOnUse"/>
<mask id="mask">
<rect width="155" height="20" rx="3" fill="#fff"/>
</mask>
<g id="icon" transform="matrix(.04486 0 0 .04481 -.48 -.63)">
<rect width="52.92" height="52.92" x="-109.72" y="-27.13" fill="url(#e)" transform="rotate(-135)"/>
<rect width="52.92" height="52.92" x="70.19" y="-39.18" fill="url(#f)" transform="rotate(45)"/>
<rect width="52.92" height="52.92" x="80.05" y="-15.74" fill="url(#g)" transform="rotate(45)"/>
</g>
</defs>
<g mask="url(#mask)">
<rect x="0" y="0" width="90" height="20" fill="#444"/>
<rect x="90" y="0" width="20" height="20" fill="#c00"/>
<rect x="110" y="0" width="45" height="20" fill="#00B600"/>
<rect x="0" y="0" width="155" height="20" fill="url(#gradient)"/>
</g>
<g>
<path class="" stroke="#fff" d="M94 6.5 h12 M94 10.5 h12 M94 14.5 h12"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Arial,Geneva,sans-serif" font-size="11">
<a xlink:href="https://github.com/danielpalme/ReportGenerator" target="_top">
<title>Generated by: ReportGenerator 5.5.10.0</title>
<use xlink:href="#icon" transform="translate(3,1) scale(3.5)"/>
</a>
<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">29.8%</text><text class="" x="132.5" y="14">29.8%</text>
</g>
<g>
<rect class="" x="90" y="0" width="65" height="20" fill-opacity="0"><title>Line coverage</title></rect>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -1,5 +1,9 @@
# MicCheck # MicCheck
[![GitHub CI](https://github.com/wamplerj/mic-check/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/wamplerj/mic-check/actions/workflows/ci.yml)
[![Gitea CI](https://git.wampler.us/wamplerj/mic-check/actions/workflows/ci.yml/badge.svg?branch=main)](https://git.wampler.us/wamplerj/mic-check/actions?workflow=ci.yml)
![Coverage](badges/coverage.svg)
Open source feature flag management platform. Manage projects, environments, feature flags, segments, and identities across your apps. Open source feature flag management platform. Manage projects, environments, feature flags, segments, and identities across your apps.
Built with .NET (API) and Vue.js + Vuetify (admin UI). Built with .NET (API) and Vue.js + Vuetify (admin UI).

32
scripts/ci/coverage.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Merges the .NET (coverlet/Cobertura) and admin (Jest/lcov) coverage output
# produced by test.sh into one report via reportgenerator, prints a summary,
# appends a build-report summary when running under Actions, and refreshes
# the coverage badge committed at badges/coverage.svg. Readme embeds that
# badge via a relative path, which resolves on both GitHub and Gitea since
# the same repo content is pushed to both remotes.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
ensure_dotnet
ensure_reportgenerator
REPORT_DIR="$CI_ROOT/coverage/report"
log "Merging coverage reports with reportgenerator"
reportgenerator \
-reports:"coverage/dotnet/**/coverage.cobertura.xml;src/admin/coverage/lcov.info" \
-targetdir:"$REPORT_DIR" \
-reporttypes:"Badges;MarkdownSummaryGithub;TextSummary"
cat "$REPORT_DIR/Summary.txt"
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
cat "$REPORT_DIR/SummaryGithub.md" >> "$GITHUB_STEP_SUMMARY"
fi
mkdir -p "$CI_ROOT/badges"
cp "$REPORT_DIR/badge_linecoverage.svg" "$CI_ROOT/badges/coverage.svg"
log "coverage.sh complete"

View File

@@ -125,6 +125,22 @@ ensure_node() {
export PATH="$install_dir/bin:$PATH" export PATH="$install_dir/bin:$PATH"
} }
# Installs the dotnet-reportgenerator-globaltool CLI (merges coverlet/Jest
# coverage output into badges + build-summary markdown) into $CI_ROOT/.dotnet-tools
# if it isn't already on PATH. Mirrors ensure_dotnet()/ensure_node() above.
ensure_reportgenerator() {
if command -v reportgenerator > /dev/null 2>&1; then
return 0
fi
local tool_dir="$CI_ROOT/.dotnet-tools"
if [[ ! -x "$tool_dir/reportgenerator" ]]; then
log "reportgenerator not found on PATH; installing dotnet-reportgenerator-globaltool"
dotnet tool install dotnet-reportgenerator-globaltool --tool-path "$tool_dir"
fi
export PATH="$tool_dir:$PATH"
}
# The IP address on which a container published on 0.0.0.0/<gateway-ip> is # The IP address on which a container published on 0.0.0.0/<gateway-ip> is
# reachable from a sibling container on docker's default bridge network (i.e. # reachable from a sibling container on docker's default bridge network (i.e.
# the docker host's bridge-side address, not its public interface). Used to # the docker host's bridge-side address, not its public interface). Used to

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Commits the coverage badge refreshed by coverage.sh straight back to the
# branch that triggered this run, so readme.md's relative badges/coverage.svg
# link stays current. GITHUB_SERVER_URL/GITHUB_REPOSITORY/GITHUB_REF_NAME are
# default context env vars on both GitHub Actions and Gitea Actions (Gitea's
# engine is GitHub-Actions-compatible); GITHUB_TOKEN must be passed in
# explicitly from the workflow (${{ github.token }}) on both platforms.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
[[ -n "${GITHUB_TOKEN:-}" ]] || fail "GITHUB_TOKEN env var is required to push the badge commit"
[[ -n "${GITHUB_SERVER_URL:-}" ]] || fail "GITHUB_SERVER_URL env var is required to push the badge commit"
[[ -n "${GITHUB_REPOSITORY:-}" ]] || fail "GITHUB_REPOSITORY env var is required to push the badge commit"
[[ -n "${GITHUB_REF_NAME:-}" ]] || fail "GITHUB_REF_NAME env var is required to push the badge commit"
if git diff --quiet -- badges/coverage.svg; then
log "badges/coverage.svg unchanged; nothing to publish"
exit 0
fi
git config user.name "miccheck-ci"
git config user.email "ci@miccheck.local"
git add badges/coverage.svg
git commit -m "chore: refresh coverage badge [skip ci]"
remote_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c http.extraheader="AUTHORIZATION: bearer ${GITHUB_TOKEN}" push "$remote_url" "HEAD:${GITHUB_REF_NAME}"
log "publish-coverage-badge.sh complete"

View File

@@ -8,10 +8,19 @@ cd "$CI_ROOT"
ensure_dotnet ensure_dotnet
# --results-directory doesn't clear prior runs - it adds a new GUID folder
# alongside old ones every time. On a runner that reuses its workspace
# (self-hosted, unlike GitHub's ephemeral ones), stale coverage from past
# runs would otherwise get merged in by coverage.sh and silently skew the
# combined percentage.
rm -rf "$CI_ROOT/coverage/dotnet"
log "Running MicCheck.Api.Tests.Unit" log "Running MicCheck.Api.Tests.Unit"
dotnet test tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj -c Release --logger trx dotnet test tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj -c Release --logger trx \
--collect:"XPlat Code Coverage" --results-directory "$CI_ROOT/coverage/dotnet" \
--settings tests/api/MicCheck.Api.Tests.Unit/coverlet.runsettings
log "Running admin Jest tests" log "Running admin Jest tests"
npm --prefix src/admin test npm --prefix src/admin test -- --coverage --coverageReporters=lcov --coverageReporters=text-summary
log "test.sh complete" log "test.sh complete"

View File

@@ -12,17 +12,15 @@ namespace MicCheck.Api.Audit;
[ApiController] [ApiController]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] [Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")] [EnableRateLimiting("AdminApi")]
[Route("api/v1")]
public class AuditLogsController( public class AuditLogsController(
AuditLogQueryService auditLogQueryService, AuditLogQueryService auditLogQueryService,
OrganizationService organizationService, OrganizationService organizationService,
ProjectService projectService, ProjectService projectService,
EnvironmentService environmentService) : ControllerBase EnvironmentService environmentService) : ControllerBase
{ {
[HttpGet("api/v1/organisation/{id}/audit-logs")] [HttpGet("organisation/{id}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization( public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(int id, [FromQuery] AuditLogFilter filter, CancellationToken ct)
int id,
[FromQuery] AuditLogFilter filter,
CancellationToken ct)
{ {
var org = await organizationService.FindByIdAsync(id, ct); var org = await organizationService.FindByIdAsync(id, ct);
if (org is null) return NotFound(); if (org is null) return NotFound();
@@ -31,11 +29,8 @@ public class AuditLogsController(
return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList())); return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
} }
[HttpGet("api/v1/project/{projectId}/audit-logs")] [HttpGet("project/{projectId}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject( public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(int projectId, [FromQuery] AuditLogFilter filter, CancellationToken ct)
int projectId,
[FromQuery] AuditLogFilter filter,
CancellationToken ct)
{ {
var project = await projectService.FindByIdAsync(projectId, ct); var project = await projectService.FindByIdAsync(projectId, ct);
if (project is null) return NotFound(); if (project is null) return NotFound();
@@ -44,11 +39,8 @@ public class AuditLogsController(
return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList())); return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
} }
[HttpGet("api/v1/environment/{apiKey}/audit-logs")] [HttpGet("environment/{apiKey}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment( public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(string apiKey, [FromQuery] AuditLogFilter filter, CancellationToken ct)
string apiKey,
[FromQuery] AuditLogFilter filter,
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();

View File

@@ -1,7 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Common.Security.Authorization;
namespace MicCheck.Api.Common.Security.ApiKeys; namespace MicCheck.Api.Common.Security.ApiKeys;
[ExcludeFromCodeCoverage(Justification = "Minimal-API route registration; requires a live HTTP pipeline to exercise, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). Branch logic is covered via ApiKeyService unit tests.")]
public static class ApiKeyEndpoints public static class ApiKeyEndpoints
{ {
public static void MapApiKeyEndpoints(this WebApplication app) public static void MapApiKeyEndpoints(this WebApplication app)

View File

@@ -1,9 +1,3 @@
namespace MicCheck.Api.Common.Security.ApiKeys; namespace MicCheck.Api.Common.Security.ApiKeys;
public record ApiKeyResponse( public record ApiKeyResponse(int Id, string Name, string Prefix, bool IsActive, DateTimeOffset? ExpiresAt, DateTimeOffset CreatedAt);
int Id,
string Name,
string Prefix,
bool IsActive,
DateTimeOffset? ExpiresAt,
DateTimeOffset CreatedAt);

View File

@@ -1,7 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace MicCheck.Api.Common.Security.Authorization; namespace MicCheck.Api.Common.Security.Authorization;
[ExcludeFromCodeCoverage(Justification = "Minimal-API route registration; requires a live HTTP pipeline to exercise, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). Branch logic is covered via AuthService unit tests.")]
public static class AuthEndpoints public static class AuthEndpoints
{ {
public static void MapAuthEndpoints(this WebApplication app) public static void MapAuthEndpoints(this WebApplication app)

View File

@@ -13,10 +13,7 @@ namespace MicCheck.Api.Features;
public class FeatureUsageController(FeatureUsageQueryService queryService, IMemoryCache cache) : ControllerBase public class FeatureUsageController(FeatureUsageQueryService queryService, IMemoryCache cache) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<ActionResult<DashboardUsageResponse>> GetDashboardUsage( public async Task<ActionResult<DashboardUsageResponse>> GetDashboardUsage(int environmentId, [FromQuery] int days = 14, CancellationToken ct = default)
int environmentId,
[FromQuery] int days = 14,
CancellationToken ct = default)
{ {
days = Math.Clamp(days, 1, 90); days = Math.Clamp(days, 1, 90);
var cacheKey = $"usage-dashboard:{environmentId}:{days}"; var cacheKey = $"usage-dashboard:{environmentId}:{days}";

View File

@@ -1,8 +1,10 @@
using System.Diagnostics.CodeAnalysis;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features; 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( public class FeatureUsageFlushBackgroundService(
FeatureUsageMetrics metrics, FeatureUsageMetrics metrics,
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,

View File

@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using MicCheck.Api.Audit; using MicCheck.Api.Audit;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -46,6 +47,7 @@ public class OrganizationService(IMicCheckDbContext db, AuditService auditServic
return org; return org;
} }
[ExcludeFromCodeCoverage(Justification = "ExecuteUpdateAsync requires a real EF Core relational query provider; our Mock<DbSet<T>> LINQ-to-Objects provider can't execute it, and CLAUDE.md disallows the EF InMemory provider as a substitute. The not-a-member early-return branch is covered by OrganizationServiceTests.")]
public async Task SetPrimaryAsync(int organizationId, int userId, CancellationToken ct = default) public async Task SetPrimaryAsync(int organizationId, int userId, CancellationToken ct = default)
{ {
var member = await db.OrganizationUsers var member = await db.OrganizationUsers

View File

@@ -1,8 +1,10 @@
using System.Diagnostics.CodeAnalysis;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace MicCheck.Api.Webhooks; namespace MicCheck.Api.Webhooks;
[ExcludeFromCodeCoverage(Justification = "Long-running BackgroundService reading from a Channel and issuing DI-scoped dispatches; exercising it cleanly requires a live DI container and DB, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). Dispatch logic is covered by WebhookDispatcherTests.")]
public class WebhookBackgroundService( public class WebhookBackgroundService(
WebhookQueue queue, WebhookQueue queue,
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,

View File

@@ -1,8 +1,10 @@
using System.Diagnostics.CodeAnalysis;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Webhooks; namespace MicCheck.Api.Webhooks;
[ExcludeFromCodeCoverage(Justification = "Timer-driven BackgroundService that resolves a DI-scoped concrete MicCheckDbContext and WebhookDispatcher; exercising it cleanly requires a live DI container and DB, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). The retry-eligibility query logic is covered by WebhookRetryTests.")]
public class WebhookRetryBackgroundService( public class WebhookRetryBackgroundService(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
ILogger<WebhookRetryBackgroundService> logger) : BackgroundService ILogger<WebhookRetryBackgroundService> logger) : BackgroundService

View File

@@ -0,0 +1,219 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Data;
using MicCheck.Api.Tests.Unit.TestSupport;
using MicCheck.Api.Users;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Audit;
[TestFixture]
public class AuditLogQueryServiceTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<AuditLog> _auditLogs = null!;
private List<User> _users = null!;
private AuditLogQueryService _service = null!;
private const int OrganizationId = 1;
private const int ProjectId = 1;
private const int EnvironmentId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_auditLogs = [];
_db.SetupDbSet(c => c.AuditLogs, _auditLogs);
_users = [];
_db.SetupDbSet(c => c.Users, _users);
_service = new AuditLogQueryService(_db.Object);
}
private AuditLog AddLog(string resourceType = "Feature", string action = "created", int? projectId = ProjectId,
int? environmentId = EnvironmentId, int? actorUserId = null, DateTimeOffset? createdAt = null)
{
var log = new AuditLog
{
Id = _auditLogs.Count + 1,
ResourceType = resourceType,
ResourceId = "1",
Action = action,
OrganizationId = OrganizationId,
ProjectId = projectId,
EnvironmentId = environmentId,
ActorUserId = actorUserId,
CreatedAt = createdAt ?? DateTimeOffset.UtcNow
};
_auditLogs.Add(log);
return log;
}
[Test]
public async Task WhenListingByOrganization_ThenOnlyLogsForThatOrganizationAreReturned()
{
AddLog();
_auditLogs.Add(new AuditLog { Id = 2, ResourceType = "Feature", ResourceId = "1", Action = "created", OrganizationId = 999, CreatedAt = DateTimeOffset.UtcNow });
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter());
Assert.That(result.Total, Is.EqualTo(1));
Assert.That(result.Items, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenListingByProject_ThenOnlyLogsForThatProjectAreReturned()
{
AddLog(projectId: ProjectId);
AddLog(projectId: 999);
var result = await _service.ListByProjectAsync(ProjectId, new AuditLogFilter());
Assert.That(result.Total, Is.EqualTo(1));
}
[Test]
public async Task WhenListingByEnvironment_ThenOnlyLogsForThatEnvironmentAreReturned()
{
AddLog(environmentId: EnvironmentId);
AddLog(environmentId: 999);
var result = await _service.ListByEnvironmentAsync(EnvironmentId, new AuditLogFilter());
Assert.That(result.Total, Is.EqualTo(1));
}
[Test]
public async Task WhenFilteringByFromDate_ThenOnlyLogsOnOrAfterAreReturned()
{
AddLog(createdAt: DateTimeOffset.UtcNow.AddDays(-10));
AddLog(createdAt: DateTimeOffset.UtcNow);
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(From: DateTimeOffset.UtcNow.AddDays(-1)));
Assert.That(result.Total, Is.EqualTo(1));
}
[Test]
public async Task WhenFilteringByToDate_ThenOnlyLogsOnOrBeforeAreReturned()
{
AddLog(createdAt: DateTimeOffset.UtcNow.AddDays(-10));
AddLog(createdAt: DateTimeOffset.UtcNow);
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(To: DateTimeOffset.UtcNow.AddDays(-1)));
Assert.That(result.Total, Is.EqualTo(1));
}
[Test]
public async Task WhenFilteringByResourceType_ThenOnlyMatchingLogsAreReturned()
{
AddLog(resourceType: "Feature");
AddLog(resourceType: "Segment");
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(ResourceType: "Segment"));
Assert.That(result.Total, Is.EqualTo(1));
Assert.That(result.Items[0].ResourceType, Is.EqualTo("Segment"));
}
[Test]
public async Task WhenFilteringByAction_ThenOnlyMatchingLogsAreReturned()
{
AddLog(action: "created");
AddLog(action: "deleted");
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(Action: "deleted"));
Assert.That(result.Total, Is.EqualTo(1));
Assert.That(result.Items[0].Action, Is.EqualTo("deleted"));
}
[Test]
public async Task WhenFilteringByProjectIdOnAnOrganizationQuery_ThenOnlyMatchingLogsAreReturned()
{
AddLog(projectId: ProjectId);
AddLog(projectId: 999);
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(ProjectId: ProjectId));
Assert.That(result.Total, Is.EqualTo(1));
}
[Test]
public async Task WhenFilteringByEnvironmentIdOnAnOrganizationQuery_ThenOnlyMatchingLogsAreReturned()
{
AddLog(environmentId: EnvironmentId);
AddLog(environmentId: 999);
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(EnvironmentId: EnvironmentId));
Assert.That(result.Total, Is.EqualTo(1));
}
[Test]
public async Task WhenFilteringByActorUserId_ThenOnlyMatchingLogsAreReturned()
{
AddLog(actorUserId: 1);
AddLog(actorUserId: 2);
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(ActorUserId: 1));
Assert.That(result.Total, Is.EqualTo(1));
}
[Test]
public async Task WhenPageSizeExceedsMaximum_ThenItIsClampedTo100()
{
for (var i = 0; i < 150; i++)
AddLog();
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(PageSize: 1000));
Assert.That(result.Items, Has.Count.EqualTo(100));
}
[Test]
public async Task WhenPageSizeIsBelowMinimum_ThenItIsClampedTo1()
{
AddLog();
AddLog();
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(PageSize: 0));
Assert.That(result.Items, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenResultsAreOrderedByCreationDate_ThenNewestAppearsFirst()
{
AddLog(createdAt: DateTimeOffset.UtcNow.AddDays(-1));
var newest = AddLog(createdAt: DateTimeOffset.UtcNow);
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter());
Assert.That(result.Items[0].Id, Is.EqualTo(newest.Id));
}
[Test]
public async Task WhenALogHasAnActorUser_ThenTheActorUserNameIsPopulated()
{
_users.Add(new User { Id = 7, Email = "alice@example.com", PasswordHash = "hash", FirstName = "Alice", LastName = "Smith", CreatedAt = DateTimeOffset.UtcNow });
AddLog(actorUserId: 7);
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter());
Assert.That(result.Items[0].ActorUserName, Is.EqualTo("Alice Smith"));
}
[Test]
public async Task WhenALogHasNoActorUser_ThenTheActorUserNameIsNull()
{
AddLog();
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter());
Assert.That(result.Items[0].ActorUserName, Is.Null);
}
}

View File

@@ -0,0 +1,76 @@
using MicCheck.Api.Audit;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Audit;
[TestFixture]
public class AuditLogResponseTests
{
[Test]
public void WhenMappingWithoutAnActorUserName_ThenItDefaultsToNull()
{
var log = new AuditLog
{
Id = 1,
ResourceType = "Feature",
ResourceId = "42",
Action = "created",
OrganizationId = 1,
CreatedAt = DateTimeOffset.UtcNow
};
var response = AuditLogResponse.From(log);
Assert.That(response.ActorUserName, Is.Null);
}
[Test]
public void WhenMappingWithAnActorUserName_ThenItIsIncluded()
{
var log = new AuditLog
{
Id = 1,
ResourceType = "Feature",
ResourceId = "42",
Action = "created",
OrganizationId = 1,
ActorUserId = 7,
CreatedAt = DateTimeOffset.UtcNow
};
var response = AuditLogResponse.From(log, "Alice Smith");
Assert.That(response.ActorUserId, Is.EqualTo(7));
Assert.That(response.ActorUserName, Is.EqualTo("Alice Smith"));
}
[Test]
public void WhenMapping_ThenAllFieldsAreCopiedFromTheLog()
{
var createdAt = DateTimeOffset.UtcNow;
var log = new AuditLog
{
Id = 1,
ResourceType = "FeatureState",
ResourceId = "5",
Action = "updated",
Changes = """{"before":{},"after":{}}""",
OrganizationId = 10,
ProjectId = 20,
EnvironmentId = 30,
CreatedAt = createdAt
};
var response = AuditLogResponse.From(log);
Assert.That(response.Id, Is.EqualTo(1));
Assert.That(response.ResourceType, Is.EqualTo("FeatureState"));
Assert.That(response.ResourceId, Is.EqualTo("5"));
Assert.That(response.Action, Is.EqualTo("updated"));
Assert.That(response.Changes, Is.EqualTo("""{"before":{},"after":{}}"""));
Assert.That(response.OrganizationId, Is.EqualTo(10));
Assert.That(response.ProjectId, Is.EqualTo(20));
Assert.That(response.EnvironmentId, Is.EqualTo(30));
Assert.That(response.CreatedAt, Is.EqualTo(createdAt));
}
}

View File

@@ -0,0 +1,118 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Common;
using MicCheck.Api.Data;
using MicCheck.Api.Environments;
using MicCheck.Api.Organizations;
using MicCheck.Api.Projects;
using MicCheck.Api.Tests.Unit.TestSupport;
using MicCheck.Api.Users;
using Microsoft.AspNetCore.Mvc;
using Moq;
using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Tests.Unit.Audit;
[TestFixture]
public class AuditLogsControllerTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<Organization> _organizations = null!;
private List<Project> _projects = null!;
private List<AppEnvironment> _environments = null!;
private List<AuditLog> _auditLogs = null!;
private AuditLogsController _controller = null!;
private const int OrganizationId = 1;
private const int ProjectId = 1;
private const int EnvironmentId = 1;
private const string ApiKey = "env-key";
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_organizations = [new Organization { Id = OrganizationId, Name = "Org", CreatedAt = DateTimeOffset.UtcNow }];
_db.SetupDbSet(c => c.Organizations, _organizations);
_projects = [new Project { Id = ProjectId, Name = "Project", OrganizationId = OrganizationId, CreatedAt = DateTimeOffset.UtcNow }];
_db.SetupDbSet(c => c.Projects, _projects);
_environments = [new AppEnvironment { Id = EnvironmentId, Name = "Production", ApiKey = ApiKey, ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }];
_db.SetupDbSet(c => c.Environments, _environments);
_auditLogs = [];
_db.SetupDbSet(c => c.AuditLogs, _auditLogs);
_db.SetupDbSet(c => c.Users, []);
var auditServiceMock = new Mock<AuditService>(_db.Object, null!, new MicCheck.Api.Webhooks.WebhookQueue());
var auditService = auditServiceMock.Object;
_controller = new AuditLogsController(
new AuditLogQueryService(_db.Object),
new OrganizationService(_db.Object, auditService),
new ProjectService(_db.Object, auditService),
new EnvironmentService(_db.Object, auditService));
}
[Test]
public async Task WhenListingByOrganizationThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.ListByOrganization(999, new AuditLogFilter(), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingByOrganizationThatExists_ThenMatchingLogsAreReturned()
{
_auditLogs.Add(new AuditLog { Id = 1, ResourceType = "Feature", ResourceId = "1", Action = "created", OrganizationId = OrganizationId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.ListByOrganization(OrganizationId, new AuditLogFilter(), CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as PaginatedResponse<AuditLogResponse>;
Assert.That(body!.Count, Is.EqualTo(1));
}
[Test]
public async Task WhenListingByProjectThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.ListByProject(999, new AuditLogFilter(), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingByProjectThatExists_ThenMatchingLogsAreReturned()
{
_auditLogs.Add(new AuditLog { Id = 1, ResourceType = "Feature", ResourceId = "1", Action = "created", OrganizationId = OrganizationId, ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.ListByProject(ProjectId, new AuditLogFilter(), CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as PaginatedResponse<AuditLogResponse>;
Assert.That(body!.Count, Is.EqualTo(1));
}
[Test]
public async Task WhenListingByEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.ListByEnvironment("missing-key", new AuditLogFilter(), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingByEnvironmentThatExists_ThenMatchingLogsAreReturned()
{
_auditLogs.Add(new AuditLog { Id = 1, ResourceType = "Feature", ResourceId = "1", Action = "created", OrganizationId = OrganizationId, EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.ListByEnvironment(ApiKey, new AuditLogFilter(), CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as PaginatedResponse<AuditLogResponse>;
Assert.That(body!.Count, Is.EqualTo(1));
}
}

View File

@@ -90,6 +90,85 @@ public class AuditServiceTests
Assert.That(log.ActorUserId, Is.Null); Assert.That(log.ActorUserId, Is.Null);
} }
[Test]
public async Task WhenLoggingWithNoChanges_ThenChangesRemainsNull()
{
await _service.LogAsync("Feature", "1", "created", organizationId: 1);
var log = _auditLogs.First();
Assert.That(log.Changes, Is.Null);
}
[Test]
public async Task WhenLoggingWithChanges_ThenChangesAndMetadataArePersisted()
{
await _service.LogAsync("Feature", "2", "updated", organizationId: 5, projectId: 6, environmentId: 7, changes: "raw-json");
var log = _auditLogs.First();
Assert.That(log.ResourceType, Is.EqualTo("Feature"));
Assert.That(log.ResourceId, Is.EqualTo("2"));
Assert.That(log.Action, Is.EqualTo("updated"));
Assert.That(log.Changes, Is.EqualTo("raw-json"));
Assert.That(log.OrganizationId, Is.EqualTo(5));
Assert.That(log.ProjectId, Is.EqualTo(6));
Assert.That(log.EnvironmentId, Is.EqualTo(7));
}
[Test]
public async Task WhenLogging_ThenAuditLogCreatedEventIsEnqueued()
{
await _service.LogAsync("Feature", "1", "created", organizationId: 1);
var events = new List<WebhookEvent>();
var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
try
{
await foreach (var e in _queue.ReadAllAsync(cts.Token))
{
events.Add(e);
break;
}
}
catch (OperationCanceledException) { }
Assert.That(events, Has.Count.EqualTo(1));
Assert.That(events[0].EventType, Is.EqualTo(WebhookEventTypes.AuditLogCreated));
}
[Test]
public async Task WhenTheHttpContextHasAnAuthenticatedUser_ThenTheActorUserIdIsResolvedFromTheClaim()
{
var identity = new System.Security.Claims.ClaimsIdentity(
[new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, "42")], "TestAuth");
var httpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext
{
User = new System.Security.Claims.ClaimsPrincipal(identity)
};
var httpContextAccessor = new Mock<IHttpContextAccessor>();
httpContextAccessor.Setup(x => x.HttpContext).Returns(httpContext);
var service = new AuditService(_db.Object, httpContextAccessor.Object, _queue);
await service.RecordAsync("Feature", "1", "created", organizationId: 1);
Assert.That(_auditLogs.First().ActorUserId, Is.EqualTo(42));
}
[Test]
public async Task WhenTheHttpContextHasNoNameIdentifierClaim_ThenTheActorUserIdIsNull()
{
var httpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext
{
User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity())
};
var httpContextAccessor = new Mock<IHttpContextAccessor>();
httpContextAccessor.Setup(x => x.HttpContext).Returns(httpContext);
var service = new AuditService(_db.Object, httpContextAccessor.Object, _queue);
await service.RecordAsync("Feature", "1", "created", organizationId: 1);
Assert.That(_auditLogs.First().ActorUserId, Is.Null);
}
[Test] [Test]
public async Task WhenRecording_ThenAuditLogCreatedEventIsEnqueued() public async Task WhenRecording_ThenAuditLogCreatedEventIsEnqueued()
{ {

View File

@@ -0,0 +1,97 @@
using MicCheck.Api.Common.Security.ApiKeys;
using MicCheck.Api.Data;
using MicCheck.Api.Tests.Unit.TestSupport;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Common.Security.ApiKeys;
[TestFixture]
public class ApiKeyServiceTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<ApiKey> _apiKeys = null!;
private ApiKeyService _service = null!;
private const int OrganizationId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_apiKeys = [];
_db.SetupDbSetWithGeneratedIds(c => c.ApiKeys, _apiKeys);
_service = new ApiKeyService(_db.Object);
}
[Test]
public async Task WhenCreatingAnApiKey_ThenTheStoredKeyIsHashedAndTheRawKeyIsReturnedOnce()
{
var result = await _service.CreateAsync(OrganizationId, "CI Key", null);
Assert.That(result.RawKey, Is.Not.Empty);
Assert.That(result.Key.Key, Is.EqualTo(ApiKeyHasher.Hash(result.RawKey)));
Assert.That(result.Key.Key, Is.Not.EqualTo(result.RawKey));
}
[Test]
public async Task WhenCreatingAnApiKey_ThenThePrefixIsTheFirstEightCharactersOfTheRawKey()
{
var result = await _service.CreateAsync(OrganizationId, "CI Key", null);
Assert.That(result.Key.Prefix, Is.EqualTo(result.RawKey[..8]));
}
[Test]
public async Task WhenCreatingAnApiKey_ThenItIsPersistedAsActiveForTheGivenOrganization()
{
var expiresAt = DateTimeOffset.UtcNow.AddDays(30);
var result = await _service.CreateAsync(OrganizationId, "CI Key", expiresAt);
var persisted = _apiKeys.Single();
Assert.That(persisted.Id, Is.EqualTo(result.Key.Id));
Assert.That(persisted.Name, Is.EqualTo("CI Key"));
Assert.That(persisted.OrganizationId, Is.EqualTo(OrganizationId));
Assert.That(persisted.IsActive, Is.True);
Assert.That(persisted.ExpiresAt, Is.EqualTo(expiresAt));
}
[Test]
public async Task WhenListingApiKeysForAnOrganization_ThenOnlyThatOrganizationsKeysAreReturned()
{
await _service.CreateAsync(OrganizationId, "Org 1 Key", null);
await _service.CreateAsync(999, "Org 2 Key", null);
var keys = await _service.ListAsync(OrganizationId);
Assert.That(keys, Has.Count.EqualTo(1));
Assert.That(keys[0].Name, Is.EqualTo("Org 1 Key"));
}
[Test]
public async Task WhenRevokingAnExistingApiKey_ThenItIsMarkedInactive()
{
var created = await _service.CreateAsync(OrganizationId, "CI Key", null);
await _service.RevokeAsync(OrganizationId, created.Key.Id);
Assert.That(_apiKeys.Single().IsActive, Is.False);
}
[Test]
public async Task WhenRevokingAnApiKeyThatBelongsToADifferentOrganization_ThenItIsNotRevoked()
{
var created = await _service.CreateAsync(OrganizationId, "CI Key", null);
await _service.RevokeAsync(999, created.Key.Id);
Assert.That(_apiKeys.Single().IsActive, Is.True);
}
[Test]
public void WhenRevokingAnApiKeyThatDoesNotExist_ThenNoExceptionIsThrown()
{
Assert.DoesNotThrowAsync(() => _service.RevokeAsync(OrganizationId, 999));
}
}

View File

@@ -75,6 +75,14 @@ public class ApiKeyAuthenticationHandlerTests
Assert.That(result.None, Is.True); Assert.That(result.None, Is.True);
} }
[Test]
public async Task WhenApiKeyValueIsEmpty_ThenAuthenticationFails()
{
var result = await AuthenticateAsync("Api-Key ");
Assert.That(result.Succeeded, Is.False);
}
[Test] [Test]
public async Task WhenApiKeyIsInvalid_ThenAuthenticationFails() public async Task WhenApiKeyIsInvalid_ThenAuthenticationFails()
{ {

View File

@@ -51,6 +51,14 @@ public class EnvironmentKeyAuthenticationHandlerTests
Assert.That(result.None, Is.True); Assert.That(result.None, Is.True);
} }
[Test]
public async Task WhenEnvironmentKeyHeaderIsEmpty_ThenAuthenticationFails()
{
var result = await AuthenticateAsync("");
Assert.That(result.Succeeded, Is.False);
}
[Test] [Test]
public async Task WhenEnvironmentKeyIsInvalid_ThenAuthenticationFails() public async Task WhenEnvironmentKeyIsInvalid_ThenAuthenticationFails()
{ {

View File

@@ -216,4 +216,10 @@ public class AuthServiceTests
Assert.That(response, Is.Null); Assert.That(response, Is.Null);
} }
[Test]
public void WhenLoggingOutWithATokenThatDoesNotExist_ThenNoExceptionIsThrown()
{
Assert.DoesNotThrowAsync(() => _service.LogoutAsync("unknown-token"));
}
} }

View File

@@ -0,0 +1,95 @@
using MicCheck.Api.Environments;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Environments;
[TestFixture]
public class CreateEnvironmentRequestValidatorTests
{
private CreateEnvironmentRequestValidator _validator = null!;
[SetUp]
public void SetUp() => _validator = new CreateEnvironmentRequestValidator();
[Test]
public void WhenTheRequestIsValid_ThenValidationSucceeds()
{
var result = _validator.Validate(new CreateEnvironmentRequest("Staging", 1));
Assert.That(result.IsValid, Is.True);
}
[Test]
public void WhenNameIsEmpty_ThenValidationFails()
{
var result = _validator.Validate(new CreateEnvironmentRequest("", 1));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenNameExceedsMaximumLength_ThenValidationFails()
{
var result = _validator.Validate(new CreateEnvironmentRequest(new string('a', 201), 1));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenProjectIdIsZeroOrLess_ThenValidationFails()
{
var result = _validator.Validate(new CreateEnvironmentRequest("Staging", 0));
Assert.That(result.IsValid, Is.False);
}
}
[TestFixture]
public class UpdateEnvironmentRequestValidatorTests
{
private UpdateEnvironmentRequestValidator _validator = null!;
[SetUp]
public void SetUp() => _validator = new UpdateEnvironmentRequestValidator();
[Test]
public void WhenTheRequestIsValid_ThenValidationSucceeds()
{
var result = _validator.Validate(new UpdateEnvironmentRequest("Renamed"));
Assert.That(result.IsValid, Is.True);
}
[Test]
public void WhenNameIsEmpty_ThenValidationFails()
{
var result = _validator.Validate(new UpdateEnvironmentRequest(""));
Assert.That(result.IsValid, Is.False);
}
}
[TestFixture]
public class CloneEnvironmentRequestValidatorTests
{
private CloneEnvironmentRequestValidator _validator = null!;
[SetUp]
public void SetUp() => _validator = new CloneEnvironmentRequestValidator();
[Test]
public void WhenTheRequestIsValid_ThenValidationSucceeds()
{
var result = _validator.Validate(new CloneEnvironmentRequest("Staging Copy"));
Assert.That(result.IsValid, Is.True);
}
[Test]
public void WhenNameIsEmpty_ThenValidationFails()
{
var result = _validator.Validate(new CloneEnvironmentRequest(""));
Assert.That(result.IsValid, Is.False);
}
}

View File

@@ -0,0 +1,69 @@
using System.Security.Claims;
using MicCheck.Api.Data;
using MicCheck.Api.Environments;
using MicCheck.Api.Organizations;
using MicCheck.Api.Projects;
using MicCheck.Api.Tests.Unit.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Tests.Unit.Environments;
[TestFixture]
public class EnvironmentDocumentControllerTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<AppEnvironment> _environments = null!;
private EnvironmentDocumentController _controller = null!;
private const int EnvironmentId = 1;
private const int ProjectId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_environments = [new AppEnvironment { Id = EnvironmentId, Name = "Production", ApiKey = "env-key", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }];
var environmentsSet = MockDbSetFactory.Create(_environments);
environmentsSet.Setup(m => m.FindAsync(It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((object[] keys, CancellationToken _) => _environments.FirstOrDefault(e => e.Id == (int)keys[0]));
_db.Setup(c => c.Environments).Returns(environmentsSet.Object);
_db.SetupDbSet(c => c.Projects, [new Project { Id = ProjectId, Name = "Test Project", OrganizationId = 1, CreatedAt = DateTimeOffset.UtcNow }]);
_db.SetupDbSet(c => c.Organizations, [new Organization { Id = 1, Name = "Org", CreatedAt = DateTimeOffset.UtcNow }]);
_db.SetupDbSet(c => c.Features, []);
_db.SetupDbSet(c => c.FeatureStates, []);
_controller = new EnvironmentDocumentController(new EnvironmentDocumentService(_db.Object));
var identity = new ClaimsIdentity([new Claim("EnvironmentId", EnvironmentId.ToString())], "TestAuth");
_controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }
};
}
[Test]
public async Task WhenTheEnvironmentDocumentExists_ThenItIsReturned()
{
var result = await _controller.Get(CancellationToken.None);
var ok = result.Result as OkObjectResult;
Assert.That(ok, Is.Not.Null);
var document = ok!.Value as EnvironmentDocumentResponse;
Assert.That(document!.Id, Is.EqualTo(EnvironmentId));
}
[Test]
public async Task WhenTheEnvironmentDocumentDoesNotExist_ThenNotFoundIsReturned()
{
_environments.Clear();
var result = await _controller.Get(CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
}

View File

@@ -0,0 +1,63 @@
using MicCheck.Api.Environments;
using MicCheck.Api.Segments;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Environments;
[TestFixture]
public class SegmentDocumentResponseTests
{
[Test]
public void WhenMappingASegment_ThenOnlyTopLevelRulesAreIncluded()
{
var segment = new Segment { Id = 1, Name = "Beta Users", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow };
var topLevelRule = new SegmentRule { Id = 1, SegmentId = 1, Type = SegmentRuleType.All };
var nestedRule = new SegmentRule { Id = 2, SegmentId = 1, ParentRuleId = 1, Type = SegmentRuleType.Any };
segment.Rules.Add(topLevelRule);
segment.Rules.Add(nestedRule);
var response = SegmentDocumentResponse.From(segment);
Assert.That(response.Id, Is.EqualTo(1));
Assert.That(response.Name, Is.EqualTo("Beta Users"));
Assert.That(response.Rules, Has.Count.EqualTo(1));
Assert.That(response.Rules[0].Id, Is.EqualTo(1));
}
}
[TestFixture]
public class SegmentRuleDocumentResponseTests
{
[Test]
public void WhenMappingARule_ThenTypeIsUpperInvariantAndChildRulesAreIncluded()
{
var rule = new SegmentRule { Id = 1, SegmentId = 1, Type = SegmentRuleType.Any };
var condition = new SegmentCondition { Id = 1, RuleId = 1, Property = "plan", Operator = SegmentConditionOperator.Equal, Value = "pro" };
var childRule = new SegmentRule { Id = 2, SegmentId = 1, ParentRuleId = 1, Type = SegmentRuleType.All };
rule.Conditions.Add(condition);
rule.ChildRules.Add(childRule);
var response = SegmentRuleDocumentResponse.From(rule);
Assert.That(response.Type, Is.EqualTo("ANY"));
Assert.That(response.Conditions, Has.Count.EqualTo(1));
Assert.That(response.Rules, Has.Count.EqualTo(1));
Assert.That(response.Rules[0].Id, Is.EqualTo(2));
}
}
[TestFixture]
public class SegmentConditionDocumentResponseTests
{
[Test]
public void WhenMappingACondition_ThenPropertyOperatorAndValueAreCopied()
{
var condition = new SegmentCondition { Id = 1, RuleId = 1, Property = "plan", Operator = SegmentConditionOperator.Contains, Value = "pro" };
var response = SegmentConditionDocumentResponse.From(condition);
Assert.That(response.Property, Is.EqualTo("plan"));
Assert.That(response.Operator, Is.EqualTo("Contains"));
Assert.That(response.Value, Is.EqualTo("pro"));
}
}

View File

@@ -0,0 +1,120 @@
using MicCheck.Api.Data;
using MicCheck.Api.Environments;
using MicCheck.Api.Features;
using MicCheck.Api.Organizations;
using MicCheck.Api.Projects;
using MicCheck.Api.Segments;
using MicCheck.Api.Tests.Unit.TestSupport;
using Moq;
using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Tests.Unit.Environments;
[TestFixture]
public class EnvironmentDocumentServiceTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<AppEnvironment> _environments = null!;
private List<Project> _projects = null!;
private List<Feature> _features = null!;
private List<FeatureState> _featureStates = null!;
private EnvironmentDocumentService _service = null!;
private const int ProjectId = 1;
private const int EnvironmentId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_environments = [new AppEnvironment { Id = EnvironmentId, Name = "Production", ApiKey = "env-key", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }];
var environmentsSet = MockDbSetFactory.Create(_environments);
environmentsSet.Setup(m => m.FindAsync(It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((object[] keys, CancellationToken _) => _environments.FirstOrDefault(e => e.Id == (int)keys[0]));
_db.Setup(c => c.Environments).Returns(environmentsSet.Object);
_projects = [new Project { Id = ProjectId, Name = "Test Project", OrganizationId = 1, CreatedAt = DateTimeOffset.UtcNow }];
_db.SetupDbSet(c => c.Projects, _projects);
_db.SetupDbSet(c => c.Organizations, [new Organization { Id = 1, Name = "Org", CreatedAt = DateTimeOffset.UtcNow }]);
_features = [];
_db.SetupDbSet(c => c.Features, _features);
_featureStates = [];
_db.SetupDbSet(c => c.FeatureStates, _featureStates);
_service = new EnvironmentDocumentService(_db.Object);
}
[Test]
public async Task WhenTheEnvironmentDoesNotExist_ThenNullIsReturned()
{
var result = await _service.GetAsync(999);
Assert.That(result, Is.Null);
}
[Test]
public async Task WhenTheEnvironmentsProjectDoesNotExist_ThenNullIsReturned()
{
_projects.Clear();
var result = await _service.GetAsync(EnvironmentId);
Assert.That(result, Is.Null);
}
[Test]
public async Task WhenTheEnvironmentExists_ThenEnvironmentLevelFeatureStatesAreIncluded()
{
var feature = new Feature { Id = 1, Name = "flag_a", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
_features.Add(feature);
_featureStates.Add(new FeatureState
{
FeatureId = feature.Id,
EnvironmentId = EnvironmentId,
Enabled = true,
Value = "on",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
var result = await _service.GetAsync(EnvironmentId);
Assert.That(result, Is.Not.Null);
Assert.That(result!.Id, Is.EqualTo(EnvironmentId));
Assert.That(result.ApiKey, Is.EqualTo("env-key"));
Assert.That(result.FeatureStates, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenTheEnvironmentExists_ThenIdentityAndSegmentScopedFeatureStatesAreExcluded()
{
var feature = new Feature { Id = 1, Name = "flag_a", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
_features.Add(feature);
_featureStates.Add(new FeatureState
{
FeatureId = feature.Id,
EnvironmentId = EnvironmentId,
IdentityId = 5,
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
var result = await _service.GetAsync(EnvironmentId);
Assert.That(result!.FeatureStates, Is.Empty);
}
[Test]
public async Task WhenTheProjectHasSegments_ThenTheyAreIncludedInTheProjectResponse()
{
_projects[0].Segments.Add(new Segment { Id = 1, Name = "Beta Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _service.GetAsync(EnvironmentId);
Assert.That(result!.Project.Segments, Has.Count.EqualTo(1));
Assert.That(result.Project.Segments[0].Name, Is.EqualTo("Beta Users"));
}
}

View File

@@ -0,0 +1,30 @@
using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Tests.Unit.Environments;
[TestFixture]
public class EnvironmentResponseTests
{
[Test]
public void WhenMappingAnEnvironment_ThenAllFieldsAreCopied()
{
var createdAt = DateTimeOffset.UtcNow;
var environment = new AppEnvironment
{
Id = 1,
Name = "Production",
ApiKey = "env-key-abc",
ProjectId = 2,
CreatedAt = createdAt
};
var response = MicCheck.Api.Environments.EnvironmentResponse.From(environment);
Assert.That(response.Id, Is.EqualTo(1));
Assert.That(response.Name, Is.EqualTo("Production"));
Assert.That(response.ApiKey, Is.EqualTo("env-key-abc"));
Assert.That(response.ProjectId, Is.EqualTo(2));
Assert.That(response.CreatedAt, Is.EqualTo(createdAt));
}
}

View File

@@ -153,4 +153,73 @@ public class EnvironmentServiceTests
Assert.That(cloned.ApiKey, Is.Not.EqualTo(source.ApiKey)); Assert.That(cloned.ApiKey, Is.Not.EqualTo(source.ApiKey));
} }
[Test]
public void WhenCloningFromANonExistentApiKey_ThenKeyNotFoundExceptionIsThrown()
{
Assert.That(async () => await _service.CloneAsync("missing-key", "Staging"), Throws.TypeOf<KeyNotFoundException>());
}
[Test]
public async Task WhenListingByProject_ThenOnlyEnvironmentsForThatProjectAreReturned()
{
await _service.CreateAsync(ProjectId, "Env1");
await _service.CreateAsync(999, "OtherProjectEnv");
var result = await _service.ListByProjectAsync(ProjectId);
Assert.That(result, Has.Count.EqualTo(1));
Assert.That(result[0].Name, Is.EqualTo("Env1"));
}
[Test]
public async Task WhenFindingByApiKeyThatDoesNotExist_ThenNullIsReturned()
{
var result = await _service.FindByApiKeyAsync("missing-key");
Assert.That(result, Is.Null);
}
[Test]
public async Task WhenFindingByApiKeyThatExists_ThenTheEnvironmentIsReturned()
{
var created = await _service.CreateAsync(ProjectId, "Production");
var result = await _service.FindByApiKeyAsync(created.ApiKey);
Assert.That(result, Is.Not.Null);
Assert.That(result!.Id, Is.EqualTo(created.Id));
}
[Test]
public async Task WhenUpdatingAnEnvironment_ThenNameIsChanged()
{
var created = await _service.CreateAsync(ProjectId, "Original");
var updated = await _service.UpdateAsync(created.ApiKey, "Renamed");
Assert.That(updated.Name, Is.EqualTo("Renamed"));
}
[Test]
public void WhenUpdatingANonExistentEnvironment_ThenKeyNotFoundExceptionIsThrown()
{
Assert.That(async () => await _service.UpdateAsync("missing-key", "Renamed"), Throws.TypeOf<KeyNotFoundException>());
}
[Test]
public async Task WhenDeletingAnExistingEnvironment_ThenItIsRemoved()
{
var created = await _service.CreateAsync(ProjectId, "ToDelete");
await _service.DeleteAsync(created.ApiKey);
Assert.That(_environments, Is.Empty);
}
[Test]
public void WhenDeletingANonExistentEnvironment_ThenNoExceptionIsThrown()
{
Assert.That(async () => await _service.DeleteAsync("missing-key"), Throws.Nothing);
}
} }

View File

@@ -0,0 +1,905 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Common;
using MicCheck.Api.Data;
using MicCheck.Api.Environments;
using MicCheck.Api.Features;
using MicCheck.Api.Identities;
using MicCheck.Api.Organizations;
using MicCheck.Api.Projects;
using MicCheck.Api.Segments;
using MicCheck.Api.Tests.Unit.TestSupport;
using MicCheck.Api.Webhooks;
using Microsoft.AspNetCore.Mvc;
using Moq;
using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Tests.Unit.Environments;
[TestFixture]
public class EnvironmentsControllerTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<AppEnvironment> _environments = null!;
private List<Project> _projects = null!;
private List<Feature> _features = null!;
private List<FeatureState> _featureStates = null!;
private List<Identity> _identities = null!;
private List<IdentityTrait> _identityTraits = null!;
private List<Segment> _segments = null!;
private List<FeatureSegment> _featureSegments = null!;
private List<Webhook> _webhooks = null!;
private List<WebhookDeliveryLog> _webhookDeliveryLogs = null!;
private List<AuditLog> _auditLogs = null!;
private EnvironmentsController _controller = null!;
private AuditLogQueryService _auditLogQueryService = null!;
private AdminIdentityService _adminIdentityService = null!;
private FeatureStateService _featureStateService = null!;
private FeatureSegmentService _featureSegmentService = null!;
private SegmentService _segmentService = null!;
private SegmentEvaluator _segmentEvaluator = null!;
private const int OrganizationId = 1;
private const int ProjectId = 1;
private const int EnvironmentId = 1;
private AppEnvironment _environment = null!;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_db.SetupDbSet(c => c.Organizations, [new Organization { Id = OrganizationId, Name = "Org", CreatedAt = DateTimeOffset.UtcNow }]);
_projects = [new Project { Id = ProjectId, Name = "Project", OrganizationId = OrganizationId, CreatedAt = DateTimeOffset.UtcNow }];
_db.SetupDbSet(c => c.Projects, _projects);
_environment = new AppEnvironment { Id = EnvironmentId, Name = "Production", ApiKey = "env-key", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
_environments = [_environment];
var environmentsSet = MockDbSetFactory.Create(_environments);
environmentsSet.Setup(m => m.Add(It.IsAny<AppEnvironment>())).Callback<AppEnvironment>(env =>
{
typeof(AppEnvironment).GetProperty(nameof(AppEnvironment.Id))!.SetValue(env, _environments.Count + 1);
_environments.Add(env);
});
environmentsSet.Setup(m => m.FindAsync(It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((object[] keys, CancellationToken _) => _environments.FirstOrDefault(e => e.Id == (int)keys[0]));
_db.Setup(c => c.Environments).Returns(environmentsSet.Object);
_features = [];
_db.SetupDbSetWithGeneratedIds(c => c.Features, _features);
_featureStates = [];
_db.SetupDbSetWithGeneratedIds(c => c.FeatureStates, _featureStates);
_identities = [];
var identitiesSet = MockDbSetFactory.Create(_identities);
identitiesSet.Setup(m => m.Add(It.IsAny<Identity>())).Callback<Identity>(i =>
{
typeof(Identity).GetProperty(nameof(Identity.Id))!.SetValue(i, _identities.Count + 1);
_identities.Add(i);
});
_db.Setup(c => c.Identities).Returns(identitiesSet.Object);
_identityTraits = [];
var traitsSet = MockDbSetFactory.Create(_identityTraits);
traitsSet.Setup(m => m.Add(It.IsAny<IdentityTrait>())).Callback<IdentityTrait>(trait =>
{
typeof(IdentityTrait).GetProperty(nameof(IdentityTrait.Id))!.SetValue(trait, _identityTraits.Count + 1);
_identityTraits.Add(trait);
_identities.FirstOrDefault(i => i.Id == trait.IdentityId)?.Traits.Add(trait);
});
traitsSet.Setup(m => m.Remove(It.IsAny<IdentityTrait>())).Callback<IdentityTrait>(trait =>
{
_identityTraits.Remove(trait);
_identities.FirstOrDefault(i => i.Id == trait.IdentityId)?.Traits.Remove(trait);
});
_db.Setup(c => c.IdentityTraits).Returns(traitsSet.Object);
_segments = [];
_db.SetupDbSetWithGeneratedIds(c => c.Segments, _segments);
_featureSegments = [];
_db.SetupDbSetWithGeneratedIds(c => c.FeatureSegments, _featureSegments);
_webhooks = [];
_db.SetupDbSetWithGeneratedIds(c => c.Webhooks, _webhooks);
_webhookDeliveryLogs = [];
_db.SetupDbSet(c => c.WebhookDeliveryLogs, _webhookDeliveryLogs);
_auditLogs = [];
_db.SetupDbSetWithGeneratedIds(c => c.AuditLogs, _auditLogs);
_db.SetupDbSet(c => c.Users, []);
var webhookQueue = new WebhookQueue();
var auditServiceMock = new Mock<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?>(),
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
auditServiceMock.Setup(a => a.RecordAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
It.IsAny<object?>(), It.IsAny<object?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var auditService = auditServiceMock.Object;
_auditLogQueryService = new AuditLogQueryService(_db.Object);
_adminIdentityService = new AdminIdentityService(_db.Object);
_featureStateService = new FeatureStateService(_db.Object, webhookQueue, auditService);
_featureSegmentService = new FeatureSegmentService(_db.Object);
_segmentService = new SegmentService(_db.Object, auditService);
_segmentEvaluator = new SegmentEvaluator();
_controller = new EnvironmentsController(
new EnvironmentService(_db.Object, auditService),
new WebhookService(_db.Object));
}
private const string MissingApiKey = "missing-key";
// ─── Core CRUD ──────────────────────────────────────────────────────────
[Test]
public async Task WhenListingEnvironments_ThenPageSizeIsClampedAndResultsAreMapped()
{
var result = await _controller.List(ProjectId, page: 1, pageSize: 1000);
var ok = result.Result as OkObjectResult;
var paged = ok!.Value as PaginatedResponse<EnvironmentResponse>;
Assert.That(paged!.Count, Is.EqualTo(1));
Assert.That(paged.Results, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenCreatingAnEnvironment_ThenItIsPersistedAndReturned()
{
var result = await _controller.Create(new CreateEnvironmentRequest("Staging", ProjectId), CancellationToken.None);
var created = result.Result as CreatedAtActionResult;
Assert.That(created, Is.Not.Null);
var body = created!.Value as EnvironmentResponse;
Assert.That(body!.Name, Is.EqualTo("Staging"));
}
[Test]
public async Task WhenGettingByApiKeyThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetByApiKey(MissingApiKey, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingByApiKeyThatExists_ThenTheEnvironmentIsReturned()
{
var result = await _controller.GetByApiKey(_environment.ApiKey, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as EnvironmentResponse;
Assert.That(body!.Id, Is.EqualTo(EnvironmentId));
}
[Test]
public async Task WhenUpdatingAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.Update(MissingApiKey, new UpdateEnvironmentRequest("Renamed"), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAnEnvironmentThatExists_ThenTheNameIsChanged()
{
var result = await _controller.Update(_environment.ApiKey, new UpdateEnvironmentRequest("Renamed"), CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as EnvironmentResponse;
Assert.That(body!.Name, Is.EqualTo("Renamed"));
}
[Test]
public async Task WhenDeletingAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.Delete(MissingApiKey, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAnEnvironmentThatExists_ThenNoContentIsReturned()
{
var result = await _controller.Delete(_environment.ApiKey, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NoContentResult>());
Assert.That(_environments, Is.Empty);
}
[Test]
public async Task WhenCloningAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.Clone(MissingApiKey, new CloneEnvironmentRequest("Clone"), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenCloningAnEnvironmentThatExists_ThenTheCloneIsReturned()
{
var result = await _controller.Clone(_environment.ApiKey, new CloneEnvironmentRequest("Clone"), CancellationToken.None);
var created = result.Result as CreatedAtActionResult;
var body = created!.Value as EnvironmentResponse;
Assert.That(body!.Name, Is.EqualTo("Clone"));
Assert.That(body.ApiKey, Is.Not.EqualTo(_environment.ApiKey));
}
// ─── Webhooks ───────────────────────────────────────────────────────────
[Test]
public async Task WhenListingWebhooksForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.ListWebhooks(MissingApiKey, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingWebhooksForAnEnvironmentThatExists_ThenTheyAreReturned()
{
_webhooks.Add(new Webhook { Id = 1, Url = "https://example.com", Scope = WebhookScope.Environment, EnvironmentId = EnvironmentId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.ListWebhooks(_environment.ApiKey, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as IReadOnlyList<WebhookResponse>;
Assert.That(body, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenCreatingAWebhookForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.CreateWebhook(MissingApiKey, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenCreatingAWebhookForAnEnvironmentThatExists_ThenItIsPersisted()
{
var result = await _controller.CreateWebhook(_environment.ApiKey, new CreateWebhookRequest("https://example.com", "secret", true), CancellationToken.None);
var created = result.Result as CreatedAtActionResult;
var body = created!.Value as WebhookResponse;
Assert.That(body!.Url, Is.EqualTo("https://example.com"));
Assert.That(_webhooks, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenUpdatingAWebhookForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.UpdateWebhook(MissingApiKey, 1, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAWebhookThatBelongsToAnotherEnvironment_ThenNotFoundIsReturned()
{
_webhooks.Add(new Webhook { Id = 1, Url = "https://example.com", Scope = WebhookScope.Environment, EnvironmentId = 999, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.UpdateWebhook(_environment.ApiKey, 1, new CreateWebhookRequest("https://updated.com", null, true), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAWebhookThatBelongsToTheEnvironment_ThenItIsUpdated()
{
_webhooks.Add(new Webhook { Id = 1, Url = "https://example.com", Scope = WebhookScope.Environment, EnvironmentId = EnvironmentId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.UpdateWebhook(_environment.ApiKey, 1, new CreateWebhookRequest("https://updated.com", null, false), CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as WebhookResponse;
Assert.That(body!.Url, Is.EqualTo("https://updated.com"));
}
[Test]
public async Task WhenListingWebhookDeliveriesForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.ListWebhookDeliveries(MissingApiKey, 1, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingWebhookDeliveriesForAWebhookThatBelongsToAnotherEnvironment_ThenNotFoundIsReturned()
{
_webhooks.Add(new Webhook { Id = 1, Url = "https://example.com", Scope = WebhookScope.Environment, EnvironmentId = 999, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.ListWebhookDeliveries(_environment.ApiKey, 1, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingWebhookDeliveriesForAWebhookThatBelongsToTheEnvironment_ThenTheyAreReturned()
{
_webhooks.Add(new Webhook { Id = 1, Url = "https://example.com", Scope = WebhookScope.Environment, EnvironmentId = EnvironmentId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
_webhookDeliveryLogs.Add(new WebhookDeliveryLog
{
Id = 1, WebhookId = 1, EventType = "flag.updated", PayloadJson = "{}", Success = true, AttemptNumber = 1, AttemptedAt = DateTimeOffset.UtcNow
});
var result = await _controller.ListWebhookDeliveries(_environment.ApiKey, 1, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as IReadOnlyList<WebhookDeliveryLogResponse>;
Assert.That(body, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenDeletingAWebhookForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.DeleteWebhook(MissingApiKey, 1, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAWebhookThatBelongsToAnotherEnvironment_ThenNotFoundIsReturned()
{
_webhooks.Add(new Webhook { Id = 1, Url = "https://example.com", Scope = WebhookScope.Environment, EnvironmentId = 999, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.DeleteWebhook(_environment.ApiKey, 1, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAWebhookThatBelongsToTheEnvironment_ThenNoContentIsReturned()
{
_webhooks.Add(new Webhook { Id = 1, Url = "https://example.com", Scope = WebhookScope.Environment, EnvironmentId = EnvironmentId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.DeleteWebhook(_environment.ApiKey, 1, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NoContentResult>());
Assert.That(_webhooks, Is.Empty);
}
// ─── Audit logs ─────────────────────────────────────────────────────────
[Test]
public async Task WhenListingAuditLogsForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.ListAuditLogs(MissingApiKey, _auditLogQueryService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingAuditLogsForAnEnvironmentThatExists_ThenTheyAreReturned()
{
_auditLogs.Add(new AuditLog { Id = 1, ResourceType = "Environment", ResourceId = "1", Action = "updated", OrganizationId = OrganizationId, EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.ListAuditLogs(_environment.ApiKey, _auditLogQueryService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as List<AuditLogResponse>;
Assert.That(body, Has.Count.EqualTo(1));
}
// ─── Identities ─────────────────────────────────────────────────────────
[Test]
public async Task WhenListingIdentitiesForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.ListIdentities(MissingApiKey, _adminIdentityService, page: 1, pageSize: 1000, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingIdentitiesForAnEnvironmentThatExists_ThenPageSizeIsClampedAndResultsMapped()
{
_identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.ListIdentities(_environment.ApiKey, _adminIdentityService, page: 1, pageSize: 1000, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as PaginatedResponse<AdminIdentityResponse>;
Assert.That(body!.Count, Is.EqualTo(1));
}
[Test]
public async Task WhenCreatingAnIdentityForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.CreateIdentity(MissingApiKey, new CreateIdentityRequest("user-1"), _adminIdentityService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenCreatingAnIdentityThatAlreadyExists_ThenConflictIsReturned()
{
_identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.CreateIdentity(_environment.ApiKey, new CreateIdentityRequest("user-1"), _adminIdentityService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<ConflictObjectResult>());
}
[Test]
public async Task WhenCreatingANewIdentity_ThenItIsPersisted()
{
var result = await _controller.CreateIdentity(_environment.ApiKey, new CreateIdentityRequest("user-1"), _adminIdentityService, CancellationToken.None);
var created = result.Result as CreatedAtActionResult;
var body = created!.Value as AdminIdentityResponse;
Assert.That(body!.Identifier, Is.EqualTo("user-1"));
}
[Test]
public async Task WhenGettingAnIdentityForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetIdentity(MissingApiKey, 1, _adminIdentityService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingAnIdentityThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetIdentity(_environment.ApiKey, 999, _adminIdentityService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingAnIdentityThatExists_ThenItIsReturned()
{
_identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.GetIdentity(_environment.ApiKey, 1, _adminIdentityService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as AdminIdentityResponse;
Assert.That(body!.Identifier, Is.EqualTo("user-1"));
}
[Test]
public async Task WhenDeletingAnIdentityForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.DeleteIdentity(MissingApiKey, 1, _adminIdentityService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAnIdentityThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.DeleteIdentity(_environment.ApiKey, 999, _adminIdentityService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAnIdentityThatExists_ThenNoContentIsReturned()
{
_identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.DeleteIdentity(_environment.ApiKey, 1, _adminIdentityService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NoContentResult>());
Assert.That(_identities, Is.Empty);
}
[Test]
public async Task WhenUpsertingATraitForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.UpsertIdentityTrait(MissingApiKey, 1, "plan", new UpsertTraitRequest("pro"), _adminIdentityService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpsertingATraitForAnIdentityThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.UpsertIdentityTrait(_environment.ApiKey, 999, "plan", new UpsertTraitRequest("pro"), _adminIdentityService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpsertingATraitForAnIdentityThatExists_ThenTheTraitIsReturned()
{
_identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.UpsertIdentityTrait(_environment.ApiKey, 1, "plan", new UpsertTraitRequest("pro"), _adminIdentityService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as TraitResponse;
Assert.That(body!.Value, Is.EqualTo("pro"));
}
[Test]
public async Task WhenDeletingATraitForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.DeleteIdentityTrait(MissingApiKey, 1, "plan", _adminIdentityService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingATraitThatDoesNotExist_ThenNotFoundIsReturned()
{
_identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.DeleteIdentityTrait(_environment.ApiKey, 1, "plan", _adminIdentityService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingATraitThatExists_ThenNoContentIsReturned()
{
var identity = new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow };
_identities.Add(identity);
await _adminIdentityService.UpsertTraitAsync(1, EnvironmentId, "plan", "pro");
var result = await _controller.DeleteIdentityTrait(_environment.ApiKey, 1, "plan", _adminIdentityService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NoContentResult>());
}
[Test]
public async Task WhenGettingFeatureStatesForAnIdentityInAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetIdentityFeatureStates(MissingApiKey, 1, _adminIdentityService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingFeatureStatesForAnIdentityThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetIdentityFeatureStates(_environment.ApiKey, 999, _adminIdentityService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingFeatureStatesForAnIdentityThatExists_ThenTheyAreReturned()
{
_identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
_featureStates.Add(new FeatureState { Id = 1, FeatureId = 1, EnvironmentId = EnvironmentId, IdentityId = 1, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow });
var result = await _controller.GetIdentityFeatureStates(_environment.ApiKey, 1, _adminIdentityService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as IReadOnlyList<FeatureStateResponse>;
Assert.That(body, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenSettingAFeatureStateForAnIdentityInAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.SetIdentityFeatureState(MissingApiKey, 1, 1, new UpdateFeatureStateRequest(true, "on"), _adminIdentityService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenSettingAFeatureStateForAnIdentityThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.SetIdentityFeatureState(_environment.ApiKey, 999, 1, new UpdateFeatureStateRequest(true, "on"), _adminIdentityService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenSettingAFeatureStateForAnIdentityThatExists_ThenTheStateIsPersisted()
{
_identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.SetIdentityFeatureState(_environment.ApiKey, 1, 1, new UpdateFeatureStateRequest(true, "on"), _adminIdentityService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as FeatureStateResponse;
Assert.That(body!.Enabled, Is.True);
Assert.That(body.Value, Is.EqualTo("on"));
}
[Test]
public async Task WhenDeletingAFeatureStateForAnIdentityInAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.DeleteIdentityFeatureState(MissingApiKey, 1, 1, _adminIdentityService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAFeatureStateForAnIdentityThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.DeleteIdentityFeatureState(_environment.ApiKey, 999, 1, _adminIdentityService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAFeatureStateForAnIdentityThatExists_ThenNoContentIsReturned()
{
_identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
_featureStates.Add(new FeatureState { Id = 1, FeatureId = 1, EnvironmentId = EnvironmentId, IdentityId = 1, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow });
var result = await _controller.DeleteIdentityFeatureState(_environment.ApiKey, 1, 1, _adminIdentityService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NoContentResult>());
Assert.That(_featureStates, Is.Empty);
}
// ─── Environment-level feature states ──────────────────────────────────
[Test]
public async Task WhenListingFeatureStatesForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.ListFeatureStates(MissingApiKey, _featureStateService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingFeatureStatesForAnEnvironmentThatExists_ThenTheyAreReturned()
{
_featureStates.Add(new FeatureState { Id = 1, FeatureId = 1, EnvironmentId = EnvironmentId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow });
var result = await _controller.ListFeatureStates(_environment.ApiKey, _featureStateService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as IReadOnlyList<FeatureStateResponse>;
Assert.That(body, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenGettingAFeatureStateForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetFeatureState(MissingApiKey, 1, _featureStateService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingAFeatureStateThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetFeatureState(_environment.ApiKey, 999, _featureStateService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingAFeatureStateThatExists_ThenItIsReturned()
{
_featureStates.Add(new FeatureState { Id = 1, FeatureId = 1, EnvironmentId = EnvironmentId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow });
var result = await _controller.GetFeatureState(_environment.ApiKey, 1, _featureStateService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as FeatureStateResponse;
Assert.That(body!.Id, Is.EqualTo(1));
}
[Test]
public async Task WhenUpdatingAFeatureStateForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.UpdateFeatureState(MissingApiKey, 1, new UpdateFeatureStateRequest(true, "on"), _featureStateService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAFeatureStateThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.UpdateFeatureState(_environment.ApiKey, 999, new UpdateFeatureStateRequest(true, "on"), _featureStateService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAFeatureStateThatExists_ThenItIsUpdated()
{
_features.Add(new Feature { Id = 1, Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow });
_featureStates.Add(new FeatureState { Id = 1, FeatureId = 1, EnvironmentId = EnvironmentId, Enabled = false, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow });
var result = await _controller.UpdateFeatureState(_environment.ApiKey, 1, new UpdateFeatureStateRequest(true, "on"), _featureStateService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as FeatureStateResponse;
Assert.That(body!.Enabled, Is.True);
}
[Test]
public async Task WhenPatchingAFeatureStateForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.PatchFeatureState(MissingApiKey, 1, new PatchFeatureStateRequest(true, null), _featureStateService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenPatchingAFeatureStateThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.PatchFeatureState(_environment.ApiKey, 999, new PatchFeatureStateRequest(true, null), _featureStateService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenPatchingAFeatureStateThatExists_ThenOnlyProvidedFieldsAreChanged()
{
_features.Add(new Feature { Id = 1, Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow });
_featureStates.Add(new FeatureState { Id = 1, FeatureId = 1, EnvironmentId = EnvironmentId, Enabled = false, Value = "original", CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow });
var result = await _controller.PatchFeatureState(_environment.ApiKey, 1, new PatchFeatureStateRequest(true, null), _featureStateService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as FeatureStateResponse;
Assert.That(body!.Enabled, Is.True);
Assert.That(body.Value, Is.EqualTo("original"));
}
// ─── Feature segments ───────────────────────────────────────────────────
[Test]
public async Task WhenListingFeatureSegmentsForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.ListFeatureSegments(MissingApiKey, 1, _featureSegmentService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingFeatureSegmentsForAnEnvironmentThatExists_ThenTheyAreReturned()
{
_segments.Add(new Segment { Id = 1, Name = "Beta", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow });
_featureSegments.Add(new FeatureSegment { Id = 1, FeatureId = 1, SegmentId = 1, EnvironmentId = EnvironmentId, Priority = 1 });
var result = await _controller.ListFeatureSegments(_environment.ApiKey, 1, _featureSegmentService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as IReadOnlyList<FeatureSegmentResponse>;
Assert.That(body, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenCreatingAFeatureSegmentForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.CreateFeatureSegment(MissingApiKey, 1, new CreateFeatureSegmentRequest(1, 1, true, null), _featureSegmentService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenCreatingAFeatureSegmentForASegmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.CreateFeatureSegment(_environment.ApiKey, 1, new CreateFeatureSegmentRequest(999, 1, true, null), _featureSegmentService, CancellationToken.None);
var notFound = result.Result as NotFoundObjectResult;
Assert.That(notFound, Is.Not.Null);
}
[Test]
public async Task WhenCreatingAFeatureSegmentForASegmentThatExists_ThenItIsPersisted()
{
_segments.Add(new Segment { Id = 1, Name = "Beta", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.CreateFeatureSegment(_environment.ApiKey, 1, new CreateFeatureSegmentRequest(1, 1, true, "on"), _featureSegmentService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as FeatureSegmentResponse;
Assert.That(body!.SegmentName, Is.EqualTo("Beta"));
}
[Test]
public async Task WhenUpdatingAFeatureSegmentForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.UpdateFeatureSegment(MissingApiKey, 1, 1, new UpdateFeatureSegmentRequest(1, true, null), _featureSegmentService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAFeatureSegmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.UpdateFeatureSegment(_environment.ApiKey, 1, 999, new UpdateFeatureSegmentRequest(1, true, null), _featureSegmentService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAFeatureSegmentThatExists_ThenItIsUpdated()
{
_segments.Add(new Segment { Id = 1, Name = "Beta", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow });
_featureSegments.Add(new FeatureSegment { Id = 1, FeatureId = 1, SegmentId = 1, EnvironmentId = EnvironmentId, Priority = 1 });
var result = await _controller.UpdateFeatureSegment(_environment.ApiKey, 1, 1, new UpdateFeatureSegmentRequest(5, true, "on"), _featureSegmentService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as FeatureSegmentResponse;
Assert.That(body!.Priority, Is.EqualTo(5));
}
[Test]
public async Task WhenDeletingAFeatureSegmentForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.DeleteFeatureSegment(MissingApiKey, 1, 1, _featureSegmentService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAFeatureSegmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.DeleteFeatureSegment(_environment.ApiKey, 1, 999, _featureSegmentService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAFeatureSegmentThatExists_ThenNoContentIsReturned()
{
_featureSegments.Add(new FeatureSegment { Id = 1, FeatureId = 1, SegmentId = 1, EnvironmentId = EnvironmentId, Priority = 1 });
var result = await _controller.DeleteFeatureSegment(_environment.ApiKey, 1, 1, _featureSegmentService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NoContentResult>());
Assert.That(_featureSegments, Is.Empty);
}
// ─── Identity segments ──────────────────────────────────────────────────
[Test]
public async Task WhenGettingIdentitySegmentsForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetIdentitySegments(MissingApiKey, 1, _adminIdentityService, _segmentService, _segmentEvaluator, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingIdentitySegmentsForAnIdentityThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetIdentitySegments(_environment.ApiKey, 999, _adminIdentityService, _segmentService, _segmentEvaluator, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingIdentitySegments_ThenOnlyMatchingSegmentsAreReturned()
{
var identity = new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow };
_identities.Add(identity);
await _adminIdentityService.UpsertTraitAsync(1, EnvironmentId, "plan", "pro");
var matchingSegment = new Segment { Id = 1, Name = "Pro Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
var matchingRule = new SegmentRule { Id = 1, SegmentId = 1, Type = SegmentRuleType.All };
matchingRule.Conditions.Add(new SegmentCondition { Id = 1, RuleId = 1, Property = "plan", Operator = SegmentConditionOperator.Equal, Value = "pro" });
matchingSegment.Rules.Add(matchingRule);
_segments.Add(matchingSegment);
var nonMatchingSegment = new Segment { Id = 2, Name = "Free Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
var nonMatchingRule = new SegmentRule { Id = 2, SegmentId = 2, Type = SegmentRuleType.All };
nonMatchingRule.Conditions.Add(new SegmentCondition { Id = 2, RuleId = 2, Property = "plan", Operator = SegmentConditionOperator.Equal, Value = "free" });
nonMatchingSegment.Rules.Add(nonMatchingRule);
_segments.Add(nonMatchingSegment);
var result = await _controller.GetIdentitySegments(_environment.ApiKey, 1, _adminIdentityService, _segmentService, _segmentEvaluator, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as IReadOnlyList<SegmentSummaryResponse>;
Assert.That(body, Has.Count.EqualTo(1));
Assert.That(body![0].Name, Is.EqualTo("Pro Users"));
}
}

View File

@@ -0,0 +1,61 @@
using MicCheck.Api.Features;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture]
public class CreateFeatureRequestValidatorTests
{
private CreateFeatureRequestValidator _validator = null!;
[SetUp]
public void SetUp() => _validator = new CreateFeatureRequestValidator();
[Test]
public void WhenTheRequestIsValid_ThenValidationSucceeds()
{
var result = _validator.Validate(new CreateFeatureRequest("dark_mode", FeatureType.Standard, null, null));
Assert.That(result.IsValid, Is.True);
}
[Test]
public void WhenNameIsEmpty_ThenValidationFails()
{
var result = _validator.Validate(new CreateFeatureRequest("", FeatureType.Standard, null, null));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenNameExceedsMaximumLength_ThenValidationFails()
{
var result = _validator.Validate(new CreateFeatureRequest(new string('a', 151), FeatureType.Standard, null, null));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenNameContainsInvalidCharacters_ThenValidationFails()
{
var result = _validator.Validate(new CreateFeatureRequest("dark mode!", FeatureType.Standard, null, null));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenInitialValueExceedsMaximumLength_ThenValidationFails()
{
var result = _validator.Validate(new CreateFeatureRequest("flag", FeatureType.Standard, new string('a', 20_001), null));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenInitialValueIsNull_ThenTheLengthRuleIsSkipped()
{
var result = _validator.Validate(new CreateFeatureRequest("flag", FeatureType.Standard, null, null));
Assert.That(result.IsValid, Is.True);
}
}

View File

@@ -0,0 +1,61 @@
using MicCheck.Api.Features;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture]
public class CreateTagRequestValidatorTests
{
private CreateTagRequestValidator _validator = null!;
[SetUp]
public void SetUp() => _validator = new CreateTagRequestValidator();
[Test]
public void WhenTheRequestIsValid_ThenValidationSucceeds()
{
var result = _validator.Validate(new CreateTagRequest("Beta", "#FF0000"));
Assert.That(result.IsValid, Is.True);
}
[Test]
public void WhenLabelIsEmpty_ThenValidationFails()
{
var result = _validator.Validate(new CreateTagRequest("", "#FF0000"));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenLabelExceedsMaximumLength_ThenValidationFails()
{
var result = _validator.Validate(new CreateTagRequest(new string('a', 101), "#FF0000"));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenColorIsEmpty_ThenValidationFails()
{
var result = _validator.Validate(new CreateTagRequest("Beta", ""));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenColorIsNotAValidHexCode_ThenValidationFails()
{
var result = _validator.Validate(new CreateTagRequest("Beta", "red"));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenColorIsAShortHandHexCode_ThenValidationSucceeds()
{
var result = _validator.Validate(new CreateTagRequest("Beta", "#F00"));
Assert.That(result.IsValid, Is.True);
}
}

View File

@@ -0,0 +1,88 @@
using MicCheck.Api.Features;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture]
public class FeatureResponseTests
{
[Test]
public void WhenMappingAFeatureWithNoTags_ThenTheResponseHasAnEmptyTagsList()
{
var feature = new Feature { Id = 1, Name = "dark_mode", ProjectId = 5, Type = FeatureType.Standard, CreatedAt = DateTimeOffset.UtcNow };
var response = FeatureResponse.From(feature);
Assert.That(response.Id, Is.EqualTo(1));
Assert.That(response.Name, Is.EqualTo("dark_mode"));
Assert.That(response.ProjectId, Is.EqualTo(5));
Assert.That(response.Tags, Is.Empty);
}
[Test]
public void WhenMappingAFeature_ThenTypeIsUpperInvariant()
{
var feature = new Feature { Id = 1, Name = "flag", ProjectId = 1, Type = FeatureType.MultiVariate, CreatedAt = DateTimeOffset.UtcNow };
var response = FeatureResponse.From(feature);
Assert.That(response.Type, Is.EqualTo("MULTIVARIATE"));
}
[Test]
public void WhenMappingAFeatureWithTags_ThenEachTagIsMapped()
{
var feature = new Feature { Id = 1, Name = "flag", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow };
feature.Tags.Add(new Tag { Id = 1, Label = "beta", Color = "#FF0000", ProjectId = 1 });
var response = FeatureResponse.From(feature);
Assert.That(response.Tags, Has.Count.EqualTo(1));
Assert.That(response.Tags[0].Label, Is.EqualTo("beta"));
}
}
[TestFixture]
public class TagResponseTests
{
[Test]
public void WhenMappingATag_ThenAllFieldsAreCopied()
{
var tag = new Tag { Id = 1, Label = "beta", Color = "#FF0000", ProjectId = 5 };
var response = TagResponse.From(tag);
Assert.That(response.Id, Is.EqualTo(1));
Assert.That(response.Label, Is.EqualTo("beta"));
Assert.That(response.Color, Is.EqualTo("#FF0000"));
Assert.That(response.ProjectId, Is.EqualTo(5));
}
}
[TestFixture]
public class FeatureStateResponseTests
{
[Test]
public void WhenMappingAFeatureState_ThenAllFieldsAreCopied()
{
var now = DateTimeOffset.UtcNow;
var state = new FeatureState
{
Id = 1,
FeatureId = 2,
EnvironmentId = 3,
Enabled = true,
Value = "on",
CreatedAt = now,
UpdatedAt = now
};
var response = FeatureStateResponse.From(state);
Assert.That(response.Id, Is.EqualTo(1));
Assert.That(response.FeatureId, Is.EqualTo(2));
Assert.That(response.EnvironmentId, Is.EqualTo(3));
Assert.That(response.Enabled, Is.True);
Assert.That(response.Value, Is.EqualTo("on"));
}
}

View File

@@ -0,0 +1,131 @@
using MicCheck.Api.Data;
using MicCheck.Api.Features;
using MicCheck.Api.Segments;
using MicCheck.Api.Tests.Unit.TestSupport;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture]
public class FeatureSegmentServiceTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<Segment> _segments = null!;
private List<FeatureSegment> _featureSegments = null!;
private List<FeatureState> _featureStates = null!;
private FeatureSegmentService _service = null!;
private const int FeatureId = 1;
private const int EnvironmentId = 1;
private const int SegmentId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_segments = [new Segment { Id = SegmentId, Name = "Premium Users", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow }];
_db.SetupDbSetWithGeneratedIds(c => c.Segments, _segments);
_featureSegments = [];
_db.SetupDbSetWithGeneratedIds(c => c.FeatureSegments, _featureSegments);
_featureStates = [];
_db.SetupDbSetWithGeneratedIds(c => c.FeatureStates, _featureStates);
_service = new FeatureSegmentService(_db.Object);
}
[Test]
public async Task WhenCreatingAFeatureSegment_ThenItIsPersistedWithAMatchingFeatureState()
{
var response = await _service.CreateAsync(FeatureId, EnvironmentId, SegmentId, priority: 1, enabled: true, value: "on");
Assert.That(response.SegmentName, Is.EqualTo("Premium Users"));
Assert.That(response.Enabled, Is.True);
Assert.That(_featureSegments, Has.Count.EqualTo(1));
Assert.That(_featureStates.Single().FeatureSegmentId, Is.EqualTo(response.Id));
}
[Test]
public void WhenCreatingAFeatureSegmentForASegmentThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown()
{
Assert.ThrowsAsync<KeyNotFoundException>(() =>
_service.CreateAsync(FeatureId, EnvironmentId, 999, priority: 1, enabled: true, value: null));
}
[Test]
public async Task WhenListingFeatureSegments_ThenTheyAreOrderedByPriorityAndIncludeTheSegmentNameAndState()
{
await _service.CreateAsync(FeatureId, EnvironmentId, SegmentId, priority: 2, enabled: true, value: "second");
_segments.Add(new Segment { Id = 2, Name = "Beta Users", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow });
await _service.CreateAsync(FeatureId, EnvironmentId, 2, priority: 1, enabled: false, value: "first");
var results = await _service.ListByFeatureAsync(FeatureId, EnvironmentId);
Assert.That(results, Has.Count.EqualTo(2));
Assert.That(results[0].Priority, Is.EqualTo(1));
Assert.That(results[0].SegmentName, Is.EqualTo("Beta Users"));
Assert.That(results[1].Priority, Is.EqualTo(2));
}
[Test]
public async Task WhenListingFeatureSegmentsForADifferentEnvironment_ThenNoneAreReturned()
{
await _service.CreateAsync(FeatureId, EnvironmentId, SegmentId, priority: 1, enabled: true, value: null);
var results = await _service.ListByFeatureAsync(FeatureId, 999);
Assert.That(results, Is.Empty);
}
[Test]
public async Task WhenUpdatingAFeatureSegmentThatDoesNotExist_ThenNullIsReturned()
{
var result = await _service.UpdateAsync(999, priority: 1, enabled: true, value: null);
Assert.That(result, Is.Null);
}
[Test]
public async Task WhenUpdatingAFeatureSegment_ThenTheExistingStateIsUpdated()
{
var created = await _service.CreateAsync(FeatureId, EnvironmentId, SegmentId, priority: 1, enabled: false, value: "off");
var updated = await _service.UpdateAsync(created.Id, priority: 5, enabled: true, value: "on");
Assert.That(updated!.Priority, Is.EqualTo(5));
Assert.That(updated.Enabled, Is.True);
Assert.That(updated.Value, Is.EqualTo("on"));
Assert.That(_featureStates, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenUpdatingAFeatureSegmentThatHasNoExistingState_ThenANewStateIsCreated()
{
var featureSegment = new FeatureSegment { Id = 1, FeatureId = FeatureId, SegmentId = SegmentId, EnvironmentId = EnvironmentId, Priority = 1 };
_featureSegments.Add(featureSegment);
var updated = await _service.UpdateAsync(featureSegment.Id, priority: 3, enabled: true, value: "on");
Assert.That(updated!.Enabled, Is.True);
Assert.That(_featureStates, Has.Count.EqualTo(1));
Assert.That(_featureStates[0].FeatureSegmentId, Is.EqualTo(featureSegment.Id));
}
[Test]
public async Task WhenDeletingAFeatureSegmentThatDoesNotExist_ThenFalseIsReturned()
{
var result = await _service.DeleteAsync(999);
Assert.That(result, Is.False);
}
[Test]
public async Task WhenDeletingAFeatureSegment_ThenTrueIsReturnedAndItIsRemoved()
{
var created = await _service.CreateAsync(FeatureId, EnvironmentId, SegmentId, priority: 1, enabled: true, value: null);
var result = await _service.DeleteAsync(created.Id);
Assert.That(result, Is.True);
Assert.That(_featureSegments.Any(fsg => fsg.Id == created.Id), Is.False);
}
}

View File

@@ -179,4 +179,68 @@ public class FeatureServiceTests
Assert.That(updated.Tags.Select(t => t.Id), Does.Not.Contain(tag.Id)); Assert.That(updated.Tags.Select(t => t.Id), Does.Not.Contain(tag.Id));
} }
[Test]
public async Task WhenFindingAFeatureByIdThatExists_ThenTheFeatureIsReturned()
{
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
var found = await _service.FindByIdAsync(feature.Id);
Assert.That(found, Is.Not.Null);
Assert.That(found!.Id, Is.EqualTo(feature.Id));
}
[Test]
public async Task WhenFindingAFeatureByIdThatDoesNotExist_ThenNullIsReturned()
{
var found = await _service.FindByIdAsync(999);
Assert.That(found, Is.Null);
}
[Test]
public void WhenUpdatingAFeatureThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown()
{
Assert.ThrowsAsync<KeyNotFoundException>(() => _service.UpdateAsync(999, "renamed", null));
}
[Test]
public void WhenAssigningATagToAFeatureThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown()
{
var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId };
_db.Object.Tags.Add(tag);
Assert.ThrowsAsync<KeyNotFoundException>(() => _service.AssignTagAsync(999, tag.Id));
}
[Test]
public async Task WhenAssigningATagThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown()
{
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
Assert.ThrowsAsync<KeyNotFoundException>(() => _service.AssignTagAsync(feature.Id, 999));
}
[Test]
public void WhenRemovingATagFromAFeatureThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown()
{
Assert.ThrowsAsync<KeyNotFoundException>(() => _service.RemoveTagAsync(999, 1));
}
[Test]
public async Task WhenRemovingATagThatIsNotAssigned_ThenTheFeatureIsUnchanged()
{
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
var updated = await _service.RemoveTagAsync(feature.Id, 999);
Assert.That(updated.Tags, Is.Empty);
}
[Test]
public void WhenDeletingAFeatureThatDoesNotExist_ThenNoExceptionIsThrown()
{
Assert.DoesNotThrowAsync(() => _service.DeleteAsync(999));
}
} }

View File

@@ -0,0 +1,174 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Data;
using MicCheck.Api.Features;
using MicCheck.Api.Organizations;
using MicCheck.Api.Projects;
using MicCheck.Api.Tests.Unit.TestSupport;
using MicCheck.Api.Webhooks;
using Moq;
using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture]
public class FeatureStateServiceTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<FeatureState> _featureStates = null!;
private List<Feature> _features = null!;
private List<AppEnvironment> _environments = null!;
private FeatureStateService _service = null!;
private const int OrganizationId = 1;
private const int ProjectId = 1;
private const int EnvironmentId = 1;
private const int FeatureId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_db.SetupDbSet(c => c.Organizations, [
new Organization { Id = OrganizationId, Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow }
]);
_db.SetupDbSet(c => c.Projects, [
new Project { Id = ProjectId, Name = "Test Project", OrganizationId = OrganizationId, CreatedAt = DateTimeOffset.UtcNow }
]);
_environments = [new AppEnvironment { Id = EnvironmentId, Name = "Production", ApiKey = "test-key", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }];
_db.SetupDbSet(c => c.Environments, _environments);
_features = [new Feature { Id = FeatureId, Name = "dark_mode", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }];
_db.SetupDbSet(c => c.Features, _features);
_featureStates = [];
_db.SetupDbSetWithGeneratedIds(c => c.FeatureStates, _featureStates);
var webhookQueue = new WebhookQueue();
var auditService = new Mock<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?>(),
It.IsAny<object?>(), It.IsAny<object?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
_service = new FeatureStateService(_db.Object, webhookQueue, auditService.Object);
}
private FeatureState AddState(bool enabled = false, string? value = null)
{
var state = new FeatureState
{
Id = 1,
FeatureId = FeatureId,
EnvironmentId = EnvironmentId,
Enabled = enabled,
Value = value,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
_featureStates.Add(state);
return state;
}
[Test]
public async Task WhenListingEnvironmentLevelStates_ThenIdentityAndSegmentOverridesAreExcluded()
{
AddState();
_featureStates.Add(new FeatureState { Id = 2, FeatureId = FeatureId, EnvironmentId = EnvironmentId, IdentityId = 5, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow });
_featureStates.Add(new FeatureState { Id = 3, FeatureId = FeatureId, EnvironmentId = EnvironmentId, FeatureSegmentId = 7, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow });
var results = await _service.ListByEnvironmentAsync(EnvironmentId);
Assert.That(results, Has.Count.EqualTo(1));
Assert.That(results[0].Id, Is.EqualTo(1));
}
[Test]
public async Task WhenFindingAStateByIdInTheCorrectEnvironment_ThenItIsReturned()
{
var state = AddState();
var found = await _service.FindByIdAsync(state.Id, EnvironmentId);
Assert.That(found, Is.Not.Null);
}
[Test]
public async Task WhenFindingAStateByIdInADifferentEnvironment_ThenNullIsReturned()
{
var state = AddState();
var found = await _service.FindByIdAsync(state.Id, 999);
Assert.That(found, Is.Null);
}
[Test]
public void WhenUpdatingAStateThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown()
{
Assert.ThrowsAsync<KeyNotFoundException>(() => _service.UpdateAsync(999, true, "on"));
}
[Test]
public async Task WhenUpdatingAState_ThenEnabledAndValueAreChangedAndVersionIsIncremented()
{
var state = AddState(enabled: false, value: "old");
var updated = await _service.UpdateAsync(state.Id, true, "new");
Assert.That(updated.Enabled, Is.True);
Assert.That(updated.Value, Is.EqualTo("new"));
Assert.That(updated.Version, Is.EqualTo(1));
}
[Test]
public void WhenPatchingAStateThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown()
{
Assert.ThrowsAsync<KeyNotFoundException>(() => _service.PatchAsync(999, true, null));
}
[Test]
public async Task WhenPatchingOnlyTheEnabledField_ThenValueIsUnchanged()
{
var state = AddState(enabled: false, value: "original");
var patched = await _service.PatchAsync(state.Id, true, null);
Assert.That(patched.Enabled, Is.True);
Assert.That(patched.Value, Is.EqualTo("original"));
}
[Test]
public async Task WhenPatchingOnlyTheValueField_ThenEnabledIsUnchanged()
{
var state = AddState(enabled: true, value: "original");
var patched = await _service.PatchAsync(state.Id, null, "updated");
Assert.That(patched.Enabled, Is.True);
Assert.That(patched.Value, Is.EqualTo("updated"));
}
[Test]
public async Task WhenUpdatingAState_ThenAWebhookEventIsEnqueued()
{
var state = AddState();
var queue = new WebhookQueue();
var auditService = new Mock<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?>(),
It.IsAny<object?>(), It.IsAny<object?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var service = new FeatureStateService(_db.Object, queue, auditService.Object);
await service.UpdateAsync(state.Id, true, "on");
using var cts = new CancellationTokenSource();
await using var enumerator = queue.ReadAllAsync(cts.Token).GetAsyncEnumerator(cts.Token);
Assert.That(await enumerator.MoveNextAsync(), Is.True);
Assert.That(enumerator.Current.EventType, Is.EqualTo(WebhookEventTypes.FlagUpdated));
Assert.That(enumerator.Current.EnvironmentId, Is.EqualTo(EnvironmentId));
Assert.That(enumerator.Current.OrganizationId, Is.EqualTo(OrganizationId));
}
}

View File

@@ -0,0 +1,93 @@
using MicCheck.Api.Data;
using MicCheck.Api.Features;
using MicCheck.Api.Tests.Unit.TestSupport;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture]
public class FeatureUsageControllerTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<FeatureUsageDaily> _usage = null!;
private IMemoryCache _cache = null!;
private FeatureUsageController _controller = null!;
private const int EnvironmentId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_usage = [];
_db.SetupDbSet(c => c.FeatureUsageDaily, _usage);
_cache = new MemoryCache(new MemoryCacheOptions());
_controller = new FeatureUsageController(new FeatureUsageQueryService(_db.Object), _cache);
}
[Test]
public async Task WhenGettingDashboardUsageWithNoData_ThenEmptyResultsAreReturned()
{
var result = await _controller.GetDashboardUsage(EnvironmentId);
var ok = result.Result as OkObjectResult;
var response = (DashboardUsageResponse)ok!.Value!;
Assert.That(response.TopFeaturesLastDay, Is.Empty);
}
[Test]
public async Task WhenGettingDashboardUsageASecondTime_ThenTheCachedResultIsReturnedWithoutQueryingAgain()
{
var first = await _controller.GetDashboardUsage(EnvironmentId);
_usage.Add(new FeatureUsageDaily
{
EnvironmentId = EnvironmentId,
FeatureId = 1,
FeatureName = "flag",
UsageDate = DateOnly.FromDateTime(DateTime.UtcNow),
Count = 100,
UpdatedAt = DateTimeOffset.UtcNow
});
var second = await _controller.GetDashboardUsage(EnvironmentId);
var firstOk = (DashboardUsageResponse)((OkObjectResult)first.Result!).Value!;
var secondOk = (DashboardUsageResponse)((OkObjectResult)second.Result!).Value!;
Assert.That(secondOk, Is.SameAs(firstOk));
}
[Test]
public async Task WhenGettingDashboardUsageForDifferentDayRanges_ThenEachRangeIsCachedSeparately()
{
var sevenDays = await _controller.GetDashboardUsage(EnvironmentId, days: 7);
var fourteenDays = await _controller.GetDashboardUsage(EnvironmentId, days: 14);
var sevenOk = (DashboardUsageResponse)((OkObjectResult)sevenDays.Result!).Value!;
var fourteenOk = (DashboardUsageResponse)((OkObjectResult)fourteenDays.Result!).Value!;
Assert.That(sevenOk, Is.Not.SameAs(fourteenOk));
}
[Test]
public async Task WhenDaysExceedsTheMaximum_ThenItIsClampedTo90()
{
_usage.Add(new FeatureUsageDaily
{
EnvironmentId = EnvironmentId,
FeatureId = 1,
FeatureName = "flag",
UsageDate = DateOnly.FromDateTime(DateTime.UtcNow),
Count = 5,
UpdatedAt = DateTimeOffset.UtcNow
});
var clamped = await _controller.GetDashboardUsage(EnvironmentId, days: 1000);
var explicitNinety = await _controller.GetDashboardUsage(EnvironmentId, days: 90);
var clampedOk = (DashboardUsageResponse)((OkObjectResult)clamped.Result!).Value!;
var explicitOk = (DashboardUsageResponse)((OkObjectResult)explicitNinety.Result!).Value!;
Assert.That(clampedOk.DailyUsage.Count, Is.EqualTo(explicitOk.DailyUsage.Count));
}
}

View File

@@ -0,0 +1,282 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Common;
using MicCheck.Api.Data;
using MicCheck.Api.Features;
using MicCheck.Api.Organizations;
using MicCheck.Api.Projects;
using MicCheck.Api.Tests.Unit.TestSupport;
using Microsoft.AspNetCore.Mvc;
using Moq;
using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture]
public class FeaturesControllerTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<Feature> _features = null!;
private List<Tag> _tags = null!;
private FeaturesController _controller = null!;
private const int OrganizationId = 1;
private const int ProjectId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_db.SetupDbSet(c => c.Organizations, [
new Organization { Id = OrganizationId, Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow }
]);
_db.SetupDbSet(c => c.Projects, [
new Project { Id = ProjectId, Name = "Test Project", OrganizationId = OrganizationId, CreatedAt = DateTimeOffset.UtcNow }
]);
_db.SetupDbSet(c => c.Environments, new List<AppEnvironment>());
_features = [];
_db.SetupDbSetWithGeneratedIds(c => c.Features, _features);
_db.SetupDbSet(c => c.FeatureStates, new List<FeatureState>());
_tags = [];
_db.SetupDbSetWithGeneratedIds(c => c.Tags, _tags);
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
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?>(),
It.IsAny<object?>(), It.IsAny<object?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var service = new FeatureService(_db.Object, auditService.Object, webhookQueue);
_controller = new FeaturesController(service);
}
private static CreateFeatureRequest ValidRequest(string name = "dark_mode") =>
new(name, FeatureType.Standard, null, null);
[Test]
public async Task WhenListingFeaturesForAProject_ThenAllFeaturesAreReturnedInAPage()
{
await _controller.Create(ProjectId, ValidRequest("feature_a"), CancellationToken.None);
await _controller.Create(ProjectId, ValidRequest("feature_b"), CancellationToken.None);
var result = await _controller.List(ProjectId);
var ok = result.Result as OkObjectResult;
var page = (PaginatedResponse<FeatureResponse>)ok!.Value!;
Assert.That(page.Count, Is.EqualTo(2));
Assert.That(page.Results, Has.Count.EqualTo(2));
}
[Test]
public async Task WhenListingFeaturesWithAPageSizeAboveTheMaximum_ThenThePageSizeIsClampedTo100()
{
for (var i = 0; i < 3; i++)
await _controller.Create(ProjectId, ValidRequest($"feature_{i}"), CancellationToken.None);
var result = await _controller.List(ProjectId, page: 1, pageSize: 1000);
var ok = result.Result as OkObjectResult;
var page = (PaginatedResponse<FeatureResponse>)ok!.Value!;
Assert.That(page.Results, Has.Count.EqualTo(3));
}
[Test]
public async Task WhenCreatingAFeatureWithValidData_ThenACreatedResultWithTheFeatureIsReturned()
{
var result = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None);
var created = result.Result as CreatedAtActionResult;
Assert.That(created, Is.Not.Null);
Assert.That(((FeatureResponse)created!.Value!).Name, Is.EqualTo("dark_mode"));
}
[Test]
public async Task WhenCreatingAFeatureThatExceedsTheProjectLimit_ThenBadRequestIsReturned()
{
for (var i = 0; i < 400; i++)
_features.Add(new Feature { Id = i + 1, Name = $"feature_{i}", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.Create(ProjectId, ValidRequest("overflow"), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<BadRequestObjectResult>());
}
[Test]
public async Task WhenGettingAFeatureByIdInTheCorrectProject_ThenTheFeatureIsReturned()
{
var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None);
var featureId = ((FeatureResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.GetById(ProjectId, featureId, CancellationToken.None);
var ok = result.Result as OkObjectResult;
Assert.That(ok, Is.Not.Null);
Assert.That(((FeatureResponse)ok!.Value!).Id, Is.EqualTo(featureId));
}
[Test]
public async Task WhenGettingAFeatureThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetById(ProjectId, 999, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingAFeatureThatBelongsToADifferentProject_ThenNotFoundIsReturned()
{
var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None);
var featureId = ((FeatureResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.GetById(999, featureId, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAFeatureThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.Update(ProjectId, 999, new UpdateFeatureRequest("renamed", null), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAFeatureInTheCorrectProject_ThenTheUpdatedFeatureIsReturned()
{
var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None);
var featureId = ((FeatureResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.Update(ProjectId, featureId, new UpdateFeatureRequest("renamed", "new desc"), CancellationToken.None);
var ok = result.Result as OkObjectResult;
Assert.That(ok, Is.Not.Null);
Assert.That(((FeatureResponse)ok!.Value!).Name, Is.EqualTo("renamed"));
}
[Test]
public async Task WhenPatchingAFeatureThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.Patch(ProjectId, 999, new PatchFeatureRequest("renamed", null, null), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenPatchingOnlyTheNameField_ThenDescriptionAndDefaultEnabledAreUnchanged()
{
var created = await _controller.Create(ProjectId, new CreateFeatureRequest("original", FeatureType.Standard, null, "original desc"), CancellationToken.None);
var featureId = ((FeatureResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.Patch(ProjectId, featureId, new PatchFeatureRequest("renamed", null, null), CancellationToken.None);
var ok = result.Result as OkObjectResult;
var response = (FeatureResponse)ok!.Value!;
Assert.That(response.Name, Is.EqualTo("renamed"));
Assert.That(response.Description, Is.EqualTo("original desc"));
Assert.That(response.DefaultEnabled, Is.False);
}
[Test]
public async Task WhenPatchingTheDefaultEnabledField_ThenItIsUpdated()
{
var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None);
var featureId = ((FeatureResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.Patch(ProjectId, featureId, new PatchFeatureRequest(null, null, true), CancellationToken.None);
var ok = result.Result as OkObjectResult;
Assert.That(((FeatureResponse)ok!.Value!).DefaultEnabled, Is.True);
}
[Test]
public async Task WhenDeletingAFeatureThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.Delete(ProjectId, 999, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAFeatureInTheCorrectProject_ThenNoContentIsReturnedAndTheFeatureIsRemoved()
{
var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None);
var featureId = ((FeatureResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.Delete(ProjectId, featureId, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NoContentResult>());
Assert.That(_features.Any(f => f.Id == featureId), Is.False);
}
[Test]
public async Task WhenAssigningATagThatDoesNotExist_ThenNotFoundIsReturned()
{
var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None);
var featureId = ((FeatureResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.AssignTag(ProjectId, featureId, 999, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenAssigningATagFromAnotherProject_ThenBadRequestIsReturned()
{
var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None);
var featureId = ((FeatureResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var foreignTag = new Tag { Id = 1, Label = "beta", Color = "#FF0000", ProjectId = 999 };
_tags.Add(foreignTag);
var result = await _controller.AssignTag(ProjectId, featureId, foreignTag.Id, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<BadRequestObjectResult>());
}
[Test]
public async Task WhenAssigningAValidTag_ThenTheFeatureResponseIncludesTheTag()
{
var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None);
var featureId = ((FeatureResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var tag = new Tag { Id = 1, Label = "beta", Color = "#FF0000", ProjectId = ProjectId };
_tags.Add(tag);
var result = await _controller.AssignTag(ProjectId, featureId, tag.Id, CancellationToken.None);
var ok = result.Result as OkObjectResult;
Assert.That(((FeatureResponse)ok!.Value!).Tags.Select(t => t.Id), Does.Contain(tag.Id));
}
[Test]
public async Task WhenFeatureForAssignTagDoesNotExistInTheProject_ThenNotFoundIsReturned()
{
var result = await _controller.AssignTag(ProjectId, 999, 1, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenRemovingTagFromFeatureThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.RemoveTag(ProjectId, 999, 1, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenRemovingAnAssignedTag_ThenTheFeatureResponseNoLongerIncludesIt()
{
var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None);
var featureId = ((FeatureResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var tag = new Tag { Id = 1, Label = "beta", Color = "#FF0000", ProjectId = ProjectId };
_tags.Add(tag);
await _controller.AssignTag(ProjectId, featureId, tag.Id, CancellationToken.None);
var result = await _controller.RemoveTag(ProjectId, featureId, tag.Id, CancellationToken.None);
var ok = result.Result as OkObjectResult;
Assert.That(((FeatureResponse)ok!.Value!).Tags.Select(t => t.Id), Does.Not.Contain(tag.Id));
}
}

View File

@@ -0,0 +1,84 @@
using MicCheck.Api.Data;
using MicCheck.Api.Features;
using MicCheck.Api.Tests.Unit.TestSupport;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture]
public class TagServiceTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<Tag> _tags = null!;
private TagService _service = null!;
private const int ProjectId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_tags = [];
_db.SetupDbSetWithGeneratedIds(c => c.Tags, _tags);
_service = new TagService(_db.Object);
}
[Test]
public async Task WhenCreatingATag_ThenItIsPersistedWithTheGivenProject()
{
var tag = await _service.CreateAsync(ProjectId, "Beta", "#FF0000");
Assert.That(tag.Label, Is.EqualTo("Beta"));
Assert.That(tag.Color, Is.EqualTo("#FF0000"));
Assert.That(tag.ProjectId, Is.EqualTo(ProjectId));
Assert.That(_tags, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenListingTagsForAProject_ThenOnlyThatProjectsTagsAreReturned()
{
await _service.CreateAsync(ProjectId, "Beta", "#FF0000");
await _service.CreateAsync(999, "Other", "#00FF00");
var tags = await _service.ListByProjectAsync(ProjectId);
Assert.That(tags, Has.Count.EqualTo(1));
Assert.That(tags[0].Label, Is.EqualTo("Beta"));
}
[Test]
public async Task WhenFindingATagByIdThatExists_ThenTheTagIsReturned()
{
var tag = await _service.CreateAsync(ProjectId, "Beta", "#FF0000");
var found = await _service.FindByIdAsync(tag.Id);
Assert.That(found, Is.Not.Null);
Assert.That(found!.Id, Is.EqualTo(tag.Id));
}
[Test]
public async Task WhenFindingATagByIdThatDoesNotExist_ThenNullIsReturned()
{
var found = await _service.FindByIdAsync(999);
Assert.That(found, Is.Null);
}
[Test]
public async Task WhenDeletingATag_ThenItIsRemovedFromTheDatabase()
{
var tag = await _service.CreateAsync(ProjectId, "Beta", "#FF0000");
await _service.DeleteAsync(tag.Id);
Assert.That(_tags.Any(t => t.Id == tag.Id), Is.False);
}
[Test]
public void WhenDeletingATagThatDoesNotExist_ThenNoExceptionIsThrown()
{
Assert.DoesNotThrowAsync(() => _service.DeleteAsync(999));
}
}

View File

@@ -0,0 +1,82 @@
using MicCheck.Api.Data;
using MicCheck.Api.Features;
using MicCheck.Api.Tests.Unit.TestSupport;
using Microsoft.AspNetCore.Mvc;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture]
public class TagsControllerTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<Tag> _tags = null!;
private TagsController _controller = null!;
private const int ProjectId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_tags = [];
_db.SetupDbSetWithGeneratedIds(c => c.Tags, _tags);
_controller = new TagsController(new TagService(_db.Object));
}
[Test]
public async Task WhenListingTagsForAProject_ThenAllOfThatProjectsTagsAreReturned()
{
await _controller.Create(ProjectId, new CreateTagRequest("Beta", "#FF0000"), CancellationToken.None);
await _controller.Create(999, new CreateTagRequest("Other", "#00FF00"), CancellationToken.None);
var result = await _controller.List(ProjectId, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var tags = (IReadOnlyList<TagResponse>)ok!.Value!;
Assert.That(tags, Has.Count.EqualTo(1));
Assert.That(tags[0].Label, Is.EqualTo("Beta"));
}
[Test]
public async Task WhenCreatingATag_ThenACreatedResultWithTheTagIsReturned()
{
var result = await _controller.Create(ProjectId, new CreateTagRequest("Beta", "#FF0000"), CancellationToken.None);
var created = result.Result as CreatedAtActionResult;
Assert.That(created, Is.Not.Null);
Assert.That(((TagResponse)created!.Value!).Label, Is.EqualTo("Beta"));
}
[Test]
public async Task WhenDeletingATagThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.Delete(ProjectId, 999, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingATagFromADifferentProject_ThenNotFoundIsReturned()
{
var created = await _controller.Create(ProjectId, new CreateTagRequest("Beta", "#FF0000"), CancellationToken.None);
var tagId = ((TagResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.Delete(999, tagId, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingATagInTheCorrectProject_ThenNoContentIsReturnedAndTheTagIsRemoved()
{
var created = await _controller.Create(ProjectId, new CreateTagRequest("Beta", "#FF0000"), CancellationToken.None);
var tagId = ((TagResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.Delete(ProjectId, tagId, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NoContentResult>());
Assert.That(_tags.Any(t => t.Id == tagId), Is.False);
}
}

View File

@@ -0,0 +1,45 @@
using MicCheck.Api.Features;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture]
public class UpdateFeatureRequestValidatorTests
{
private UpdateFeatureRequestValidator _validator = null!;
[SetUp]
public void SetUp() => _validator = new UpdateFeatureRequestValidator();
[Test]
public void WhenTheRequestIsValid_ThenValidationSucceeds()
{
var result = _validator.Validate(new UpdateFeatureRequest("dark_mode", "A description"));
Assert.That(result.IsValid, Is.True);
}
[Test]
public void WhenNameIsEmpty_ThenValidationFails()
{
var result = _validator.Validate(new UpdateFeatureRequest("", null));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenNameExceedsMaximumLength_ThenValidationFails()
{
var result = _validator.Validate(new UpdateFeatureRequest(new string('a', 151), null));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenNameContainsInvalidCharacters_ThenValidationFails()
{
var result = _validator.Validate(new UpdateFeatureRequest("dark mode!", null));
Assert.That(result.IsValid, Is.False);
}
}

View File

@@ -0,0 +1,34 @@
using MicCheck.Api.Identities;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Identities;
[TestFixture]
public class AdminIdentityResponseTests
{
[Test]
public void WhenMappingAnIdentityWithNoTraits_ThenTheResponseHasAnEmptyTraitsList()
{
var identity = new Identity { Id = 1, Identifier = "user-1", EnvironmentId = 5, CreatedAt = DateTimeOffset.UtcNow };
var response = AdminIdentityResponse.From(identity);
Assert.That(response.Id, Is.EqualTo(1));
Assert.That(response.Identifier, Is.EqualTo("user-1"));
Assert.That(response.EnvironmentId, Is.EqualTo(5));
Assert.That(response.Traits, Is.Empty);
}
[Test]
public void WhenMappingAnIdentityWithTraits_ThenEachTraitIsMapped()
{
var identity = new Identity { Id = 1, Identifier = "user-1", EnvironmentId = 1, CreatedAt = DateTimeOffset.UtcNow };
identity.Traits.Add(new IdentityTrait { Key = "plan", Value = "premium" });
var response = AdminIdentityResponse.From(identity);
Assert.That(response.Traits, Has.Count.EqualTo(1));
Assert.That(response.Traits[0].Key, Is.EqualTo("plan"));
Assert.That(response.Traits[0].Value, Is.EqualTo("premium"));
}
}

View File

@@ -0,0 +1,251 @@
using MicCheck.Api.Data;
using MicCheck.Api.Features;
using MicCheck.Api.Identities;
using MicCheck.Api.Tests.Unit.TestSupport;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Identities;
[TestFixture]
public class AdminIdentityServiceTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<Identity> _identities = null!;
private List<IdentityTrait> _traits = null!;
private List<FeatureState> _featureStates = null!;
private AdminIdentityService _service = null!;
private const int EnvironmentId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_identities = [];
_db.SetupDbSetWithGeneratedIds(c => c.Identities, _identities);
_traits = [];
var traitsSet = MockDbSetFactory.Create(_traits);
traitsSet.Setup(m => m.Add(It.IsAny<IdentityTrait>())).Callback<IdentityTrait>(trait =>
{
_traits.Add(trait);
_identities.FirstOrDefault(i => i.Id == trait.IdentityId)?.Traits.Add(trait);
});
traitsSet.Setup(m => m.Remove(It.IsAny<IdentityTrait>())).Callback<IdentityTrait>(trait =>
{
_traits.Remove(trait);
_identities.FirstOrDefault(i => i.Id == trait.IdentityId)?.Traits.Remove(trait);
});
_db.Setup(c => c.IdentityTraits).Returns(traitsSet.Object);
_featureStates = [];
_db.SetupDbSetWithGeneratedIds(c => c.FeatureStates, _featureStates);
_service = new AdminIdentityService(_db.Object);
}
[Test]
public async Task WhenCreatingAnIdentityWithANewIdentifier_ThenItIsPersisted()
{
var identity = await _service.CreateAsync(EnvironmentId, "user-1");
Assert.That(identity, Is.Not.Null);
Assert.That(identity!.Identifier, Is.EqualTo("user-1"));
Assert.That(_identities, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenCreatingAnIdentityWithAnIdentifierThatAlreadyExistsInTheEnvironment_ThenNullIsReturned()
{
await _service.CreateAsync(EnvironmentId, "user-1");
var result = await _service.CreateAsync(EnvironmentId, "user-1");
Assert.That(result, Is.Null);
Assert.That(_identities, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenCreatingAnIdentityWithTheSameIdentifierInADifferentEnvironment_ThenItIsPersisted()
{
await _service.CreateAsync(EnvironmentId, "user-1");
var result = await _service.CreateAsync(999, "user-1");
Assert.That(result, Is.Not.Null);
Assert.That(_identities, Has.Count.EqualTo(2));
}
[Test]
public async Task WhenListingIdentitiesForAnEnvironment_ThenOnlyThatEnvironmentsIdentitiesAreReturnedWithTheTotalCount()
{
await _service.CreateAsync(EnvironmentId, "user-1");
await _service.CreateAsync(EnvironmentId, "user-2");
await _service.CreateAsync(999, "other-env-user");
var result = await _service.ListAsync(EnvironmentId, page: 1, pageSize: 20);
Assert.That(result.Total, Is.EqualTo(2));
Assert.That(result.Items, Has.Count.EqualTo(2));
}
[Test]
public async Task WhenListingIdentitiesWithPagination_ThenTheSecondPageSkipsTheFirstPageSize()
{
for (var i = 0; i < 5; i++)
await _service.CreateAsync(EnvironmentId, $"user-{i}");
var result = await _service.ListAsync(EnvironmentId, page: 2, pageSize: 2);
Assert.That(result.Total, Is.EqualTo(5));
Assert.That(result.Items, Has.Count.EqualTo(2));
Assert.That(result.Items[0].Identifier, Is.EqualTo("user-2"));
}
[Test]
public async Task WhenFindingAnIdentityByIdInTheCorrectEnvironment_ThenItIsReturned()
{
var identity = await _service.CreateAsync(EnvironmentId, "user-1");
var found = await _service.FindByIdAsync(identity!.Id, EnvironmentId);
Assert.That(found, Is.Not.Null);
}
[Test]
public async Task WhenFindingAnIdentityByIdInADifferentEnvironment_ThenNullIsReturned()
{
var identity = await _service.CreateAsync(EnvironmentId, "user-1");
var found = await _service.FindByIdAsync(identity!.Id, 999);
Assert.That(found, Is.Null);
}
[Test]
public async Task WhenUpsertingATraitForAnIdentityThatDoesNotExist_ThenNullIsReturned()
{
var result = await _service.UpsertTraitAsync(999, EnvironmentId, "plan", "premium");
Assert.That(result, Is.Null);
}
[Test]
public async Task WhenUpsertingANewTrait_ThenItIsAddedToTheIdentity()
{
var identity = await _service.CreateAsync(EnvironmentId, "user-1");
var response = await _service.UpsertTraitAsync(identity!.Id, EnvironmentId, "plan", "premium");
Assert.That(response!.Key, Is.EqualTo("plan"));
Assert.That(response.Value, Is.EqualTo("premium"));
Assert.That(_traits, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenUpsertingATraitThatAlreadyExists_ThenItsValueIsUpdatedWithoutDuplication()
{
var identity = await _service.CreateAsync(EnvironmentId, "user-1");
await _service.UpsertTraitAsync(identity!.Id, EnvironmentId, "plan", "free");
await _service.UpsertTraitAsync(identity.Id, EnvironmentId, "plan", "premium");
Assert.That(_traits, Has.Count.EqualTo(1));
Assert.That(_traits[0].Value, Is.EqualTo("premium"));
}
[Test]
public async Task WhenDeletingATraitFromAnIdentityThatDoesNotExist_ThenFalseIsReturned()
{
var result = await _service.DeleteTraitAsync(999, EnvironmentId, "plan");
Assert.That(result, Is.False);
}
[Test]
public async Task WhenDeletingATraitThatDoesNotExistOnTheIdentity_ThenFalseIsReturned()
{
var identity = await _service.CreateAsync(EnvironmentId, "user-1");
var result = await _service.DeleteTraitAsync(identity!.Id, EnvironmentId, "unknown");
Assert.That(result, Is.False);
}
[Test]
public async Task WhenDeletingATraitThatExists_ThenItIsRemovedAndTrueIsReturned()
{
var identity = await _service.CreateAsync(EnvironmentId, "user-1");
await _service.UpsertTraitAsync(identity!.Id, EnvironmentId, "plan", "premium");
var result = await _service.DeleteTraitAsync(identity.Id, EnvironmentId, "plan");
Assert.That(result, Is.True);
Assert.That(_traits, Is.Empty);
}
[Test]
public void WhenDeletingAnIdentityThatDoesNotExist_ThenNoExceptionIsThrown()
{
Assert.DoesNotThrowAsync(() => _service.DeleteAsync(999));
}
[Test]
public async Task WhenDeletingAnIdentity_ThenItIsRemovedFromTheDatabase()
{
var identity = await _service.CreateAsync(EnvironmentId, "user-1");
await _service.DeleteAsync(identity!.Id);
Assert.That(_identities.Any(i => i.Id == identity.Id), Is.False);
}
[Test]
public async Task WhenGettingFeatureStatesForAnIdentity_ThenOnlyThatIdentitysStatesInTheEnvironmentAreReturned()
{
_featureStates.Add(new FeatureState { Id = 1, FeatureId = 1, EnvironmentId = EnvironmentId, IdentityId = 1, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow });
_featureStates.Add(new FeatureState { Id = 2, FeatureId = 2, EnvironmentId = EnvironmentId, IdentityId = 2, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow });
var states = await _service.GetFeatureStatesAsync(1, EnvironmentId);
Assert.That(states, Has.Count.EqualTo(1));
Assert.That(states[0].FeatureId, Is.EqualTo(1));
}
[Test]
public async Task WhenSettingAFeatureStateThatDoesNotExist_ThenANewOverrideIsCreated()
{
var state = await _service.SetFeatureStateAsync(1, EnvironmentId, featureId: 1, enabled: true, value: "on");
Assert.That(state.IdentityId, Is.EqualTo(1));
Assert.That(state.Enabled, Is.True);
Assert.That(_featureStates, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenSettingAFeatureStateThatAlreadyExists_ThenItIsUpdatedAndVersionIncremented()
{
var created = await _service.SetFeatureStateAsync(1, EnvironmentId, featureId: 1, enabled: false, value: "off");
var versionBeforeUpdate = created.Version;
var updated = await _service.SetFeatureStateAsync(1, EnvironmentId, featureId: 1, enabled: true, value: "on");
Assert.That(_featureStates, Has.Count.EqualTo(1));
Assert.That(updated.Enabled, Is.True);
Assert.That(updated.Version, Is.EqualTo(versionBeforeUpdate + 1));
}
[Test]
public void WhenDeletingAFeatureStateThatDoesNotExist_ThenNoExceptionIsThrown()
{
Assert.DoesNotThrowAsync(() => _service.DeleteFeatureStateAsync(1, EnvironmentId, featureId: 1));
}
[Test]
public async Task WhenDeletingAFeatureStateThatExists_ThenItIsRemoved()
{
await _service.SetFeatureStateAsync(1, EnvironmentId, featureId: 1, enabled: true, value: "on");
await _service.DeleteFeatureStateAsync(1, EnvironmentId, featureId: 1);
Assert.That(_featureStates, Is.Empty);
}
}

View File

@@ -0,0 +1,92 @@
using System.Security.Claims;
using MicCheck.Api.Data;
using MicCheck.Api.Environments;
using MicCheck.Api.Features;
using MicCheck.Api.Identities;
using MicCheck.Api.Projects;
using MicCheck.Api.Segments;
using MicCheck.Api.Tests.Unit.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Moq;
using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Tests.Unit.Identities;
[TestFixture]
public class IdentitiesControllerTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<Identity> _identities = null!;
private IdentitiesController _controller = null!;
private const int EnvironmentId = 1;
private const int ProjectId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_db.SetupDbSet(c => c.Projects, [
new Project { Id = ProjectId, Name = "Test Project", OrganizationId = 1, CreatedAt = DateTimeOffset.UtcNow }
]);
var environments = new List<AppEnvironment> {
new() { Id = EnvironmentId, Name = "Test Env", ApiKey = "test-env-key", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }
};
var environmentsSet = MockDbSetFactory.Create(environments);
environmentsSet.Setup(m => m.FindAsync(It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((object[] keys, CancellationToken _) => environments.FirstOrDefault(e => e.Id == (int)keys[0]));
_db.Setup(c => c.Environments).Returns(environmentsSet.Object);
_db.SetupDbSet(c => c.Features, [
new Feature { Id = 1, Name = "dark_mode", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }
]);
_db.SetupDbSet(c => c.FeatureStates, [
new FeatureState { FeatureId = 1, EnvironmentId = EnvironmentId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }
]);
_identities = [];
_db.SetupDbSetWithGeneratedIds(c => c.Identities, _identities);
_db.SetupDbSet(c => c.IdentityTraits, new List<IdentityTrait>());
_db.SetupDbSetWithGeneratedIds(c => c.Segments, new List<Segment>());
_db.SetupDbSet(c => c.SegmentRules, new List<SegmentRule>());
_db.SetupDbSet(c => c.SegmentConditions, new List<SegmentCondition>());
_db.SetupDbSetWithGeneratedIds(c => c.FeatureSegments, new List<FeatureSegment>());
var meterFactory = new Mock<System.Diagnostics.Metrics.IMeterFactory>();
meterFactory.Setup(f => f.Create(It.IsAny<System.Diagnostics.Metrics.MeterOptions>()))
.Returns(new System.Diagnostics.Metrics.Meter("test"));
var usageMetrics = new FeatureUsageMetrics(meterFactory.Object);
var evaluationService = new FeatureEvaluationService(_db.Object, new SegmentEvaluator(), new FlagCache(new MemoryCache(new MemoryCacheOptions())), usageMetrics);
var resolutionService = new IdentityResolutionService(_db.Object);
_controller = new IdentitiesController(evaluationService, resolutionService);
var identity = new ClaimsIdentity([new Claim("EnvironmentId", EnvironmentId.ToString())], "EnvironmentKey");
_controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }
};
}
[Test]
public async Task WhenIdentifierIsMissing_ThenBadRequestIsReturned()
{
var result = await _controller.GetByIdentifier("", CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<BadRequestObjectResult>());
}
[Test]
public async Task WhenGettingByIdentifier_ThenFlagsAndTraitsAreReturnedForTheResolvedIdentity()
{
var result = await _controller.GetByIdentifier("user-1", CancellationToken.None);
var ok = result.Result as OkObjectResult;
Assert.That(ok, Is.Not.Null);
var response = (IdentityResponse)ok!.Value!;
Assert.That(response.Flags, Has.Count.EqualTo(1));
Assert.That(response.Flags[0].Feature.Name, Is.EqualTo("dark_mode"));
Assert.That(_identities.Any(i => i.Identifier == "user-1"), Is.True);
}
}

View File

@@ -0,0 +1,125 @@
using System.Text.Json;
using MicCheck.Api.Identities;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Identities;
[TestFixture]
public class TraitInputTests
{
private static JsonElement ParseElement(string json) => JsonDocument.Parse(json).RootElement;
[Test]
public void WhenTraitValueIsAJsonString_ThenGetStringValueReturnsTheString()
{
var trait = new TraitInput("plan", ParseElement("\"premium\""));
Assert.That(trait.GetStringValue(), Is.EqualTo("premium"));
Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.String));
}
[Test]
public void WhenTraitValueIsJsonTrue_ThenGetStringValueReturnsTrueAndTypeIsBoolean()
{
var trait = new TraitInput("beta", ParseElement("true"));
Assert.That(trait.GetStringValue(), Is.EqualTo("true"));
Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Boolean));
}
[Test]
public void WhenTraitValueIsJsonFalse_ThenGetStringValueReturnsFalseAndTypeIsBoolean()
{
var trait = new TraitInput("beta", ParseElement("false"));
Assert.That(trait.GetStringValue(), Is.EqualTo("false"));
Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Boolean));
}
[Test]
public void WhenTraitValueIsJsonNull_ThenGetStringValueReturnsEmptyString()
{
var trait = new TraitInput("plan", ParseElement("null"));
Assert.That(trait.GetStringValue(), Is.EqualTo(string.Empty));
}
[Test]
public void WhenTraitValueIsAJsonInteger_ThenTypeIsInteger()
{
var trait = new TraitInput("age", ParseElement("42"));
Assert.That(trait.GetStringValue(), Is.EqualTo("42"));
Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Integer));
}
[Test]
public void WhenTraitValueIsAJsonFloat_ThenTypeIsFloat()
{
var trait = new TraitInput("score", ParseElement("4.5"));
Assert.That(trait.GetStringValue(), Is.EqualTo("4.5"));
Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Float));
}
[Test]
public void WhenTraitValueIsAJsonArray_ThenGetStringValueFallsBackToToString()
{
var element = ParseElement("[1,2,3]");
var trait = new TraitInput("list", element);
Assert.That(trait.GetStringValue(), Is.EqualTo(element.ToString()));
Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.String));
}
[Test]
public void WhenTraitValueIsNull_ThenGetStringValueReturnsEmptyStringAndTypeIsString()
{
var trait = new TraitInput("plan", null);
Assert.That(trait.GetStringValue(), Is.EqualTo(string.Empty));
Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.String));
}
[Test]
public void WhenTraitValueIsAPlainBool_ThenTypeIsBoolean()
{
var trait = new TraitInput("beta", true);
Assert.That(trait.GetStringValue(), Is.EqualTo("True"));
Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Boolean));
}
[Test]
public void WhenTraitValueIsAPlainInt_ThenTypeIsInteger()
{
var trait = new TraitInput("age", 42);
Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Integer));
}
[Test]
public void WhenTraitValueIsAPlainLong_ThenTypeIsInteger()
{
var trait = new TraitInput("age", 42L);
Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Integer));
}
[Test]
public void WhenTraitValueIsAPlainDouble_ThenTypeIsFloat()
{
var trait = new TraitInput("score", 4.5d);
Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Float));
}
[Test]
public void WhenTraitValueIsAPlainString_ThenTypeIsString()
{
var trait = new TraitInput("plan", "premium");
Assert.That(trait.GetStringValue(), Is.EqualTo("premium"));
Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.String));
}
}

View File

@@ -0,0 +1,103 @@
using MicCheck.Api.Organizations;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Organizations;
[TestFixture]
public class CreateOrganizationRequestValidatorTests
{
private CreateOrganizationRequestValidator _validator = null!;
[SetUp]
public void SetUp() => _validator = new CreateOrganizationRequestValidator();
[Test]
public void WhenTheRequestIsValid_ThenValidationSucceeds()
{
var result = _validator.Validate(new CreateOrganizationRequest("My Org"));
Assert.That(result.IsValid, Is.True);
}
[Test]
public void WhenNameIsEmpty_ThenValidationFails()
{
var result = _validator.Validate(new CreateOrganizationRequest(""));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenNameExceedsMaximumLength_ThenValidationFails()
{
var result = _validator.Validate(new CreateOrganizationRequest(new string('a', 201)));
Assert.That(result.IsValid, Is.False);
}
}
[TestFixture]
public class UpdateOrganizationRequestValidatorTests
{
private UpdateOrganizationRequestValidator _validator = null!;
[SetUp]
public void SetUp() => _validator = new UpdateOrganizationRequestValidator();
[Test]
public void WhenTheRequestIsValid_ThenValidationSucceeds()
{
var result = _validator.Validate(new UpdateOrganizationRequest("Renamed"));
Assert.That(result.IsValid, Is.True);
}
[Test]
public void WhenNameIsEmpty_ThenValidationFails()
{
var result = _validator.Validate(new UpdateOrganizationRequest(""));
Assert.That(result.IsValid, Is.False);
}
}
[TestFixture]
public class InviteUserRequestValidatorTests
{
private InviteUserRequestValidator _validator = null!;
[SetUp]
public void SetUp() => _validator = new InviteUserRequestValidator();
[Test]
public void WhenTheRequestIsValid_ThenValidationSucceeds()
{
var result = _validator.Validate(new InviteUserRequest(1, "Admin"));
Assert.That(result.IsValid, Is.True);
}
[Test]
public void WhenUserIdIsZeroOrLess_ThenValidationFails()
{
var result = _validator.Validate(new InviteUserRequest(0, "User"));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenRoleIsEmpty_ThenValidationFails()
{
var result = _validator.Validate(new InviteUserRequest(1, ""));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenRoleIsNotAValidOrganizationRole_ThenValidationFails()
{
var result = _validator.Validate(new InviteUserRequest(1, "SuperAdmin"));
Assert.That(result.IsValid, Is.False);
}
}

View File

@@ -0,0 +1,58 @@
using MicCheck.Api.Organizations;
using MicCheck.Api.Users;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Organizations;
[TestFixture]
public class OrganizationResponseTests
{
[Test]
public void WhenMappingAnOrganizationWithoutSpecifyingPrimary_ThenIsPrimaryDefaultsToFalse()
{
var org = new Organization { Id = 1, Name = "My Org", CreatedAt = DateTimeOffset.UtcNow };
var response = OrganizationResponse.From(org);
Assert.That(response.Id, Is.EqualTo(1));
Assert.That(response.Name, Is.EqualTo("My Org"));
Assert.That(response.IsPrimary, Is.False);
}
[Test]
public void WhenMappingAnOrganizationAsPrimary_ThenIsPrimaryIsTrue()
{
var org = new Organization { Id = 1, Name = "My Org", CreatedAt = DateTimeOffset.UtcNow };
var response = OrganizationResponse.From(org, isPrimary: true);
Assert.That(response.IsPrimary, Is.True);
}
}
[TestFixture]
public class OrganizationMemberResponseTests
{
[Test]
public void WhenMappingAMember_ThenFieldsAreSourcedFromTheAssociatedUser()
{
var user = new User
{
Id = 1,
Email = "alice@example.com",
PasswordHash = "hashed",
FirstName = "Alice",
LastName = "Smith",
CreatedAt = DateTimeOffset.UtcNow
};
var member = new OrganizationUser { OrganizationId = 1, UserId = 1, Role = OrganizationRole.Admin, User = user };
var response = OrganizationMemberResponse.From(member);
Assert.That(response.UserId, Is.EqualTo(1));
Assert.That(response.FirstName, Is.EqualTo("Alice"));
Assert.That(response.LastName, Is.EqualTo("Smith"));
Assert.That(response.Email, Is.EqualTo("alice@example.com"));
Assert.That(response.Role, Is.EqualTo("Admin"));
}
}

View File

@@ -0,0 +1,339 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Data;
using MicCheck.Api.Organizations;
using MicCheck.Api.Tests.Unit.TestSupport;
using MicCheck.Api.Users;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Organizations;
[TestFixture]
public class OrganizationServiceTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<Organization> _organizations = null!;
private List<OrganizationUser> _members = null!;
private List<User> _users = null!;
private OrganizationService _service = null!;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_organizations = [];
_members = [];
var organizationsSet = MockDbSetFactory.Create(_organizations);
organizationsSet.Setup(m => m.Add(It.IsAny<Organization>())).Callback<Organization>(org =>
{
typeof(Organization).GetProperty(nameof(Organization.Id))!.SetValue(org, _organizations.Count + 1);
_organizations.Add(org);
// Mimics EF Core's cascade-insert and FK fixup of a loaded, mapped collection navigation on SaveChanges.
foreach (var member in org.Members)
{
typeof(OrganizationUser).GetProperty(nameof(OrganizationUser.OrganizationId))!.SetValue(member, org.Id);
_members.Add(member);
}
});
_db.Setup(c => c.Organizations).Returns(organizationsSet.Object);
_db.SetupDbSet(c => c.OrganizationUsers, _members);
_users = [];
_db.SetupDbSet(c => c.Users, _users);
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
var auditService = new Mock<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?>(),
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
_service = new OrganizationService(_db.Object, auditService.Object);
}
[Test]
public async Task WhenCreatingAnOrganization_ThenItIsPersistedWithTheCreatorAsAdmin()
{
var org = await _service.CreateAsync("My Org", creatorUserId: 1);
Assert.That(org.Name, Is.EqualTo("My Org"));
Assert.That(_members.Single().UserId, Is.EqualTo(1));
Assert.That(_members.Single().Role, Is.EqualTo(OrganizationRole.Admin));
}
[Test]
public async Task WhenCreatingAUsersFirstOrganization_ThenItIsMarkedAsPrimary()
{
var org = await _service.CreateAsync("First Org", creatorUserId: 1);
Assert.That(_members.Single(m => m.OrganizationId == org.Id).IsPrimary, Is.True);
}
[Test]
public async Task WhenCreatingASecondOrganizationForAUser_ThenItIsNotMarkedAsPrimary()
{
await _service.CreateAsync("First Org", creatorUserId: 1);
var second = await _service.CreateAsync("Second Org", creatorUserId: 1);
Assert.That(_members.Single(m => m.OrganizationId == second.Id).IsPrimary, Is.False);
}
[Test]
public async Task WhenListingOrganizationsForAUser_ThenOnlyThatUsersOrganizationsAreReturnedWithPrimaryFlag()
{
var org = await _service.CreateAsync("My Org", creatorUserId: 1);
await _service.CreateAsync("Other User Org", creatorUserId: 2);
var results = await _service.ListForUserAsync(1);
Assert.That(results, Has.Count.EqualTo(1));
Assert.That(results[0].Org.Id, Is.EqualTo(org.Id));
Assert.That(results[0].IsPrimary, Is.True);
}
[Test]
public async Task WhenFindingAnOrganizationByIdThatExists_ThenItIsReturned()
{
var org = await _service.CreateAsync("My Org", creatorUserId: 1);
var found = await _service.FindByIdAsync(org.Id);
Assert.That(found, Is.Not.Null);
}
[Test]
public async Task WhenFindingAnOrganizationByIdThatDoesNotExist_ThenNullIsReturned()
{
var found = await _service.FindByIdAsync(999);
Assert.That(found, Is.Null);
}
[Test]
public void WhenSettingPrimaryForAUserThatIsNotAMember_ThenKeyNotFoundExceptionIsThrown()
{
Assert.ThrowsAsync<KeyNotFoundException>(() => _service.SetPrimaryAsync(999, 1));
}
[Test]
public void WhenUpdatingAnOrganizationThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown()
{
Assert.ThrowsAsync<KeyNotFoundException>(() => _service.UpdateAsync(999, "renamed"));
}
[Test]
public async Task WhenUpdatingAnOrganization_ThenTheNameIsChanged()
{
var org = await _service.CreateAsync("Old Name", creatorUserId: 1);
var updated = await _service.UpdateAsync(org.Id, "New Name");
Assert.That(updated.Name, Is.EqualTo("New Name"));
}
[Test]
public void WhenDeletingAnOrganizationThatDoesNotExist_ThenNoExceptionIsThrown()
{
Assert.DoesNotThrowAsync(() => _service.DeleteAsync(999));
}
[Test]
public async Task WhenDeletingAnOrganization_ThenItIsRemoved()
{
var org = await _service.CreateAsync("My Org", creatorUserId: 1);
await _service.DeleteAsync(org.Id);
Assert.That(_organizations.Any(o => o.Id == org.Id), Is.False);
}
[Test]
public async Task WhenListingMembers_ThenOnlyThatOrganizationsMembersAreReturned()
{
var org = await _service.CreateAsync("My Org", creatorUserId: 1);
await _service.CreateAsync("Other Org", creatorUserId: 2);
var members = await _service.ListMembersAsync(org.Id);
Assert.That(members, Has.Count.EqualTo(1));
Assert.That(members[0].UserId, Is.EqualTo(1));
}
[Test]
public async Task WhenInvitingAUserWhoIsNotYetAMember_ThenTheyAreAddedWithTheGivenRole()
{
var org = await _service.CreateAsync("My Org", creatorUserId: 1);
await _service.InviteUserAsync(org.Id, userId: 2, OrganizationRole.User);
Assert.That(_members.Single(m => m.UserId == 2).Role, Is.EqualTo(OrganizationRole.User));
}
[Test]
public async Task WhenInvitingAUserWhoIsAlreadyAMember_ThenTheirRoleIsUpdatedWithoutDuplication()
{
var org = await _service.CreateAsync("My Org", creatorUserId: 1);
await _service.InviteUserAsync(org.Id, userId: 2, OrganizationRole.User);
await _service.InviteUserAsync(org.Id, userId: 2, OrganizationRole.Admin);
Assert.That(_members.Count(m => m.UserId == 2), Is.EqualTo(1));
Assert.That(_members.Single(m => m.UserId == 2).Role, Is.EqualTo(OrganizationRole.Admin));
}
[Test]
public void WhenRemovingAMemberThatDoesNotExist_ThenNoExceptionIsThrown()
{
Assert.DoesNotThrowAsync(() => _service.RemoveMemberAsync(1, 999));
}
[Test]
public async Task WhenRemovingAMember_ThenTheyAreNoLongerListed()
{
var org = await _service.CreateAsync("My Org", creatorUserId: 1);
await _service.InviteUserAsync(org.Id, userId: 2, OrganizationRole.User);
await _service.RemoveMemberAsync(org.Id, 2);
Assert.That(_members.Any(m => m.UserId == 2), Is.False);
}
[Test]
public void WhenGettingAnInviteTokenForAnOrganizationThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown()
{
Assert.ThrowsAsync<KeyNotFoundException>(() => _service.GetOrCreateInviteTokenAsync(999));
}
[Test]
public async Task WhenGettingAnInviteTokenForTheFirstTime_ThenANewTokenIsGeneratedAndPersisted()
{
var org = await _service.CreateAsync("My Org", creatorUserId: 1);
var token = await _service.GetOrCreateInviteTokenAsync(org.Id);
Assert.That(token, Is.Not.Null.And.Not.Empty);
Assert.That(_organizations.Single(o => o.Id == org.Id).InviteToken, Is.EqualTo(token));
}
[Test]
public async Task WhenGettingAnInviteTokenASecondTime_ThenTheSameTokenIsReturned()
{
var org = await _service.CreateAsync("My Org", creatorUserId: 1);
var first = await _service.GetOrCreateInviteTokenAsync(org.Id);
var second = await _service.GetOrCreateInviteTokenAsync(org.Id);
Assert.That(second, Is.EqualTo(first));
}
[Test]
public void WhenRegeneratingAnInviteTokenForAnOrganizationThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown()
{
Assert.ThrowsAsync<KeyNotFoundException>(() => _service.RegenerateInviteTokenAsync(999));
}
[Test]
public async Task WhenRegeneratingAnInviteToken_ThenANewTokenReplacesTheOld()
{
var org = await _service.CreateAsync("My Org", creatorUserId: 1);
var first = await _service.GetOrCreateInviteTokenAsync(org.Id);
var regenerated = await _service.RegenerateInviteTokenAsync(org.Id);
Assert.That(regenerated, Is.Not.EqualTo(first));
}
[Test]
public async Task WhenFindingAnOrganizationByAValidInviteToken_ThenItIsReturned()
{
var org = await _service.CreateAsync("My Org", creatorUserId: 1);
var token = await _service.GetOrCreateInviteTokenAsync(org.Id);
var found = await _service.FindByInviteTokenAsync(token);
Assert.That(found, Is.Not.Null);
Assert.That(found!.Id, Is.EqualTo(org.Id));
}
[Test]
public async Task WhenFindingAnOrganizationByAnInvalidInviteToken_ThenNullIsReturned()
{
var found = await _service.FindByInviteTokenAsync("not-a-real-token");
Assert.That(found, Is.Null);
}
private User AddUser(int id, string email, bool isActive = true) => new()
{
Id = id,
Email = email,
PasswordHash = "hashed",
FirstName = "Test",
LastName = "User",
IsActive = isActive,
CreatedAt = DateTimeOffset.UtcNow
};
[Test]
public async Task WhenInvitingByEmailForAUserThatDoesNotExist_ThenTheResultIndicatesFailure()
{
var org = await _service.CreateAsync("My Org", creatorUserId: 1);
var results = await _service.InviteUsersByEmailAsync(org.Id, [new InviteByEmailEntry("nobody@example.com", "User")]);
Assert.That(results[0].Success, Is.False);
Assert.That(results[0].Error, Is.Not.Null);
}
[Test]
public async Task WhenInvitingByEmailWithAnInvalidRole_ThenTheResultIndicatesFailure()
{
var org = await _service.CreateAsync("My Org", creatorUserId: 1);
_users.Add(AddUser(2, "bob@example.com"));
var results = await _service.InviteUsersByEmailAsync(org.Id, [new InviteByEmailEntry("bob@example.com", "NotARole")]);
Assert.That(results[0].Success, Is.False);
}
[Test]
public async Task WhenInvitingByEmailForAnInactiveUser_ThenTheResultIndicatesFailure()
{
var org = await _service.CreateAsync("My Org", creatorUserId: 1);
_users.Add(AddUser(2, "bob@example.com", isActive: false));
var results = await _service.InviteUsersByEmailAsync(org.Id, [new InviteByEmailEntry("bob@example.com", "User")]);
Assert.That(results[0].Success, Is.False);
}
[Test]
public async Task WhenInvitingByEmailForAValidActiveUser_ThenTheyAreAddedAsAMemberAndSuccessIsReturned()
{
var org = await _service.CreateAsync("My Org", creatorUserId: 1);
_users.Add(AddUser(2, "bob@example.com"));
var results = await _service.InviteUsersByEmailAsync(org.Id, [new InviteByEmailEntry(" BOB@Example.com ", "Admin")]);
Assert.That(results[0].Success, Is.True);
Assert.That(_members.Single(m => m.UserId == 2).Role, Is.EqualTo(OrganizationRole.Admin));
}
[Test]
public void WhenAcceptingAnInviteWithAnInvalidToken_ThenKeyNotFoundExceptionIsThrown()
{
Assert.ThrowsAsync<KeyNotFoundException>(() => _service.AcceptInviteAsync("not-a-real-token", 2));
}
[Test]
public async Task WhenAcceptingAValidInvite_ThenTheUserIsAddedAsAMemberWithUserRole()
{
var org = await _service.CreateAsync("My Org", creatorUserId: 1);
var token = await _service.GetOrCreateInviteTokenAsync(org.Id);
await _service.AcceptInviteAsync(token, userId: 2);
Assert.That(_members.Single(m => m.UserId == 2).Role, Is.EqualTo(OrganizationRole.User));
}
}

View File

@@ -0,0 +1,486 @@
using System.Security.Claims;
using MicCheck.Api.Audit;
using MicCheck.Api.Common;
using MicCheck.Api.Data;
using MicCheck.Api.Organizations;
using MicCheck.Api.Tests.Unit.TestSupport;
using MicCheck.Api.Users;
using MicCheck.Api.Webhooks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Organizations;
[TestFixture]
public class OrganizationsControllerTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<Organization> _organizations = null!;
private List<OrganizationUser> _members = null!;
private List<User> _users = null!;
private List<Webhook> _webhooks = null!;
private OrganizationsController _controller = null!;
private const int UserId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_organizations = [];
_members = [];
var organizationsSet = MockDbSetFactory.Create(_organizations);
organizationsSet.Setup(m => m.Add(It.IsAny<Organization>())).Callback<Organization>(org =>
{
typeof(Organization).GetProperty(nameof(Organization.Id))!.SetValue(org, _organizations.Count + 1);
_organizations.Add(org);
foreach (var member in org.Members)
{
typeof(OrganizationUser).GetProperty(nameof(OrganizationUser.OrganizationId))!.SetValue(member, org.Id);
_members.Add(member);
}
});
_db.Setup(c => c.Organizations).Returns(organizationsSet.Object);
_db.SetupDbSet(c => c.OrganizationUsers, _members);
_users = [];
_db.SetupDbSet(c => c.Users, _users);
_webhooks = [];
_db.SetupDbSetWithGeneratedIds(c => c.Webhooks, _webhooks);
var webhookQueue = new WebhookQueue();
var auditService = new Mock<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?>(),
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
_controller = new OrganizationsController(new OrganizationService(_db.Object, auditService.Object), new WebhookService(_db.Object));
var identity = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, UserId.ToString())], "TestAuth");
_controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }
};
}
private void Unauthenticate() =>
_controller.ControllerContext.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity());
[Test]
public async Task WhenListingWithoutAuthentication_ThenUnauthorizedIsReturned()
{
Unauthenticate();
var result = await _controller.List();
Assert.That(result.Result, Is.InstanceOf<UnauthorizedResult>());
}
[Test]
public async Task WhenListingOrganizationsForTheCurrentUser_ThenTheyAreReturnedInAPage()
{
await _controller.Create(new CreateOrganizationRequest("Org A"), CancellationToken.None);
await _controller.Create(new CreateOrganizationRequest("Org B"), CancellationToken.None);
var result = await _controller.List();
var ok = result.Result as OkObjectResult;
var page = (PaginatedResponse<OrganizationResponse>)ok!.Value!;
Assert.That(page.Count, Is.EqualTo(2));
}
[Test]
public async Task WhenCreatingWithoutAuthentication_ThenUnauthorizedIsReturned()
{
Unauthenticate();
var result = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<UnauthorizedResult>());
}
[Test]
public async Task WhenCreatingAnOrganization_ThenACreatedResultWithTheOrganizationIsReturned()
{
var result = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None);
var created = result.Result as CreatedAtActionResult;
Assert.That(created, Is.Not.Null);
Assert.That(((OrganizationResponse)created!.Value!).Name, Is.EqualTo("My Org"));
}
[Test]
public async Task WhenGettingAnOrganizationByIdThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetById(999, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingAnOrganizationByIdThatExists_ThenItIsReturned()
{
var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None);
var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.GetById(orgId, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<OkObjectResult>());
}
[Test]
public async Task WhenUpdatingAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.Update(999, new UpdateOrganizationRequest("renamed"), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAnOrganization_ThenTheUpdatedOrganizationIsReturned()
{
var created = await _controller.Create(new CreateOrganizationRequest("Old Name"), CancellationToken.None);
var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.Update(orgId, new UpdateOrganizationRequest("New Name"), CancellationToken.None);
var ok = result.Result as OkObjectResult;
Assert.That(((OrganizationResponse)ok!.Value!).Name, Is.EqualTo("New Name"));
}
[Test]
public async Task WhenSettingPrimaryWithoutAuthentication_ThenUnauthorizedIsReturned()
{
Unauthenticate();
var result = await _controller.SetPrimary(1, CancellationToken.None);
Assert.That(result, Is.InstanceOf<UnauthorizedResult>());
}
[Test]
public async Task WhenSettingPrimaryForAUserThatIsNotAMember_ThenNotFoundIsReturned()
{
var result = await _controller.SetPrimary(999, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.Delete(999, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAnOrganization_ThenNoContentIsReturnedAndItIsRemoved()
{
var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None);
var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.Delete(orgId, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NoContentResult>());
Assert.That(_organizations.Any(o => o.Id == orgId), Is.False);
}
[Test]
public async Task WhenListingUsersForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.ListUsers(999, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingUsersForAnOrganization_ThenMembersAreReturned()
{
var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None);
var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var user = new User { Id = UserId, Email = "alice@example.com", PasswordHash = "hashed", FirstName = "Alice", LastName = "Smith", CreatedAt = DateTimeOffset.UtcNow };
_users.Add(user);
var member = _members.Single(m => m.OrganizationId == orgId);
typeof(OrganizationUser).GetProperty(nameof(OrganizationUser.User))!.SetValue(member, user);
var result = await _controller.ListUsers(orgId, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var members = (IReadOnlyList<OrganizationMemberResponse>)ok!.Value!;
Assert.That(members, Has.Count.EqualTo(1));
Assert.That(members[0].Email, Is.EqualTo("alice@example.com"));
}
[Test]
public async Task WhenInvitingAUserToAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.InviteUser(999, new InviteUserRequest(2, "User"), CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenInvitingAUser_ThenOkIsReturnedAndTheyBecomeAMember()
{
var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None);
var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.InviteUser(orgId, new InviteUserRequest(2, "Admin"), CancellationToken.None);
Assert.That(result, Is.InstanceOf<OkResult>());
Assert.That(_members.Single(m => m.UserId == 2).Role, Is.EqualTo(OrganizationRole.Admin));
}
[Test]
public async Task WhenInvitingUsersByEmailForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.InviteUsersByEmail(999, new InviteUsersByEmailRequest([]), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenInvitingUsersByEmail_ThenResultsAreReturnedForEachEntry()
{
var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None);
var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.InviteUsersByEmail(
orgId, new InviteUsersByEmailRequest([new InviteByEmailEntry("nobody@example.com", "User")]), CancellationToken.None);
var ok = result.Result as OkObjectResult;
var results = (IReadOnlyList<InviteByEmailResult>)ok!.Value!;
Assert.That(results, Has.Count.EqualTo(1));
Assert.That(results[0].Success, Is.False);
}
[Test]
public async Task WhenGettingInviteLinkForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetInviteLink(999, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingInviteLink_ThenATokenIsReturned()
{
var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None);
var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.GetInviteLink(orgId, CancellationToken.None);
var ok = result.Result as OkObjectResult;
Assert.That(((InviteTokenResponse)ok!.Value!).Token, Is.Not.Empty);
}
[Test]
public async Task WhenRegeneratingInviteLinkForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.RegenerateInviteLink(999, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenRegeneratingInviteLink_ThenANewTokenIsReturned()
{
var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None);
var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var first = await _controller.GetInviteLink(orgId, CancellationToken.None);
var firstToken = ((InviteTokenResponse)((OkObjectResult)first.Result!).Value!).Token;
var result = await _controller.RegenerateInviteLink(orgId, CancellationToken.None);
var ok = result.Result as OkObjectResult;
Assert.That(((InviteTokenResponse)ok!.Value!).Token, Is.Not.EqualTo(firstToken));
}
[Test]
public async Task WhenAcceptingInviteWithoutAuthentication_ThenUnauthorizedIsReturned()
{
Unauthenticate();
var result = await _controller.AcceptInvite("some-token", CancellationToken.None);
Assert.That(result, Is.InstanceOf<UnauthorizedResult>());
}
[Test]
public async Task WhenAcceptingInviteWithAnInvalidToken_ThenNotFoundIsReturned()
{
var result = await _controller.AcceptInvite("not-a-real-token", CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundObjectResult>());
}
[Test]
public async Task WhenAcceptingAValidInvite_ThenOkIsReturnedAndTheUserBecomesAMember()
{
var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None);
var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var link = await _controller.GetInviteLink(orgId, CancellationToken.None);
var token = ((InviteTokenResponse)((OkObjectResult)link.Result!).Value!).Token;
Unauthenticate();
var identity = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, "2")], "TestAuth");
_controller.ControllerContext.HttpContext.User = new ClaimsPrincipal(identity);
var result = await _controller.AcceptInvite(token, CancellationToken.None);
Assert.That(result, Is.InstanceOf<OkResult>());
Assert.That(_members.Any(m => m.UserId == 2), Is.True);
}
[Test]
public async Task WhenRemovingUserFromAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.RemoveUser(999, 2, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenRemovingAUser_ThenNoContentIsReturnedAndTheyAreNoLongerAMember()
{
var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None);
var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
await _controller.InviteUser(orgId, new InviteUserRequest(2, "User"), CancellationToken.None);
var result = await _controller.RemoveUser(orgId, 2, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NoContentResult>());
Assert.That(_members.Any(m => m.UserId == 2), Is.False);
}
[Test]
public async Task WhenListingWebhooksForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.ListWebhooks(999, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenCreatingAWebhookForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.CreateWebhook(999, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenCreatingAWebhook_ThenACreatedResultWithTheWebhookIsReturned()
{
var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None);
var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.CreateWebhook(orgId, new CreateWebhookRequest("https://example.com", "secret", true), CancellationToken.None);
var webhookCreated = result.Result as CreatedAtActionResult;
Assert.That(webhookCreated, Is.Not.Null);
Assert.That(((WebhookResponse)webhookCreated!.Value!).Url, Is.EqualTo("https://example.com"));
}
[Test]
public async Task WhenListingWebhooksForAnOrganization_ThenTheyAreReturned()
{
var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None);
var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
await _controller.CreateWebhook(orgId, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None);
var result = await _controller.ListWebhooks(orgId, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var webhooks = (IReadOnlyList<WebhookResponse>)ok!.Value!;
Assert.That(webhooks, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenUpdatingAWebhookForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.UpdateWebhook(999, 1, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAWebhookThatDoesNotExist_ThenNotFoundIsReturned()
{
var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None);
var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.UpdateWebhook(orgId, 999, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAWebhookThatBelongsToADifferentOrganization_ThenNotFoundIsReturned()
{
var org1 = await _controller.Create(new CreateOrganizationRequest("Org 1"), CancellationToken.None);
var org1Id = ((OrganizationResponse)((CreatedAtActionResult)org1.Result!).Value!).Id;
var org2 = await _controller.Create(new CreateOrganizationRequest("Org 2"), CancellationToken.None);
var org2Id = ((OrganizationResponse)((CreatedAtActionResult)org2.Result!).Value!).Id;
var webhook = await _controller.CreateWebhook(org1Id, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None);
var webhookId = ((WebhookResponse)((CreatedAtActionResult)webhook.Result!).Value!).Id;
var result = await _controller.UpdateWebhook(org2Id, webhookId, new CreateWebhookRequest("https://other.com", null, false), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAWebhook_ThenItIsChanged()
{
var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None);
var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var webhook = await _controller.CreateWebhook(orgId, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None);
var webhookId = ((WebhookResponse)((CreatedAtActionResult)webhook.Result!).Value!).Id;
var result = await _controller.UpdateWebhook(orgId, webhookId, new CreateWebhookRequest("https://updated.com", null, false), CancellationToken.None);
var ok = result.Result as OkObjectResult;
Assert.That(((WebhookResponse)ok!.Value!).Url, Is.EqualTo("https://updated.com"));
}
[Test]
public async Task WhenDeletingAWebhookForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.DeleteWebhook(999, 1, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAWebhookThatBelongsToADifferentOrganization_ThenNotFoundIsReturned()
{
var org1 = await _controller.Create(new CreateOrganizationRequest("Org 1"), CancellationToken.None);
var org1Id = ((OrganizationResponse)((CreatedAtActionResult)org1.Result!).Value!).Id;
var org2 = await _controller.Create(new CreateOrganizationRequest("Org 2"), CancellationToken.None);
var org2Id = ((OrganizationResponse)((CreatedAtActionResult)org2.Result!).Value!).Id;
var webhook = await _controller.CreateWebhook(org1Id, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None);
var webhookId = ((WebhookResponse)((CreatedAtActionResult)webhook.Result!).Value!).Id;
var result = await _controller.DeleteWebhook(org2Id, webhookId, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAWebhook_ThenItIsRemoved()
{
var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None);
var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var webhook = await _controller.CreateWebhook(orgId, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None);
var webhookId = ((WebhookResponse)((CreatedAtActionResult)webhook.Result!).Value!).Id;
var result = await _controller.DeleteWebhook(orgId, webhookId, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NoContentResult>());
Assert.That(_webhooks.Any(w => w.Id == webhookId), Is.False);
}
}

View File

@@ -0,0 +1,45 @@
using MicCheck.Api.Projects;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Projects;
[TestFixture]
public class CreateProjectRequestValidatorTests
{
private CreateProjectRequestValidator _validator = null!;
[SetUp]
public void SetUp() => _validator = new CreateProjectRequestValidator();
[Test]
public void WhenTheRequestIsValid_ThenValidationSucceeds()
{
var result = _validator.Validate(new CreateProjectRequest("My Project", 1));
Assert.That(result.IsValid, Is.True);
}
[Test]
public void WhenNameIsEmpty_ThenValidationFails()
{
var result = _validator.Validate(new CreateProjectRequest("", 1));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenNameExceedsMaximumLength_ThenValidationFails()
{
var result = _validator.Validate(new CreateProjectRequest(new string('a', 201), 1));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenOrganizationIdIsZeroOrLess_ThenValidationFails()
{
var result = _validator.Validate(new CreateProjectRequest("My Project", 0));
Assert.That(result.IsValid, Is.False);
}
}

View File

@@ -0,0 +1,45 @@
using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Projects;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Projects;
[TestFixture]
public class ProjectResponseTests
{
[Test]
public void WhenMappingAProject_ThenAllFieldsAreCopied()
{
var project = new Project { Id = 1, Name = "My Project", OrganizationId = 5, HideDisabledFlags = true, CreatedAt = DateTimeOffset.UtcNow };
var response = ProjectResponse.From(project);
Assert.That(response.Id, Is.EqualTo(1));
Assert.That(response.Name, Is.EqualTo("My Project"));
Assert.That(response.OrganizationId, Is.EqualTo(5));
Assert.That(response.HideDisabledFlags, Is.True);
}
}
[TestFixture]
public class UserPermissionResponseTests
{
[Test]
public void WhenMappingAUserProjectPermission_ThenPermissionsAreMappedToStrings()
{
var permission = new UserProjectPermission
{
UserId = 1,
ProjectId = 2,
IsAdmin = true,
Permissions = [ProjectPermission.ViewProject, ProjectPermission.EditFeature]
};
var response = UserPermissionResponse.From(permission);
Assert.That(response.UserId, Is.EqualTo(1));
Assert.That(response.ProjectId, Is.EqualTo(2));
Assert.That(response.IsAdmin, Is.True);
Assert.That(response.Permissions, Is.EqualTo(new[] { "ViewProject", "EditFeature" }));
}
}

View File

@@ -0,0 +1,168 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Data;
using MicCheck.Api.Organizations;
using MicCheck.Api.Projects;
using MicCheck.Api.Tests.Unit.TestSupport;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Projects;
[TestFixture]
public class ProjectServiceTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<Project> _projects = null!;
private List<UserProjectPermission> _permissions = null!;
private ProjectService _service = null!;
private const int OrganizationId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_db.SetupDbSet(c => c.Organizations, [
new Organization { Id = OrganizationId, Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow }
]);
_projects = [];
_db.SetupDbSetWithGeneratedIds(c => c.Projects, _projects);
_permissions = [];
_db.SetupDbSet(c => c.UserProjectPermissions, _permissions);
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
var auditService = new Mock<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?>(),
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
_service = new ProjectService(_db.Object, auditService.Object);
}
[Test]
public async Task WhenCreatingAProject_ThenItIsPersistedWithTheGivenOrganization()
{
var project = await _service.CreateAsync(OrganizationId, "My Project");
Assert.That(project.Name, Is.EqualTo("My Project"));
Assert.That(project.OrganizationId, Is.EqualTo(OrganizationId));
Assert.That(_projects, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenListingProjectsForAnOrganization_ThenOnlyThatOrganizationsProjectsAreReturned()
{
await _service.CreateAsync(OrganizationId, "Project A");
await _service.CreateAsync(999, "Other Org Project");
var projects = await _service.ListByOrganizationAsync(OrganizationId);
Assert.That(projects, Has.Count.EqualTo(1));
Assert.That(projects[0].Name, Is.EqualTo("Project A"));
}
[Test]
public async Task WhenFindingAProjectByIdThatExists_ThenItIsReturned()
{
var project = await _service.CreateAsync(OrganizationId, "My Project");
var found = await _service.FindByIdAsync(project.Id);
Assert.That(found, Is.Not.Null);
Assert.That(found!.Id, Is.EqualTo(project.Id));
}
[Test]
public async Task WhenFindingAProjectByIdThatDoesNotExist_ThenNullIsReturned()
{
var found = await _service.FindByIdAsync(999);
Assert.That(found, Is.Null);
}
[Test]
public void WhenUpdatingAProjectThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown()
{
Assert.ThrowsAsync<KeyNotFoundException>(() => _service.UpdateAsync(999, "renamed", true));
}
[Test]
public async Task WhenUpdatingAProject_ThenNameAndHideDisabledFlagsAreChanged()
{
var project = await _service.CreateAsync(OrganizationId, "Old Name");
var updated = await _service.UpdateAsync(project.Id, "New Name", true);
Assert.That(updated.Name, Is.EqualTo("New Name"));
Assert.That(updated.HideDisabledFlags, Is.True);
}
[Test]
public void WhenDeletingAProjectThatDoesNotExist_ThenNoExceptionIsThrown()
{
Assert.DoesNotThrowAsync(() => _service.DeleteAsync(999));
}
[Test]
public async Task WhenDeletingAProject_ThenItIsRemovedFromTheDatabase()
{
var project = await _service.CreateAsync(OrganizationId, "My Project");
await _service.DeleteAsync(project.Id);
Assert.That(_projects.Any(p => p.Id == project.Id), Is.False);
}
[Test]
public async Task WhenListingUserPermissionsForAProject_ThenOnlyThatProjectsPermissionsAreReturned()
{
_permissions.Add(new UserProjectPermission { ProjectId = 1, UserId = 1 });
_permissions.Add(new UserProjectPermission { ProjectId = 999, UserId = 2 });
var perms = await _service.ListUserPermissionsAsync(1);
Assert.That(perms, Has.Count.EqualTo(1));
Assert.That(perms[0].UserId, Is.EqualTo(1));
}
[Test]
public async Task WhenSettingUserPermissionsForAUserWithNoExistingPermissions_ThenANewRecordIsCreated()
{
var perm = await _service.SetUserPermissionsAsync(1, userId: 5, isAdmin: true, [ProjectPermission.ViewProject]);
Assert.That(perm.IsAdmin, Is.True);
Assert.That(perm.Permissions, Does.Contain(ProjectPermission.ViewProject));
Assert.That(_permissions, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenSettingUserPermissionsForAUserThatAlreadyHasPermissions_ThenTheExistingRecordIsUpdated()
{
await _service.SetUserPermissionsAsync(1, userId: 5, isAdmin: false, [ProjectPermission.ViewProject]);
var updated = await _service.SetUserPermissionsAsync(1, userId: 5, isAdmin: true, [ProjectPermission.DeleteFeature]);
Assert.That(_permissions, Has.Count.EqualTo(1));
Assert.That(updated.IsAdmin, Is.True);
Assert.That(updated.Permissions, Is.EqualTo(new[] { ProjectPermission.DeleteFeature }));
}
[Test]
public void WhenRemovingUserPermissionsThatDoNotExist_ThenNoExceptionIsThrown()
{
Assert.DoesNotThrowAsync(() => _service.RemoveUserPermissionsAsync(1, 5));
}
[Test]
public async Task WhenRemovingUserPermissions_ThenTheRecordIsRemoved()
{
await _service.SetUserPermissionsAsync(1, userId: 5, isAdmin: true, [ProjectPermission.ViewProject]);
await _service.RemoveUserPermissionsAsync(1, 5);
Assert.That(_permissions, Is.Empty);
}
}

View File

@@ -0,0 +1,236 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Common;
using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Data;
using MicCheck.Api.Organizations;
using MicCheck.Api.Projects;
using MicCheck.Api.Tests.Unit.TestSupport;
using Microsoft.AspNetCore.Mvc;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Projects;
[TestFixture]
public class ProjectsControllerTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<Project> _projects = null!;
private List<UserProjectPermission> _permissions = null!;
private ProjectsController _controller = null!;
private const int OrganizationId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_db.SetupDbSet(c => c.Organizations, [
new Organization { Id = OrganizationId, Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow }
]);
_projects = [];
_db.SetupDbSetWithGeneratedIds(c => c.Projects, _projects);
_permissions = [];
_db.SetupDbSet(c => c.UserProjectPermissions, _permissions);
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
var auditService = new Mock<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?>(),
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
_controller = new ProjectsController(new ProjectService(_db.Object, auditService.Object));
}
[Test]
public async Task WhenListingProjectsForAnOrganization_ThenTheyAreReturnedInAPage()
{
await _controller.Create(new CreateProjectRequest("Project A", OrganizationId), CancellationToken.None);
await _controller.Create(new CreateProjectRequest("Project B", OrganizationId), CancellationToken.None);
var result = await _controller.List(OrganizationId);
var ok = result.Result as OkObjectResult;
var page = (PaginatedResponse<ProjectResponse>)ok!.Value!;
Assert.That(page.Count, Is.EqualTo(2));
Assert.That(page.Results, Has.Count.EqualTo(2));
}
[Test]
public async Task WhenListingProjectsWithAPageSizeAboveTheMaximum_ThenItIsClampedTo100()
{
for (var i = 0; i < 3; i++)
await _controller.Create(new CreateProjectRequest($"Project {i}", OrganizationId), CancellationToken.None);
var result = await _controller.List(OrganizationId, page: 1, pageSize: 1000);
var ok = result.Result as OkObjectResult;
var page = (PaginatedResponse<ProjectResponse>)ok!.Value!;
Assert.That(page.Results, Has.Count.EqualTo(3));
}
[Test]
public async Task WhenCreatingAProject_ThenACreatedResultWithTheProjectIsReturned()
{
var result = await _controller.Create(new CreateProjectRequest("My Project", OrganizationId), CancellationToken.None);
var created = result.Result as CreatedAtActionResult;
Assert.That(created, Is.Not.Null);
Assert.That(((ProjectResponse)created!.Value!).Name, Is.EqualTo("My Project"));
}
[Test]
public async Task WhenGettingAProjectByIdThatExists_ThenItIsReturned()
{
var created = await _controller.Create(new CreateProjectRequest("My Project", OrganizationId), CancellationToken.None);
var projectId = ((ProjectResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.GetById(projectId, CancellationToken.None);
var ok = result.Result as OkObjectResult;
Assert.That(ok, Is.Not.Null);
Assert.That(((ProjectResponse)ok!.Value!).Id, Is.EqualTo(projectId));
}
[Test]
public async Task WhenGettingAProjectByIdThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetById(999, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAProjectThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.Update(999, new UpdateProjectRequest("renamed", false), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAProject_ThenTheUpdatedProjectIsReturned()
{
var created = await _controller.Create(new CreateProjectRequest("Old Name", OrganizationId), CancellationToken.None);
var projectId = ((ProjectResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.Update(projectId, new UpdateProjectRequest("New Name", true), CancellationToken.None);
var ok = result.Result as OkObjectResult;
var response = (ProjectResponse)ok!.Value!;
Assert.That(response.Name, Is.EqualTo("New Name"));
Assert.That(response.HideDisabledFlags, Is.True);
}
[Test]
public async Task WhenDeletingAProjectThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.Delete(999, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAProject_ThenNoContentIsReturnedAndItIsRemoved()
{
var created = await _controller.Create(new CreateProjectRequest("My Project", OrganizationId), CancellationToken.None);
var projectId = ((ProjectResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.Delete(projectId, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NoContentResult>());
Assert.That(_projects.Any(p => p.Id == projectId), Is.False);
}
[Test]
public async Task WhenListingUserPermissionsForAProjectThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.ListUserPermissions(999, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingUserPermissionsForAProject_ThenTheyAreReturned()
{
var created = await _controller.Create(new CreateProjectRequest("My Project", OrganizationId), CancellationToken.None);
var projectId = ((ProjectResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
await _controller.CreateUserPermissions(projectId, new SetUserPermissionsRequest(5, true, ["ViewProject"]), CancellationToken.None);
var result = await _controller.ListUserPermissions(projectId, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var perms = (IReadOnlyList<UserPermissionResponse>)ok!.Value!;
Assert.That(perms, Has.Count.EqualTo(1));
Assert.That(perms[0].UserId, Is.EqualTo(5));
}
[Test]
public async Task WhenCreatingUserPermissionsForAProjectThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.CreateUserPermissions(999, new SetUserPermissionsRequest(5, true, []), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenCreatingUserPermissions_ThenThePermissionsAreParsedAndPersisted()
{
var created = await _controller.Create(new CreateProjectRequest("My Project", OrganizationId), CancellationToken.None);
var projectId = ((ProjectResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.CreateUserPermissions(
projectId, new SetUserPermissionsRequest(5, true, ["ViewProject", "EditFeature"]), CancellationToken.None);
var ok = result.Result as OkObjectResult;
var response = (UserPermissionResponse)ok!.Value!;
Assert.That(response.Permissions, Is.EqualTo(new[] { "ViewProject", "EditFeature" }));
}
[Test]
public async Task WhenUpdatingUserPermissionsForAProjectThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.UpdateUserPermissions(999, 5, new SetUserPermissionsRequest(5, true, []), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingUserPermissions_ThenTheExistingRecordIsChanged()
{
var created = await _controller.Create(new CreateProjectRequest("My Project", OrganizationId), CancellationToken.None);
var projectId = ((ProjectResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
await _controller.CreateUserPermissions(projectId, new SetUserPermissionsRequest(5, false, ["ViewProject"]), CancellationToken.None);
var result = await _controller.UpdateUserPermissions(projectId, 5, new SetUserPermissionsRequest(5, true, ["DeleteFeature"]), CancellationToken.None);
var ok = result.Result as OkObjectResult;
var response = (UserPermissionResponse)ok!.Value!;
Assert.That(response.IsAdmin, Is.True);
Assert.That(response.Permissions, Is.EqualTo(new[] { "DeleteFeature" }));
Assert.That(_permissions, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenDeletingUserPermissionsForAProjectThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.DeleteUserPermissions(999, 5, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingUserPermissions_ThenTheRecordIsRemoved()
{
var created = await _controller.Create(new CreateProjectRequest("My Project", OrganizationId), CancellationToken.None);
var projectId = ((ProjectResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
await _controller.CreateUserPermissions(projectId, new SetUserPermissionsRequest(5, true, ["ViewProject"]), CancellationToken.None);
var result = await _controller.DeleteUserPermissions(projectId, 5, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NoContentResult>());
Assert.That(_permissions, Is.Empty);
}
}

View File

@@ -0,0 +1,45 @@
using MicCheck.Api.Projects;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Projects;
[TestFixture]
public class SetUserPermissionsRequestValidatorTests
{
private SetUserPermissionsRequestValidator _validator = null!;
[SetUp]
public void SetUp() => _validator = new SetUserPermissionsRequestValidator();
[Test]
public void WhenTheRequestIsValid_ThenValidationSucceeds()
{
var result = _validator.Validate(new SetUserPermissionsRequest(1, true, ["ViewProject", "EditFeature"]));
Assert.That(result.IsValid, Is.True);
}
[Test]
public void WhenUserIdIsZeroOrLess_ThenValidationFails()
{
var result = _validator.Validate(new SetUserPermissionsRequest(0, true, []));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenAPermissionIsInvalid_ThenValidationFails()
{
var result = _validator.Validate(new SetUserPermissionsRequest(1, false, ["NotAPermission"]));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenPermissionsListIsEmpty_ThenValidationSucceeds()
{
var result = _validator.Validate(new SetUserPermissionsRequest(1, true, []));
Assert.That(result.IsValid, Is.True);
}
}

View File

@@ -0,0 +1,37 @@
using MicCheck.Api.Projects;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Projects;
[TestFixture]
public class UpdateProjectRequestValidatorTests
{
private UpdateProjectRequestValidator _validator = null!;
[SetUp]
public void SetUp() => _validator = new UpdateProjectRequestValidator();
[Test]
public void WhenTheRequestIsValid_ThenValidationSucceeds()
{
var result = _validator.Validate(new UpdateProjectRequest("Renamed", true));
Assert.That(result.IsValid, Is.True);
}
[Test]
public void WhenNameIsEmpty_ThenValidationFails()
{
var result = _validator.Validate(new UpdateProjectRequest("", false));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenNameExceedsMaximumLength_ThenValidationFails()
{
var result = _validator.Validate(new UpdateProjectRequest(new string('a', 201), false));
Assert.That(result.IsValid, Is.False);
}
}

View File

@@ -0,0 +1,120 @@
using MicCheck.Api.Segments;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Segments;
[TestFixture]
public class CreateSegmentRequestValidatorTests
{
private CreateSegmentRequestValidator _validator = null!;
[SetUp]
public void SetUp() => _validator = new CreateSegmentRequestValidator();
private static CreateSegmentRequest ValidRequest() => new(
"Premium Users",
[new CreateSegmentRuleRequest("All", [new CreateSegmentConditionRequest("plan", "Equal", "premium")])]);
[Test]
public void WhenTheRequestIsValid_ThenValidationSucceeds()
{
var result = _validator.Validate(ValidRequest());
Assert.That(result.IsValid, Is.True);
}
[Test]
public void WhenTheNameIsEmpty_ThenValidationFails()
{
var request = ValidRequest() with { Name = "" };
var result = _validator.Validate(request);
Assert.That(result.IsValid, Is.False);
Assert.That(result.Errors.Any(e => e.PropertyName == "Name"), Is.True);
}
[Test]
public void WhenTheNameExceedsMaximumLength_ThenValidationFails()
{
var request = ValidRequest() with { Name = new string('a', 201) };
var result = _validator.Validate(request);
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenRulesIsNull_ThenValidationFails()
{
var request = ValidRequest() with { Rules = null! };
var result = _validator.Validate(request);
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenARuleTypeIsInvalid_ThenValidationFails()
{
var request = new CreateSegmentRequest(
"Test",
[new CreateSegmentRuleRequest("NotAType", [new CreateSegmentConditionRequest("plan", "Equal", "premium")])]);
var result = _validator.Validate(request);
Assert.That(result.IsValid, Is.False);
}
[TestCase("All")]
[TestCase("Any")]
[TestCase("None")]
[TestCase("all")]
public void WhenARuleTypeIsAValidCaseInsensitiveName_ThenValidationSucceeds(string type)
{
var request = new CreateSegmentRequest(
"Test",
[new CreateSegmentRuleRequest(type, [new CreateSegmentConditionRequest("plan", "Equal", "premium")])]);
var result = _validator.Validate(request);
Assert.That(result.IsValid, Is.True);
}
[Test]
public void WhenAConditionPropertyIsEmpty_ThenValidationFails()
{
var request = new CreateSegmentRequest(
"Test",
[new CreateSegmentRuleRequest("All", [new CreateSegmentConditionRequest("", "Equal", "premium")])]);
var result = _validator.Validate(request);
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenAConditionOperatorIsInvalid_ThenValidationFails()
{
var request = new CreateSegmentRequest(
"Test",
[new CreateSegmentRuleRequest("All", [new CreateSegmentConditionRequest("plan", "NotAnOperator", "premium")])]);
var result = _validator.Validate(request);
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenANestedChildRuleHasAnInvalidOperator_ThenValidationStillSucceedsBecauseChildRulesAreNotValidatedRecursively()
{
var childRule = new CreateSegmentRuleRequest("All", [new CreateSegmentConditionRequest("plan", "NotAnOperator", "premium")]);
var request = new CreateSegmentRequest(
"Test",
[new CreateSegmentRuleRequest("All", [new CreateSegmentConditionRequest("plan", "Equal", "premium")], [childRule])]);
var result = _validator.Validate(request);
Assert.That(result.IsValid, Is.True);
}
}

View File

@@ -274,6 +274,51 @@ public class SegmentEvaluatorTests
Assert.That(_evaluator.Evaluate(segment, [], "another-user"), Is.False); Assert.That(_evaluator.Evaluate(segment, [], "another-user"), Is.False);
} }
[Test]
public void WhenModuloConditionValueIsMalformed_ThenSegmentDoesNotMatch()
{
var segment = BuildSegment(SegmentRuleType.All,
Condition("userId", SegmentConditionOperator.ModuloValueDivisorRemainder, "not-a-modulo-spec"));
var traits = new[] { Trait("userId", "9") };
Assert.That(_evaluator.Evaluate(segment, traits), Is.False);
}
[Test]
public void WhenModuloDivisorIsZero_ThenSegmentDoesNotMatch()
{
var segment = BuildSegment(SegmentRuleType.All,
Condition("userId", SegmentConditionOperator.ModuloValueDivisorRemainder, "0|0"));
var traits = new[] { Trait("userId", "9") };
Assert.That(_evaluator.Evaluate(segment, traits), Is.False);
}
[Test]
public void WhenPercentageSplitValueIsNotANumber_ThenSegmentDoesNotMatch()
{
var segment = BuildSegment(SegmentRuleType.All,
Condition("", SegmentConditionOperator.PercentageSplit, "not-a-number"));
Assert.That(_evaluator.Evaluate(segment, [], "any-user"), Is.False);
}
[Test]
public void WhenARuleHasAChildRuleThatFails_ThenTheParentRuleDoesNotMatchEvenIfItsOwnConditionsPass()
{
var segment = new Segment { Name = "Test", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow };
var parent = new SegmentRule { Type = SegmentRuleType.All };
parent.Conditions.Add(Condition("plan", SegmentConditionOperator.Equal, "premium"));
var child = new SegmentRule { Type = SegmentRuleType.All };
child.Conditions.Add(Condition("country", SegmentConditionOperator.Equal, "US"));
parent.ChildRules.Add(child);
segment.Rules.Add(parent);
var traits = new[] { Trait("plan", "premium"), Trait("country", "UK") };
Assert.That(_evaluator.Evaluate(segment, traits), Is.False);
}
[Test] [Test]
public void WhenRuleTypeIsAny_ThenAtLeastOneConditionMustMatch() public void WhenRuleTypeIsAny_ThenAtLeastOneConditionMustMatch()
{ {

View File

@@ -0,0 +1,84 @@
using MicCheck.Api.Segments;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Segments;
[TestFixture]
public class SegmentResponseTests
{
[Test]
public void WhenMappingASegmentWithNoRules_ThenTheResponseHasAnEmptyRulesList()
{
var segment = new Segment { Id = 1, Name = "Premium Users", ProjectId = 5, CreatedAt = DateTimeOffset.UtcNow };
var response = SegmentResponse.From(segment);
Assert.That(response.Id, Is.EqualTo(segment.Id));
Assert.That(response.Name, Is.EqualTo("Premium Users"));
Assert.That(response.ProjectId, Is.EqualTo(5));
Assert.That(response.Rules, Is.Empty);
}
[Test]
public void WhenMappingASegmentWithTopLevelRules_ThenOnlyRulesWithoutAParentAreAtTheTopLevel()
{
var segment = new Segment { Id = 1, Name = "Test", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow };
var topLevelRule = new SegmentRule { Id = 1, SegmentId = 1, Type = SegmentRuleType.All };
var childRule = new SegmentRule { Id = 2, SegmentId = 1, ParentRuleId = 1, Type = SegmentRuleType.Any };
segment.Rules.Add(topLevelRule);
segment.Rules.Add(childRule);
var response = SegmentResponse.From(segment);
Assert.That(response.Rules, Has.Count.EqualTo(1));
Assert.That(response.Rules[0].Id, Is.EqualTo(topLevelRule.Id));
}
[Test]
public void WhenMappingARuleWithChildRules_ThenChildRulesAreNestedUnderTheParent()
{
var parent = new SegmentRule { Id = 1, SegmentId = 1, Type = SegmentRuleType.All };
var child = new SegmentRule { Id = 2, SegmentId = 1, ParentRuleId = 1, Type = SegmentRuleType.Any };
var allRules = new List<SegmentRule> { parent, child };
var response = SegmentRuleResponse.From(parent, allRules);
Assert.That(response.Type, Is.EqualTo(nameof(SegmentRuleType.All)));
Assert.That(response.ChildRules, Has.Count.EqualTo(1));
Assert.That(response.ChildRules[0].Id, Is.EqualTo(child.Id));
}
[Test]
public void WhenMappingARuleWithConditions_ThenEachConditionIsMapped()
{
var rule = new SegmentRule { Id = 1, SegmentId = 1, Type = SegmentRuleType.All };
rule.Conditions.Add(new SegmentCondition { Id = 1, RuleId = 1, Property = "plan", Operator = SegmentConditionOperator.Equal, Value = "premium" });
var response = SegmentRuleResponse.From(rule, [rule]);
Assert.That(response.Conditions, Has.Count.EqualTo(1));
Assert.That(response.Conditions[0].Property, Is.EqualTo("plan"));
}
[Test]
public void WhenMappingACondition_ThenIdPropertyOperatorAndValueAreCopied()
{
var condition = new SegmentCondition { Id = 7, RuleId = 1, Property = "plan", Operator = SegmentConditionOperator.Contains, Value = "premium" };
var response = SegmentConditionResponse.From(condition);
Assert.That(response.Id, Is.EqualTo(7));
Assert.That(response.Property, Is.EqualTo("plan"));
Assert.That(response.Operator, Is.EqualTo(nameof(SegmentConditionOperator.Contains)));
Assert.That(response.Value, Is.EqualTo("premium"));
}
[Test]
public void WhenCreatingASegmentSummaryResponse_ThenIdAndNameAreSet()
{
var response = new SegmentSummaryResponse(1, "Premium Users");
Assert.That(response.Id, Is.EqualTo(1));
Assert.That(response.Name, Is.EqualTo("Premium Users"));
}
}

View File

@@ -136,4 +136,51 @@ public class SegmentServiceTests
Assert.That(segments, Has.Count.EqualTo(2)); Assert.That(segments, Has.Count.EqualTo(2));
} }
[Test]
public async Task WhenFindingASegmentByIdThatExists_ThenTheSegmentIsReturned()
{
var segment = await _service.CreateAsync(ProjectId, "Segment", [SingleConditionRule()]);
var found = await _service.FindByIdAsync(segment.Id);
Assert.That(found, Is.Not.Null);
Assert.That(found!.Id, Is.EqualTo(segment.Id));
}
[Test]
public async Task WhenFindingASegmentByIdThatDoesNotExist_ThenNullIsReturned()
{
var found = await _service.FindByIdAsync(999);
Assert.That(found, Is.Null);
}
[Test]
public void WhenUpdatingASegmentThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown()
{
Assert.ThrowsAsync<KeyNotFoundException>(() =>
_service.UpdateAsync(999, "Renamed", [SingleConditionRule()]));
}
[Test]
public void WhenDeletingASegmentThatDoesNotExist_ThenNoExceptionIsThrown()
{
Assert.DoesNotThrowAsync(() => _service.DeleteAsync(999));
}
[Test]
public async Task WhenCreatingASegmentWithNestedChildRules_ThenChildRulesArePersistedWithTheParentAsTheirParent()
{
var childRule = new SegmentRuleDefinition(SegmentRuleType.Any, [new SegmentConditionDefinition("country", SegmentConditionOperator.Equal, "US")]);
var parentRule = new SegmentRuleDefinition(SegmentRuleType.All, [new SegmentConditionDefinition("plan", SegmentConditionOperator.Equal, "premium")], [childRule]);
var segment = await _service.CreateAsync(ProjectId, "Nested", [parentRule]);
var persistedRules = _segmentRules.Where(r => r.SegmentId == segment.Id).ToList();
var parent = persistedRules.Single(r => r.ParentRuleId == null);
var child = persistedRules.Single(r => r.ParentRuleId == parent.Id);
Assert.That(child.Conditions.Single().Property, Is.EqualTo("country"));
}
} }

View File

@@ -0,0 +1,197 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Common;
using MicCheck.Api.Data;
using MicCheck.Api.Organizations;
using MicCheck.Api.Projects;
using MicCheck.Api.Segments;
using MicCheck.Api.Tests.Unit.TestSupport;
using Microsoft.AspNetCore.Mvc;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Segments;
[TestFixture]
public class SegmentsControllerTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<Segment> _segments = null!;
private List<SegmentRule> _segmentRules = null!;
private List<SegmentCondition> _segmentConditions = null!;
private SegmentsController _controller = null!;
private const int OrganizationId = 1;
private const int ProjectId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_db.SetupDbSet(c => c.Organizations, [
new Organization { Id = OrganizationId, Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow }
]);
_db.SetupDbSet(c => c.Projects, [
new Project { Id = ProjectId, Name = "Test Project", OrganizationId = OrganizationId, CreatedAt = DateTimeOffset.UtcNow }
]);
_segments = [];
_db.SetupDbSetWithGeneratedIds(c => c.Segments, _segments);
_segmentRules = [];
_db.SetupDbSetWithGeneratedIds(c => c.SegmentRules, _segmentRules);
_segmentConditions = [];
_db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, _segmentConditions);
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
var auditService = new Mock<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?>(),
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var service = new SegmentService(_db.Object, auditService.Object);
_controller = new SegmentsController(service);
}
private static CreateSegmentRequest ValidRequest(string name = "Premium Users") => new(
name,
[new CreateSegmentRuleRequest("All", [new CreateSegmentConditionRequest("plan", "Equal", "premium")])]);
[Test]
public async Task WhenListingSegmentsForAProject_ThenAllSegmentsAreReturnedInAPage()
{
await _controller.Create(ProjectId, ValidRequest("Segment A"), CancellationToken.None);
await _controller.Create(ProjectId, ValidRequest("Segment B"), CancellationToken.None);
var result = await _controller.List(ProjectId);
var ok = result.Result as OkObjectResult;
var page = (PaginatedResponse<SegmentResponse>)ok!.Value!;
Assert.That(page.Count, Is.EqualTo(2));
Assert.That(page.Results, Has.Count.EqualTo(2));
}
[Test]
public async Task WhenListingSegmentsWithAPageSizeAboveTheMaximum_ThenThePageSizeIsClampedTo100()
{
for (var i = 0; i < 3; i++)
await _controller.Create(ProjectId, ValidRequest($"Segment {i}"), CancellationToken.None);
var result = await _controller.List(ProjectId, page: 1, pageSize: 1000);
var ok = result.Result as OkObjectResult;
var page = (PaginatedResponse<SegmentResponse>)ok!.Value!;
Assert.That(page.Results, Has.Count.EqualTo(3));
}
[Test]
public async Task WhenCreatingASegmentWithValidData_ThenACreatedResultWithTheSegmentIsReturned()
{
var result = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None);
var created = result.Result as CreatedAtActionResult;
Assert.That(created, Is.Not.Null);
Assert.That(((SegmentResponse)created!.Value!).Name, Is.EqualTo("Premium Users"));
}
[Test]
public async Task WhenCreatingASegmentThatExceedsTheProjectLimit_ThenBadRequestIsReturned()
{
for (var i = 0; i < 100; i++)
{
_segments.Add(new Segment { Id = i + 1, Name = $"segment_{i}", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow });
}
var result = await _controller.Create(ProjectId, ValidRequest("overflow"), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<BadRequestObjectResult>());
}
[Test]
public async Task WhenGettingASegmentByIdInTheCorrectProject_ThenTheSegmentIsReturned()
{
var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None);
var segmentId = ((SegmentResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.GetById(ProjectId, segmentId, CancellationToken.None);
var ok = result.Result as OkObjectResult;
Assert.That(ok, Is.Not.Null);
Assert.That(((SegmentResponse)ok!.Value!).Id, Is.EqualTo(segmentId));
}
[Test]
public async Task WhenGettingASegmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetById(ProjectId, 999, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingASegmentThatBelongsToADifferentProject_ThenNotFoundIsReturned()
{
var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None);
var segmentId = ((SegmentResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.GetById(999, segmentId, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingASegmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.Update(ProjectId, 999, ValidRequest(), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingASegmentInTheCorrectProject_ThenTheUpdatedSegmentIsReturned()
{
var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None);
var segmentId = ((SegmentResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.Update(ProjectId, segmentId, ValidRequest("Renamed"), CancellationToken.None);
var ok = result.Result as OkObjectResult;
Assert.That(ok, Is.Not.Null);
Assert.That(((SegmentResponse)ok!.Value!).Name, Is.EqualTo("Renamed"));
}
[Test]
public async Task WhenUpdatingASegmentWithTooManyConditions_ThenBadRequestIsReturned()
{
var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None);
var segmentId = ((SegmentResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var conditions = Enumerable.Range(0, 101)
.Select(i => new CreateSegmentConditionRequest($"prop_{i}", "Equal", "val"))
.ToList();
var overflowRequest = new CreateSegmentRequest("Too Many", [new CreateSegmentRuleRequest("All", conditions)]);
var result = await _controller.Update(ProjectId, segmentId, overflowRequest, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<BadRequestObjectResult>());
}
[Test]
public async Task WhenDeletingASegmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.Delete(ProjectId, 999, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingASegmentInTheCorrectProject_ThenNoContentIsReturnedAndTheSegmentIsRemoved()
{
var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None);
var segmentId = ((SegmentResponse)((CreatedAtActionResult)created.Result!).Value!).Id;
var result = await _controller.Delete(ProjectId, segmentId, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NoContentResult>());
Assert.That(_segments.Any(s => s.Id == segmentId), Is.False);
}
}

View File

@@ -0,0 +1,161 @@
using MicCheck.Api.Data;
using MicCheck.Api.Tests.Unit.TestSupport;
using MicCheck.Api.Users;
using Microsoft.AspNetCore.Identity;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Users;
[TestFixture]
public class UserServiceTests
{
private Mock<IMicCheckDbContext> _db = null!;
private Mock<IPasswordHasher<User>> _passwordHasher = null!;
private List<User> _users = null!;
private UserService _service = null!;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_users = [];
_db.SetupDbSet(c => c.Users, _users);
_passwordHasher = new Mock<IPasswordHasher<User>>();
_service = new UserService(_db.Object, _passwordHasher.Object);
}
private User AddUser(int id = 1, bool isActive = true, string email = "alice@example.com") => new()
{
Id = id,
Email = email,
PasswordHash = "hashed",
FirstName = "Alice",
LastName = "Smith",
IsActive = isActive,
CreatedAt = DateTimeOffset.UtcNow
};
[Test]
public async Task WhenFindingByIdForAnActiveUser_ThenTheUserIsReturned()
{
var user = AddUser();
_users.Add(user);
var result = await _service.FindByIdAsync(user.Id);
Assert.That(result, Is.SameAs(user));
}
[Test]
public async Task WhenFindingByIdForAnInactiveUser_ThenNullIsReturned()
{
var user = AddUser(isActive: false);
_users.Add(user);
var result = await _service.FindByIdAsync(user.Id);
Assert.That(result, Is.Null);
}
[Test]
public async Task WhenFindingByIdForAnUnknownUser_ThenNullIsReturned()
{
var result = await _service.FindByIdAsync(999);
Assert.That(result, Is.Null);
}
[Test]
public async Task WhenUpdatingTheProfileOfAnUnknownUser_ThenNullIsReturnedWithoutConflict()
{
var result = await _service.UpdateProfileAsync(999, "Bob", "Jones", "bob@example.com");
Assert.That(result.User, Is.Null);
Assert.That(result.EmailConflict, Is.False);
}
[Test]
public async Task WhenUpdatingTheProfileWithANewUniqueEmail_ThenTheUserFieldsAreUpdated()
{
var user = AddUser();
_users.Add(user);
var result = await _service.UpdateProfileAsync(user.Id, "Bob", "Jones", "bob@example.com");
Assert.That(result.EmailConflict, Is.False);
Assert.That(result.User!.FirstName, Is.EqualTo("Bob"));
Assert.That(result.User.LastName, Is.EqualTo("Jones"));
Assert.That(result.User.Email, Is.EqualTo("bob@example.com"));
}
[Test]
public async Task WhenUpdatingTheProfileWithTheSameEmailInDifferentCase_ThenNoConflictIsDetected()
{
var user = AddUser(email: "alice@example.com");
_users.Add(user);
var result = await _service.UpdateProfileAsync(user.Id, "Alice", "Smith", "ALICE@EXAMPLE.COM");
Assert.That(result.EmailConflict, Is.False);
Assert.That(result.User!.Email, Is.EqualTo("ALICE@EXAMPLE.COM"));
}
[Test]
public async Task WhenUpdatingTheProfileWithAnEmailAlreadyUsedByAnotherUser_ThenAConflictIsReturnedAndTheUserIsUnchanged()
{
var user = AddUser(id: 1, email: "alice@example.com");
var otherUser = AddUser(id: 2, email: "taken@example.com");
_users.Add(user);
_users.Add(otherUser);
var result = await _service.UpdateProfileAsync(user.Id, "Bob", "Jones", "taken@example.com");
Assert.That(result.EmailConflict, Is.True);
Assert.That(result.User, Is.Null);
Assert.That(user.Email, Is.EqualTo("alice@example.com"));
}
[Test]
public async Task WhenChangingThePasswordForAnUnknownUser_ThenFalseIsReturned()
{
var result = await _service.ChangePasswordAsync(999, "current", "new");
Assert.That(result, Is.False);
}
[Test]
public async Task WhenChangingThePasswordWithAnIncorrectCurrentPassword_ThenFalseIsReturnedAndTheHashIsUnchanged()
{
var user = AddUser();
_users.Add(user);
_passwordHasher
.Setup(h => h.VerifyHashedPassword(user, user.PasswordHash, "wrong"))
.Returns(PasswordVerificationResult.Failed);
var result = await _service.ChangePasswordAsync(user.Id, "wrong", "new-password");
Assert.That(result, Is.False);
Assert.That(user.PasswordHash, Is.EqualTo("hashed"));
}
[Test]
public async Task WhenChangingThePasswordWithACorrectCurrentPassword_ThenTheHashIsUpdatedAndTrueIsReturned()
{
var user = AddUser();
_users.Add(user);
_passwordHasher
.Setup(h => h.VerifyHashedPassword(user, user.PasswordHash, "current"))
.Returns(PasswordVerificationResult.Success);
_passwordHasher
.Setup(h => h.HashPassword(user, "new-password"))
.Returns("new-hashed");
var result = await _service.ChangePasswordAsync(user.Id, "current", "new-password");
Assert.That(result, Is.True);
Assert.That(user.PasswordHash, Is.EqualTo("new-hashed"));
}
}

View File

@@ -0,0 +1,217 @@
using System.Security.Claims;
using MicCheck.Api.Data;
using MicCheck.Api.Tests.Unit.TestSupport;
using MicCheck.Api.Users;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Users;
[TestFixture]
public class UsersControllerTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<User> _users = null!;
private UsersController _controller = null!;
private const int UserId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_users = [];
_db.SetupDbSet(c => c.Users, _users);
var passwordHasher = new Mock<IPasswordHasher<User>>();
passwordHasher
.Setup(h => h.VerifyHashedPassword(It.IsAny<User>(), "hashed", "correct-password"))
.Returns(PasswordVerificationResult.Success);
passwordHasher
.Setup(h => h.VerifyHashedPassword(It.IsAny<User>(), "hashed", It.Is<string>(p => p != "correct-password")))
.Returns(PasswordVerificationResult.Failed);
passwordHasher
.Setup(h => h.HashPassword(It.IsAny<User>(), It.IsAny<string>()))
.Returns("new-hashed");
var service = new UserService(_db.Object, passwordHasher.Object);
_controller = new UsersController(service);
}
private User AddUser(int id = UserId, string email = "alice@example.com") => new()
{
Id = id,
Email = email,
PasswordHash = "hashed",
FirstName = "Alice",
LastName = "Smith",
IsActive = true,
CreatedAt = DateTimeOffset.UtcNow
};
private void AuthenticateAs(int? userId)
{
var claims = userId is null ? [] : new[] { new Claim(ClaimTypes.NameIdentifier, userId.Value.ToString()) };
var identity = new ClaimsIdentity(claims, userId is null ? null : "TestAuth");
_controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }
};
}
[Test]
public async Task WhenGettingAUserById_ThenTheMatchingUserIsReturned()
{
var user = AddUser();
_users.Add(user);
var result = await _controller.GetById(user.Id, CancellationToken.None);
var ok = result.Result as OkObjectResult;
Assert.That(ok, Is.Not.Null);
Assert.That(((UserResponse)ok!.Value!).Id, Is.EqualTo(user.Id));
}
[Test]
public async Task WhenGettingAUserByIdThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetById(999, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingTheCurrentUserWithoutAuthentication_ThenUnauthorizedIsReturned()
{
AuthenticateAs(null);
var result = await _controller.GetMe(CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<UnauthorizedResult>());
}
[Test]
public async Task WhenGettingTheCurrentUser_ThenTheAuthenticatedUsersProfileIsReturned()
{
var user = AddUser();
_users.Add(user);
AuthenticateAs(user.Id);
var result = await _controller.GetMe(CancellationToken.None);
var ok = result.Result as OkObjectResult;
Assert.That(ok, Is.Not.Null);
Assert.That(((UserResponse)ok!.Value!).Email, Is.EqualTo(user.Email));
}
[Test]
public async Task WhenUpdatingTheProfileWithoutAuthentication_ThenUnauthorizedIsReturned()
{
AuthenticateAs(null);
var result = await _controller.UpdateMe(new UpdateProfileRequest("Bob", "Jones", "bob@example.com"), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<UnauthorizedResult>());
}
[Test]
public async Task WhenUpdatingTheProfileWithAMissingField_ThenBadRequestIsReturned()
{
var user = AddUser();
_users.Add(user);
AuthenticateAs(user.Id);
var result = await _controller.UpdateMe(new UpdateProfileRequest("", "Jones", "bob@example.com"), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<BadRequestObjectResult>());
}
[Test]
public async Task WhenUpdatingTheProfileWithAnEmailTakenByAnotherUser_ThenConflictIsReturned()
{
var user = AddUser(id: 1, email: "alice@example.com");
var otherUser = AddUser(id: 2, email: "taken@example.com");
_users.Add(user);
_users.Add(otherUser);
AuthenticateAs(user.Id);
var result = await _controller.UpdateMe(new UpdateProfileRequest("Alice", "Smith", "taken@example.com"), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<ConflictObjectResult>());
}
[Test]
public async Task WhenUpdatingTheProfileForAUserThatNoLongerExists_ThenNotFoundIsReturned()
{
AuthenticateAs(999);
var result = await _controller.UpdateMe(new UpdateProfileRequest("Bob", "Jones", "bob@example.com"), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingTheProfileWithValidData_ThenTheUpdatedProfileIsReturned()
{
var user = AddUser();
_users.Add(user);
AuthenticateAs(user.Id);
var result = await _controller.UpdateMe(new UpdateProfileRequest(" Bob ", " Jones ", " bob@example.com "), CancellationToken.None);
var ok = result.Result as OkObjectResult;
Assert.That(ok, Is.Not.Null);
var response = (UserResponse)ok!.Value!;
Assert.That(response.FirstName, Is.EqualTo("Bob"));
Assert.That(response.LastName, Is.EqualTo("Jones"));
Assert.That(response.Email, Is.EqualTo("bob@example.com"));
}
[Test]
public async Task WhenChangingPasswordWithoutAuthentication_ThenUnauthorizedIsReturned()
{
AuthenticateAs(null);
var result = await _controller.ChangePassword(new ChangePasswordRequest("current", "new"), CancellationToken.None);
Assert.That(result, Is.InstanceOf<UnauthorizedResult>());
}
[Test]
public async Task WhenChangingPasswordWithAMissingField_ThenBadRequestIsReturned()
{
var user = AddUser();
_users.Add(user);
AuthenticateAs(user.Id);
var result = await _controller.ChangePassword(new ChangePasswordRequest("", "new"), CancellationToken.None);
Assert.That(result, Is.InstanceOf<BadRequestObjectResult>());
}
[Test]
public async Task WhenChangingPasswordWithAnIncorrectCurrentPassword_ThenBadRequestIsReturned()
{
var user = AddUser();
_users.Add(user);
AuthenticateAs(user.Id);
var result = await _controller.ChangePassword(new ChangePasswordRequest("wrong-password", "new-password"), CancellationToken.None);
Assert.That(result, Is.InstanceOf<BadRequestObjectResult>());
}
[Test]
public async Task WhenChangingPasswordWithACorrectCurrentPassword_ThenNoContentIsReturned()
{
var user = AddUser();
_users.Add(user);
AuthenticateAs(user.Id);
var result = await _controller.ChangePassword(new ChangePasswordRequest("correct-password", "new-password"), CancellationToken.None);
Assert.That(result, Is.InstanceOf<NoContentResult>());
}
}

View File

@@ -0,0 +1,46 @@
using MicCheck.Api.Webhooks;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Webhooks;
[TestFixture]
public class CreateWebhookRequestValidatorTests
{
private CreateWebhookRequestValidator _validator = null!;
[SetUp]
public void SetUp() => _validator = new CreateWebhookRequestValidator();
[Test]
public void WhenTheRequestIsValid_ThenValidationSucceeds()
{
var result = _validator.Validate(new CreateWebhookRequest("https://example.com/hook", "secret", true));
Assert.That(result.IsValid, Is.True);
}
[Test]
public void WhenUrlIsEmpty_ThenValidationFails()
{
var result = _validator.Validate(new CreateWebhookRequest("", null, true));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenUrlExceedsMaximumLength_ThenValidationFails()
{
var longUrl = "https://example.com/" + new string('a', 500);
var result = _validator.Validate(new CreateWebhookRequest(longUrl, null, true));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenUrlIsNotAnAbsoluteUri_ThenValidationFails()
{
var result = _validator.Validate(new CreateWebhookRequest("not a valid url", null, true));
Assert.That(result.IsValid, Is.False);
}
}

View File

@@ -163,6 +163,95 @@ public class WebhookDispatcherTests
Assert.That(log.ResponseStatusCode, Is.EqualTo(500)); Assert.That(log.ResponseStatusCode, Is.EqualTo(500));
} }
[Test]
public async Task WhenAnEnvironmentScopedEventIsDispatched_ThenOrganizationScopedWebhooksForThatOrgAlsoReceiveIt()
{
var (db, webhooks, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.OK);
const int orgId = 1;
const int envId = 5;
webhooks.Add(new Webhook { Url = "https://env.example.com", Scope = WebhookScope.Environment, EnvironmentId = envId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
webhooks.Add(new Webhook { Url = "https://org.example.com", Scope = WebhookScope.Organization, OrganizationId = orgId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
webhooks.Add(new Webhook { Url = "https://other-env.example.com", Scope = WebhookScope.Environment, EnvironmentId = 999, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<WebhookDispatcher>.Instance);
await dispatcher.DispatchAsync(new WebhookEvent
{
EventType = WebhookEventTypes.FlagUpdated,
EnvironmentId = envId,
OrganizationId = orgId,
Data = new { }
});
Assert.That(deliveryLogs, Has.Count.EqualTo(2));
}
[Test]
public async Task WhenAnOrganizationScopedEventIsDispatched_ThenEnvironmentScopedWebhooksAreNotIncluded()
{
var (db, webhooks, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.OK);
const int orgId = 1;
webhooks.Add(new Webhook { Url = "https://env.example.com", Scope = WebhookScope.Environment, EnvironmentId = 5, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
webhooks.Add(new Webhook { Url = "https://org.example.com", Scope = WebhookScope.Organization, OrganizationId = orgId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<WebhookDispatcher>.Instance);
await dispatcher.DispatchAsync(new WebhookEvent
{
EventType = WebhookEventTypes.AuditLogCreated,
EnvironmentId = null,
OrganizationId = orgId,
Data = new { }
});
Assert.That(deliveryLogs, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenAWebhookIsDisabled_ThenItIsExcludedFromDispatch()
{
var (db, webhooks, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.OK);
const int orgId = 1;
webhooks.Add(new Webhook { Url = "https://example.com", Scope = WebhookScope.Organization, OrganizationId = orgId, Enabled = false, CreatedAt = DateTimeOffset.UtcNow });
var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<WebhookDispatcher>.Instance);
await dispatcher.DispatchAsync(new WebhookEvent
{
EventType = WebhookEventTypes.FlagUpdated,
OrganizationId = orgId,
Data = new { }
});
Assert.That(deliveryLogs, Is.Empty);
}
[Test]
public async Task WhenTheHttpClientThrows_ThenTheDeliveryLogRecordsTheErrorMessage()
{
var db = new Mock<IMicCheckDbContext>();
var webhooks = new List<Webhook> { new() { Url = "https://example.com", Scope = WebhookScope.Organization, OrganizationId = 1, Enabled = true, CreatedAt = DateTimeOffset.UtcNow } };
db.SetupDbSetWithGeneratedIds(c => c.Webhooks, webhooks);
var deliveryLogs = new List<WebhookDeliveryLog>();
db.SetupDbSet(c => c.WebhookDeliveryLogs, deliveryLogs);
var handler = new Mock<HttpMessageHandler>();
handler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Connection refused"));
var httpClient = new HttpClient(handler.Object);
var factory = new Mock<IHttpClientFactory>();
factory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var dispatcher = new WebhookDispatcher(db.Object, factory.Object, NullLogger<WebhookDispatcher>.Instance);
await dispatcher.DispatchAsync(new WebhookEvent { EventType = WebhookEventTypes.FlagUpdated, OrganizationId = 1, Data = new { } });
var log = deliveryLogs.First();
Assert.That(log.Success, Is.False);
Assert.That(log.ErrorMessage, Is.EqualTo("Connection refused"));
Assert.That(log.ResponseStatusCode, Is.Null);
}
[Test] [Test]
public async Task WhenNoWebhooksAreRegistered_ThenNoDeliveryLogsAreCreated() public async Task WhenNoWebhooksAreRegistered_ThenNoDeliveryLogsAreCreated()
{ {

View File

@@ -0,0 +1,48 @@
using MicCheck.Api.Webhooks;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Webhooks;
[TestFixture]
public class WebhookQueueTests
{
[Test]
public async Task WhenAnEventIsEnqueued_ThenItCanBeReadBack()
{
var queue = new WebhookQueue();
var webhookEvent = new WebhookEvent
{
EventType = WebhookEventTypes.FlagUpdated,
OrganizationId = 1,
Data = new { }
};
await queue.EnqueueAsync(webhookEvent);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
await using var enumerator = queue.ReadAllAsync(cts.Token).GetAsyncEnumerator(cts.Token);
await enumerator.MoveNextAsync();
Assert.That(enumerator.Current, Is.SameAs(webhookEvent));
}
[Test]
public async Task WhenMultipleEventsAreEnqueued_ThenTheyAreReadInOrder()
{
var queue = new WebhookQueue();
var first = new WebhookEvent { EventType = WebhookEventTypes.FlagUpdated, OrganizationId = 1, Data = new { } };
var second = new WebhookEvent { EventType = WebhookEventTypes.FlagDeleted, OrganizationId = 1, Data = new { } };
await queue.EnqueueAsync(first);
await queue.EnqueueAsync(second);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
await using var enumerator = queue.ReadAllAsync(cts.Token).GetAsyncEnumerator(cts.Token);
await enumerator.MoveNextAsync();
Assert.That(enumerator.Current, Is.SameAs(first));
await enumerator.MoveNextAsync();
Assert.That(enumerator.Current, Is.SameAs(second));
}
}

View File

@@ -0,0 +1,110 @@
using MicCheck.Api.Webhooks;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Webhooks;
[TestFixture]
public class WebhookResponseTests
{
[Test]
public void WhenMappingAnEnvironmentScopedWebhook_ThenScopeIsSerializedAsItsName()
{
var webhook = new Webhook
{
Id = 1,
Url = "https://example.com/hook",
Secret = "s3cr3t",
Scope = WebhookScope.Environment,
EnvironmentId = 5,
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow
};
var response = WebhookResponse.From(webhook);
Assert.That(response.Id, Is.EqualTo(1));
Assert.That(response.Url, Is.EqualTo("https://example.com/hook"));
Assert.That(response.Secret, Is.EqualTo("s3cr3t"));
Assert.That(response.Scope, Is.EqualTo("Environment"));
Assert.That(response.Enabled, Is.True);
Assert.That(response.EnvironmentId, Is.EqualTo(5));
Assert.That(response.OrganizationId, Is.Null);
}
[Test]
public void WhenMappingAnOrganizationScopedWebhook_ThenScopeIsSerializedAsItsName()
{
var webhook = new Webhook
{
Id = 2,
Url = "https://example.com/hook",
Scope = WebhookScope.Organization,
OrganizationId = 9,
Enabled = false,
CreatedAt = DateTimeOffset.UtcNow
};
var response = WebhookResponse.From(webhook);
Assert.That(response.Scope, Is.EqualTo("Organization"));
Assert.That(response.OrganizationId, Is.EqualTo(9));
Assert.That(response.EnvironmentId, Is.Null);
}
}
[TestFixture]
public class WebhookDeliveryLogResponseTests
{
[Test]
public void WhenMappingASuccessfulDelivery_ThenAllFieldsAreCopied()
{
var log = new WebhookDeliveryLog
{
Id = 1,
WebhookId = 2,
EventType = "FLAG_UPDATED",
PayloadJson = "{}",
ResponseStatusCode = 200,
ResponseBody = "ok",
Success = true,
AttemptNumber = 1,
AttemptedAt = DateTimeOffset.UtcNow,
Duration = TimeSpan.FromMilliseconds(42)
};
var response = WebhookDeliveryLogResponse.From(log);
Assert.That(response.Id, Is.EqualTo(1));
Assert.That(response.WebhookId, Is.EqualTo(2));
Assert.That(response.EventType, Is.EqualTo("FLAG_UPDATED"));
Assert.That(response.Success, Is.True);
Assert.That(response.ResponseStatusCode, Is.EqualTo(200));
Assert.That(response.ResponseBody, Is.EqualTo("ok"));
Assert.That(response.ErrorMessage, Is.Null);
Assert.That(response.AttemptNumber, Is.EqualTo(1));
Assert.That(response.Duration, Is.EqualTo(TimeSpan.FromMilliseconds(42)));
}
[Test]
public void WhenMappingAFailedDelivery_ThenErrorMessageIsCopied()
{
var log = new WebhookDeliveryLog
{
Id = 1,
WebhookId = 2,
EventType = "FLAG_UPDATED",
PayloadJson = "{}",
Success = false,
ErrorMessage = "Connection refused",
AttemptNumber = 2,
AttemptedAt = DateTimeOffset.UtcNow,
Duration = TimeSpan.Zero
};
var response = WebhookDeliveryLogResponse.From(log);
Assert.That(response.Success, Is.False);
Assert.That(response.ErrorMessage, Is.EqualTo("Connection refused"));
Assert.That(response.ResponseStatusCode, Is.Null);
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat code coverage">
<Configuration>
<Exclude>[MicCheck.Api]MicCheck.Api.Migrations.*,[MicCheck.Api]MicCheck.Api.Data.*</Exclude>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>