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:
@@ -0,0 +1,5 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
||||
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();
|
||||
.Select(i => i.GetProperty("name").GetString()!).ToArray();
|
||||
await Assert.That(ingredientNames).IsEquivalentTo(new[] { "broth cube", "water" });
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
||||
|
||||
var hits = await Client.GetFromJsonAsync<List<JsonElement>>("/api/recipes?q=Pa");
|
||||
|
||||
await Assert.That(hits!.Select(h => h.GetProperty("title").GetString()))
|
||||
await Assert.That(hits!.Select(h => h.GetProperty("title").GetString()!))
|
||||
.IsEquivalentTo(new[] { "Pancakes", "Pad Thai" });
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
|
||||
|
||||
await Assert.That(body.GetProperty("name").GetString()).IsEqualTo("groceries");
|
||||
var items = body.GetProperty("items").EnumerateArray()
|
||||
.Select(i => i.GetProperty("name").GetString()).ToArray();
|
||||
.Select(i => i.GetProperty("name").GetString()!).ToArray();
|
||||
await Assert.That(items).IsEquivalentTo(new[] { "bread", "milk" });
|
||||
}
|
||||
|
||||
@@ -377,7 +377,7 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
|
||||
|
||||
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/lists/{list.Id}");
|
||||
var items = body.GetProperty("items").EnumerateArray()
|
||||
.Select(i => i.GetProperty("name").GetString()).ToArray();
|
||||
.Select(i => i.GetProperty("name").GetString()!).ToArray();
|
||||
await Assert.That(items).IsEquivalentTo(new[] { "kept" });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
using TUnit.Core.Interfaces;
|
||||
@@ -16,8 +15,7 @@ public sealed class PostgresFixture : IAsyncInitializer, IAsyncDisposable
|
||||
{
|
||||
private const string TemplateDbName = "yeschef_template";
|
||||
|
||||
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:17")
|
||||
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder("postgres:17")
|
||||
.WithDatabase("postgres")
|
||||
.WithUsername("postgres")
|
||||
.WithPassword("postgres")
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace YesChef.Api.Entities;
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<UnitCategory>))]
|
||||
public enum UnitCategory
|
||||
{
|
||||
Count = 0,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace YesChef.Api.Entities;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,6 +9,7 @@ namespace YesChef.Api.Entities;
|
||||
/// 32-bit integer so families can OR in additional categories over time.
|
||||
/// </summary>
|
||||
[System.Flags]
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<UnitCategoryFlags>))]
|
||||
public enum UnitCategoryFlags
|
||||
{
|
||||
None = 0,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using YesChef.Api.Auth;
|
||||
using YesChef.Api.Data;
|
||||
using YesChef.Api.Entities;
|
||||
using YesChef.Api.Features.ShoppingLists;
|
||||
|
||||
namespace YesChef.Api.Features.Products;
|
||||
|
||||
@@ -10,6 +12,7 @@ public static class ProductEndpoints
|
||||
/// <summary>Discriminator on product DTOs so the client can route subsequent
|
||||
/// PUT/POST calls to the right code path. The catalog UI itself doesn't
|
||||
/// need to surface this — both kinds render the same way.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ProductKind>))]
|
||||
public enum ProductKind { Global, Family }
|
||||
|
||||
public record ProductDto(
|
||||
@@ -239,6 +242,30 @@ public static class ProductEndpoints
|
||||
ovr.AllowedUnitCategories ?? product.AllowedUnitCategories));
|
||||
});
|
||||
|
||||
// Look up the per-store section memory for a product so the add-item
|
||||
// UI can pre-fill the dropdown the moment the user picks a product
|
||||
// from the typeahead. The backend already applies this memory on POST
|
||||
// when no section is supplied — this endpoint just lets the client
|
||||
// mirror that decision so the user can see (and override) it.
|
||||
// Returns { sectionId: int | null }. No memory → null, not 404.
|
||||
group.MapGet("/global/{id:int}/section", async (int id, int storeId, YesChefDb db, HttpContext http) =>
|
||||
{
|
||||
var familyId = http.User.GetFamilyId();
|
||||
var sectionId = await ShoppingListEndpoints.LookupRememberedSectionAsync(db, familyId, storeId, id, null);
|
||||
return Results.Ok(new { sectionId });
|
||||
});
|
||||
|
||||
group.MapGet("/family/{id:int}/section", async (int id, int storeId, YesChefDb db, HttpContext http) =>
|
||||
{
|
||||
var familyId = http.User.GetFamilyId();
|
||||
// FamilyProducts are tenant-scoped — refuse to leak even the
|
||||
// existence of another family's product id.
|
||||
if (!await db.FamilyProducts.AnyAsync(p => p.Id == id && p.FamilyId == familyId))
|
||||
return Results.NotFound();
|
||||
var sectionId = await ShoppingListEndpoints.LookupRememberedSectionAsync(db, familyId, storeId, null, id);
|
||||
return Results.Ok(new { sectionId });
|
||||
});
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using YesChef.Api.Auth;
|
||||
using YesChef.Api.Data;
|
||||
@@ -9,6 +10,7 @@ public static class UnitEndpoints
|
||||
{
|
||||
/// <summary>Discriminator so the client can route subsequent PUT/DELETE
|
||||
/// calls to the right code path. Global units cannot be edited.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<UnitKind>))]
|
||||
public enum UnitKind { Global, Family }
|
||||
|
||||
public record UnitDto(
|
||||
|
||||
@@ -56,6 +56,9 @@
|
||||
let newItemAllowedUnitCategories = $state(0);
|
||||
let loading = $state(true);
|
||||
let connection: HubConnection | null = null;
|
||||
// Seq guard so a slow section lookup can't overwrite the section a
|
||||
// later (faster) product pick already set.
|
||||
let sectionLookupSeq = 0;
|
||||
|
||||
const listId = $derived(Number(page.params.id));
|
||||
const uncheckedItems = $derived(items.filter((i) => !i.isChecked));
|
||||
@@ -203,19 +206,39 @@
|
||||
}
|
||||
}
|
||||
|
||||
function onItemProductChange(product: ProductSuggestion | null) {
|
||||
async function onItemProductChange(product: ProductSuggestion | null) {
|
||||
const seq = ++sectionLookupSeq;
|
||||
if (product === null) {
|
||||
newItemProductId = null;
|
||||
newItemFamilyProductId = null;
|
||||
newItemAllowedUnitCategories = 0;
|
||||
} else if (product.kind === 'Global') {
|
||||
// Don't clobber the user's section choice when they edit the
|
||||
// name and the link clears — they may have set it deliberately.
|
||||
return;
|
||||
}
|
||||
if (product.kind === 'Global') {
|
||||
newItemProductId = product.id;
|
||||
newItemFamilyProductId = null;
|
||||
newItemAllowedUnitCategories = product.allowedUnitCategories;
|
||||
} else {
|
||||
newItemProductId = null;
|
||||
newItemFamilyProductId = product.id;
|
||||
newItemAllowedUnitCategories = product.allowedUnitCategories;
|
||||
}
|
||||
newItemAllowedUnitCategories = product.allowedUnitCategories;
|
||||
|
||||
// Mirror the backend's auto-assign rule: pre-fill the section dropdown
|
||||
// from per-store memory so the user can see (and override) what's
|
||||
// about to happen. Null result means no memory — reset to "No section".
|
||||
if (!list) return;
|
||||
const path = product.kind === 'Global' ? 'global' : 'family';
|
||||
try {
|
||||
const res = await api<{ sectionId: number | null }>(
|
||||
`/api/products/${path}/${product.id}/section?storeId=${list.store.id}`
|
||||
);
|
||||
if (seq !== sectionLookupSeq) return;
|
||||
newItemSectionId = res.sectionId;
|
||||
} catch {
|
||||
// Lookup failure shouldn't block adding an item — leave the
|
||||
// section alone and let the backend auto-assign on POST.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user