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:
@@ -0,0 +1,53 @@
|
||||
using YesChef.Api.Data;
|
||||
using YesChef.Api.Entities;
|
||||
|
||||
namespace YesChef.Api.IntegrationTests.Builders;
|
||||
|
||||
public sealed class RecipeBuilder
|
||||
{
|
||||
private string _title = $"Recipe-{Guid.NewGuid():N}"[..24];
|
||||
private string? _description;
|
||||
private string? _instructions;
|
||||
private int? _servings;
|
||||
private string? _sourceUrl;
|
||||
private int? _createdByUserId;
|
||||
private readonly List<RecipeIngredient> _ingredients = [];
|
||||
|
||||
public RecipeBuilder Titled(string title) { _title = title; return this; }
|
||||
public RecipeBuilder Describes(string description) { _description = description; return this; }
|
||||
public RecipeBuilder WithInstructions(string instructions) { _instructions = instructions; return this; }
|
||||
public RecipeBuilder Serves(int servings) { _servings = servings; return this; }
|
||||
public RecipeBuilder SourcedFrom(string url) { _sourceUrl = url; return this; }
|
||||
public RecipeBuilder CreatedBy(User user) { _createdByUserId = user.Id; return this; }
|
||||
public RecipeBuilder CreatedBy(int userId) { _createdByUserId = userId; return this; }
|
||||
|
||||
public RecipeBuilder WithIngredient(string name, string? quantity = null, int sortOrder = 0)
|
||||
{
|
||||
_ingredients.Add(new RecipeIngredient { Name = name, Quantity = quantity, SortOrder = sortOrder });
|
||||
return this;
|
||||
}
|
||||
|
||||
public Recipe Build()
|
||||
{
|
||||
if (_createdByUserId is null) throw new InvalidOperationException("Recipe requires a creator. Call CreatedBy().");
|
||||
|
||||
return new Recipe
|
||||
{
|
||||
Title = _title,
|
||||
Description = _description,
|
||||
Instructions = _instructions,
|
||||
Servings = _servings,
|
||||
SourceUrl = _sourceUrl,
|
||||
CreatedByUserId = _createdByUserId.Value,
|
||||
Ingredients = _ingredients
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<Recipe> PersistAsync(YesChefDb db)
|
||||
{
|
||||
var recipe = Build();
|
||||
db.Recipes.Add(recipe);
|
||||
await db.SaveChangesAsync();
|
||||
return recipe;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using YesChef.Api.Data;
|
||||
using YesChef.Api.Entities;
|
||||
|
||||
namespace YesChef.Api.IntegrationTests.Builders;
|
||||
|
||||
public sealed class ShoppingListBuilder
|
||||
{
|
||||
private string _name = $"List-{Guid.NewGuid():N}"[..20];
|
||||
private int? _storeId;
|
||||
private int? _createdByUserId;
|
||||
private bool _archived;
|
||||
private readonly List<ShoppingListItem> _items = [];
|
||||
|
||||
public ShoppingListBuilder Named(string name) { _name = name; return this; }
|
||||
public ShoppingListBuilder ForStore(Store store) { _storeId = store.Id; return this; }
|
||||
public ShoppingListBuilder ForStore(int storeId) { _storeId = storeId; return this; }
|
||||
public ShoppingListBuilder CreatedBy(User user) { _createdByUserId = user.Id; return this; }
|
||||
public ShoppingListBuilder CreatedBy(int userId) { _createdByUserId = userId; return this; }
|
||||
public ShoppingListBuilder Archived() { _archived = true; return this; }
|
||||
|
||||
public ShoppingListBuilder WithItem(string name, bool isChecked = false, int sortOrder = 0)
|
||||
{
|
||||
_items.Add(new ShoppingListItem { Name = name, IsChecked = isChecked, SortOrder = sortOrder });
|
||||
return this;
|
||||
}
|
||||
|
||||
public ShoppingList Build()
|
||||
{
|
||||
if (_storeId is null) throw new InvalidOperationException("ShoppingList requires a Store. Call ForStore().");
|
||||
if (_createdByUserId is null) throw new InvalidOperationException("ShoppingList requires a creator. Call CreatedBy().");
|
||||
|
||||
return new ShoppingList
|
||||
{
|
||||
Name = _name,
|
||||
StoreId = _storeId.Value,
|
||||
CreatedByUserId = _createdByUserId.Value,
|
||||
IsArchived = _archived,
|
||||
Items = _items
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ShoppingList> PersistAsync(YesChefDb db)
|
||||
{
|
||||
var list = Build();
|
||||
db.ShoppingLists.Add(list);
|
||||
await db.SaveChangesAsync();
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using YesChef.Api.Data;
|
||||
using YesChef.Api.Entities;
|
||||
|
||||
namespace YesChef.Api.IntegrationTests.Builders;
|
||||
|
||||
public sealed class StoreBuilder
|
||||
{
|
||||
private string _name = $"Store-{Guid.NewGuid():N}"[..20];
|
||||
private int _sortOrder;
|
||||
|
||||
public StoreBuilder Named(string name) { _name = name; return this; }
|
||||
public StoreBuilder WithSortOrder(int sortOrder) { _sortOrder = sortOrder; return this; }
|
||||
|
||||
public Store Build() => new() { Name = _name, SortOrder = _sortOrder };
|
||||
|
||||
public async Task<Store> PersistAsync(YesChefDb db)
|
||||
{
|
||||
var store = Build();
|
||||
db.Stores.Add(store);
|
||||
await db.SaveChangesAsync();
|
||||
return store;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using YesChef.Api.Data;
|
||||
using YesChef.Api.Entities;
|
||||
|
||||
namespace YesChef.Api.IntegrationTests.Builders;
|
||||
|
||||
public sealed class UserBuilder
|
||||
{
|
||||
private string _name = $"user-{Guid.NewGuid():N}"[..16];
|
||||
private string _password = "correct-horse-battery-staple";
|
||||
|
||||
public UserBuilder Named(string name) { _name = name; return this; }
|
||||
public UserBuilder WithPassword(string password) { _password = password; return this; }
|
||||
|
||||
public string PlaintextPassword => _password;
|
||||
|
||||
public User Build()
|
||||
{
|
||||
var user = new User { Name = _name, PasswordHash = "" };
|
||||
user.PasswordHash = new PasswordHasher<User>().HashPassword(user, _password);
|
||||
return user;
|
||||
}
|
||||
|
||||
public async Task<User> PersistAsync(YesChefDb db)
|
||||
{
|
||||
var user = Build();
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
return user;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user