test: cover the meal wishlist (rules, validators, bUnit, integration); fix: bind wishlist search and sort as expressions

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 18:54:31 +02:00
1 parent 019060ccea
commit 5ce407f401
7 files changed
+332 -2

No files matched your search

@@ -153,7 +153,7 @@ else
{
<MealWishList Wishes="_wishes" CanDecide="CanDecideWish" CanDelete="CanDeleteWish"
OnAccept="AcceptWishAsync" OnReject="RejectWishAsync" OnDelete="DeleteWishAsync" />
<DishWishlist Items="_wishlist" Search="_wishlistSearch" Sort="_wishlistSort" CanPlan="CanEdit" CanDelete="CanDeleteWishlistDish"
<DishWishlist Items="_wishlist" Search="@_wishlistSearch" Sort="@_wishlistSort" CanPlan="CanEdit" CanDelete="CanDeleteWishlistDish"
OnSearch="SearchWishlistAsync" OnSort="SortWishlistAsync" OnPlan="PlanWishlistDishAsync" OnDelete="DeleteWishlistDishAsync" />
}
}
+7 -1
View File
@@ -888,10 +888,16 @@ button.fp-meal-dish {
}
/* Essenswünsche */
.fp-meal-wishes {
.fp-meal-wishes,
.fp-wishlist {
margin-top: 16px;
}
.fp-wishlist-search {
flex: 1 1 220px;
max-width: 360px;
}
.fp-wish-items {
list-style: none;
margin: 0;
@@ -283,6 +283,125 @@ public sealed class MealTests(AppFixture fixture)
(await admin.PostAsJsonAsync($"/api/meals/{Guid.NewGuid()}/to-shopping", new IngredientsToShoppingRequest(list.Id), Ct)).StatusCode);
}
private static async Task<List<WishlistDishDto>> WishlistAsync(HttpClient client, string? query = null, string? sort = null) =>
(await client.GetFromJsonAsync<List<WishlistDishDto>>(
$"/api/meals/wishlist?q={Uri.EscapeDataString(query ?? string.Empty)}&sort={sort}", Ct))!;
[Fact]
public async Task Wishes_and_planned_dishes_land_in_a_searchable_deduplicated_wishlist()
{
using var family = (await fixture.CreateSignedInUserAsync(Roles.Family, Ct)).Client;
var (childUser, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
using var _ = child;
var day = NewWeek();
var key = Guid.NewGuid().ToString("N")[..8];
var dish = $"Wunsch{key}";
// Wunsch legt das Gericht an; erneuter Wunsch (andere Schreibweise) dedupliziert und zählt hoch
Assert.Equal(HttpStatusCode.Created, (await child.PostAsJsonAsync("/api/meals/wishes", new CreateMealWishRequest(dish, "mit Soße"), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Created, (await child.PostAsJsonAsync("/api/meals/wishes", new CreateMealWishRequest($" {dish.ToUpperInvariant()} "), Ct)).StatusCode);
var listed = Assert.Single(await WishlistAsync(child, key));
Assert.Equal((dish, "mit Soße", 2, 0, childUser.Id, childUser.DisplayName), (listed.Name, listed.Note, listed.WishCount, listed.UseCount, listed.WishedBy!.Value, listed.WishedByName));
Assert.Null(listed.LastPlannedAt);
Assert.Equal(listed.Id, Assert.Single(await WishlistAsync(family, key.ToUpperInvariant())).Id);
// Planen: Zähler und letztes Plandatum; Platz belegt → nur mit Ersetzen; ungültige Mahlzeit → 400
var planned = await family.PostAsJsonAsync($"/api/meals/wishlist/{listed.Id}/plan", new PlanWishlistDishRequest(day, Lunch), Ct);
Assert.Equal(HttpStatusCode.OK, planned.StatusCode);
var entry = (await planned.Content.ReadFromJsonAsync<MealPlanEntryDto>(Ct))!;
Assert.Equal((dish, "mit Soße", day, Lunch), (entry.DishName, entry.Note, entry.Date, entry.MealType));
var after = Assert.Single(await WishlistAsync(family, key));
Assert.Equal(1, after.UseCount);
Assert.NotNull(after.LastPlannedAt);
Assert.Equal(HttpStatusCode.Conflict,
(await family.PostAsJsonAsync($"/api/meals/wishlist/{listed.Id}/plan", new PlanWishlistDishRequest(day, Lunch), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest,
(await family.PostAsJsonAsync($"/api/meals/wishlist/{listed.Id}/plan", new PlanWishlistDishRequest(day, "Mitternacht"), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound,
(await family.PostAsJsonAsync($"/api/meals/wishlist/{Guid.NewGuid()}/plan", new PlanWishlistDishRequest(day, Dinner), Ct)).StatusCode);
var replaced = await family.PostAsJsonAsync($"/api/meals/wishlist/{listed.Id}/plan", new PlanWishlistDishRequest(day, Lunch, Replace: true), Ct);
Assert.Equal(HttpStatusCode.OK, replaced.StatusCode);
Assert.Equal(2, Assert.Single(await WishlistAsync(family, key)).UseCount);
// Direkt geplante Gerichte erscheinen ebenfalls; Sortierung nach Häufigkeit und Name
var other = $"Direkt{key}";
await CreateAsync(family, day, Dinner, other);
var byName = (await WishlistAsync(family, key, "name")).Select(d => d.Name).ToList();
Assert.Equal([other, dish], byName);
Assert.Equal(dish, (await WishlistAsync(family, key, "mostPlanned"))[0].Name);
Assert.Equal(2, (await WishlistAsync(family, key, "lastPlanned")).Count);
Assert.Equal(2, (await WishlistAsync(family, key, "nonsense")).Count);
}
[Fact]
public async Task Wishlist_rights_are_enforced_per_object()
{
using var family = (await fixture.CreateSignedInUserAsync(Roles.Family, Ct)).Client;
var (_, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, sibling) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, guest) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
using var _ = child;
using var __ = sibling;
using var ___ = guest;
var day = NewWeek();
var key = Guid.NewGuid().ToString("N")[..8];
await child.PostAsJsonAsync("/api/meals/wishes", new CreateMealWishRequest($"Eigen{key}"), Ct);
await child.PostAsJsonAsync("/api/meals/wishes", new CreateMealWishRequest($"Geplant{key}"), Ct);
var own = Assert.Single(await WishlistAsync(child, $"eigen{key}"));
var planned = Assert.Single(await WishlistAsync(child, $"geplant{key}"));
// Gäste sehen nichts, Kinder planen nicht
Assert.Equal(HttpStatusCode.Forbidden, (await guest.GetAsync("/api/meals/wishlist", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await guest.DeleteAsync($"/api/meals/wishlist/{own.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden,
(await child.PostAsJsonAsync($"/api/meals/wishlist/{own.Id}/plan", new PlanWishlistDishRequest(day, Lunch), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden,
(await guest.PostAsJsonAsync($"/api/meals/wishlist/{own.Id}/plan", new PlanWishlistDishRequest(day, Lunch), Ct)).StatusCode);
Assert.Equal(2, (await WishlistAsync(sibling, key)).Count);
// Löschen: Geschwister nicht fremde, Kind eigene nur ohne Planung, Familie alles
Assert.Equal(HttpStatusCode.Forbidden, (await sibling.DeleteAsync($"/api/meals/wishlist/{own.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await family.PostAsJsonAsync($"/api/meals/wishlist/{planned.Id}/plan", new PlanWishlistDishRequest(day, Lunch), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await child.DeleteAsync($"/api/meals/wishlist/{planned.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NoContent, (await child.DeleteAsync($"/api/meals/wishlist/{own.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NoContent, (await child.DeleteAsync($"/api/meals/wishlist/{own.Id}", Ct)).StatusCode); // idempotent
Assert.Equal(HttpStatusCode.NoContent, (await family.DeleteAsync($"/api/meals/wishlist/{planned.Id}", Ct)).StatusCode);
Assert.Empty(await WishlistAsync(family, key));
}
[Fact]
public async Task Deleted_wishlist_dish_is_reactivated_by_a_new_wish_or_plan()
{
using var family = (await fixture.CreateSignedInUserAsync(Roles.Family, Ct)).Client;
var (_, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
using var _ = child;
var day = NewWeek();
var key = Guid.NewGuid().ToString("N")[..8];
var dish = $"Zurück{key}";
await child.PostAsJsonAsync("/api/meals/wishes", new CreateMealWishRequest(dish), Ct);
var first = Assert.Single(await WishlistAsync(family, key));
Assert.Equal(HttpStatusCode.NoContent, (await family.DeleteAsync($"/api/meals/wishlist/{first.Id}", Ct)).StatusCode);
Assert.Empty(await WishlistAsync(family, key));
Assert.Empty((await family.GetFromJsonAsync<List<DishSuggestionDto>>($"/api/meals/dishes?q={key}", Ct))!);
// Erneuter Wunsch: dasselbe Gericht (gleiche Id) ist wieder da – kein Duplikat, kein Index-Fehler
await child.PostAsJsonAsync("/api/meals/wishes", new CreateMealWishRequest(dish.ToUpperInvariant()), Ct);
var again = Assert.Single(await WishlistAsync(family, key));
Assert.Equal(first.Id, again.Id);
// Löschen und über den Plan reaktivieren
await family.DeleteAsync($"/api/meals/wishlist/{first.Id}", Ct);
Assert.Empty(await WishlistAsync(family, key));
await CreateAsync(family, day, Lunch, dish);
var planned = Assert.Single(await WishlistAsync(family, key));
Assert.Equal((first.Id, 1), (planned.Id, planned.UseCount));
Assert.NotNull(planned.LastPlannedAt);
}
[Fact]
public async Task Changes_are_broadcast_via_signalr()
{
@@ -169,4 +169,49 @@ public sealed class MealPlanRulesTests
Assert.True(MealPlanRules.CanDeleteWish(AccessLevel.Full, sibling, MealWishStatus.Rejected, child));
Assert.False(MealPlanRules.CanDeleteWish(AccessLevel.None, child, MealWishStatus.Open, child));
}
[Fact]
public void Wishlist_is_visible_to_family_and_children_but_not_to_guests()
{
Assert.True(MealPlanRules.CanSeeWishlist(AccessLevel.Full));
Assert.True(MealPlanRules.CanSeeWishlist(AccessLevel.Restricted));
Assert.False(MealPlanRules.CanSeeWishlist(AccessLevel.None));
}
[Fact]
public void Child_deletes_only_own_wishlist_entries_that_were_never_planned()
{
var child = Guid.NewGuid();
var sibling = Guid.NewGuid();
Assert.True(MealPlanRules.CanDeleteWishlistEntry(AccessLevel.Restricted, child, wasPlanned: false, child));
Assert.False(MealPlanRules.CanDeleteWishlistEntry(AccessLevel.Restricted, child, wasPlanned: true, child));
Assert.False(MealPlanRules.CanDeleteWishlistEntry(AccessLevel.Restricted, sibling, wasPlanned: false, child));
Assert.False(MealPlanRules.CanDeleteWishlistEntry(AccessLevel.Restricted, null, wasPlanned: false, child));
Assert.True(MealPlanRules.CanDeleteWishlistEntry(AccessLevel.Full, sibling, wasPlanned: true, child));
Assert.True(MealPlanRules.CanDeleteWishlistEntry(AccessLevel.Full, null, wasPlanned: false, child));
Assert.False(MealPlanRules.CanDeleteWishlistEntry(AccessLevel.None, child, wasPlanned: false, child));
}
[Theory]
[InlineData(null, WishlistSort.LastPlanned)]
[InlineData("", WishlistSort.LastPlanned)]
[InlineData("unbekannt", WishlistSort.LastPlanned)]
[InlineData("mostPlanned", WishlistSort.MostPlanned)]
[InlineData(" MOSTPLANNED ", WishlistSort.MostPlanned)]
[InlineData("name", WishlistSort.Name)]
public void Wishlist_sort_falls_back_to_last_planned(string? input, string expected) =>
Assert.Equal(expected, MealPlanRules.NormalizeWishlistSort(input));
[Fact]
public void Deleted_dish_is_restored_when_wished_or_planned_again()
{
var dish = new Dish { Name = "Pizza", IsDeleted = true, DeletedAt = DateTimeOffset.UtcNow, DeletedBy = Guid.NewGuid() };
dish.Restore();
Assert.False(dish.IsDeleted);
Assert.Null(dish.DeletedAt);
Assert.Null(dish.DeletedBy);
}
}
@@ -56,6 +56,18 @@ public sealed class MealValidatorTests
Assert.False(new AcceptMealWishRequestValidator().Validate(new AcceptMealWishRequest(Day, new string('x', 41))).IsValid);
}
[Fact]
public void Planning_a_wishlist_dish_needs_day_and_meal()
{
var validator = new PlanWishlistDishRequestValidator();
Assert.True(validator.Validate(new PlanWishlistDishRequest(Day, "Mittagessen")).IsValid);
Assert.True(validator.Validate(new PlanWishlistDishRequest(Day, "Mittagessen", Replace: true)).IsValid);
Assert.False(validator.Validate(new PlanWishlistDishRequest(default, "Mittagessen")).IsValid);
Assert.False(validator.Validate(new PlanWishlistDishRequest(Day, " ")).IsValid);
Assert.False(validator.Validate(new PlanWishlistDishRequest(Day, new string('x', MealPlanEntry.MealTypeMaxLength + 1))).IsValid);
}
[Fact]
public void Ingredient_transfer_limits_the_number_of_ingredients()
{
@@ -28,8 +28,19 @@ internal sealed class FakeMealsApi : IMealsApi
new("Pfannkuchen", null, [], 3),
];
public List<WishlistDishDto> Wishlist { get; } = [];
public List<(string? Query, string Sort)> WishlistQueries { get; } = [];
private int _version;
public WishlistDishDto SeedDish(string name, int used = 0, int wished = 0, DateTimeOffset? lastPlanned = null, Guid? wishedBy = null, string? wishedByName = null)
{
var dish = new WishlistDishDto(Guid.NewGuid(), name, null, null, [], wished, used, lastPlanned, wishedBy, wishedByName);
Wishlist.Add(dish);
return dish;
}
public MealPlanEntryDto Seed(DateOnly date, string mealType, string dish, IReadOnlyList<string>? ingredients = null, string? note = null) =>
Add(new MealPlanEntryDto(Guid.NewGuid(), date, mealType, dish, note, null, ingredients ?? [], null, null, DateTimeOffset.UtcNow, NextVersion()));
@@ -131,6 +142,36 @@ internal sealed class FakeMealsApi : IMealsApi
public Task<ApiResult<bool>> DeleteWishAsync(Guid id, CancellationToken ct = default) => Ok(Wishes.RemoveAll(w => w.Id == id) >= 0);
public Task<ApiResult<List<WishlistDishDto>>> GetWishlistAsync(string? query, string sort, CancellationToken ct = default)
{
WishlistQueries.Add((query, sort));
var items = Wishlist.Where(d => string.IsNullOrWhiteSpace(query) || d.Name.Contains(query.Trim(), StringComparison.OrdinalIgnoreCase));
var ordered = sort switch
{
WishlistSort.MostPlanned => items.OrderByDescending(d => d.UseCount),
WishlistSort.Name => items.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase),
_ => items.OrderByDescending(d => d.LastPlannedAt),
};
return Ok(ordered.ToList());
}
public Task<ApiResult<MealPlanEntryDto>> PlanWishlistDishAsync(Guid id, PlanWishlistDishRequest request, CancellationToken ct = default)
{
var dish = Wishlist.Single(d => d.Id == id);
if (Entries.Any(e => e.Date == request.Date && e.MealType == request.MealType) && !request.Replace)
{
return Task.FromResult(ApiResult<MealPlanEntryDto>.Fail(new ApiProblem { Status = 409, Detail = "Platz belegt." }));
}
Entries.RemoveAll(e => e.Date == request.Date && e.MealType == request.MealType);
var entry = Add(new MealPlanEntryDto(Guid.NewGuid(), request.Date, request.MealType, dish.Name, dish.Note, dish.RecipeUrl, dish.Ingredients,
null, null, DateTimeOffset.UtcNow, NextVersion()));
Wishlist[Wishlist.IndexOf(dish)] = dish with { UseCount = dish.UseCount + 1, LastPlannedAt = DateTimeOffset.UtcNow };
return Ok(entry);
}
public Task<ApiResult<bool>> DeleteWishlistDishAsync(Guid id, CancellationToken ct = default) => Ok(Wishlist.RemoveAll(d => d.Id == id) >= 0);
private MealPlanEntryDto Add(MealPlanEntryDto entry)
{
Entries.Add(entry);
@@ -300,6 +300,113 @@ public sealed class MealsPageTests : BunitContext
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".fp-meal-list .fp-meal-dish")));
}
[Fact]
public void Wishlist_shows_dishes_with_planning_history()
{
_api.SeedDish("Pizza", used: 3, wished: 2, lastPlanned: new DateTimeOffset(2026, 10, 5, 12, 0, 0, TimeSpan.Zero), wishedByName: "Anna");
_api.SeedDish("Gulasch");
SignInAs(Roles.Family);
var cut = Render<MealsPageHost>();
Assert.Equal(2, cut.FindAll(".fp-wishlist-item").Count);
var pizza = cut.FindAll(".fp-wishlist-item").Single(i => i.QuerySelector(".fp-wishlist-name")!.TextContent == "Pizza");
Assert.Contains("zuletzt geplant am 05.10.2026", pizza.QuerySelector(".fp-wishlist-meta")!.TextContent);
Assert.Contains("3-mal geplant", pizza.QuerySelector(".fp-wishlist-meta")!.TextContent);
Assert.Contains("2-mal gewünscht (zuletzt Anna)", pizza.QuerySelector(".fp-wishlist-meta")!.TextContent);
var gulasch = cut.FindAll(".fp-wishlist-item").Single(i => i.QuerySelector(".fp-wishlist-name")!.TextContent == "Gulasch");
Assert.Contains("noch nie geplant", gulasch.QuerySelector(".fp-wishlist-meta")!.TextContent);
}
[Fact]
public void Wishlist_is_searched_and_sorted_through_the_api()
{
_api.SeedDish("Pizza", used: 1);
_api.SeedDish("Nudeln", used: 5);
_api.SeedDish("Pizzabrötchen", used: 2);
SignInAs(Roles.Family);
var cut = Render<MealsPageHost>();
cut.Find("#wishlist-search").Change("pizza");
cut.WaitForAssertion(() => Assert.Equal(["Pizza", "Pizzabrötchen"], cut.FindAll(".fp-wishlist-name").Select(n => n.TextContent).Order()));
Assert.Equal(("pizza", WishlistSort.LastPlanned), _api.WishlistQueries[^1]);
cut.Find(".fp-wishlist-sort-mostPlanned").Click();
cut.WaitForAssertion(() => Assert.Equal(["Pizzabrötchen", "Pizza"], cut.FindAll(".fp-wishlist-name").Select(n => n.TextContent)));
cut.Find("#wishlist-search").Change("kuchen");
cut.WaitForAssertion(() => Assert.Contains("Kein Gericht gefunden.", cut.Find(".fp-wishlist-empty").TextContent));
}
[Fact]
public void Family_plans_a_wishlist_dish_into_a_free_slot()
{
var dish = _api.SeedDish("Pizza");
SignInAs(Roles.Family);
var cut = Render<MealsPageHost>();
cut.Find(".fp-wishlist-plan").Click();
cut.WaitForElement(Slot(Today.AddDays(1), "Abendessen")).Click();
cut.WaitForAssertion(() => Assert.Equal("Pizza", cut.Find(".fp-meal-list .fp-meal-dish").TextContent));
var entry = Assert.Single(_api.Entries);
Assert.Equal((Today.AddDays(1), "Abendessen", "Pizza"), (entry.Date, entry.MealType, entry.DishName));
cut.WaitForAssertion(() => Assert.Contains("1-mal geplant", cut.Find(".fp-wishlist-meta").TextContent));
Assert.Equal(dish.Id, _api.Wishlist.Single().Id);
}
[Fact]
public void Planning_a_wishlist_dish_into_an_occupied_slot_asks_before_replacing()
{
_api.Seed(Today, "Mittagessen", "Linsen");
_api.SeedDish("Pizza");
SignInAs(Roles.Admin);
var cut = Render<MealsPageHost>();
cut.Find(".fp-wishlist-plan").Click();
cut.WaitForElement(Slot(Today, "Mittagessen")).Click();
cut.WaitForElement(".fp-accept-replace");
Assert.Contains("Linsen", cut.Find(".fp-accept-replace").TextContent);
Assert.Equal("Linsen", Assert.Single(_api.Entries).DishName);
cut.Find(".fp-accept-replace-confirm").Click();
cut.WaitForAssertion(() => Assert.Equal("Pizza", Assert.Single(_api.Entries).DishName));
}
[Fact]
public void Child_sees_the_wishlist_but_cannot_plan_and_deletes_only_own_unplanned_dishes()
{
_api.SeedDish("Eigenes", wishedBy: UserId);
_api.SeedDish("Geplantes", used: 2, wishedBy: UserId, lastPlanned: DateTimeOffset.UtcNow);
_api.SeedDish("Fremdes", wishedBy: Guid.NewGuid());
SignInAs(Roles.Child);
var cut = Render<MealsPageHost>();
Assert.Equal(3, cut.FindAll(".fp-wishlist-item").Count);
Assert.Empty(cut.FindAll(".fp-wishlist-plan"));
var deletable = cut.FindAll(".fp-wishlist-item").Where(i => i.QuerySelector(".fp-wishlist-delete") is not null).ToList();
Assert.Equal("Eigenes", Assert.Single(deletable).QuerySelector(".fp-wishlist-name")!.TextContent);
cut.Find(".fp-wishlist-delete").Click();
cut.WaitForAssertion(() => Assert.DoesNotContain(_api.Wishlist, d => d.Name == "Eigenes"));
Assert.Equal(2, cut.FindAll(".fp-wishlist-item").Count);
}
[Fact]
public void Family_deletes_any_wishlist_dish()
{
_api.SeedDish("Pizza", used: 4, lastPlanned: DateTimeOffset.UtcNow, wishedBy: Guid.NewGuid());
SignInAs(Roles.Family);
var cut = Render<MealsPageHost>();
cut.Find(".fp-wishlist-delete").Click();
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".fp-wishlist-item")));
Assert.Empty(_api.Wishlist);
Assert.Contains("Die Wunschliste ist noch leer.", cut.Find(".fp-wishlist-empty").TextContent);
}
[Fact]
public void Navigation_moves_by_plan_length()
{