Add TUnit-based unit and integration tests for backend

Set up YesChef.Api.UnitTests and YesChef.Api.IntegrationTests projects
running on TUnit + Microsoft.Testing.Platform. Integration tests use a
single Postgres 17 Testcontainer per session and clone a migrated
template database per test (`CREATE DATABASE … TEMPLATE …`) so tests
remain fully isolated and run in parallel without replaying migrations
each time.

Test-author DX is built around fluent entity builders, a TestDataFactory
for common scenarios, and a two-level base hierarchy
(IntegrationTest / AuthenticatedIntegrationTest) whose `[Before(Test)]`
hooks stand up the per-test database, app factory, default user, and
authenticated HttpClient — leaving each test body focused on the action
under test.

Adds src/backend/global.json to opt `dotnet test` into MTP mode on the
.NET 10 SDK, and updates CLAUDE.md with how to run the tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Josh Rogers
2026-05-06 20:56:29 -05:00
parent 7ca2dc46d9
commit 76e8de9484
23 changed files with 1199 additions and 0 deletions
@@ -0,0 +1,95 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using YesChef.Api.Auth;
using YesChef.Api.Entities;
namespace YesChef.Api.UnitTests.Auth;
public class JwtTokenServiceTests
{
private const string Secret = "this-is-a-test-secret-that-is-definitely-long-enough-for-hs256";
private static JwtTokenService BuildService(string secret = Secret)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Jwt:Secret"] = secret })
.Build();
return new JwtTokenService(config);
}
private static JwtSecurityToken Decode(string token) =>
new JwtSecurityTokenHandler().ReadJwtToken(token);
[Test]
public async Task GenerateToken_includes_user_id_and_name_claims()
{
var service = BuildService();
var user = new User { Id = 42, Name = "alice", PasswordHash = "x" };
var jwt = Decode(service.GenerateToken(user));
await Assert.That(jwt.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value).IsEqualTo("42");
await Assert.That(jwt.Claims.First(c => c.Type == ClaimTypes.Name).Value).IsEqualTo("alice");
}
[Test]
public async Task GenerateToken_expires_in_about_30_days()
{
var service = BuildService();
var user = new User { Id = 1, Name = "bob", PasswordHash = "x" };
var jwt = Decode(service.GenerateToken(user));
var expectedExpiry = DateTime.UtcNow.AddDays(30);
var delta = (jwt.ValidTo - expectedExpiry).Duration();
await Assert.That(delta).IsLessThan(TimeSpan.FromMinutes(1));
}
[Test]
public async Task GenerateToken_signs_with_hs256_using_configured_secret()
{
var service = BuildService();
var user = new User { Id = 7, Name = "carol", PasswordHash = "x" };
var token = service.GenerateToken(user);
var validator = new JwtSecurityTokenHandler();
var parameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Secret))
};
var principal = validator.ValidateToken(token, parameters, out var validatedToken);
await Assert.That(principal.Identity!.IsAuthenticated).IsTrue();
await Assert.That(((JwtSecurityToken)validatedToken).SignatureAlgorithm)
.IsEqualTo(SecurityAlgorithms.HmacSha256);
}
[Test]
public async Task GenerateToken_with_different_secret_fails_validation()
{
var service = BuildService();
var token = service.GenerateToken(new User { Id = 1, Name = "x", PasswordHash = "x" });
var validator = new JwtSecurityTokenHandler();
var parameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes("a-completely-different-secret-of-sufficient-length"))
};
await Assert.That(() => validator.ValidateToken(token, parameters, out _))
.ThrowsExactly<SecurityTokenSignatureKeyNotFoundException>();
}
}