From a62d90092c91fb1ec648ad2b01b766914e74187e Mon Sep 17 00:00:00 2001 From: Zekbot001 <280472700+Zekbot001@users.noreply.github.com> Date: Sun, 31 May 2026 20:45:29 +0200 Subject: [PATCH 1/2] fix(marketplace): surface vote failures --- src/components/MarketplaceVoteButton.test.tsx | 81 +++++++++++++++++ .../directory/DirectoryVoteButton.tsx | 88 +++++++++++-------- src/components/mcp/McpVoteButton.tsx | 28 ++++-- src/components/prompts/PromptVoteButton.tsx | 28 ++++-- src/components/skills/SkillVoteButton.tsx | 28 ++++-- 5 files changed, 188 insertions(+), 65 deletions(-) create mode 100644 src/components/MarketplaceVoteButton.test.tsx diff --git a/src/components/MarketplaceVoteButton.test.tsx b/src/components/MarketplaceVoteButton.test.tsx new file mode 100644 index 00000000..d1bba568 --- /dev/null +++ b/src/components/MarketplaceVoteButton.test.tsx @@ -0,0 +1,81 @@ +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(); + }); +}); 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..a2b4a698 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,21 +35,27 @@ 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..ab47a7c3 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,21 +35,27 @@ 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..b599be35 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,21 +35,27 @@ 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}

}
); } From 6438f18cacec68bfd29184337b1ef8655a500fe2 Mon Sep 17 00:00:00 2001 From: Zekbot001 <280472700+Zekbot001@users.noreply.github.com> Date: Sun, 31 May 2026 20:51:30 +0200 Subject: [PATCH 2/2] fix(marketplace): declare vote action buttons --- src/components/MarketplaceVoteButton.test.tsx | 10 ++++++++++ src/components/mcp/McpVoteButton.tsx | 2 ++ src/components/prompts/PromptVoteButton.tsx | 2 ++ src/components/skills/SkillVoteButton.tsx | 2 ++ 4 files changed, 16 insertions(+) diff --git a/src/components/MarketplaceVoteButton.test.tsx b/src/components/MarketplaceVoteButton.test.tsx index d1bba568..980bc021 100644 --- a/src/components/MarketplaceVoteButton.test.tsx +++ b/src/components/MarketplaceVoteButton.test.tsx @@ -78,4 +78,14 @@ describe("marketplace vote errors", () => { 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/mcp/McpVoteButton.tsx b/src/components/mcp/McpVoteButton.tsx index a2b4a698..8a7a8cfe 100644 --- a/src/components/mcp/McpVoteButton.tsx +++ b/src/components/mcp/McpVoteButton.tsx @@ -57,6 +57,7 @@ export function McpVoteButton({