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,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;
}
}
@@ -0,0 +1,96 @@
using System.Net;
using System.Net.Http.Json;
using YesChef.Api.Auth;
using YesChef.Api.IntegrationTests.Infrastructure;
namespace YesChef.Api.IntegrationTests.Features;
public class AuthEndpointsTests : IntegrationTest
{
[Test]
public async Task Register_creates_user_and_returns_token()
{
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("alice", "hunter2", YesChefAppFactory.FamilyCode));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<AuthEndpoints.AuthResponse>();
await Assert.That(body!.Name).IsEqualTo("alice");
await Assert.That(body.Token).IsNotNullOrEmpty();
await Assert.That(await UseDbAsync(db => db.Users.AnyAsync(u => u.Name == "alice"))).IsTrue();
}
[Test]
public async Task Register_rejects_wrong_family_code()
{
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("bob", "pw", "wrong-code"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task Register_rejects_duplicate_name()
{
await Data.RegisterAsync("carol");
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("carol", "another", YesChefAppFactory.FamilyCode));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
}
[Test]
public async Task Login_returns_token_for_valid_credentials()
{
await Data.RegisterAsync("dave", "secret");
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/login",
new AuthEndpoints.LoginRequest("dave", "secret"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<AuthEndpoints.AuthResponse>();
await Assert.That(body!.Name).IsEqualTo("dave");
}
[Test]
public async Task Login_returns_unauthorized_for_unknown_user()
{
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/login",
new AuthEndpoints.LoginRequest("ghost", "anything"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
}
[Test]
public async Task Login_returns_unauthorized_for_wrong_password()
{
await Data.RegisterAsync("eve", "right-password");
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/login",
new AuthEndpoints.LoginRequest("eve", "wrong-password"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
}
[Test]
public async Task Me_returns_current_user_when_authenticated()
{
using var session = await Data.RegisterAsync("frank");
var response = await session.Client.GetAsync("/api/auth/me");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<MeResponse>();
await Assert.That(body!.name).IsEqualTo("frank");
}
[Test]
public async Task Me_returns_unauthorized_without_token()
{
var response = await AnonymousClient.GetAsync("/api/auth/me");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
}
private record MeResponse(int id, string name);
}
@@ -0,0 +1,123 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using YesChef.Api.Entities;
using YesChef.Api.Features.Recipes;
using YesChef.Api.IntegrationTests.Builders;
using YesChef.Api.IntegrationTests.Infrastructure;
namespace YesChef.Api.IntegrationTests.Features;
public class RecipeEndpointsTests : AuthenticatedIntegrationTest
{
private Task<Recipe> CreateRecipeAsync(Action<RecipeBuilder> configure) =>
Data.CreateRecipeAsync(b => { b.CreatedBy(User); configure(b); });
[Test]
public async Task Create_persists_recipe_and_ingredients()
{
var request = new RecipeEndpoints.CreateRecipeRequest(
Title: "Pasta",
Description: "weeknight",
Instructions: "boil",
Servings: 4,
SourceUrl: "https://example.com/pasta",
Ingredients:
[
new RecipeEndpoints.IngredientRequest("pasta", "1 lb", 1),
new RecipeEndpoints.IngredientRequest("salt", null, 2),
]);
var response = await Client.PostAsJsonAsync("/api/recipes", request);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
var recipe = await UseDbAsync(db => db.Recipes.Include(r => r.Ingredients).SingleAsync());
await Assert.That(recipe.Title).IsEqualTo("Pasta");
await Assert.That(recipe.Servings).IsEqualTo(4);
await Assert.That(recipe.Ingredients.Count).IsEqualTo(2);
}
[Test]
public async Task Get_by_id_returns_full_recipe_including_creator_and_ingredients()
{
var recipe = await CreateRecipeAsync(b => b
.Titled("Soup").Describes("warm").Serves(2)
.WithIngredient("water", "4 cups", 2)
.WithIngredient("broth cube", "1", 1));
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/recipes/{recipe.Id}");
await Assert.That(body.GetProperty("title").GetString()).IsEqualTo("Soup");
await Assert.That(body.GetProperty("createdBy").GetString()).IsEqualTo(User.Name);
var ingredientNames = body.GetProperty("ingredients").EnumerateArray()
.Select(i => i.GetProperty("name").GetString()).ToArray();
await Assert.That(ingredientNames).IsEquivalentTo(new[] { "broth cube", "water" });
}
[Test]
public async Task Get_by_id_returns_404_when_missing()
{
var response = await Client.GetAsync("/api/recipes/99999");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
}
[Test]
public async Task List_filters_by_q_substring()
{
await CreateRecipeAsync(b => b.Titled("Pancakes"));
await CreateRecipeAsync(b => b.Titled("Waffles"));
await CreateRecipeAsync(b => b.Titled("Pad Thai"));
var hits = await Client.GetFromJsonAsync<List<JsonElement>>("/api/recipes?q=Pa");
await Assert.That(hits!.Select(h => h.GetProperty("title").GetString()))
.IsEquivalentTo(new[] { "Pancakes", "Pad Thai" });
}
[Test]
public async Task Update_replaces_ingredients()
{
var recipe = await CreateRecipeAsync(b => b
.Titled("Stew")
.WithIngredient("beef", "1 lb", 1)
.WithIngredient("carrot", "2", 2));
var update = new RecipeEndpoints.UpdateRecipeRequest(
Title: "Veggie Stew",
Description: null,
Instructions: null,
Servings: 6,
SourceUrl: null,
Ingredients:
[
new RecipeEndpoints.IngredientRequest("potato", "3", 1),
new RecipeEndpoints.IngredientRequest("onion", "1", 2),
new RecipeEndpoints.IngredientRequest("carrot", "4", 3),
]);
var response = await Client.PutAsJsonAsync($"/api/recipes/{recipe.Id}", update);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var refreshed = await UseDbAsync(db =>
db.Recipes.Include(r => r.Ingredients).SingleAsync(r => r.Id == recipe.Id));
await Assert.That(refreshed.Title).IsEqualTo("Veggie Stew");
await Assert.That(refreshed.Servings).IsEqualTo(6);
await Assert.That(refreshed.Ingredients.Select(i => i.Name))
.IsEquivalentTo(new[] { "potato", "onion", "carrot" });
await Assert.That(await UseDbAsync(db => db.RecipeIngredients.CountAsync())).IsEqualTo(3);
}
[Test]
public async Task Delete_removes_recipe_and_cascades_ingredients()
{
var recipe = await CreateRecipeAsync(b => b
.Titled("Toast")
.WithIngredient("bread", "2 slices", 1));
var response = await Client.DeleteAsync($"/api/recipes/{recipe.Id}");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
await Assert.That(await UseDbAsync(db => db.Recipes.CountAsync())).IsEqualTo(0);
await Assert.That(await UseDbAsync(db => db.RecipeIngredients.CountAsync())).IsEqualTo(0);
}
}
@@ -0,0 +1,211 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using YesChef.Api.Entities;
using YesChef.Api.Features.ShoppingLists;
using YesChef.Api.IntegrationTests.Builders;
using YesChef.Api.IntegrationTests.Infrastructure;
namespace YesChef.Api.IntegrationTests.Features;
public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
{
public Store Store { get; private set; } = null!;
[Before(Test)]
public async Task SetUpStore()
{
Store = await Data.CreateStoreAsync();
}
private Task<ShoppingList> CreateListAsync(Action<ShoppingListBuilder>? extra = null) =>
Data.CreateListAsync(b =>
{
b.ForStore(Store).CreatedBy(User);
extra?.Invoke(b);
});
[Test]
public async Task Create_persists_list_owned_by_caller()
{
var response = await Client.PostAsJsonAsync("/api/lists",
new ShoppingListEndpoints.CreateListRequest("Weekly", Store.Id));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
var list = await UseDbAsync(db => db.ShoppingLists.Include(l => l.CreatedByUser).SingleAsync());
await Assert.That(list.Name).IsEqualTo("Weekly");
await Assert.That(list.StoreId).IsEqualTo(Store.Id);
await Assert.That(list.CreatedByUser.Name).IsEqualTo(User.Name);
}
[Test]
public async Task List_excludes_archived_and_orders_by_updatedAt_desc()
{
await CreateListAsync(b => b.Named("old"));
await Task.Delay(15);
await CreateListAsync(b => b.Named("new"));
await CreateListAsync(b => b.Named("archived").Archived());
var lists = await Client.GetFromJsonAsync<List<JsonElement>>("/api/lists");
var names = lists!.Select(l => l.GetProperty("name").GetString()!).ToArray();
await Assert.That(names).IsEquivalentTo(new[] { "new", "old" });
}
[Test]
public async Task List_filters_by_storeId_when_provided()
{
var otherStore = await Data.CreateStoreAsync(b => b.Named("Other"));
await CreateListAsync(b => b.Named("from-default"));
await Data.CreateListAsync(b => b.Named("from-other").ForStore(otherStore).CreatedBy(User));
var lists = await Client.GetFromJsonAsync<List<JsonElement>>($"/api/lists?storeId={Store.Id}");
await Assert.That(lists!.Count).IsEqualTo(1);
await Assert.That(lists[0].GetProperty("name").GetString()).IsEqualTo("from-default");
}
[Test]
public async Task Get_by_id_returns_list_with_items_in_sort_order()
{
var list = await CreateListAsync(b => b
.Named("groceries")
.WithItem("milk", sortOrder: 2)
.WithItem("bread", sortOrder: 1));
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/lists/{list.Id}");
await Assert.That(body.GetProperty("name").GetString()).IsEqualTo("groceries");
var items = body.GetProperty("items").EnumerateArray()
.Select(i => i.GetProperty("name").GetString()).ToArray();
await Assert.That(items).IsEquivalentTo(new[] { "bread", "milk" });
}
[Test]
public async Task Get_by_id_returns_404_when_missing()
{
var response = await Client.GetAsync("/api/lists/99999");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
}
[Test]
public async Task Update_changes_name_and_store()
{
var newStore = await Data.CreateStoreAsync(b => b.Named("New"));
var list = await CreateListAsync();
var response = await Client.PutAsJsonAsync($"/api/lists/{list.Id}",
new ShoppingListEndpoints.UpdateListRequest("renamed", newStore.Id));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var refreshed = await UseDbAsync(db => db.ShoppingLists.SingleAsync(l => l.Id == list.Id));
await Assert.That(refreshed.Name).IsEqualTo("renamed");
await Assert.That(refreshed.StoreId).IsEqualTo(newStore.Id);
}
[Test]
public async Task Delete_archives_rather_than_removing()
{
var list = await CreateListAsync();
var response = await Client.DeleteAsync($"/api/lists/{list.Id}");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
var refreshed = await UseDbAsync(db => db.ShoppingLists.SingleAsync(l => l.Id == list.Id));
await Assert.That(refreshed.IsArchived).IsTrue();
}
[Test]
public async Task Add_item_appends_to_list()
{
var list = await CreateListAsync();
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("eggs", 3));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
var items = await UseDbAsync(db =>
db.ShoppingListItems.Where(i => i.ShoppingListId == list.Id).ToListAsync());
await Assert.That(items.Count).IsEqualTo(1);
await Assert.That(items[0].Name).IsEqualTo("eggs");
await Assert.That(items[0].SortOrder).IsEqualTo(3);
}
[Test]
public async Task Add_item_returns_404_for_missing_list()
{
var response = await Client.PostAsJsonAsync("/api/lists/99999/items",
new ShoppingListEndpoints.AddItemRequest("eggs"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
}
[Test]
public async Task Check_toggles_item_state_and_attributes_to_user()
{
var list = await CreateListAsync(b => b.WithItem("milk"));
var itemId = list.Items[0].Id;
var first = await Client.PatchAsync($"/api/lists/{list.Id}/items/{itemId}/check", null);
await Assert.That(first.StatusCode).IsEqualTo(HttpStatusCode.OK);
var afterCheck = await UseDbAsync(db => db.ShoppingListItems.SingleAsync(i => i.Id == itemId));
await Assert.That(afterCheck.IsChecked).IsTrue();
await Assert.That(afterCheck.CheckedByUserId).IsEqualTo(User.Id);
var second = await Client.PatchAsync($"/api/lists/{list.Id}/items/{itemId}/check", null);
await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.OK);
var afterUncheck = await UseDbAsync(db => db.ShoppingListItems.SingleAsync(i => i.Id == itemId));
await Assert.That(afterUncheck.IsChecked).IsFalse();
await Assert.That(afterUncheck.CheckedByUserId).IsNull();
}
[Test]
public async Task Delete_item_removes_it()
{
var list = await CreateListAsync(b => b.WithItem("milk"));
var itemId = list.Items[0].Id;
var response = await Client.DeleteAsync($"/api/lists/{list.Id}/items/{itemId}");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
await Assert.That(await UseDbAsync(db => db.ShoppingListItems.CountAsync())).IsEqualTo(0);
}
[Test]
public async Task Add_recipe_appends_all_ingredients_with_quantity_prefix()
{
var list = await CreateListAsync(b => b.WithItem("existing", sortOrder: 5));
var recipe = await Data.CreateRecipeAsync(b => b
.Titled("Pancakes").CreatedBy(User)
.WithIngredient("flour", "2 cups", 1)
.WithIngredient("eggs", "2", 2)
.WithIngredient("salt", null, 3));
var response = await Client.PostAsync($"/api/lists/{list.Id}/add-recipe/{recipe.Id}", null);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var items = await UseDbAsync(db =>
db.ShoppingListItems.Where(i => i.ShoppingListId == list.Id)
.OrderBy(i => i.SortOrder).ToListAsync());
await Assert.That(items.Count).IsEqualTo(4);
await Assert.That(items.Select(i => i.Name)).IsEquivalentTo(new[]
{
"existing", "2 cups flour", "2 eggs", "salt"
});
await Assert.That(items.Where(i => i.RecipeId == recipe.Id).Count()).IsEqualTo(3);
await Assert.That(items[3].SortOrder).IsGreaterThan(5);
}
[Test]
public async Task Add_recipe_returns_404_when_recipe_missing()
{
var list = await CreateListAsync();
var response = await Client.PostAsync($"/api/lists/{list.Id}/add-recipe/99999", null);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
}
}
@@ -0,0 +1,90 @@
using System.Net;
using System.Net.Http.Json;
using YesChef.Api.Entities;
using YesChef.Api.Features.Stores;
using YesChef.Api.IntegrationTests.Builders;
using YesChef.Api.IntegrationTests.Infrastructure;
namespace YesChef.Api.IntegrationTests.Features;
public class StoreEndpointsTests : AuthenticatedIntegrationTest
{
[Test]
public async Task List_returns_stores_in_sort_order()
{
await UseDbAsync(async db =>
{
await new StoreBuilder().Named("Bravo").WithSortOrder(2).PersistAsync(db);
await new StoreBuilder().Named("Alpha").WithSortOrder(1).PersistAsync(db);
await new StoreBuilder().Named("Charlie").WithSortOrder(3).PersistAsync(db);
});
var stores = await Client.GetFromJsonAsync<List<Store>>("/api/stores");
await Assert.That(stores!.Select(s => s.Name)).IsEquivalentTo(new[] { "Alpha", "Bravo", "Charlie" });
}
[Test]
public async Task Create_persists_store_and_returns_201()
{
var response = await Client.PostAsJsonAsync("/api/stores",
new StoreEndpoints.CreateStoreRequest("Whole Foods", 5));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
await Assert.That(await UseDbAsync(db => db.Stores.Select(s => s.Name).SingleAsync()))
.IsEqualTo("Whole Foods");
}
[Test]
public async Task Update_changes_name_and_sort_order()
{
var store = await Data.CreateStoreAsync(b => b.Named("Old Name").WithSortOrder(1));
var response = await Client.PutAsJsonAsync($"/api/stores/{store.Id}",
new StoreEndpoints.UpdateStoreRequest("New Name", 99));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var refreshed = await UseDbAsync(db => db.Stores.SingleAsync(s => s.Id == store.Id));
await Assert.That(refreshed.Name).IsEqualTo("New Name");
await Assert.That(refreshed.SortOrder).IsEqualTo(99);
}
[Test]
public async Task Update_returns_404_for_unknown_store()
{
var response = await Client.PutAsJsonAsync("/api/stores/99999",
new StoreEndpoints.UpdateStoreRequest("x", 0));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
}
[Test]
public async Task Delete_removes_unused_store()
{
var store = await Data.CreateStoreAsync();
var response = await Client.DeleteAsync($"/api/stores/{store.Id}");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
await Assert.That(await UseDbAsync(db => db.Stores.CountAsync())).IsEqualTo(0);
}
[Test]
public async Task Delete_blocks_when_store_has_lists()
{
var store = await Data.CreateStoreAsync();
await Data.CreateListAsync(b => b.ForStore(store).CreatedBy(User));
var response = await Client.DeleteAsync($"/api/stores/{store.Id}");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
await Assert.That(await UseDbAsync(db => db.Stores.AnyAsync(s => s.Id == store.Id))).IsTrue();
}
[Test]
public async Task Endpoints_require_authentication()
{
var response = await AnonymousClient.GetAsync("/api/stores");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
}
}
@@ -0,0 +1,18 @@
using TUnit.Core;
using TUnit.Core.Interfaces;
using YesChef.Api.IntegrationTests.Infrastructure;
[assembly: ParallelLimiter<IntegrationTestParallelLimit>]
namespace YesChef.Api.IntegrationTests.Infrastructure;
/// <summary>
/// Caps how many integration tests run concurrently. Each test stands up a
/// WebApplicationFactory, opens DB connections, and creates a per-test
/// database from the template — running too many at once saturates the
/// container and causes connection timeouts.
/// </summary>
public sealed class IntegrationTestParallelLimit : IParallelLimit
{
public int Limit { get; } = Math.Max(2, Environment.ProcessorCount / 2);
}
@@ -0,0 +1,32 @@
using YesChef.Api.Entities;
namespace YesChef.Api.IntegrationTests.Infrastructure;
/// <summary>
/// Integration test base that pre-registers a default user and exposes an
/// authenticated <see cref="HttpClient"/>. Use this for any feature whose
/// endpoints require authentication — i.e. nearly everything except the
/// registration/login endpoints themselves.
/// </summary>
public abstract class AuthenticatedIntegrationTest : IntegrationTest
{
public User User { get; private set; } = null!;
public HttpClient Client { get; private set; } = null!;
public string Token { get; private set; } = null!;
[Before(Test)]
public async Task SetUpAuthentication()
{
var session = await Data.RegisterAsync();
Client = session.Client;
Token = session.Token;
User = await UseDbAsync(db => db.Users.SingleAsync(u => u.Name == session.Name));
}
[After(Test)]
public Task TearDownAuthentication()
{
Client?.Dispose();
return Task.CompletedTask;
}
}
@@ -0,0 +1,53 @@
using Microsoft.Extensions.DependencyInjection;
using YesChef.Api.Data;
namespace YesChef.Api.IntegrationTests.Infrastructure;
/// <summary>
/// Base class for integration tests. Each test gets its own database (cloned
/// from the migrated template), its own <see cref="WebApplicationFactory"/>,
/// and an unauthenticated HTTP client. Derived classes can layer on more setup
/// via additional <c>[Before(Test)]</c> hooks — TUnit runs base hooks first.
/// </summary>
public abstract class IntegrationTest
{
[ClassDataSource<PostgresFixture>(Shared = SharedType.PerTestSession)]
public required PostgresFixture Postgres { get; init; }
private TestDatabase _database = null!;
public YesChefAppFactory App { get; private set; } = null!;
public HttpClient AnonymousClient { get; private set; } = null!;
public TestDataFactory Data => new(this);
[Before(Test)]
public async Task SetUpIntegrationTest()
{
_database = await Postgres.CreateDatabaseAsync();
App = new YesChefAppFactory { ConnectionString = _database.ConnectionString };
AnonymousClient = App.CreateClient();
}
[After(Test)]
public async Task TearDownIntegrationTest()
{
AnonymousClient.Dispose();
await App.DisposeAsync();
await _database.DisposeAsync();
}
/// <summary>Run an action against a fresh DbContext scoped to the test app.</summary>
public async Task UseDbAsync(Func<YesChefDb, Task> action)
{
await using var scope = App.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<YesChefDb>();
await action(db);
}
public async Task<T> UseDbAsync<T>(Func<YesChefDb, Task<T>> action)
{
await using var scope = App.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<YesChefDb>();
return await action(db);
}
}
@@ -0,0 +1,102 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using Testcontainers.PostgreSql;
using TUnit.Core.Interfaces;
using YesChef.Api.Data;
namespace YesChef.Api.IntegrationTests.Infrastructure;
/// <summary>
/// One Postgres container per test session. A migrated "template" database is
/// created once; each test asks the fixture for a fresh DB cloned from the
/// template via <c>CREATE DATABASE … TEMPLATE …</c>, which is far cheaper than
/// replaying migrations.
/// </summary>
public sealed class PostgresFixture : IAsyncInitializer, IAsyncDisposable
{
private const string TemplateDbName = "yeschef_template";
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:17")
.WithDatabase("postgres")
.WithUsername("postgres")
.WithPassword("postgres")
.Build();
private string _adminConnectionString = null!;
public async Task InitializeAsync()
{
await _container.StartAsync();
_adminConnectionString = _container.GetConnectionString();
await CreateTemplateAsync();
}
private async Task CreateTemplateAsync()
{
await ExecuteOnPostgresAsync($"""CREATE DATABASE "{TemplateDbName}";""");
var templateConnectionString = BuildConnectionString(TemplateDbName);
var options = new DbContextOptionsBuilder<YesChefDb>()
.UseNpgsql(templateConnectionString)
.Options;
await using (var db = new YesChefDb(options))
{
await db.Database.MigrateAsync();
}
// Postgres holds idle pooled connections to the template; close them so
// the template is free to be used as a CREATE DATABASE source.
NpgsqlConnection.ClearAllPools();
}
public async Task<TestDatabase> CreateDatabaseAsync()
{
var name = $"yeschef_test_{Guid.NewGuid():N}";
await ExecuteOnPostgresAsync($"""CREATE DATABASE "{name}" TEMPLATE "{TemplateDbName}";""");
return new TestDatabase(name, BuildConnectionString(name), this);
}
internal async Task DropDatabaseAsync(string name)
{
NpgsqlConnection.ClearAllPools();
await ExecuteOnPostgresAsync($"""DROP DATABASE IF EXISTS "{name}" WITH (FORCE);""");
}
private async Task ExecuteOnPostgresAsync(string sql)
{
await using var conn = new NpgsqlConnection(_adminConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(sql, conn);
await cmd.ExecuteNonQueryAsync();
}
private string BuildConnectionString(string database)
{
var builder = new NpgsqlConnectionStringBuilder(_adminConnectionString)
{
Database = database,
// Per-test apps open a small number of connections; the high default
// pool keeps the cluster from being flooded under heavy parallelism.
MaxPoolSize = 10,
Timeout = 60,
};
return builder.ConnectionString;
}
public async ValueTask DisposeAsync()
{
NpgsqlConnection.ClearAllPools();
await _container.DisposeAsync();
}
}
/// <summary>Handle to a per-test database. Disposing drops it.</summary>
public sealed class TestDatabase(string name, string connectionString, PostgresFixture fixture) : IAsyncDisposable
{
public string Name { get; } = name;
public string ConnectionString { get; } = connectionString;
public async ValueTask DisposeAsync() => await fixture.DropDatabaseAsync(Name);
}
@@ -0,0 +1,66 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using YesChef.Api.Auth;
using YesChef.Api.Entities;
using YesChef.Api.IntegrationTests.Builders;
namespace YesChef.Api.IntegrationTests.Infrastructure;
/// <summary>
/// One-stop shop for common test setup. Each method either persists directly
/// via the DbContext or registers via the public API (when an auth token is
/// also needed). Returns rich handles so tests can chain assertions.
/// </summary>
public sealed class TestDataFactory(IntegrationTest test)
{
public Task<User> CreateUserAsync(Action<UserBuilder>? configure = null)
{
var builder = new UserBuilder();
configure?.Invoke(builder);
return test.UseDbAsync(db => builder.PersistAsync(db));
}
public Task<Store> CreateStoreAsync(Action<StoreBuilder>? configure = null)
{
var builder = new StoreBuilder();
configure?.Invoke(builder);
return test.UseDbAsync(db => builder.PersistAsync(db));
}
public Task<ShoppingList> CreateListAsync(Action<ShoppingListBuilder> configure)
{
var builder = new ShoppingListBuilder();
configure(builder);
return test.UseDbAsync(db => builder.PersistAsync(db));
}
public Task<Recipe> CreateRecipeAsync(Action<RecipeBuilder> configure)
{
var builder = new RecipeBuilder();
configure(builder);
return test.UseDbAsync(db => builder.PersistAsync(db));
}
/// <summary>
/// Register a user via the public API and return an authenticated client
/// plus the resulting user record. Goes through the API on purpose so
/// tests use the same JWT lifecycle as real callers.
/// </summary>
public async Task<AuthenticatedUser> RegisterAsync(string? name = null, string password = "correct-horse-battery-staple")
{
name ??= $"user-{Guid.NewGuid():N}"[..16];
var register = new AuthEndpoints.RegisterRequest(name, password, YesChefAppFactory.FamilyCode);
var response = await test.AnonymousClient.PostAsJsonAsync("/api/auth/register", register);
response.EnsureSuccessStatusCode();
var auth = await response.Content.ReadFromJsonAsync<AuthEndpoints.AuthResponse>();
var client = test.App.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", auth!.Token);
return new AuthenticatedUser(name, password, auth.Token, client);
}
}
public sealed record AuthenticatedUser(string Name, string Password, string Token, HttpClient Client) : IDisposable
{
public void Dispose() => Client.Dispose();
}
@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using YesChef.Api.Data;
namespace YesChef.Api.IntegrationTests.Infrastructure;
/// <summary>
/// WebApplicationFactory for the YesChef API. Replaces the configured
/// connection string with the per-test database, pins JWT and family-code
/// settings, and disables the startup migration (the template DB is already
/// migrated; the per-test DB is a clone).
/// </summary>
public sealed class YesChefAppFactory : WebApplicationFactory<Program>
{
public const string FamilyCode = "test-family-code";
public const string JwtSecret = "test-jwt-secret-of-sufficient-length-for-hs256-signing";
public required string ConnectionString { get; init; }
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = ConnectionString,
["Jwt:Secret"] = JwtSecret,
["FamilyCode"] = FamilyCode,
});
});
builder.ConfigureServices(services =>
{
// Replace whatever DbContext registration came from Program.cs with
// one bound to the per-test connection string. We can't override via
// config alone because AddDbContext captures the connection string
// at registration time.
var descriptor = services.Single(d => d.ServiceType == typeof(DbContextOptions<YesChefDb>));
services.Remove(descriptor);
services.AddDbContext<YesChefDb>(options => options.UseNpgsql(ConnectionString));
});
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<OutputType>Exe</OutputType>
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="TUnit" Version="1.43.11" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.11.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.7" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\YesChef.Api\YesChef.Api.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="TUnit.Core" />
<Using Include="TUnit.Assertions" />
<Using Include="TUnit.Assertions.Extensions" />
<Using Include="Microsoft.EntityFrameworkCore" />
</ItemGroup>
</Project>