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

@@ -1,91 +0,0 @@
using MicCheck.Api.Data;
using MicCheck.Api.Organizations;
using MicCheck.Api.Users;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Data;
[TestFixture]
public class DatabaseSeederTests
{
private MicCheckDbContext _db = null!;
private DatabaseSeeder _seeder = null!;
[SetUp]
public void SetUp()
{
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_db = new MicCheckDbContext(options);
_seeder = new DatabaseSeeder(_db, new PasswordHasher<User>());
}
[TearDown]
public void TearDown() => _db.Dispose();
[Test]
public async Task WhenTheDatabaseIsEmpty_ThenSeedingCreatesADeterministicAdminUser()
{
await _seeder.SeedAsync();
var admin = await _db.Users.SingleOrDefaultAsync(u => u.Email == DatabaseSeeder.SeedAdminEmail);
Assert.That(admin, Is.Not.Null);
Assert.That(admin!.IsActive, Is.True);
}
[Test]
public async Task WhenTheDatabaseIsEmpty_ThenTheSeededAdminPasswordVerifiesAgainstTheKnownCredential()
{
await _seeder.SeedAsync();
var admin = await _db.Users.SingleAsync(u => u.Email == DatabaseSeeder.SeedAdminEmail);
var hasher = new PasswordHasher<User>();
var result = hasher.VerifyHashedPassword(admin, admin.PasswordHash, DatabaseSeeder.SeedAdminPassword);
Assert.That(result, Is.Not.EqualTo(PasswordVerificationResult.Failed));
}
[Test]
public async Task WhenTheDatabaseIsEmpty_ThenTheSeededAdminIsAnAdminOfTheDefaultOrganization()
{
await _seeder.SeedAsync();
var organization = await _db.Organizations.SingleAsync(o => o.Name == "Default");
var admin = await _db.Users.SingleAsync(u => u.Email == DatabaseSeeder.SeedAdminEmail);
var membership = await _db.OrganizationUsers
.SingleAsync(ou => ou.OrganizationId == organization.Id && ou.UserId == admin.Id);
Assert.That(membership.Role, Is.EqualTo(OrganizationRole.Admin));
Assert.That(membership.IsPrimary, Is.True);
}
[Test]
public async Task WhenTheDatabaseIsEmpty_ThenTheSeededDevelopmentEnvironmentHasTheFixedApiKey()
{
await _seeder.SeedAsync();
var development = await _db.Environments.SingleAsync(e => e.Name == "Development");
var staging = await _db.Environments.SingleAsync(e => e.Name == "Staging");
var production = await _db.Environments.SingleAsync(e => e.Name == "Production");
Assert.That(development.ApiKey, Is.EqualTo(DatabaseSeeder.SeedDevelopmentEnvironmentKey));
Assert.That(staging.ApiKey, Is.Not.EqualTo(DatabaseSeeder.SeedDevelopmentEnvironmentKey));
Assert.That(production.ApiKey, Is.Not.EqualTo(DatabaseSeeder.SeedDevelopmentEnvironmentKey));
}
[Test]
public async Task WhenSeedingRunsTwice_ThenItDoesNotCreateDuplicateOrganizationsOrUsers()
{
await _seeder.SeedAsync();
await _seeder.SeedAsync();
Assert.That(await _db.Organizations.CountAsync(), Is.EqualTo(1));
Assert.That(await _db.Users.CountAsync(u => u.Email == DatabaseSeeder.SeedAdminEmail), Is.EqualTo(1));
Assert.That(await _db.Environments.CountAsync(), Is.EqualTo(3));
}
}

View File

@@ -1,224 +0,0 @@
using NUnit.Framework;
using MicCheck.Api.Data;
using MicCheck.Api.Features;
using MicCheck.Api.Organizations;
using MicCheck.Api.Projects;
using MicCheck.Api.Segments;
using MicCheck.Api.Identities;
using MicCheck.Api.Audit;
using MicCheck.Api.Webhooks;
using MicCheck.Api.Common.Security.ApiKeys;
using MicCheck.Api.Users;
using Microsoft.EntityFrameworkCore;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Tests.Unit.Data;
[TestFixture]
public class MicCheckDbContextTests
{
private MicCheckDbContext _db = null!;
[SetUp]
public void SetUp()
{
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_db = new MicCheckDbContext(options);
}
[TearDown]
public void TearDown() => _db.Dispose();
[Test]
public async Task WhenAnOrganizationIsSaved_ThenItCanBeRetrievedById()
{
var organization = new Organization { Name = "Acme", CreatedAt = DateTimeOffset.UtcNow };
_db.Organizations.Add(organization);
await _db.SaveChangesAsync();
var retrieved = await _db.Organizations.FindAsync(organization.Id);
Assert.That(retrieved, Is.Not.Null);
Assert.That(retrieved!.Name, Is.EqualTo("Acme"));
}
[Test]
public async Task WhenAProjectIsSaved_ThenItCanBeRetrievedByOrganization()
{
var organization = new Organization { Name = "Acme", CreatedAt = DateTimeOffset.UtcNow };
_db.Organizations.Add(organization);
await _db.SaveChangesAsync();
var project = new Project { Name = "My Project", OrganizationId = organization.Id, CreatedAt = DateTimeOffset.UtcNow };
_db.Projects.Add(project);
await _db.SaveChangesAsync();
var projects = await _db.Projects.Where(p => p.OrganizationId == organization.Id).ToListAsync();
Assert.That(projects, Has.Count.EqualTo(1));
Assert.That(projects[0].Name, Is.EqualTo("My Project"));
}
[Test]
public async Task WhenAnEnvironmentIsSaved_ThenItCanBeRetrievedByApiKey()
{
var project = new Project { Name = "Test", OrganizationId = 1, CreatedAt = DateTimeOffset.UtcNow };
_db.Projects.Add(project);
await _db.SaveChangesAsync();
var env = new AppEnvironment { Name = "Production", ApiKey = "env-key-abc", ProjectId = project.Id, CreatedAt = DateTimeOffset.UtcNow };
_db.Environments.Add(env);
await _db.SaveChangesAsync();
var retrieved = await _db.Environments.FirstOrDefaultAsync(e => e.ApiKey == "env-key-abc");
Assert.That(retrieved, Is.Not.Null);
Assert.That(retrieved!.Name, Is.EqualTo("Production"));
}
[Test]
public async Task WhenAFeatureIsSaved_ThenItCanBeRetrievedByProject()
{
var feature = new Feature { Name = "dark_mode", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow };
_db.Features.Add(feature);
await _db.SaveChangesAsync();
var features = await _db.Features.Where(f => f.ProjectId == 1).ToListAsync();
Assert.That(features, Has.Count.EqualTo(1));
Assert.That(features[0].Name, Is.EqualTo("dark_mode"));
}
[Test]
public async Task WhenAFeatureStateIsSaved_ThenItCanBeQueriedByEnvironment()
{
var state = new FeatureState
{
FeatureId = 1,
EnvironmentId = 1,
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
_db.FeatureStates.Add(state);
await _db.SaveChangesAsync();
var states = await _db.FeatureStates.Where(fs => fs.EnvironmentId == 1).ToListAsync();
Assert.That(states, Has.Count.EqualTo(1));
Assert.That(states[0].Enabled, Is.True);
}
[Test]
public async Task WhenASegmentIsSaved_ThenItsRulesCanBeLoaded()
{
var segment = new Segment { Name = "Power Users", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow };
_db.Segments.Add(segment);
await _db.SaveChangesAsync();
var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All };
_db.SegmentRules.Add(rule);
await _db.SaveChangesAsync();
var loaded = await _db.Segments
.Include(s => s.Rules)
.FirstAsync(s => s.Id == segment.Id);
Assert.That(loaded.Rules, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenAnIdentityIsSaved_ThenItsTraitsCanBeLoaded()
{
var identity = new Identity { Identifier = "user-123", EnvironmentId = 1, CreatedAt = DateTimeOffset.UtcNow };
_db.Identities.Add(identity);
await _db.SaveChangesAsync();
_db.IdentityTraits.Add(new IdentityTrait { IdentityId = identity.Id, Key = "plan", Value = "premium" });
await _db.SaveChangesAsync();
var loaded = await _db.Identities
.Include(i => i.Traits)
.FirstAsync(i => i.Id == identity.Id);
Assert.That(loaded.Traits, Has.Count.EqualTo(1));
Assert.That(loaded.Traits.First().Key, Is.EqualTo("plan"));
}
[Test]
public async Task WhenAnAuditLogIsSaved_ThenItCanBeFilteredByOrganization()
{
_db.AuditLogs.Add(new AuditLog
{
ResourceType = "Feature",
ResourceId = "1",
Action = "Created",
OrganizationId = 42,
CreatedAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync();
var logs = await _db.AuditLogs.Where(a => a.OrganizationId == 42).ToListAsync();
Assert.That(logs, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenAWebhookIsSaved_ThenItCanBeFilteredByEnvironment()
{
_db.Webhooks.Add(new Webhook
{
Url = "https://example.com/hook",
Scope = WebhookScope.Environment,
EnvironmentId = 5,
CreatedAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync();
var webhooks = await _db.Webhooks.Where(w => w.EnvironmentId == 5).ToListAsync();
Assert.That(webhooks, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenAnApiKeyIsSaved_ThenItCanBeRetrievedByHashedKey()
{
_db.ApiKeys.Add(new ApiKey
{
Key = "hashed-value-xyz",
Prefix = "hashed-va",
Name = "CI Key",
OrganizationId = 1,
IsActive = true,
CreatedAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync();
var key = await _db.ApiKeys.FirstOrDefaultAsync(k => k.Key == "hashed-value-xyz");
Assert.That(key, Is.Not.Null);
Assert.That(key!.IsActive, Is.True);
}
[Test]
public async Task WhenAUserIsSaved_ThenItCanBeRetrievedByEmail()
{
_db.Users.Add(new User
{
Email = "alice@example.com",
PasswordHash = "hashed",
FirstName = "Alice",
LastName = "Smith",
IsActive = true,
CreatedAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync();
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == "alice@example.com");
Assert.That(user, Is.Not.Null);
Assert.That(user!.FirstName, Is.EqualTo("Alice"));
}
}