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
# 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
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)
/.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
[![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.
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"
}
# 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

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
# --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"

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>