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.
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
let editingId = $state<number | null>(null);
|
||||
let editName = $state('');
|
||||
let loading = $state(true);
|
||||
let pendingDelete = $state<Store | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
stores = await api<Store[]>('/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<Store[]>('/api/stores');
|
||||
try {
|
||||
await api('/api/stores', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: newName, sortOrder: stores.length })
|
||||
});
|
||||
newName = '';
|
||||
stores = await api<Store[]>('/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<Store[]>('/api/stores');
|
||||
try {
|
||||
await api(`/api/stores/${editingId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name: editName, sortOrder: store.sortOrder })
|
||||
});
|
||||
editingId = null;
|
||||
stores = await api<Store[]>('/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');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -93,10 +109,42 @@
|
||||
{:else}
|
||||
<span class="flex-1 font-medium">{store.name}</span>
|
||||
<button onclick={() => startEdit(store)} class="text-sm text-gray-400">Edit</button>
|
||||
<button onclick={() => deleteStore(store.id)} class="text-sm text-danger">Delete</button>
|
||||
<button onclick={() => requestDelete(store)} class="text-sm text-danger">Delete</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if pendingDelete}
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-store-title"
|
||||
>
|
||||
<div class="w-full max-w-sm rounded-lg bg-white p-5 shadow-xl">
|
||||
<h3 id="delete-store-title" class="mb-2 text-lg font-semibold">Delete store?</h3>
|
||||
<p class="mb-5 text-sm text-gray-600">
|
||||
Delete <span class="font-medium">{pendingDelete.name}</span>? This can't be undone.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (pendingDelete = null)}
|
||||
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={confirmDelete}
|
||||
class="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user