Compare commits

...

3 Commits

Author SHA1 Message Date
James Wampler
7fc021500c Add unit tests for MicCheck.Api.Users
Covers UserService (profile update, email conflict, password change)
and UsersController (auth/validation/not-found/conflict branches),
raising the namespace from 3% coverage.
2026-07-05 13:11:29 -07:00
James Wampler
3888168571 Exclude Migrations namespace from dotnet code coverage
EF migrations are generated scaffolding with no meaningful logic to
test; counting them against the coverage target only dilutes the
signal.
2026-07-05 13:11:25 -07:00
James Wampler
fe22ba01b7 Add coverage reporting/badges and split CI between Gitea and GitHub
All checks were successful
CI / build-and-push (push) Successful in 38s
CI / deploy-qa (push) Has been skipped
CI / smoke-qa (push) Has been skipped
Merge dotnet+jest coverage via reportgenerator, publish a self-hosted
coverage badge and build-status badges on the readme. Gitea remains the
full pipeline (build/test/docker push/deploy-qa/smoke); GitHub only
builds and tests since it has no registry secrets or qa runner.
2026-07-05 10:12:12 -07:00
11 changed files with 647 additions and 3 deletions

View File

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

3
.gitignore vendored
View File

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

138
badges/coverage.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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