Files
YesChef/src/backend/YesChef.Api.IntegrationTests/Features/ProductEndpointsTests.cs
T
Josh Rogers fd6b0accc8 Filter unit dropdown by product allowed-unit categories
Adds a UnitCategoryFlags column to Product, FamilyProduct, and
FamilyProductOverride so each product can advertise which unit categories
it is typically packaged by (e.g. flour: Weight | Volume). The product
endpoints round-trip the flag, search projects the effective value with
the override applied, and the frontend QuantityInput soft-filters its
dropdown by the selected product's flag, with a "show all units" escape
hatch for ad-hoc overrides.

No backend rejection on a unit outside the allowed set — the flag is
purely a hint. Default value is None (no filter), so existing data is
unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:17:43 -05:00

366 lines
14 KiB
C#

using System.Net;
using System.Net.Http.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<int> 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<List<ProductEndpoints.ProductDto>>("/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<List<ProductEndpoints.ProductDto>>("/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<List<ProductEndpoints.ProductDto>>("/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<List<ProductEndpoints.ProductDto>>("/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<List<ProductEndpoints.ProductDto>>("/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<ProductEndpoints.ProductDto>();
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<ProductEndpoints.ProductDto>();
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<List<ProductEndpoints.ProductDto>>("/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);
}
}