Pre-fill list section on product pick; tighten backend warnings

- Adds GET /api/products/{kind}/{id}/section?storeId=... exposing the
  per-store memory the list page mirrors when a product is picked, so the
  section dropdown reflects what the backend would auto-assign on POST.
- Treats backend warnings as errors via Directory.Build.props; fixes the
  surfaced warnings (obsolete PostgreSqlBuilder ctor, nullable string[]
  in IsEquivalentTo, redundant nullable flow).
- Annotates wire-exposed enums (ProductKind, UnitKind, UnitCategory,
  UnitCategoryFlags) with JsonStringEnumConverter so they round-trip as
  strings regardless of caller options. Unblocks the integration tests
  that deserialize DTOs via GetFromJsonAsync without the global converter.
This commit is contained in:
Josh Rogers
2026-05-15 21:30:00 -05:00
parent f38530cf81
commit 6d84aad94b
11 changed files with 205 additions and 28 deletions
@@ -1,5 +1,6 @@
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;
@@ -343,9 +344,9 @@ public class ProductEndpointsTests : AuthenticatedIntegrationTest
await db.SaveChangesAsync();
});
var results = await Client.GetFromJsonAsync<List<ProductEndpoints.ProductDto>>("/api/products?q=");
var results = (await Client.GetFromJsonAsync<List<ProductEndpoints.ProductDto>>("/api/products?q="))!;
var milk = results!.Single(r => r.Name == "Milk");
var milk = results.Single(r => r.Name == "Milk");
await Assert.That(milk.AllowedUnitCategories).IsEqualTo(UnitCategoryFlags.Volume);
var eggs = results.Single(r => r.Name == "Eggs");
@@ -362,4 +363,132 @@ public class ProductEndpointsTests : AuthenticatedIntegrationTest
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<JsonElement>(
$"/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<JsonElement>(
$"/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<JsonElement>(
$"/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<JsonElement>(
$"/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);
}
}