Refactor nunit tests off InMemory/WebApplicationFactory, remove tuple returns (#4)
All checks were successful
CI / build-and-push (push) Successful in 48s
CI / deploy-qa (push) Successful in 12s
CI / smoke-qa (push) Successful in 20s

## Summary
- Extract `IMicCheckDbContext` and mock DB access with Moq instead of the EF InMemory provider, per CLAUDE.md testing guidelines.
- Rewrite HTTP-pipeline tests to exercise controllers/auth handlers directly instead of `WebApplicationFactory`.
- Remove tuple return types across the API in favor of named records (`PagedResult<T>`, `ProfileUpdateResult`, `ApiKeyCreationResult`).

## Test plan
- [x] `dotnet build` (full solution)
- [x] `dotnet test tests/api/MicCheck.Api.Tests.Unit` — 187/187 pass
- [x] `dotnet test tests/api/MicCheck.Api.Tests.Integration` — 8/8 pass (live API + Postgres via Aspire)
- [x] Verified Aspire AppHost/dashboard starts and API responds

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-07-04 21:40:47 -07:00
parent 1556b486d2
commit 20188c61a2
62 changed files with 1406 additions and 2458 deletions

View File

@@ -4,28 +4,28 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Users;
public class UserService(MicCheckDbContext db, IPasswordHasher<User> passwordHasher)
public class UserService(IMicCheckDbContext db, IPasswordHasher<User> passwordHasher)
{
public async Task<User?> FindByIdAsync(int userId, CancellationToken ct = default) =>
await db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct);
public async Task<(User? User, bool EmailConflict)> UpdateProfileAsync(
public async Task<ProfileUpdateResult> UpdateProfileAsync(
int userId, string firstName, string lastName, string email, CancellationToken ct = default)
{
var user = await db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct);
if (user is null) return (null, false);
if (user is null) return new ProfileUpdateResult(null, EmailConflict: false);
if (!string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase))
{
var emailTaken = await db.Users.AnyAsync(u => u.Email == email && u.Id != userId, ct);
if (emailTaken) return (null, true);
if (emailTaken) return new ProfileUpdateResult(null, EmailConflict: true);
}
user.FirstName = firstName;
user.LastName = lastName;
user.Email = email;
await db.SaveChangesAsync(ct);
return (user, false);
return new ProfileUpdateResult(user, EmailConflict: false);
}
public async Task<bool> ChangePasswordAsync(
@@ -42,3 +42,5 @@ public class UserService(MicCheckDbContext db, IPasswordHasher<User> passwordHas
return true;
}
}
public record ProfileUpdateResult(User? User, bool EmailConflict);

View File

@@ -39,11 +39,11 @@ public class UsersController(UserService userService) : ControllerBase
string.IsNullOrWhiteSpace(request.Email))
return BadRequest("First name, last name, and email are required.");
var (user, emailConflict) = await userService.UpdateProfileAsync(
var result = await userService.UpdateProfileAsync(
userId.Value, request.FirstName.Trim(), request.LastName.Trim(), request.Email.Trim(), ct);
if (emailConflict) return Conflict("Email is already in use.");
return user is null ? NotFound() : Ok(UserResponse.From(user));
if (result.EmailConflict) return Conflict("Email is already in use.");
return result.User is null ? NotFound() : Ok(UserResponse.From(result.User));
}
[HttpPost("me/change-password")]