diff --git a/src/components/feed/PollDisplay.test.tsx b/src/components/feed/PollDisplay.test.tsx new file mode 100644 index 00000000..1a4bee33 --- /dev/null +++ b/src/components/feed/PollDisplay.test.tsx @@ -0,0 +1,74 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PollDisplay } from "./PollDisplay"; + +const initialResults = { + options: [ + { id: "option-1", text: "First", votes: 1, percentage: 100 }, + { id: "option-2", text: "Second", votes: 0, percentage: 0 }, + ], + total_votes: 1, + user_vote: "option-1", +}; + +const changedResults = { + options: [ + { id: "option-1", text: "First", votes: 0, percentage: 0 }, + { id: "option-2", text: "Second", votes: 1, percentage: 100 }, + ], + total_votes: 1, + user_vote: "option-2", +}; + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("PollDisplay", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("lets an authenticated viewer change an existing vote", async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(jsonResponse(initialResults)) + .mockResolvedValueOnce(jsonResponse({ success: true })) + .mockResolvedValueOnce(jsonResponse(changedResults)); + + render(); + await userEvent.click(await screen.findByRole("radio", { name: /Second/ })); + + expect(fetch).toHaveBeenNthCalledWith( + 2, + "/api/posts/post-1/poll/vote", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ option_id: "option-2" }), + }) + ); + await waitFor(() => { + expect(screen.getByRole("radio", { name: /Second/ })).toHaveAttribute( + "aria-checked", + "true" + ); + }); + }); + + it("does not submit the already-selected option again", async () => { + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(initialResults)); + + render(); + await userEvent.click(await screen.findByRole("radio", { name: /First/ })); + + expect(fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/feed/PollDisplay.tsx b/src/components/feed/PollDisplay.tsx index 6a28212b..276c3d47 100644 --- a/src/components/feed/PollDisplay.tsx +++ b/src/components/feed/PollDisplay.tsx @@ -37,7 +37,7 @@ export function PollDisplay({ postId, isLoggedIn }: PollDisplayProps) { }, [postId]); const handleVote = async (optionId: string) => { - if (!isLoggedIn || voting) return; + if (!isLoggedIn || voting || optionId === userVote) return; setVoting(true); try { @@ -74,20 +74,19 @@ export function PollDisplay({ postId, isLoggedIn }: PollDisplayProps) { const showResults = hasVoted || !isLoggedIn; return ( -
e.stopPropagation()}> +
e.stopPropagation()} + role={showResults && isLoggedIn ? "radiogroup" : undefined} + aria-label={showResults && isLoggedIn ? "Poll choices" : undefined} + > {options.map((option) => { const isSelected = userVote === option.id; const isWinner = showResults && option.votes === Math.max(...options.map((o) => o.votes)) && option.votes > 0; if (showResults) { - return ( -
+ const result = ( + <> {/* Background bar */}
+ + ); + const className = cn( + "relative rounded-md border overflow-hidden transition-colors", + isSelected ? "border-primary" : "border-border" + ); + + return isLoggedIn ? ( + + ) : ( +
+ {result}
); }