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()
+ })
+})
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()
+ })
+})
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()
+ })
})