diff --git a/src/components/MarketplaceVoteButton.test.tsx b/src/components/MarketplaceVoteButton.test.tsx new file mode 100644 index 00000000..980bc021 --- /dev/null +++ b/src/components/MarketplaceVoteButton.test.tsx @@ -0,0 +1,91 @@ +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 { DirectoryVoteButton } from "@/components/directory/DirectoryVoteButton"; +import { McpVoteButton } from "@/components/mcp/McpVoteButton"; +import { PromptVoteButton } from "@/components/prompts/PromptVoteButton"; +import { SkillVoteButton } from "@/components/skills/SkillVoteButton"; + +const forms = [ + { + name: "directory", + render: () => ( + + ), + }, + { + name: "MCP", + render: () => ( + + ), + }, + { + name: "prompt", + render: () => ( + + ), + }, + { + name: "skill", + render: () => ( + + ), + }, +]; + +describe("marketplace vote errors", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it.each(forms)("shows API errors when the $name vote request fails", async ({ render: renderForm }) => { + vi.mocked(fetch).mockResolvedValue( + new Response(JSON.stringify({ error: "Vote denied" }), { status: 403 }) + ); + + render(renderForm()); + await userEvent.click(screen.getByTitle("Upvote")); + + expect(await screen.findByRole("alert")).toHaveTextContent("Vote denied"); + expect(screen.getByTitle("Upvote")).toBeEnabled(); + }); + + it("shows a fallback error when a vote request fails at the network layer", async () => { + vi.mocked(fetch).mockRejectedValue(new Error("offline")); + + render(forms[0].render()); + await userEvent.click(screen.getByTitle("Upvote")); + + expect(await screen.findByRole("alert")).toHaveTextContent("Failed to update vote"); + expect(screen.getByTitle("Upvote")).toBeEnabled(); + }); +}); diff --git a/src/components/directory/DirectoryVoteButton.tsx b/src/components/directory/DirectoryVoteButton.tsx index 343691a6..02a60c5f 100644 --- a/src/components/directory/DirectoryVoteButton.tsx +++ b/src/components/directory/DirectoryVoteButton.tsx @@ -23,9 +23,11 @@ export function DirectoryVoteButton({ const [score, setScore] = useState(initialScore); const [userVote, setUserVote] = useState(initialUserVote); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); async function handleVote(voteType: 1 | -1) { setLoading(true); + setError(null); try { const res = await fetch(`/api/directory/${listingId}/vote`, { method: "POST", @@ -33,50 +35,60 @@ export function DirectoryVoteButton({ body: JSON.stringify({ vote_type: voteType }), }); - if (res.ok) { - const data = await res.json(); - setUpvotes(data.upvotes); - setDownvotes(data.downvotes); - setScore(data.score); - setUserVote(data.user_vote); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.error || "Failed to update vote"); + return; } + + const data = await res.json(); + setUpvotes(data.upvotes); + setDownvotes(data.downvotes); + setScore(data.score); + setUserVote(data.user_vote); } catch { - // silently fail + setError("Failed to update vote"); + } finally { + setLoading(false); } - setLoading(false); } return ( -
- - 0 ? "text-green-500" : score < 0 ? "text-red-500" : "text-muted-foreground" - }`}> - {score} - - +
+
+ + 0 ? "text-green-500" : score < 0 ? "text-red-500" : "text-muted-foreground" + }`}> + {score} + + +
+ {error &&

{error}

}
); } diff --git a/src/components/mcp/McpVoteButton.tsx b/src/components/mcp/McpVoteButton.tsx index 83712923..8a7a8cfe 100644 --- a/src/components/mcp/McpVoteButton.tsx +++ b/src/components/mcp/McpVoteButton.tsx @@ -23,9 +23,11 @@ export function McpVoteButton({ const [score, setScore] = useState(initialScore); const [userVote, setUserVote] = useState(initialUserVote); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); async function handleVote(voteType: 1 | -1) { setLoading(true); + setError(null); try { const res = await fetch(`/api/mcp/${slug}/vote`, { method: "POST", @@ -33,22 +35,29 @@ export function McpVoteButton({ body: JSON.stringify({ vote_type: voteType }), }); - if (res.ok) { - const data = await res.json(); - setUpvotes(data.upvotes); - setDownvotes(data.downvotes); - setScore(data.score); - setUserVote(data.user_vote); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.error || "Failed to update vote"); + return; } + + const data = await res.json(); + setUpvotes(data.upvotes); + setDownvotes(data.downvotes); + setScore(data.score); + setUserVote(data.user_vote); } catch { - // silently fail + setError("Failed to update vote"); + } finally { + setLoading(false); } - setLoading(false); } return ( -
+
+
+
+ {error &&

{error}

}
); } diff --git a/src/components/prompts/PromptVoteButton.tsx b/src/components/prompts/PromptVoteButton.tsx index 977e8475..4cdbe69c 100644 --- a/src/components/prompts/PromptVoteButton.tsx +++ b/src/components/prompts/PromptVoteButton.tsx @@ -23,9 +23,11 @@ export function PromptVoteButton({ const [score, setScore] = useState(initialScore); const [userVote, setUserVote] = useState(initialUserVote); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); async function handleVote(voteType: 1 | -1) { setLoading(true); + setError(null); try { const res = await fetch(`/api/prompts/${slug}/vote`, { method: "POST", @@ -33,22 +35,29 @@ export function PromptVoteButton({ body: JSON.stringify({ vote_type: voteType }), }); - if (res.ok) { - const data = await res.json(); - setUpvotes(data.upvotes); - setDownvotes(data.downvotes); - setScore(data.score); - setUserVote(data.user_vote); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.error || "Failed to update vote"); + return; } + + const data = await res.json(); + setUpvotes(data.upvotes); + setDownvotes(data.downvotes); + setScore(data.score); + setUserVote(data.user_vote); } catch { - // silently fail + setError("Failed to update vote"); + } finally { + setLoading(false); } - setLoading(false); } return ( -
+
+
+
+ {error &&

{error}

}
); } diff --git a/src/components/skills/SkillVoteButton.tsx b/src/components/skills/SkillVoteButton.tsx index a92ceaa1..95816038 100644 --- a/src/components/skills/SkillVoteButton.tsx +++ b/src/components/skills/SkillVoteButton.tsx @@ -23,9 +23,11 @@ export function SkillVoteButton({ const [score, setScore] = useState(initialScore); const [userVote, setUserVote] = useState(initialUserVote); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); async function handleVote(voteType: 1 | -1) { setLoading(true); + setError(null); try { const res = await fetch(`/api/skills/${slug}/vote`, { method: "POST", @@ -33,22 +35,29 @@ export function SkillVoteButton({ body: JSON.stringify({ vote_type: voteType }), }); - if (res.ok) { - const data = await res.json(); - setUpvotes(data.upvotes); - setDownvotes(data.downvotes); - setScore(data.score); - setUserVote(data.user_vote); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.error || "Failed to update vote"); + return; } + + const data = await res.json(); + setUpvotes(data.upvotes); + setDownvotes(data.downvotes); + setScore(data.score); + setUserVote(data.user_vote); } catch { - // silently fail + setError("Failed to update vote"); + } finally { + setLoading(false); } - setLoading(false); } return ( -
+
+
+
+ {error &&

{error}

}
); }