6d84aad94b
- 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.
495 lines
19 KiB
C#
495 lines
19 KiB
C#
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<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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|