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;
|
||||
}
|
||||
}
|
||||
@@ -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.Run();
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.7" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<Solution>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"test": {
|
||||
"runner": "Microsoft.Testing.Platform"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user