Distinguish picked-up from removed shopping list items

Soft-remove items via RemovedAt/RemovedByUserId instead of hard
deleting so the row survives for undo and future history reporting.
DELETE now sets the removal fields; a new POST .../restore clears
them. Active list reads (summary, detail, check toggle) filter to
RemovedAt IS NULL. Frontend surfaces an Undo toast on remove and
handles a new ItemRestored SignalR event.
This commit is contained in:
Josh Rogers
2026-05-08 20:07:41 -05:00
parent 9b2db931ee
commit 7fcae09afb
8 changed files with 682 additions and 15 deletions
@@ -162,7 +162,7 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
}
[Test]
public async Task Delete_item_removes_it()
public async Task Delete_item_soft_removes_with_attribution()
{
var list = await CreateListAsync(b => b.WithItem("milk"));
var itemId = list.Items[0].Id;
@@ -170,7 +170,81 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
var response = await Client.DeleteAsync($"/api/lists/{list.Id}/items/{itemId}");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
await Assert.That(await UseDbAsync(db => db.ShoppingListItems.CountAsync())).IsEqualTo(0);
var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync(i => i.Id == itemId));
await Assert.That(item.RemovedAt).IsNotNull();
await Assert.That(item.RemovedByUserId).IsEqualTo(User.Id);
}
[Test]
public async Task Get_by_id_excludes_removed_items()
{
var list = await CreateListAsync(b => b.WithItem("kept").WithItem("gone"));
var goneId = list.Items.Single(i => i.Name == "gone").Id;
await Client.DeleteAsync($"/api/lists/{list.Id}/items/{goneId}");
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/lists/{list.Id}");
var items = body.GetProperty("items").EnumerateArray()
.Select(i => i.GetProperty("name").GetString()).ToArray();
await Assert.That(items).IsEquivalentTo(new[] { "kept" });
}
[Test]
public async Task Delete_item_returns_404_when_already_removed()
{
var list = await CreateListAsync(b => b.WithItem("milk"));
var itemId = list.Items[0].Id;
await Client.DeleteAsync($"/api/lists/{list.Id}/items/{itemId}");
var response = await Client.DeleteAsync($"/api/lists/{list.Id}/items/{itemId}");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
}
[Test]
public async Task Restore_item_clears_removed_fields()
{
var list = await CreateListAsync(b => b.WithItem("milk"));
var itemId = list.Items[0].Id;
await Client.DeleteAsync($"/api/lists/{list.Id}/items/{itemId}");
var response = await Client.PostAsync($"/api/lists/{list.Id}/items/{itemId}/restore", null);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync(i => i.Id == itemId));
await Assert.That(item.RemovedAt).IsNull();
await Assert.That(item.RemovedByUserId).IsNull();
}
[Test]
public async Task Restore_returns_404_when_item_not_removed()
{
var list = await CreateListAsync(b => b.WithItem("milk"));
var itemId = list.Items[0].Id;
var response = await Client.PostAsync($"/api/lists/{list.Id}/items/{itemId}/restore", null);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
}
[Test]
public async Task Check_returns_404_for_removed_item()
{
var list = await CreateListAsync(b => b.WithItem("milk"));
var itemId = list.Items[0].Id;
await Client.DeleteAsync($"/api/lists/{list.Id}/items/{itemId}");
var response = await Client.PatchAsync($"/api/lists/{list.Id}/items/{itemId}/check", null);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
}
[Test]
public async Task List_summary_excludes_removed_items_from_counts()
{
var list = await CreateListAsync(b => b.WithItem("kept").WithItem("gone"));
var goneId = list.Items.Single(i => i.Name == "gone").Id;
await Client.DeleteAsync($"/api/lists/{list.Id}/items/{goneId}");
var lists = await Client.GetFromJsonAsync<List<JsonElement>>("/api/lists");
var summary = lists!.Single(l => l.GetProperty("id").GetInt32() == list.Id);
await Assert.That(summary.GetProperty("itemCount").GetInt32()).IsEqualTo(1);
}
[Test]