using System.Net; using System.Net.Http.Json; using System.Text.Json; using YesChef.Api.Entities; using YesChef.Api.Features.Products; using YesChef.Api.IntegrationTests.Infrastructure; namespace YesChef.Api.IntegrationTests.Features; public class ProductEndpointsTests : AuthenticatedIntegrationTest { private Task GetFamilyIdAsync() => UseDbAsync(db => db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync()); [Test] public async Task Search_returns_global_and_family_products_merged() { var familyId = await GetFamilyIdAsync(); await UseDbAsync(async db => { db.Products.Add(new Product { Name = "Bananas" }); db.Products.Add(new Product { Name = "Carrots" }); db.FamilyProducts.Add(new FamilyProduct { FamilyId = familyId, Name = "House Bread" }); await db.SaveChangesAsync(); }); var results = await Client.GetFromJsonAsync>("/api/products?q="); await Assert.That(results!.Select(r => r.Name)).IsEquivalentTo(new[] { "Bananas", "Carrots", "House Bread" }); } [Test] public async Task Search_filters_case_insensitively_on_effective_name() { await UseDbAsync(async db => { db.Products.Add(new Product { Name = "Bananas" }); db.Products.Add(new Product { Name = "Apples" }); await db.SaveChangesAsync(); }); var results = await Client.GetFromJsonAsync>("/api/products?q=ban"); await Assert.That(results!.Select(r => r.Name)).IsEquivalentTo(new[] { "Bananas" }); } [Test] public async Task Search_applies_family_override_to_global_product() { var familyId = await GetFamilyIdAsync(); Product apples = null!; await UseDbAsync(async db => { apples = new Product { Name = "Apples", Brand = "Generic" }; db.Products.Add(apples); await db.SaveChangesAsync(); db.FamilyProductOverrides.Add(new FamilyProductOverride { FamilyId = familyId, ProductId = apples.Id, Brand = "Honeycrisp", }); await db.SaveChangesAsync(); }); var results = await Client.GetFromJsonAsync>("/api/products?q=apple"); var dto = results!.Single(); await Assert.That(dto.Name).IsEqualTo("Apples"); await Assert.That(dto.Brand).IsEqualTo("Honeycrisp"); await Assert.That(dto.IsOverridden).IsTrue(); } [Test] public async Task Search_does_not_leak_other_family_private_products() { await UseDbAsync(async db => { var otherFamily = new Family { Name = "Other", InviteCode = "other-code" }; db.Families.Add(otherFamily); await db.SaveChangesAsync(); db.FamilyProducts.Add(new FamilyProduct { FamilyId = otherFamily.Id, Name = "Other Family Bread" }); await db.SaveChangesAsync(); }); var results = await Client.GetFromJsonAsync>("/api/products?q=bread"); await Assert.That(results!).IsEmpty(); } [Test] public async Task Search_does_not_leak_other_family_overrides() { await UseDbAsync(async db => { var apples = new Product { Name = "Apples", Brand = "Generic" }; db.Products.Add(apples); var otherFamily = new Family { Name = "Other", InviteCode = "other-code" }; db.Families.Add(otherFamily); await db.SaveChangesAsync(); db.FamilyProductOverrides.Add(new FamilyProductOverride { FamilyId = otherFamily.Id, ProductId = apples.Id, Brand = "Their Honeycrisp", }); await db.SaveChangesAsync(); }); var results = await Client.GetFromJsonAsync>("/api/products?q=apple"); var dto = results!.Single(); await Assert.That(dto.Brand).IsEqualTo("Generic"); await Assert.That(dto.IsOverridden).IsFalse(); } [Test] public async Task Create_persists_family_product_and_returns_201() { var response = await Client.PostAsJsonAsync("/api/products", new ProductEndpoints.CreateProductRequest("Sourdough", "Local Bakery", null)); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); var dto = await response.Content.ReadFromJsonAsync(); await Assert.That(dto!.Kind).IsEqualTo(ProductEndpoints.ProductKind.Family); await Assert.That(dto.Brand).IsEqualTo("Local Bakery"); var persisted = await UseDbAsync(db => db.FamilyProducts.SingleAsync()); await Assert.That(persisted.Name).IsEqualTo("Sourdough"); } [Test] public async Task Create_returns_409_for_duplicate_name_within_family() { await Client.PostAsJsonAsync("/api/products", new ProductEndpoints.CreateProductRequest("Sourdough", null, null)); var response = await Client.PostAsJsonAsync("/api/products", new ProductEndpoints.CreateProductRequest("Sourdough", null, null)); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); } [Test] public async Task Create_returns_400_when_name_missing() { var response = await Client.PostAsJsonAsync("/api/products", new ProductEndpoints.CreateProductRequest(" ", null, null)); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); } [Test] public async Task Update_family_product_changes_fields() { var familyId = await GetFamilyIdAsync(); var product = await UseDbAsync(async db => { var p = new FamilyProduct { FamilyId = familyId, Name = "Old", Brand = "OldBrand" }; db.FamilyProducts.Add(p); await db.SaveChangesAsync(); return p; }); var response = await Client.PutAsJsonAsync($"/api/products/family/{product.Id}", new ProductEndpoints.UpdateProductRequest("New", "NewBrand", "New notes")); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); var refreshed = await UseDbAsync(db => db.FamilyProducts.SingleAsync(p => p.Id == product.Id)); await Assert.That(refreshed.Name).IsEqualTo("New"); await Assert.That(refreshed.Brand).IsEqualTo("NewBrand"); await Assert.That(refreshed.Notes).IsEqualTo("New notes"); } [Test] public async Task Update_family_product_404_for_unknown_id() { var response = await Client.PutAsJsonAsync("/api/products/family/99999", new ProductEndpoints.UpdateProductRequest("x", null, null)); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); } [Test] public async Task Update_global_product_creates_override_for_this_family_only() { var familyId = await GetFamilyIdAsync(); var apples = await UseDbAsync(async db => { var p = new Product { Name = "Apples", Brand = "Generic" }; db.Products.Add(p); await db.SaveChangesAsync(); return p; }); var response = await Client.PutAsJsonAsync($"/api/products/global/{apples.Id}", new ProductEndpoints.UpdateProductRequest(null, "Honeycrisp", null)); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); var dto = await response.Content.ReadFromJsonAsync(); await Assert.That(dto!.Brand).IsEqualTo("Honeycrisp"); await Assert.That(dto.IsOverridden).IsTrue(); // Global row untouched. var global = await UseDbAsync(db => db.Products.SingleAsync(p => p.Id == apples.Id)); await Assert.That(global.Brand).IsEqualTo("Generic"); // Override stored under the caller's family. var ovr = await UseDbAsync(db => db.FamilyProductOverrides.SingleAsync()); await Assert.That(ovr.FamilyId).IsEqualTo(familyId); await Assert.That(ovr.Brand).IsEqualTo("Honeycrisp"); } [Test] public async Task Update_global_product_upserts_existing_override() { var familyId = await GetFamilyIdAsync(); var apples = await UseDbAsync(async db => { var p = new Product { Name = "Apples", Brand = "Generic" }; db.Products.Add(p); await db.SaveChangesAsync(); db.FamilyProductOverrides.Add(new FamilyProductOverride { FamilyId = familyId, ProductId = p.Id, Brand = "Honeycrisp", }); await db.SaveChangesAsync(); return p; }); var response = await Client.PutAsJsonAsync($"/api/products/global/{apples.Id}", new ProductEndpoints.UpdateProductRequest(null, "Pink Lady", "Family favorite")); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); var ovr = await UseDbAsync(db => db.FamilyProductOverrides.SingleAsync()); await Assert.That(ovr.Brand).IsEqualTo("Pink Lady"); await Assert.That(ovr.Notes).IsEqualTo("Family favorite"); } [Test] public async Task Update_global_product_404_for_unknown_id() { var response = await Client.PutAsJsonAsync("/api/products/global/99999", new ProductEndpoints.UpdateProductRequest(null, "x", null)); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); } [Test] public async Task Create_family_product_persists_allowed_unit_categories() { var response = await Client.PostAsJsonAsync("/api/products", new ProductEndpoints.CreateProductRequest("House Flour", null, null, UnitCategoryFlags.Weight | UnitCategoryFlags.Volume)); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); var stored = await UseDbAsync(db => db.FamilyProducts.SingleAsync()); await Assert.That(stored.AllowedUnitCategories) .IsEqualTo(UnitCategoryFlags.Weight | UnitCategoryFlags.Volume); } [Test] public async Task Update_family_product_changes_allowed_unit_categories() { var familyId = await GetFamilyIdAsync(); var product = await UseDbAsync(async db => { var p = new FamilyProduct { FamilyId = familyId, Name = "Flour", AllowedUnitCategories = UnitCategoryFlags.Weight, }; db.FamilyProducts.Add(p); await db.SaveChangesAsync(); return p; }); var response = await Client.PutAsJsonAsync($"/api/products/family/{product.Id}", new ProductEndpoints.UpdateProductRequest(null, null, null, UnitCategoryFlags.Weight | UnitCategoryFlags.Volume)); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); var stored = await UseDbAsync(db => db.FamilyProducts.SingleAsync()); await Assert.That(stored.AllowedUnitCategories) .IsEqualTo(UnitCategoryFlags.Weight | UnitCategoryFlags.Volume); } [Test] public async Task Update_global_product_writes_allowed_unit_categories_to_override() { var apples = await UseDbAsync(async db => { var p = new Product { Name = "Milk", AllowedUnitCategories = UnitCategoryFlags.Volume, }; db.Products.Add(p); await db.SaveChangesAsync(); return p; }); var response = await Client.PutAsJsonAsync($"/api/products/global/{apples.Id}", new ProductEndpoints.UpdateProductRequest(null, null, null, UnitCategoryFlags.Volume | UnitCategoryFlags.Packaging)); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); var ovr = await UseDbAsync(db => db.FamilyProductOverrides.SingleAsync()); await Assert.That(ovr.AllowedUnitCategories) .IsEqualTo(UnitCategoryFlags.Volume | UnitCategoryFlags.Packaging); // The global row remains unchanged. var global = await UseDbAsync(db => db.Products.SingleAsync(p => p.Id == apples.Id)); await Assert.That(global.AllowedUnitCategories).IsEqualTo(UnitCategoryFlags.Volume); } [Test] public async Task Search_projects_effective_allowed_unit_categories() { var familyId = await GetFamilyIdAsync(); await UseDbAsync(async db => { db.Products.Add(new Product { Name = "Milk", AllowedUnitCategories = UnitCategoryFlags.Volume }); db.Products.Add(new Product { Name = "Eggs", AllowedUnitCategories = UnitCategoryFlags.Count }); db.FamilyProducts.Add(new FamilyProduct { FamilyId = familyId, Name = "House Flour", AllowedUnitCategories = UnitCategoryFlags.Weight, }); await db.SaveChangesAsync(); // Override Eggs to widen to Count | Packaging for this family. var eggs = db.Products.Single(p => p.Name == "Eggs"); db.FamilyProductOverrides.Add(new FamilyProductOverride { FamilyId = familyId, ProductId = eggs.Id, AllowedUnitCategories = UnitCategoryFlags.Count | UnitCategoryFlags.Packaging, }); await db.SaveChangesAsync(); }); var results = (await Client.GetFromJsonAsync>("/api/products?q="))!; var milk = results.Single(r => r.Name == "Milk"); await Assert.That(milk.AllowedUnitCategories).IsEqualTo(UnitCategoryFlags.Volume); var eggs = results.Single(r => r.Name == "Eggs"); await Assert.That(eggs.AllowedUnitCategories) .IsEqualTo(UnitCategoryFlags.Count | UnitCategoryFlags.Packaging); var flour = results.Single(r => r.Name == "House Flour"); await Assert.That(flour.AllowedUnitCategories).IsEqualTo(UnitCategoryFlags.Weight); } [Test] public async Task Endpoints_require_authentication() { var response = await AnonymousClient.GetAsync("/api/products"); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); } [Test] public async Task Get_global_product_section_returns_remembered_section() { var familyId = await GetFamilyIdAsync(); var store = await Data.CreateStoreAsync(); var (product, section) = await UseDbAsync(async db => { var p = new Product { Name = "Bananas-Lookup" }; var s = new StoreSection { FamilyId = familyId, StoreId = store.Id, Name = "Produce", SortOrder = 1 }; db.Products.Add(p); db.StoreSections.Add(s); await db.SaveChangesAsync(); db.ProductStoreSections.Add(new ProductStoreSection { FamilyId = familyId, StoreId = store.Id, ProductId = p.Id, StoreSectionId = s.Id, }); await db.SaveChangesAsync(); return (p, s); }); var body = await Client.GetFromJsonAsync( $"/api/products/global/{product.Id}/section?storeId={store.Id}"); await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(section.Id); } [Test] public async Task Get_global_product_section_returns_null_when_no_memory() { var store = await Data.CreateStoreAsync(); var product = await UseDbAsync(async db => { var p = new Product { Name = "Unknown-Lookup" }; db.Products.Add(p); await db.SaveChangesAsync(); return p; }); var body = await Client.GetFromJsonAsync( $"/api/products/global/{product.Id}/section?storeId={store.Id}"); await Assert.That(body.GetProperty("sectionId").ValueKind).IsEqualTo(JsonValueKind.Null); } [Test] public async Task Get_global_product_section_is_scoped_to_store() { var familyId = await GetFamilyIdAsync(); var storeA = await Data.CreateStoreAsync(b => b.Named("A")); var storeB = await Data.CreateStoreAsync(b => b.Named("B")); var product = await UseDbAsync(async db => { var p = new Product { Name = "Apples-Lookup" }; var sectionA = new StoreSection { FamilyId = familyId, StoreId = storeA.Id, Name = "Produce A", SortOrder = 1 }; db.Products.Add(p); db.StoreSections.Add(sectionA); await db.SaveChangesAsync(); db.ProductStoreSections.Add(new ProductStoreSection { FamilyId = familyId, StoreId = storeA.Id, ProductId = p.Id, StoreSectionId = sectionA.Id, }); await db.SaveChangesAsync(); return p; }); // Lookup at store B — no memory there even though store A has one. var body = await Client.GetFromJsonAsync( $"/api/products/global/{product.Id}/section?storeId={storeB.Id}"); await Assert.That(body.GetProperty("sectionId").ValueKind).IsEqualTo(JsonValueKind.Null); } [Test] public async Task Get_family_product_section_returns_remembered_section() { var familyId = await GetFamilyIdAsync(); var store = await Data.CreateStoreAsync(); var (product, section) = await UseDbAsync(async db => { var p = new FamilyProduct { FamilyId = familyId, Name = "House Bread" }; var s = new StoreSection { FamilyId = familyId, StoreId = store.Id, Name = "Bakery", SortOrder = 1 }; db.FamilyProducts.Add(p); db.StoreSections.Add(s); await db.SaveChangesAsync(); db.ProductStoreSections.Add(new ProductStoreSection { FamilyId = familyId, StoreId = store.Id, FamilyProductId = p.Id, StoreSectionId = s.Id, }); await db.SaveChangesAsync(); return (p, s); }); var body = await Client.GetFromJsonAsync( $"/api/products/family/{product.Id}/section?storeId={store.Id}"); await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(section.Id); } [Test] public async Task Get_family_product_section_returns_404_for_other_family_product() { var store = await Data.CreateStoreAsync(); var otherFamilyProductId = await UseDbAsync(async db => { var otherFamily = new Family { Name = "Other", InviteCode = "other-code" }; db.Families.Add(otherFamily); await db.SaveChangesAsync(); var p = new FamilyProduct { FamilyId = otherFamily.Id, Name = "Their Bread" }; db.FamilyProducts.Add(p); await db.SaveChangesAsync(); return p.Id; }); var response = await Client.GetAsync( $"/api/products/family/{otherFamilyProductId}/section?storeId={store.Id}"); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); } }