diff --git a/src/app/directory/[id]/DirectoryOwnerActions.test.tsx b/src/app/directory/[id]/DirectoryOwnerActions.test.tsx new file mode 100644 index 00000000..0e785ed1 --- /dev/null +++ b/src/app/directory/[id]/DirectoryOwnerActions.test.tsx @@ -0,0 +1,74 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DirectoryOwnerActions } from "./DirectoryOwnerActions"; + +const { push, refresh } = vi.hoisted(() => ({ + push: vi.fn(), + refresh: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push, refresh }), +})); + +const listing = { + id: "listing-1", + title: "Example", + url: "https://example.com", + description: null, + tags: [], + logo_url: null, + banner_url: null, + screenshot_url: null, + status: "active", +}; + +describe("DirectoryOwnerActions", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn()); + vi.stubGlobal("confirm", vi.fn(() => true)); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("keeps the listing in place when a visibility update fails", async () => { + vi.mocked(fetch).mockResolvedValue( + new Response(JSON.stringify({ error: "Visibility denied" }), { status: 403 }) + ); + + render(); + await userEvent.click(screen.getByRole("button", { name: "Hide" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Visibility denied"); + expect(refresh).not.toHaveBeenCalled(); + }); + + it("keeps the listing in place when deletion fails", async () => { + vi.mocked(fetch).mockResolvedValue( + new Response(JSON.stringify({ error: "Delete denied" }), { status: 403 }) + ); + + render(); + await userEvent.click(screen.getByRole("button", { name: "Delete" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Delete denied"); + expect(push).not.toHaveBeenCalled(); + }); + + it("re-enables saving when an edit request fails at the network layer", async () => { + vi.mocked(fetch).mockRejectedValue(new Error("offline")); + + render(); + await userEvent.click(screen.getByRole("button", { name: "Edit" })); + await userEvent.click(screen.getByRole("button", { name: "Save Changes" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Update failed"); + expect(screen.getByRole("button", { name: "Save Changes" })).toBeEnabled(); + expect(refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/directory/[id]/DirectoryOwnerActions.tsx b/src/app/directory/[id]/DirectoryOwnerActions.tsx index eaddcc9c..3f21bae1 100644 --- a/src/app/directory/[id]/DirectoryOwnerActions.tsx +++ b/src/app/directory/[id]/DirectoryOwnerActions.tsx @@ -22,6 +22,15 @@ interface DirectoryOwnerActionsProps { }; } +async function responseError(res: Response, fallback: string) { + try { + const data = await res.json(); + return data.error || fallback; + } catch { + return fallback; + } +} + export function DirectoryOwnerActions({ listing }: DirectoryOwnerActionsProps) { const router = useRouter(); const [editing, setEditing] = useState(false); @@ -45,49 +54,76 @@ export function DirectoryOwnerActions({ listing }: DirectoryOwnerActionsProps) { .map((t) => t.trim()) .filter(Boolean); - const res = await fetch(`/api/directory/${listing.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - title, - url, - description: description || undefined, - tags, - logo_url: logoUrl || null, - banner_url: bannerUrl || null, - screenshot_url: screenshotUrl || null, - }), - }); - - if (!res.ok) { - const data = await res.json(); - setError(data.error || "Update failed"); + try { + const res = await fetch(`/api/directory/${listing.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title, + url, + description: description || undefined, + tags, + logo_url: logoUrl || null, + banner_url: bannerUrl || null, + screenshot_url: screenshotUrl || null, + }), + }); + + if (!res.ok) { + setError(await responseError(res, "Update failed")); + return; + } + + setEditing(false); + router.refresh(); + } catch { + setError("Update failed"); + } finally { setLoading(false); - return; } - - setEditing(false); - setLoading(false); - router.refresh(); } async function handleToggleVisibility() { + setError(""); setLoading(true); - const newStatus = listing.status === "active" ? "hidden" : "active"; - await fetch(`/api/directory/${listing.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: newStatus }), - }); - setLoading(false); - router.refresh(); + try { + const newStatus = listing.status === "active" ? "hidden" : "active"; + const res = await fetch(`/api/directory/${listing.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: newStatus }), + }); + + if (!res.ok) { + setError(await responseError(res, "Visibility update failed")); + return; + } + + router.refresh(); + } catch { + setError("Visibility update failed"); + } finally { + setLoading(false); + } } async function handleDelete() { if (!confirm("Delete this listing? This cannot be undone and there is no refund.")) return; + setError(""); setLoading(true); - await fetch(`/api/directory/${listing.id}`, { method: "DELETE" }); - router.push("/directory"); + try { + const res = await fetch(`/api/directory/${listing.id}`, { method: "DELETE" }); + if (!res.ok) { + setError(await responseError(res, "Delete failed")); + return; + } + + router.push("/directory"); + } catch { + setError("Delete failed"); + } finally { + setLoading(false); + } } if (editing) { @@ -95,7 +131,7 @@ export function DirectoryOwnerActions({ listing }: DirectoryOwnerActionsProps) {

Edit Listing

{error && ( -
+
{error}
)} @@ -130,7 +166,7 @@ export function DirectoryOwnerActions({ listing }: DirectoryOwnerActionsProps) {
- +
@@ -140,8 +176,13 @@ export function DirectoryOwnerActions({ listing }: DirectoryOwnerActionsProps) { return (

Owner Actions

+ {error && ( +
+ {error} +
+ )}
-