diff --git a/CLAUDE.md b/CLAUDE.md index 5d8fab5..0166e3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. +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`): ```powershell diff --git a/src/backend/YesChef.Api.IntegrationTests/Builders/RecipeBuilder.cs b/src/backend/YesChef.Api.IntegrationTests/Builders/RecipeBuilder.cs new file mode 100644 index 0000000..2fb5e92 --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Builders/RecipeBuilder.cs @@ -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 _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 PersistAsync(YesChefDb db) + { + var recipe = Build(); + db.Recipes.Add(recipe); + await db.SaveChangesAsync(); + return recipe; + } +} diff --git a/src/backend/YesChef.Api.IntegrationTests/Builders/ShoppingListBuilder.cs b/src/backend/YesChef.Api.IntegrationTests/Builders/ShoppingListBuilder.cs new file mode 100644 index 0000000..4d56982 --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Builders/ShoppingListBuilder.cs @@ -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 _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 PersistAsync(YesChefDb db) + { + var list = Build(); + db.ShoppingLists.Add(list); + await db.SaveChangesAsync(); + return list; + } +} diff --git a/src/backend/YesChef.Api.IntegrationTests/Builders/StoreBuilder.cs b/src/backend/YesChef.Api.IntegrationTests/Builders/StoreBuilder.cs new file mode 100644 index 0000000..9d03903 --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Builders/StoreBuilder.cs @@ -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 PersistAsync(YesChefDb db) + { + var store = Build(); + db.Stores.Add(store); + await db.SaveChangesAsync(); + return store; + } +} diff --git a/src/backend/YesChef.Api.IntegrationTests/Builders/UserBuilder.cs b/src/backend/YesChef.Api.IntegrationTests/Builders/UserBuilder.cs new file mode 100644 index 0000000..5c2dd42 --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Builders/UserBuilder.cs @@ -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().HashPassword(user, _password); + return user; + } + + public async Task PersistAsync(YesChefDb db) + { + var user = Build(); + db.Users.Add(user); + await db.SaveChangesAsync(); + return user; + } +} diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/AuthEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/AuthEndpointsTests.cs new file mode 100644 index 0000000..2399572 --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Features/AuthEndpointsTests.cs @@ -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(); + 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(); + 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(); + 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); +} diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs new file mode 100644 index 0000000..ce79a41 --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs @@ -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 CreateRecipeAsync(Action 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($"/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>("/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); + } +} diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs new file mode 100644 index 0000000..26599d9 --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs @@ -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 CreateListAsync(Action? 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>("/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>($"/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($"/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); + } +} diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/StoreEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/StoreEndpointsTests.cs new file mode 100644 index 0000000..b32dcc5 --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Features/StoreEndpointsTests.cs @@ -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>("/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); + } +} diff --git a/src/backend/YesChef.Api.IntegrationTests/Infrastructure/AssemblyInfo.cs b/src/backend/YesChef.Api.IntegrationTests/Infrastructure/AssemblyInfo.cs new file mode 100644 index 0000000..cbbcb00 --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Infrastructure/AssemblyInfo.cs @@ -0,0 +1,18 @@ +using TUnit.Core; +using TUnit.Core.Interfaces; +using YesChef.Api.IntegrationTests.Infrastructure; + +[assembly: ParallelLimiter] + +namespace YesChef.Api.IntegrationTests.Infrastructure; + +/// +/// 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. +/// +public sealed class IntegrationTestParallelLimit : IParallelLimit +{ + public int Limit { get; } = Math.Max(2, Environment.ProcessorCount / 2); +} diff --git a/src/backend/YesChef.Api.IntegrationTests/Infrastructure/AuthenticatedIntegrationTest.cs b/src/backend/YesChef.Api.IntegrationTests/Infrastructure/AuthenticatedIntegrationTest.cs new file mode 100644 index 0000000..a176762 --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Infrastructure/AuthenticatedIntegrationTest.cs @@ -0,0 +1,32 @@ +using YesChef.Api.Entities; + +namespace YesChef.Api.IntegrationTests.Infrastructure; + +/// +/// Integration test base that pre-registers a default user and exposes an +/// authenticated . Use this for any feature whose +/// endpoints require authentication — i.e. nearly everything except the +/// registration/login endpoints themselves. +/// +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; + } +} diff --git a/src/backend/YesChef.Api.IntegrationTests/Infrastructure/IntegrationTest.cs b/src/backend/YesChef.Api.IntegrationTests/Infrastructure/IntegrationTest.cs new file mode 100644 index 0000000..67d947f --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Infrastructure/IntegrationTest.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.DependencyInjection; +using YesChef.Api.Data; + +namespace YesChef.Api.IntegrationTests.Infrastructure; + +/// +/// Base class for integration tests. Each test gets its own database (cloned +/// from the migrated template), its own , +/// and an unauthenticated HTTP client. Derived classes can layer on more setup +/// via additional [Before(Test)] hooks — TUnit runs base hooks first. +/// +public abstract class IntegrationTest +{ + [ClassDataSource(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(); + } + + /// Run an action against a fresh DbContext scoped to the test app. + public async Task UseDbAsync(Func action) + { + await using var scope = App.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await action(db); + } + + public async Task UseDbAsync(Func> action) + { + await using var scope = App.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await action(db); + } +} diff --git a/src/backend/YesChef.Api.IntegrationTests/Infrastructure/PostgresFixture.cs b/src/backend/YesChef.Api.IntegrationTests/Infrastructure/PostgresFixture.cs new file mode 100644 index 0000000..9dd41e7 --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Infrastructure/PostgresFixture.cs @@ -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; + +/// +/// 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 CREATE DATABASE … TEMPLATE …, which is far cheaper than +/// replaying migrations. +/// +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() + .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 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(); + } +} + +/// Handle to a per-test database. Disposing drops it. +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); +} diff --git a/src/backend/YesChef.Api.IntegrationTests/Infrastructure/TestDataFactory.cs b/src/backend/YesChef.Api.IntegrationTests/Infrastructure/TestDataFactory.cs new file mode 100644 index 0000000..0e4d38c --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Infrastructure/TestDataFactory.cs @@ -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; + +/// +/// 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. +/// +public sealed class TestDataFactory(IntegrationTest test) +{ + public Task CreateUserAsync(Action? configure = null) + { + var builder = new UserBuilder(); + configure?.Invoke(builder); + return test.UseDbAsync(db => builder.PersistAsync(db)); + } + + public Task CreateStoreAsync(Action? configure = null) + { + var builder = new StoreBuilder(); + configure?.Invoke(builder); + return test.UseDbAsync(db => builder.PersistAsync(db)); + } + + public Task CreateListAsync(Action configure) + { + var builder = new ShoppingListBuilder(); + configure(builder); + return test.UseDbAsync(db => builder.PersistAsync(db)); + } + + public Task CreateRecipeAsync(Action configure) + { + var builder = new RecipeBuilder(); + configure(builder); + return test.UseDbAsync(db => builder.PersistAsync(db)); + } + + /// + /// 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. + /// + public async Task 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(); + + 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(); +} diff --git a/src/backend/YesChef.Api.IntegrationTests/Infrastructure/YesChefAppFactory.cs b/src/backend/YesChef.Api.IntegrationTests/Infrastructure/YesChefAppFactory.cs new file mode 100644 index 0000000..4e99178 --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Infrastructure/YesChefAppFactory.cs @@ -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; + +/// +/// 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). +/// +public sealed class YesChefAppFactory : WebApplicationFactory +{ + 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 + { + ["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)); + services.Remove(descriptor); + services.AddDbContext(options => options.UseNpgsql(ConnectionString)); + }); + } +} diff --git a/src/backend/YesChef.Api.IntegrationTests/YesChef.Api.IntegrationTests.csproj b/src/backend/YesChef.Api.IntegrationTests/YesChef.Api.IntegrationTests.csproj new file mode 100644 index 0000000..fff2282 --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/YesChef.Api.IntegrationTests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + true + Exe + true + + + + + + + + + + + + + + + + + + + + + diff --git a/src/backend/YesChef.Api.UnitTests/Auth/ClaimsPrincipalExtensionsTests.cs b/src/backend/YesChef.Api.UnitTests/Auth/ClaimsPrincipalExtensionsTests.cs new file mode 100644 index 0000000..0383619 --- /dev/null +++ b/src/backend/YesChef.Api.UnitTests/Auth/ClaimsPrincipalExtensionsTests.cs @@ -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(); + } +} diff --git a/src/backend/YesChef.Api.UnitTests/Auth/JwtTokenServiceTests.cs b/src/backend/YesChef.Api.UnitTests/Auth/JwtTokenServiceTests.cs new file mode 100644 index 0000000..fb018b7 --- /dev/null +++ b/src/backend/YesChef.Api.UnitTests/Auth/JwtTokenServiceTests.cs @@ -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 { ["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(); + } +} diff --git a/src/backend/YesChef.Api.UnitTests/YesChef.Api.UnitTests.csproj b/src/backend/YesChef.Api.UnitTests/YesChef.Api.UnitTests.csproj new file mode 100644 index 0000000..b74b35a --- /dev/null +++ b/src/backend/YesChef.Api.UnitTests/YesChef.Api.UnitTests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + true + Exe + true + + + + + + + + + + + + + + + + + diff --git a/src/backend/YesChef.Api/Program.cs b/src/backend/YesChef.Api/Program.cs index 2111916..4d1ad74 100644 --- a/src/backend/YesChef.Api/Program.cs +++ b/src/backend/YesChef.Api/Program.cs @@ -67,3 +67,5 @@ app.MapGroup("/api/recipes").MapRecipeEndpoints().RequireAuthorization(); app.MapHub("/hubs/shopping-list"); app.Run(); + +public partial class Program; diff --git a/src/backend/YesChef.Api/YesChef.Api.csproj b/src/backend/YesChef.Api/YesChef.Api.csproj index 3ba3061..125fa9b 100644 --- a/src/backend/YesChef.Api/YesChef.Api.csproj +++ b/src/backend/YesChef.Api/YesChef.Api.csproj @@ -12,6 +12,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/src/backend/YesChef.slnx b/src/backend/YesChef.slnx index 05d7e88..9ff9b07 100644 --- a/src/backend/YesChef.slnx +++ b/src/backend/YesChef.slnx @@ -1,3 +1,5 @@ + + diff --git a/src/backend/global.json b/src/backend/global.json new file mode 100644 index 0000000..3140116 --- /dev/null +++ b/src/backend/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +}