Compare commits
13 Commits
main
...
4ad3285afa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ad3285afa | ||
|
|
837c51d366 | ||
|
|
4f04d805ac | ||
|
|
7b33bca6a9 | ||
|
|
d6594521f8 | ||
|
|
6ed173ff7c | ||
|
|
678f605197 | ||
|
|
117fc3bde7 | ||
|
|
1f09f0a3d2 | ||
|
|
0595ff5642 | ||
|
|
7fc021500c | ||
|
|
3888168571 | ||
|
|
fe22ba01b7 |
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
@@ -2,15 +2,25 @@
|
||||
# (both look under .github/workflows/). Every non-checkout step just invokes a
|
||||
# bash script under scripts/ci/, so the entire pipeline is reproducible by
|
||||
# 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
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, build-runner-fix]
|
||||
paths-ignore: [badges/**]
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
REGISTRY: ${{ secrets.REGISTRY }}
|
||||
REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }}
|
||||
@@ -25,14 +35,25 @@ jobs:
|
||||
- name: Test
|
||||
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
|
||||
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
|
||||
run: ./scripts/ci/docker-build.sh
|
||||
|
||||
- name: Push Docker images
|
||||
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
|
||||
run: ./scripts/ci/docker-push.sh
|
||||
|
||||
deploy-qa:
|
||||
needs: build-and-push
|
||||
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
|
||||
runs-on: [self-hosted, qa]
|
||||
env:
|
||||
REGISTRY: ${{ secrets.REGISTRY }}
|
||||
@@ -56,6 +77,7 @@ jobs:
|
||||
|
||||
smoke-qa:
|
||||
needs: deploy-qa
|
||||
if: github.server_url != 'https://github.com'
|
||||
runs-on: [self-hosted, qa]
|
||||
env:
|
||||
QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -476,3 +476,6 @@ ehthumbs.db
|
||||
|
||||
# Self-installed .NET SDK (scripts/ci/lib.sh ensure_dotnet, used when a CI runner lacks the SDK)
|
||||
/.dotnet/
|
||||
|
||||
# Self-installed reportgenerator CLI (scripts/ci/lib.sh ensure_reportgenerator)
|
||||
/.dotnet-tools/
|
||||
|
||||
33
CLAUDE.md
33
CLAUDE.md
@@ -1,10 +1,10 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for Claude Code (claude.ai/code) when working in this repo.
|
||||
Guidance for Claude Code (claude.ai/code) in this repo.
|
||||
|
||||
## Project
|
||||
|
||||
MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, projects, environments.
|
||||
MicCheck: open source. asp.net, c#, typescript, VueJS. Manage feature flags, projects, environments.
|
||||
|
||||
## Planned Structure
|
||||
|
||||
@@ -18,27 +18,30 @@ MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, pr
|
||||
- Use latest LTS .NET + latest supported nuget packages for that version
|
||||
- Set `langVersion` to latest in all csproj files; enable nullable
|
||||
- Organize code by feature/area, not type (e.g. `features` namespace)
|
||||
- New features need unit tests covering as much logic as possible (both nunit and jest)
|
||||
- Any modified file: evaluate for missing test coverage and that all tests pass
|
||||
- New features need unit tests covering logic as much as possible (both nunit and jest)
|
||||
- Modified file: check missing test coverage, all tests pass
|
||||
|
||||
# Coding
|
||||
- Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper
|
||||
- Descriptive names all classes/methods. No generic: Provider, Manager, Helper
|
||||
- Match formatting/style from `.editorconfig`
|
||||
- Wrap lines at 220 characters, leave single line if fewer
|
||||
- Place interfaces that are implemented by a single class at the bottom of the class file. An interface with multiple implementations of an interface should be in a seperate file.
|
||||
- Do not use tuples for return types. Prefer records or classes for multiple values
|
||||
- Do not use `sealed`
|
||||
- Wrap lines at 220 chars, single line if fewer
|
||||
- Interfaces implemented by single class → bottom of class file. Interface w/ multiple implementations → separate file.
|
||||
- No tuples for return types. Prefer records or classes for multiple values
|
||||
- No `sealed`
|
||||
- Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible.
|
||||
|
||||
## Testing
|
||||
- Min 70% code coverage, target 90%. Unit tests focus end-user scenarios first.
|
||||
- Don't write tests just for coverage. Call out missing coverage rather than cover stuff not valuable to end user.
|
||||
- Code not cleanly unit-testable → mark `[ExcludeFromCodeCoverage]` or exclude namespace from coverage in .runsettings file
|
||||
- BDD-style unit tests, end-to-end as possible, no external resources (DB, filesystem). e.g. `WhenAUserDoesSomething_ThenAThingAppears`
|
||||
- Mock external deps with Moq
|
||||
- Mock EntityFramework DBContexts with an extracted interface and Moq. Do not rely on InMemory provider.
|
||||
- New features need unit tests covering as much logic as possible
|
||||
- Any modified file: evaluate for missing test coverage-
|
||||
- Mock external deps w/ Moq
|
||||
- Mock EntityFramework DBContexts via extracted interface + Moq. Don't rely on InMemory provider.
|
||||
- New features need unit tests covering logic as much as possible
|
||||
- Modified file: check missing test coverage
|
||||
- No "Mock" in mocked object names
|
||||
- No Arrange/Act/Assert comments
|
||||
- All tests should pass before commit
|
||||
- All tests pass before commit
|
||||
|
||||
## Claude
|
||||
- Plans = `.md` files in `docs/plans/`. Admin → `docs/plans/admin/`, API → `docs/plans/api/`
|
||||
@@ -47,4 +50,4 @@ MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, pr
|
||||
- Plan implemented from `docs/plans/<name>.md` → save summary as `docs/plans/<name>_output.md`
|
||||
|
||||
## Stack
|
||||
`.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes.
|
||||
`.gitignore` set for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack change.
|
||||
|
||||
55
CLAUDE.original.md
Executable file → Normal file
55
CLAUDE.original.md
Executable file → Normal file
@@ -1,44 +1,53 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
Guidance for Claude Code (claude.ai/code) when working in this repo.
|
||||
|
||||
## Project
|
||||
|
||||
MicCheck is an open source project written in asp.net, c#, typescript and VueJS that manages feature flags, their projects, and their environments.
|
||||
MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, projects, environments.
|
||||
|
||||
## Planned Structure
|
||||
|
||||
- `src/admin/` — administrative components in VueJS
|
||||
- `src/api/` - api and restful endpoints in .NET
|
||||
- `src/admin/` — VueJS admin components
|
||||
- `src/api/` - .NET API + REST endpoints
|
||||
- `tests/` — test suite
|
||||
- `docs/` — documentation
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Ensure all projects are using the latest LTS version of .NET as well as the latest supported nuget packages for that .NET version
|
||||
- Ensure that langVersion is set to latest in all csproj files and nullable is enabled
|
||||
- Code in all projects should be organized by feature or area instead of type. (e.g. a features namespace with all feature related code in it or in child namespaces of it)
|
||||
- All new feature requests should include corresponding unit tests that cover as much of the logic as possible.
|
||||
- Any file modified should be evaluated for potential test cases and missing coverage areas.
|
||||
- Use latest LTS .NET + latest supported nuget packages for that version
|
||||
- Set `langVersion` to latest in all csproj files; enable nullable
|
||||
- Organize code by feature/area, not type (e.g. `features` namespace)
|
||||
- New features need unit tests covering as much logic as possible (both nunit and jest)
|
||||
- Any modified file: evaluate for missing test coverage and that all tests pass
|
||||
|
||||
# Coding
|
||||
- Use descriptive names for all classes and method created. Avoid generic names like Provider, Manager, Helper
|
||||
- Coding should match formating and style rules in the .editorconfig file
|
||||
- Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper
|
||||
- Match formatting/style from `.editorconfig`
|
||||
- Wrap lines at 220 characters, leave single line if fewer
|
||||
- Place interfaces that are implemented by a single class at the bottom of the class file. An interface with multiple implementations of an interface should be in a seperate file.
|
||||
- Do not use tuples for return types. Prefer records or classes for multiple values
|
||||
- Do not use `sealed`
|
||||
- Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible.
|
||||
|
||||
## Testing
|
||||
- Write unit tests in a BDD style, testing as much code end-to-end as possible without without touching external resources like databases or file systems (e.g. WhenAUserDoesSomething_ThenAThingAppears)
|
||||
- Mock any external dependencies using Moq.
|
||||
- Do not name any mocked objects with the word Mock in them
|
||||
- Do not include any Arrange / Act / Assert comments in the code
|
||||
|
||||
- Require a minimum of 70% code coverage with a target of 90%. Unit tests should focus on end-user scenarios first.
|
||||
- Do not write tests for just to increase code coverage. Call out lack of test coverage rather than covering something that isn't valuable to the end user.
|
||||
- Code that can not be cleanly unit tested should be marked with [ExcludeFromCodeCoverage] or have it's namespace excluded from code coverage.
|
||||
- BDD-style unit tests, end-to-end as possible, no external resources (DB, filesystem). e.g. `WhenAUserDoesSomething_ThenAThingAppears`
|
||||
- Mock external deps with Moq
|
||||
- Mock EntityFramework DBContexts with an extracted interface and Moq. Do not rely on InMemory provider.
|
||||
- New features need unit tests covering as much logic as possible
|
||||
- Any modified file: evaluate for missing test coverage-
|
||||
- No "Mock" in mocked object names
|
||||
- No Arrange/Act/Assert comments
|
||||
- All tests should pass before commit
|
||||
|
||||
## Claude
|
||||
- Create all plans as .md file located in the `docs/` folder. Admin in `docs/admin/` and API in `docs/api/`
|
||||
- Divide up large plans into discrete chucks of functionality so each can be built and committed independently.
|
||||
- When generating a plan from a .md file in /docs/ save the plan in the same folder with the same filename minus the .md extention, but with a _plan.md at the end
|
||||
- When implementing a plan from a .md file in /docs/ save the summary of the plan in the same folder with the same filename minus the .md extention, but with a _output.md at the end
|
||||
|
||||
- Plans = `.md` files in `docs/plans/`. Admin → `docs/plans/admin/`, API → `docs/plans/api/`
|
||||
- Split large plans into discrete chunks — each buildable + committable independently
|
||||
- Plan generated from `docs/plans/<name>.md` → save as `docs/plans/<name>_plan.md`
|
||||
- Plan implemented from `docs/plans/<name>.md` → save summary as `docs/plans/<name>_output.md`
|
||||
|
||||
## Stack
|
||||
|
||||
The `.gitignore` is configured for a .NET/Visual Studio project (C#, NuGet, MSBuild). If this changes, update this file accordingly.
|
||||
`.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes.
|
||||
|
||||
138
badges/coverage.svg
Normal file
138
badges/coverage.svg
Normal 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 |
@@ -1,5 +1,9 @@
|
||||
# MicCheck
|
||||
|
||||
[](https://github.com/wamplerj/mic-check/actions/workflows/ci.yml)
|
||||
[](https://git.wampler.us/wamplerj/mic-check/actions?workflow=ci.yml)
|
||||

|
||||
|
||||
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).
|
||||
|
||||
32
scripts/ci/coverage.sh
Executable file
32
scripts/ci/coverage.sh
Executable 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"
|
||||
@@ -125,6 +125,22 @@ ensure_node() {
|
||||
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
|
||||
# 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
|
||||
|
||||
30
scripts/ci/publish-coverage-badge.sh
Executable file
30
scripts/ci/publish-coverage-badge.sh
Executable 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"
|
||||
@@ -8,10 +8,19 @@ cd "$CI_ROOT"
|
||||
|
||||
ensure_dotnet
|
||||
|
||||
# --results-directory doesn't clear prior runs - it adds a new GUID folder
|
||||
# alongside old ones every time. On a runner that reuses its workspace
|
||||
# (self-hosted, unlike GitHub's ephemeral ones), stale coverage from past
|
||||
# runs would otherwise get merged in by coverage.sh and silently skew the
|
||||
# combined percentage.
|
||||
rm -rf "$CI_ROOT/coverage/dotnet"
|
||||
|
||||
log "Running MicCheck.Api.Tests.Unit"
|
||||
dotnet test tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj -c Release --logger trx
|
||||
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"
|
||||
npm --prefix src/admin test
|
||||
npm --prefix src/admin test -- --coverage --coverageReporters=lcov --coverageReporters=text-summary
|
||||
|
||||
log "test.sh complete"
|
||||
|
||||
@@ -12,17 +12,15 @@ namespace MicCheck.Api.Audit;
|
||||
[ApiController]
|
||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||
[EnableRateLimiting("AdminApi")]
|
||||
[Route("api/v1")]
|
||||
public class AuditLogsController(
|
||||
AuditLogQueryService auditLogQueryService,
|
||||
OrganizationService organizationService,
|
||||
ProjectService projectService,
|
||||
EnvironmentService environmentService) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/v1/organisation/{id}/audit-logs")]
|
||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(
|
||||
int id,
|
||||
[FromQuery] AuditLogFilter filter,
|
||||
CancellationToken ct)
|
||||
[HttpGet("organisation/{id}/audit-logs")]
|
||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(int id, [FromQuery] AuditLogFilter filter, CancellationToken ct)
|
||||
{
|
||||
var org = await organizationService.FindByIdAsync(id, ct);
|
||||
if (org is null) return NotFound();
|
||||
@@ -31,11 +29,8 @@ public class AuditLogsController(
|
||||
return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
|
||||
}
|
||||
|
||||
[HttpGet("api/v1/project/{projectId}/audit-logs")]
|
||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(
|
||||
int projectId,
|
||||
[FromQuery] AuditLogFilter filter,
|
||||
CancellationToken ct)
|
||||
[HttpGet("project/{projectId}/audit-logs")]
|
||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(int projectId, [FromQuery] AuditLogFilter filter, CancellationToken ct)
|
||||
{
|
||||
var project = await projectService.FindByIdAsync(projectId, ct);
|
||||
if (project is null) return NotFound();
|
||||
@@ -44,11 +39,8 @@ public class AuditLogsController(
|
||||
return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
|
||||
}
|
||||
|
||||
[HttpGet("api/v1/environment/{apiKey}/audit-logs")]
|
||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(
|
||||
string apiKey,
|
||||
[FromQuery] AuditLogFilter filter,
|
||||
CancellationToken ct)
|
||||
[HttpGet("environment/{apiKey}/audit-logs")]
|
||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(string apiKey, [FromQuery] AuditLogFilter filter, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.ApiKeys;
|
||||
|
||||
[ExcludeFromCodeCoverage(Justification = "Minimal-API route registration; requires a live HTTP pipeline to exercise, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). Branch logic is covered via ApiKeyService unit tests.")]
|
||||
public static class ApiKeyEndpoints
|
||||
{
|
||||
public static void MapApiKeyEndpoints(this WebApplication app)
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
namespace MicCheck.Api.Common.Security.ApiKeys;
|
||||
|
||||
public record ApiKeyResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
string Prefix,
|
||||
bool IsActive,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
DateTimeOffset CreatedAt);
|
||||
public record ApiKeyResponse(int Id, string Name, string Prefix, bool IsActive, DateTimeOffset? ExpiresAt, DateTimeOffset CreatedAt);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
[ExcludeFromCodeCoverage(Justification = "Minimal-API route registration; requires a live HTTP pipeline to exercise, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). Branch logic is covered via AuthService unit tests.")]
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
public static void MapAuthEndpoints(this WebApplication app)
|
||||
|
||||
@@ -13,10 +13,7 @@ namespace MicCheck.Api.Features;
|
||||
public class FeatureUsageController(FeatureUsageQueryService queryService, IMemoryCache cache) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<DashboardUsageResponse>> GetDashboardUsage(
|
||||
int environmentId,
|
||||
[FromQuery] int days = 14,
|
||||
CancellationToken ct = default)
|
||||
public async Task<ActionResult<DashboardUsageResponse>> GetDashboardUsage(int environmentId, [FromQuery] int days = 14, CancellationToken ct = default)
|
||||
{
|
||||
days = Math.Clamp(days, 1, 90);
|
||||
var cacheKey = $"usage-dashboard:{environmentId}:{days}";
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
[ExcludeFromCodeCoverage(Justification = "Timer-driven BackgroundService that issues raw SQL through a DI-scoped concrete MicCheckDbContext; exercising it cleanly requires a live DB, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). DrainAccumulated's bucketing logic is covered by FeatureUsageMetricsTests.")]
|
||||
public class FeatureUsageFlushBackgroundService(
|
||||
FeatureUsageMetrics metrics,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using MicCheck.Api.Audit;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -46,6 +47,7 @@ public class OrganizationService(IMicCheckDbContext db, AuditService auditServic
|
||||
return org;
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage(Justification = "ExecuteUpdateAsync requires a real EF Core relational query provider; our Mock<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)
|
||||
{
|
||||
var member = await db.OrganizationUsers
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace MicCheck.Api.Webhooks;
|
||||
|
||||
[ExcludeFromCodeCoverage(Justification = "Long-running BackgroundService reading from a Channel and issuing DI-scoped dispatches; exercising it cleanly requires a live DI container and DB, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). Dispatch logic is covered by WebhookDispatcherTests.")]
|
||||
public class WebhookBackgroundService(
|
||||
WebhookQueue queue,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Webhooks;
|
||||
|
||||
[ExcludeFromCodeCoverage(Justification = "Timer-driven BackgroundService that resolves a DI-scoped concrete MicCheckDbContext and WebhookDispatcher; exercising it cleanly requires a live DI container and DB, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). The retry-eligibility query logic is covered by WebhookRetryTests.")]
|
||||
public class WebhookRetryBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<WebhookRetryBackgroundService> logger) : BackgroundService
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,85 @@ public class AuditServiceTests
|
||||
Assert.That(log.ActorUserId, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenLoggingWithNoChanges_ThenChangesRemainsNull()
|
||||
{
|
||||
await _service.LogAsync("Feature", "1", "created", organizationId: 1);
|
||||
|
||||
var log = _auditLogs.First();
|
||||
Assert.That(log.Changes, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenLoggingWithChanges_ThenChangesAndMetadataArePersisted()
|
||||
{
|
||||
await _service.LogAsync("Feature", "2", "updated", organizationId: 5, projectId: 6, environmentId: 7, changes: "raw-json");
|
||||
|
||||
var log = _auditLogs.First();
|
||||
Assert.That(log.ResourceType, Is.EqualTo("Feature"));
|
||||
Assert.That(log.ResourceId, Is.EqualTo("2"));
|
||||
Assert.That(log.Action, Is.EqualTo("updated"));
|
||||
Assert.That(log.Changes, Is.EqualTo("raw-json"));
|
||||
Assert.That(log.OrganizationId, Is.EqualTo(5));
|
||||
Assert.That(log.ProjectId, Is.EqualTo(6));
|
||||
Assert.That(log.EnvironmentId, Is.EqualTo(7));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenLogging_ThenAuditLogCreatedEventIsEnqueued()
|
||||
{
|
||||
await _service.LogAsync("Feature", "1", "created", organizationId: 1);
|
||||
|
||||
var events = new List<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]
|
||||
public async Task WhenRecording_ThenAuditLogCreatedEventIsEnqueued()
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,14 @@ public class ApiKeyAuthenticationHandlerTests
|
||||
Assert.That(result.None, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenApiKeyValueIsEmpty_ThenAuthenticationFails()
|
||||
{
|
||||
var result = await AuthenticateAsync("Api-Key ");
|
||||
|
||||
Assert.That(result.Succeeded, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenApiKeyIsInvalid_ThenAuthenticationFails()
|
||||
{
|
||||
|
||||
@@ -51,6 +51,14 @@ public class EnvironmentKeyAuthenticationHandlerTests
|
||||
Assert.That(result.None, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenEnvironmentKeyHeaderIsEmpty_ThenAuthenticationFails()
|
||||
{
|
||||
var result = await AuthenticateAsync("");
|
||||
|
||||
Assert.That(result.Succeeded, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenEnvironmentKeyIsInvalid_ThenAuthenticationFails()
|
||||
{
|
||||
|
||||
@@ -216,4 +216,10 @@ public class AuthServiceTests
|
||||
|
||||
Assert.That(response, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenLoggingOutWithATokenThatDoesNotExist_ThenNoExceptionIsThrown()
|
||||
{
|
||||
Assert.DoesNotThrowAsync(() => _service.LogoutAsync("unknown-token"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -153,4 +153,73 @@ public class EnvironmentServiceTests
|
||||
|
||||
Assert.That(cloned.ApiKey, Is.Not.EqualTo(source.ApiKey));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenCloningFromANonExistentApiKey_ThenKeyNotFoundExceptionIsThrown()
|
||||
{
|
||||
Assert.That(async () => await _service.CloneAsync("missing-key", "Staging"), Throws.TypeOf<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -179,4 +179,68 @@ public class FeatureServiceTests
|
||||
|
||||
Assert.That(updated.Tags.Select(t => t.Id), Does.Not.Contain(tag.Id));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenFindingAFeatureByIdThatExists_ThenTheFeatureIsReturned()
|
||||
{
|
||||
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
|
||||
|
||||
var found = await _service.FindByIdAsync(feature.Id);
|
||||
|
||||
Assert.That(found, Is.Not.Null);
|
||||
Assert.That(found!.Id, Is.EqualTo(feature.Id));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenFindingAFeatureByIdThatDoesNotExist_ThenNullIsReturned()
|
||||
{
|
||||
var found = await _service.FindByIdAsync(999);
|
||||
|
||||
Assert.That(found, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenUpdatingAFeatureThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown()
|
||||
{
|
||||
Assert.ThrowsAsync<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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
125
tests/api/MicCheck.Api.Tests.Unit/Identities/TraitInputTests.cs
Normal file
125
tests/api/MicCheck.Api.Tests.Unit/Identities/TraitInputTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" }));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -274,6 +274,51 @@ public class SegmentEvaluatorTests
|
||||
Assert.That(_evaluator.Evaluate(segment, [], "another-user"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenModuloConditionValueIsMalformed_ThenSegmentDoesNotMatch()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("userId", SegmentConditionOperator.ModuloValueDivisorRemainder, "not-a-modulo-spec"));
|
||||
var traits = new[] { Trait("userId", "9") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenModuloDivisorIsZero_ThenSegmentDoesNotMatch()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("userId", SegmentConditionOperator.ModuloValueDivisorRemainder, "0|0"));
|
||||
var traits = new[] { Trait("userId", "9") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenPercentageSplitValueIsNotANumber_ThenSegmentDoesNotMatch()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("", SegmentConditionOperator.PercentageSplit, "not-a-number"));
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, [], "any-user"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenARuleHasAChildRuleThatFails_ThenTheParentRuleDoesNotMatchEvenIfItsOwnConditionsPass()
|
||||
{
|
||||
var segment = new Segment { Name = "Test", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow };
|
||||
var parent = new SegmentRule { Type = SegmentRuleType.All };
|
||||
parent.Conditions.Add(Condition("plan", SegmentConditionOperator.Equal, "premium"));
|
||||
var child = new SegmentRule { Type = SegmentRuleType.All };
|
||||
child.Conditions.Add(Condition("country", SegmentConditionOperator.Equal, "US"));
|
||||
parent.ChildRules.Add(child);
|
||||
segment.Rules.Add(parent);
|
||||
|
||||
var traits = new[] { Trait("plan", "premium"), Trait("country", "UK") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenRuleTypeIsAny_ThenAtLeastOneConditionMustMatch()
|
||||
{
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -136,4 +136,51 @@ public class SegmentServiceTests
|
||||
|
||||
Assert.That(segments, Has.Count.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenFindingASegmentByIdThatExists_ThenTheSegmentIsReturned()
|
||||
{
|
||||
var segment = await _service.CreateAsync(ProjectId, "Segment", [SingleConditionRule()]);
|
||||
|
||||
var found = await _service.FindByIdAsync(segment.Id);
|
||||
|
||||
Assert.That(found, Is.Not.Null);
|
||||
Assert.That(found!.Id, Is.EqualTo(segment.Id));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenFindingASegmentByIdThatDoesNotExist_ThenNullIsReturned()
|
||||
{
|
||||
var found = await _service.FindByIdAsync(999);
|
||||
|
||||
Assert.That(found, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenUpdatingASegmentThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown()
|
||||
{
|
||||
Assert.ThrowsAsync<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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
161
tests/api/MicCheck.Api.Tests.Unit/Users/UserServiceTests.cs
Normal file
161
tests/api/MicCheck.Api.Tests.Unit/Users/UserServiceTests.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
217
tests/api/MicCheck.Api.Tests.Unit/Users/UsersControllerTests.cs
Normal file
217
tests/api/MicCheck.Api.Tests.Unit/Users/UsersControllerTests.cs
Normal 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>());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,95 @@ public class WebhookDispatcherTests
|
||||
Assert.That(log.ResponseStatusCode, Is.EqualTo(500));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAnEnvironmentScopedEventIsDispatched_ThenOrganizationScopedWebhooksForThatOrgAlsoReceiveIt()
|
||||
{
|
||||
var (db, webhooks, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.OK);
|
||||
|
||||
const int orgId = 1;
|
||||
const int envId = 5;
|
||||
webhooks.Add(new Webhook { Url = "https://env.example.com", Scope = WebhookScope.Environment, EnvironmentId = envId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
|
||||
webhooks.Add(new Webhook { Url = "https://org.example.com", Scope = WebhookScope.Organization, OrganizationId = orgId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
|
||||
webhooks.Add(new Webhook { Url = "https://other-env.example.com", Scope = WebhookScope.Environment, EnvironmentId = 999, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
|
||||
|
||||
var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<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]
|
||||
public async Task WhenNoWebhooksAreRegistered_ThenNoDeliveryLogsAreCreated()
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
12
tests/api/MicCheck.Api.Tests.Unit/coverlet.runsettings
Normal file
12
tests/api/MicCheck.Api.Tests.Unit/coverlet.runsettings
Normal 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>
|
||||
Reference in New Issue
Block a user