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:
@@ -35,6 +35,16 @@ dotnet ef database update --project src/backend/YesChef.Api
|
|||||||
|
|
||||||
Note: `Program.cs` calls `db.Database.MigrateAsync()` on startup, so running the API auto-applies pending migrations against the configured `ConnectionStrings:DefaultConnection`.
|
Note: `Program.cs` calls `db.Database.MigrateAsync()` on startup, so running the API auto-applies pending migrations against the configured `ConnectionStrings:DefaultConnection`.
|
||||||
|
|
||||||
|
Backend tests (TUnit on Microsoft.Testing.Platform; integration tests use Testcontainers + Postgres). The `src/backend/global.json` opts `dotnet test` into MTP mode, so use `--solution` / `--project` flags:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test --solution src/backend/YesChef.slnx # all tests
|
||||||
|
dotnet test --project src/backend/YesChef.Api.UnitTests/YesChef.Api.UnitTests.csproj
|
||||||
|
dotnet test --project src/backend/YesChef.Api.IntegrationTests/YesChef.Api.IntegrationTests.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Integration tests start a single Postgres 17 container per test session, create a migrated `yeschef_template` database, then clone a fresh DB per test via `CREATE DATABASE … TEMPLATE …`. Tests run in parallel (capped by `IntegrationTestParallelLimit`) and require Docker (Rancher Desktop or Docker Desktop).
|
||||||
|
|
||||||
Full stack via Docker (requires `.env` populated from `.env.example`):
|
Full stack via Docker (requires `.env` populated from `.env.example`):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+32
@@ -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>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using YesChef.Api.Auth;
|
||||||
|
|
||||||
|
namespace YesChef.Api.UnitTests.Auth;
|
||||||
|
|
||||||
|
public class ClaimsPrincipalExtensionsTests
|
||||||
|
{
|
||||||
|
private static ClaimsPrincipal Build(params Claim[] claims) =>
|
||||||
|
new(new ClaimsIdentity(claims, authenticationType: "test"));
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetUserId_parses_name_identifier_claim()
|
||||||
|
{
|
||||||
|
var principal = Build(new Claim(ClaimTypes.NameIdentifier, "123"));
|
||||||
|
await Assert.That(principal.GetUserId()).IsEqualTo(123);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetUserName_returns_name_claim()
|
||||||
|
{
|
||||||
|
var principal = Build(new Claim(ClaimTypes.Name, "alice"));
|
||||||
|
await Assert.That(principal.GetUserName()).IsEqualTo("alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetUserId_throws_when_claim_missing()
|
||||||
|
{
|
||||||
|
var principal = Build();
|
||||||
|
await Assert.That(() => principal.GetUserId()).Throws<ArgumentNullException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<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" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\YesChef.Api\YesChef.Api.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="TUnit.Core" />
|
||||||
|
<Using Include="TUnit.Assertions" />
|
||||||
|
<Using Include="TUnit.Assertions.Extensions" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -67,3 +67,5 @@ app.MapGroup("/api/recipes").MapRecipeEndpoints().RequireAuthorization();
|
|||||||
app.MapHub<ShoppingListHub>("/hubs/shopping-list");
|
app.MapHub<ShoppingListHub>("/hubs/shopping-list");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
public partial class Program;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.7" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Project Path="YesChef.Api/YesChef.Api.csproj" />
|
<Project Path="YesChef.Api/YesChef.Api.csproj" />
|
||||||
|
<Project Path="YesChef.Api.UnitTests/YesChef.Api.UnitTests.csproj" />
|
||||||
|
<Project Path="YesChef.Api.IntegrationTests/YesChef.Api.IntegrationTests.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"test": {
|
||||||
|
"runner": "Microsoft.Testing.Platform"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user