From d4db819e72209f40243bdde1eeb1209db1230b5c Mon Sep 17 00:00:00 2001 From: Josh Rogers Date: Fri, 8 May 2026 21:07:57 -0500 Subject: [PATCH] Polish Store endpoints: 409 conflicts and confirm-before-delete Backend pre-checks duplicate names on POST/PUT and returns 409 Conflict (replacing the previous 500 from the unique constraint). DELETE-with-active-lists also returns 409 instead of 400 for semantic accuracy. Frontend addStore and saveEdit now surface API errors via toast instead of failing silently. Delete is now gated by a confirmation modal so accidental clicks no longer destroy data. --- .../Features/StoreEndpointsTests.cs | 41 +++++++++- .../Features/Stores/StoreEndpoints.cs | 12 ++- src/frontend/src/routes/stores/+page.svelte | 80 +++++++++++++++---- 3 files changed, 114 insertions(+), 19 deletions(-) diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/StoreEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/StoreEndpointsTests.cs index b32dcc5..882b949 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Features/StoreEndpointsTests.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Features/StoreEndpointsTests.cs @@ -77,10 +77,49 @@ public class StoreEndpointsTests : AuthenticatedIntegrationTest var response = await Client.DeleteAsync($"/api/stores/{store.Id}"); - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); await Assert.That(await UseDbAsync(db => db.Stores.AnyAsync(s => s.Id == store.Id))).IsTrue(); } + [Test] + public async Task Create_returns_409_for_duplicate_name() + { + await Data.CreateStoreAsync(b => b.Named("Kroger")); + + var response = await Client.PostAsJsonAsync("/api/stores", + new StoreEndpoints.CreateStoreRequest("Kroger")); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + await Assert.That(await UseDbAsync(db => db.Stores.CountAsync())).IsEqualTo(1); + } + + [Test] + public async Task Update_returns_409_when_renaming_to_existing_name() + { + await Data.CreateStoreAsync(b => b.Named("Kroger")); + var publix = await Data.CreateStoreAsync(b => b.Named("Publix")); + + var response = await Client.PutAsJsonAsync($"/api/stores/{publix.Id}", + new StoreEndpoints.UpdateStoreRequest("Kroger", 0)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + var refreshed = await UseDbAsync(db => db.Stores.SingleAsync(s => s.Id == publix.Id)); + await Assert.That(refreshed.Name).IsEqualTo("Publix"); + } + + [Test] + public async Task Update_allows_keeping_same_name() + { + var store = await Data.CreateStoreAsync(b => b.Named("Kroger")); + + var response = await Client.PutAsJsonAsync($"/api/stores/{store.Id}", + new StoreEndpoints.UpdateStoreRequest("Kroger", 7)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + var refreshed = await UseDbAsync(db => db.Stores.SingleAsync(s => s.Id == store.Id)); + await Assert.That(refreshed.SortOrder).IsEqualTo(7); + } + [Test] public async Task Endpoints_require_authentication() { diff --git a/src/backend/YesChef.Api/Features/Stores/StoreEndpoints.cs b/src/backend/YesChef.Api/Features/Stores/StoreEndpoints.cs index 7013ff8..c3fba3c 100644 --- a/src/backend/YesChef.Api/Features/Stores/StoreEndpoints.cs +++ b/src/backend/YesChef.Api/Features/Stores/StoreEndpoints.cs @@ -23,9 +23,13 @@ public static class StoreEndpoints group.MapPost("/", async (CreateStoreRequest request, YesChefDb db, HttpContext http) => { + var familyId = http.User.GetFamilyId(); + if (await db.Stores.AnyAsync(s => s.FamilyId == familyId && s.Name == request.Name)) + return Results.Conflict(new { error = $"A store named \"{request.Name}\" already exists." }); + var store = new Store { - FamilyId = http.User.GetFamilyId(), + FamilyId = familyId, Name = request.Name, SortOrder = request.SortOrder, }; @@ -40,6 +44,10 @@ public static class StoreEndpoints var store = await db.Stores.FirstOrDefaultAsync(s => s.Id == id && s.FamilyId == familyId); if (store is null) return Results.NotFound(); + if (request.Name != store.Name && + await db.Stores.AnyAsync(s => s.FamilyId == familyId && s.Name == request.Name && s.Id != id)) + return Results.Conflict(new { error = $"A store named \"{request.Name}\" already exists." }); + store.Name = request.Name; store.SortOrder = request.SortOrder; await db.SaveChangesAsync(); @@ -53,7 +61,7 @@ public static class StoreEndpoints if (store is null) return Results.NotFound(); var hasLists = await db.ShoppingLists.AnyAsync(l => l.StoreId == id && l.FamilyId == familyId); - if (hasLists) return Results.BadRequest(new { error = "Store has shopping lists. Remove them first." }); + if (hasLists) return Results.Conflict(new { error = "Store has shopping lists. Remove them first." }); db.Stores.Remove(store); await db.SaveChangesAsync(); diff --git a/src/frontend/src/routes/stores/+page.svelte b/src/frontend/src/routes/stores/+page.svelte index ea7e12e..5102c87 100644 --- a/src/frontend/src/routes/stores/+page.svelte +++ b/src/frontend/src/routes/stores/+page.svelte @@ -14,6 +14,7 @@ let editingId = $state(null); let editName = $state(''); let loading = $state(true); + let pendingDelete = $state(null); onMount(async () => { stores = await api('/api/stores'); @@ -22,12 +23,16 @@ async function addStore() { if (!newName.trim()) return; - await api('/api/stores', { - method: 'POST', - body: JSON.stringify({ name: newName, sortOrder: stores.length }) - }); - newName = ''; - stores = await api('/api/stores'); + try { + await api('/api/stores', { + method: 'POST', + body: JSON.stringify({ name: newName, sortOrder: stores.length }) + }); + newName = ''; + stores = await api('/api/stores'); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Failed to add store'); + } } function startEdit(store: Store) { @@ -38,20 +43,31 @@ async function saveEdit() { if (!editName.trim() || !editingId) return; const store = stores.find((s) => s.id === editingId)!; - await api(`/api/stores/${editingId}`, { - method: 'PUT', - body: JSON.stringify({ name: editName, sortOrder: store.sortOrder }) - }); - editingId = null; - stores = await api('/api/stores'); + try { + await api(`/api/stores/${editingId}`, { + method: 'PUT', + body: JSON.stringify({ name: editName, sortOrder: store.sortOrder }) + }); + editingId = null; + stores = await api('/api/stores'); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Failed to update store'); + } } - async function deleteStore(id: number) { + function requestDelete(store: Store) { + pendingDelete = store; + } + + async function confirmDelete() { + if (!pendingDelete) return; + const id = pendingDelete.id; + pendingDelete = null; try { await api(`/api/stores/${id}`, { method: 'DELETE' }); stores = stores.filter((s) => s.id !== id); - } catch (e: any) { - toast.error(e.message); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Failed to delete store'); } } @@ -93,10 +109,42 @@ {:else} {store.name} - + {/if} {/each} {/if} + +{#if pendingDelete} + +{/if}