From 11b0caa4fa3eed8df4c93e81310ee2d4ffe7df0b Mon Sep 17 00:00:00 2001 From: olitreadwell Date: Wed, 5 Aug 2026 06:51:24 +1200 Subject: [PATCH 1/3] Send logged-out visitors to login when they click Follow A logged-out visitor could click a Follow button (bill, profile, or ballot question) and nothing would happen. The button called the Firestore follow/unfollow write with an undefined uid, which fails silently for a signed-out user, so the click looked broken (#2059). Following only makes sense for a signed-in user. This makes the button send a logged-out visitor to /login, with a redirect back to the current page, the same pattern the app already uses for other auth-only pages (see requireAuth in components/auth/service.tsx). - BaseFollowButton now checks for a uid before calling the follow or unfollow action. If there is no uid, it pushes to /login?redirect= instead. - Added a test that covers both cases: a logged-out click redirects to login without calling followAction, and a logged-in click still calls followAction as before. - Split the onClick assignment into two statements instead of a nested ternary, per an AI code review pass (open-code-review): the repo's rules disallow nested ternaries. Behaviour is unchanged. --- components/shared/FollowButton.test.tsx | 58 +++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 components/shared/FollowButton.test.tsx diff --git a/components/shared/FollowButton.test.tsx b/components/shared/FollowButton.test.tsx new file mode 100644 index 000000000..9b7af9745 --- /dev/null +++ b/components/shared/FollowButton.test.tsx @@ -0,0 +1,58 @@ +import "@testing-library/jest-dom" +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { BaseFollowButton } from "./FollowButton" + +jest.mock("next-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }) +})) + +const push = jest.fn() +jest.mock("next/router", () => ({ + useRouter: () => ({ push, asPath: "/testimony/abc/1" }) +})) + +let uid: string | undefined +jest.mock("../auth", () => ({ + useAuth: () => ({ user: uid ? { uid } : null }) +})) + +describe("BaseFollowButton", () => { + beforeEach(() => { + push.mockClear() + uid = undefined + }) + + it("sends a logged-out visitor to login instead of following", async () => { + const followAction = jest.fn() + render( + + ) + + await userEvent.click(screen.getByRole("button")) + + expect(followAction).not.toHaveBeenCalled() + expect(push).toHaveBeenCalledWith("/login?redirect=%2Ftestimony%2Fabc%2F1") + }) + + it("follows when a logged-in user clicks the button", async () => { + uid = "user-1" + const followAction = jest.fn().mockResolvedValue(undefined) + render( + + ) + + await userEvent.click(screen.getByRole("button")) + + expect(followAction).toHaveBeenCalled() + expect(push).not.toHaveBeenCalled() + }) +}) From 4e6d36dbc7da2a788baac2a42c41428ffeb7873f Mon Sep 17 00:00:00 2001 From: olitreadwell Date: Tue, 11 Aug 2026 22:16:29 +1200 Subject: [PATCH 2/3] Send logged-out visitors to login when they click Follow on a testimony page The testimony detail page has its own separate Follow/Unfollow list item (PolicyActions), independent of BaseFollowButton. It had the same bug: a logged-out visitor clicking "Follow" called followBill or followBallotQuestion with an undefined uid, which writes to a Firestore path keyed on "undefined" and fails silently, so the click looked broken. Reproduced with a test that mocks a logged-out user and confirms the click called followBallotQuestion(undefined, ...) without any redirect. - handleClick now checks for a uid before calling FollowClick or UnfollowClick. If there is no uid, it pushes to /login?redirect=, the same pattern BaseFollowButton uses. - Added a test that covers the logged-out case: the click redirects to login and never calls followBallotQuestion or followBill. --- .../PolicyActions.test.tsx | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/components/testimony/TestimonyDetailPage/PolicyActions.test.tsx b/components/testimony/TestimonyDetailPage/PolicyActions.test.tsx index 0464c0917..27183ba8b 100644 --- a/components/testimony/TestimonyDetailPage/PolicyActions.test.tsx +++ b/components/testimony/TestimonyDetailPage/PolicyActions.test.tsx @@ -20,12 +20,18 @@ jest.mock("components/featureFlags", () => ({ }) })) +let uid: string | undefined = "user-1" jest.mock("components/auth", () => ({ useAuth: () => ({ - user: { uid: "user-1" } + user: uid ? { uid } : null }) })) +const push = jest.fn() +jest.mock("next/router", () => ({ + useRouter: () => ({ push, asPath: "/testimony/abc/1" }) +})) + jest.mock("components/db/api", () => ({ dbService: () => ({ getBallotQuestion: mockGetBallotQuestion @@ -78,6 +84,8 @@ jest.mock("components/publish", () => ({ describe("PolicyActions", () => { beforeEach(() => { jest.clearAllMocks() + uid = "user-1" + push.mockClear() mockFollowsTopic.mockResolvedValue(false) mockFollowBill.mockResolvedValue(undefined) mockUnfollowBill.mockResolvedValue(undefined) @@ -124,4 +132,33 @@ describe("PolicyActions", () => { }) expect(mockFollowBill).not.toHaveBeenCalled() }) + + it("sends a logged-out visitor to login instead of following", async () => { + uid = undefined + + render( + + + + ) + + fireEvent.click( + screen.getByText("Follow Ballot Question 25-14: Should we do the thing?") + ) + + await waitFor(() => { + expect(push).toHaveBeenCalledWith( + "/login?redirect=%2Ftestimony%2Fabc%2F1" + ) + }) + + expect(mockFollowBallotQuestion).not.toHaveBeenCalled() + expect(mockFollowBill).not.toHaveBeenCalled() + }) }) From b35a121be60f6acaad510dd60b0d99e3465c64e4 Mon Sep 17 00:00:00 2001 From: olitreadwell Date: Wed, 19 Aug 2026 19:20:02 +1200 Subject: [PATCH 3/3] Add tests for shared presentational components --- components/shared/LabeledIcon.test.tsx | 22 ++++++ components/shared/MessageBanner.test.tsx | 21 ++++++ components/shared/PaginatedItemsCard.test.tsx | 74 +++++++++++++++++++ components/shared/TitledSectionCard.test.tsx | 29 ++++++++ 4 files changed, 146 insertions(+) create mode 100644 components/shared/LabeledIcon.test.tsx create mode 100644 components/shared/MessageBanner.test.tsx create mode 100644 components/shared/PaginatedItemsCard.test.tsx create mode 100644 components/shared/TitledSectionCard.test.tsx diff --git a/components/shared/LabeledIcon.test.tsx b/components/shared/LabeledIcon.test.tsx new file mode 100644 index 000000000..8a8eebf4a --- /dev/null +++ b/components/shared/LabeledIcon.test.tsx @@ -0,0 +1,22 @@ +import "@testing-library/jest-dom" +import { render, screen } from "@testing-library/react" +import { LabeledIcon } from "./LabeledIcon" + +describe("LabeledIcon", () => { + it("renders the image with the provided src", () => { + render() + expect(screen.getByRole("img")).toHaveAttribute("src", "/avatar.png") + }) + + it("renders the main and sub text", () => { + render( + + ) + expect(screen.getByText("Jane Doe")).toBeInTheDocument() + expect(screen.getByText("Senator")).toBeInTheDocument() + }) +}) diff --git a/components/shared/MessageBanner.test.tsx b/components/shared/MessageBanner.test.tsx new file mode 100644 index 000000000..e4244d8ec --- /dev/null +++ b/components/shared/MessageBanner.test.tsx @@ -0,0 +1,21 @@ +import "@testing-library/jest-dom" +import { render, screen } from "@testing-library/react" +import { MessageBanner } from "./MessageBanner" + +describe("MessageBanner", () => { + it("renders the heading and content", () => { + render() + expect(screen.getByText("Welcome")).toBeInTheDocument() + expect(screen.getByText("Get started here")).toBeInTheDocument() + }) + + it("renders the icon image when provided", () => { + render() + expect(screen.getByRole("img")).toHaveAttribute("src", "/icon.png") + }) + + it("renders without an icon when none is provided", () => { + render() + expect(screen.queryByRole("img")).not.toBeInTheDocument() + }) +}) diff --git a/components/shared/PaginatedItemsCard.test.tsx b/components/shared/PaginatedItemsCard.test.tsx new file mode 100644 index 000000000..af65bc572 --- /dev/null +++ b/components/shared/PaginatedItemsCard.test.tsx @@ -0,0 +1,74 @@ +import "@testing-library/jest-dom" +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { PaginatedItemsCard } from "./PaginatedItemsCard" + +jest.mock("next-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }) +})) + +const ItemCard = ({ name }: { name: string }) =>
{name}
+ +const items = Array.from({ length: 25 }, (_, i) => ({ name: `Item ${i + 1}` })) + +describe("PaginatedItemsCard", () => { + it("shows only the first page of items", () => { + render( + + ) + expect(screen.getByText("Item 1")).toBeInTheDocument() + expect(screen.getByText("Item 10")).toBeInTheDocument() + expect(screen.queryByText("Item 11")).not.toBeInTheDocument() + }) + + it("moves to the next page and back", async () => { + const user = userEvent.setup() + render( + + ) + const buttons = screen.getAllByRole("button") + await user.click(buttons[2]) + expect(screen.getByText("Item 11")).toBeInTheDocument() + await user.click(buttons[0]) + expect(screen.getByText("Item 1")).toBeInTheDocument() + }) + + it("shows an error alert instead of items when an error is present", () => { + render( + + ) + expect(screen.getByText("Something went wrong")).toBeInTheDocument() + expect(screen.queryByText("Item 1")).not.toBeInTheDocument() + }) + + it("shows a spinner while loading", () => { + const { container } = render( + + ) + expect(container.querySelector(".spinner-border")).toBeInTheDocument() + }) +}) diff --git a/components/shared/TitledSectionCard.test.tsx b/components/shared/TitledSectionCard.test.tsx new file mode 100644 index 000000000..1244d92c2 --- /dev/null +++ b/components/shared/TitledSectionCard.test.tsx @@ -0,0 +1,29 @@ +import "@testing-library/jest-dom" +import { render, screen } from "@testing-library/react" +import TitledSectionCard from "./TitledSectionCard" + +describe("TitledSectionCard", () => { + it("renders the title and children", () => { + render( + +

Body content

+
+ ) + expect(screen.getByText("My Section")).toBeInTheDocument() + expect(screen.getByText("Body content")).toBeInTheDocument() + }) + + it("omits the header when no title is provided", () => { + render(Body only) + expect(screen.getByText("Body only")).toBeInTheDocument() + }) + + it("renders the footer when provided", () => { + render( + Save}> + Body + + ) + expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument() + }) +})