From dc730c3bb033b55515246d34230911fb2a817cf9 Mon Sep 17 00:00:00 2001 From: Seneca Artemis <77749468+belhajManel@users.noreply.github.com> Date: Fri, 20 Dec 2024 15:54:01 +0100 Subject: [PATCH] test: Write unit tests for settings page and components (#290) --- src/components/common/Banner/Banner.tsx | 2 +- .../DeleteAccountSetting.test.tsx | 104 +++++++++++++++++ .../EditBirthplaceSetting.test.tsx | 88 +++++++++++++++ .../EditMinorSetting.test.tsx | 46 ++++++++ .../EditNameSetting/EditNameSetting.test.tsx | 105 ++++++++++++++++++ .../EditNameSetting/EditNameSetting.tsx | 2 +- .../EditResidenceSetting.test.tsx | 89 +++++++++++++++ .../EditThemeSetting.test.tsx | 49 ++++++++ tests/vitest.setup.ts | 10 ++ 9 files changed, 493 insertions(+), 2 deletions(-) create mode 100644 src/components/settings/DeleteAccountSetting/DeleteAccountSetting.test.tsx create mode 100644 src/components/settings/EditBirthplaceSetting/EditBirthplaceSetting.test.tsx create mode 100644 src/components/settings/EditMinorSetting/EditMinorSetting.test.tsx create mode 100644 src/components/settings/EditNameSetting/EditNameSetting.test.tsx create mode 100644 src/components/settings/EditResidenceSetting/EditResidenceSetting.test.tsx create mode 100644 src/components/settings/EditThemeSetting/EditThemeSetting.test.tsx diff --git a/src/components/common/Banner/Banner.tsx b/src/components/common/Banner/Banner.tsx index 89fc623..ecb16f5 100644 --- a/src/components/common/Banner/Banner.tsx +++ b/src/components/common/Banner/Banner.tsx @@ -63,7 +63,7 @@ export function Banner({ children, icon: Icon, variant }: BannerProps) { Icon = Icon ?? DefaultIcon(); return ( -
+
{children}
diff --git a/src/components/settings/DeleteAccountSetting/DeleteAccountSetting.test.tsx b/src/components/settings/DeleteAccountSetting/DeleteAccountSetting.test.tsx new file mode 100644 index 0000000..fecd4d1 --- /dev/null +++ b/src/components/settings/DeleteAccountSetting/DeleteAccountSetting.test.tsx @@ -0,0 +1,104 @@ +import { useAuthActions } from "@convex-dev/auth/react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useMutation } from "convex/react"; +import { toast } from "sonner"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DeleteAccountSetting } from "./DeleteAccountSetting"; + +describe("DeleteAccountSetting", () => { + const mockSignOut = vi.fn(); + const mockDeleteAccount = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + (useMutation as unknown as ReturnType).mockReturnValue( + mockDeleteAccount, + ); + (useAuthActions as unknown as ReturnType).mockReturnValue({ + signOut: mockSignOut, + }); + }); + + it("renders the DeleteAccountSetting component", () => { + render(); + expect( + screen.getByRole("button", { name: "Delete account" }), + ).toBeInTheDocument(); + expect( + screen.getByText("Permanently delete your Namesake account and data."), + ).toBeInTheDocument(); + }); + + it("opens the delete account modal when the button is clicked", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Delete account" })); + expect(screen.getByText("Delete account?")).toBeInTheDocument(); + expect( + screen.getByText( + "This will permanently erase your account and all data.", + ), + ).toBeInTheDocument(); + }); + + it("shows an error if the confirmation text is incorrect", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Delete account" })); + + const input = screen.getByLabelText("Type DELETE to confirm"); + await user.type(input, "WRONG_TEXT"); + + await user.click(screen.getByRole("button", { name: "Delete account" })); + expect(screen.getByRole("alert")).toHaveTextContent( + "Please type DELETE to confirm.", + ); + + expect(mockDeleteAccount).not.toHaveBeenCalled(); + }); + + it("submits the form successfully", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Delete account" })); + + const input = screen.getByLabelText("Type DELETE to confirm"); + await user.type(input, "DELETE"); + await user.click(screen.getByRole("button", { name: "Delete account" })); + + await waitFor(() => { + expect(mockDeleteAccount).toHaveBeenCalled(); + expect(mockSignOut).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith("Account deleted."); + }); + }); + + it("displays an error if account deletion fails", async () => { + const user = userEvent.setup(); + mockDeleteAccount.mockRejectedValue(new Error("Deletion failed")); + + render(); + await user.click(screen.getByRole("button", { name: "Delete account" })); + + const input = screen.getByLabelText("Type DELETE to confirm"); + await user.type(input, "DELETE"); + await user.click(screen.getByRole("button", { name: "Delete account" })); + + expect( + await screen.findByText("Failed to delete account. Please try again."), + ).toBeInTheDocument(); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it("closes the modal when 'Cancel' is clicked", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Delete account" })); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => { + expect(screen.queryByText("Delete account?")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/settings/EditBirthplaceSetting/EditBirthplaceSetting.test.tsx b/src/components/settings/EditBirthplaceSetting/EditBirthplaceSetting.test.tsx new file mode 100644 index 0000000..ddac8d3 --- /dev/null +++ b/src/components/settings/EditBirthplaceSetting/EditBirthplaceSetting.test.tsx @@ -0,0 +1,88 @@ +import type { Doc, Id } from "@convex/_generated/dataModel"; +import { JURISDICTIONS } from "@convex/constants"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useMutation } from "convex/react"; +import { toast } from "sonner"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EditBirthplaceSetting } from "./EditBirthplaceSetting"; + +describe("EditBirthplaceSetting", () => { + const mockUser: Doc<"users"> = { + _id: "user123" as Id<"users">, + _creationTime: 123, + role: "user", + birthplace: "CA", + }; + const mockSetBirthplace = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + (useMutation as unknown as ReturnType).mockReturnValue( + mockSetBirthplace, + ); + }); + + it("renders correct jurisdiction if it exists", () => { + render(); + expect(screen.getByText(JURISDICTIONS.CA)).toBeInTheDocument(); + }); + + it("renders 'Set birthplace' if birthplace is not set", () => { + render( + , + ); + expect( + screen.getByRole("button", { name: "Set birthplace" }), + ).toBeInTheDocument(); + }); + + it("populates correct jurisdiction when modal is opened", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: JURISDICTIONS.CA })); + expect( + screen.getByRole("button", { name: `${JURISDICTIONS.CA} State` }), + ).toBeInTheDocument(); + }); + + it("updates birthplace and submits the form", async () => { + const user = userEvent.setup(); + mockSetBirthplace.mockResolvedValueOnce(undefined); + + render(); + await user.click(screen.getByRole("button", { name: JURISDICTIONS.CA })); + const stateSelect = screen.getByLabelText("State"); + + await user.click(stateSelect); + + await user.click(screen.getByRole("option", { name: JURISDICTIONS.NY })); + + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(mockSetBirthplace).toHaveBeenCalledWith({ + birthplace: "NY", + }); + + expect(toast.success).toHaveBeenCalledWith("Birthplace updated."); + }); + + it("displays an error message if the update fails", async () => { + const user = userEvent.setup(); + mockSetBirthplace.mockRejectedValueOnce(new Error("Update failed")); + + render(); + + await user.click(screen.getByRole("button", { name: JURISDICTIONS.CA })); + const stateSelect = screen.getByLabelText("State"); + await user.click(stateSelect); + + await user.click(screen.getByRole("option", { name: JURISDICTIONS.NY })); + + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Failed to update birthplace. Please try again.", + ); + }); +}); diff --git a/src/components/settings/EditMinorSetting/EditMinorSetting.test.tsx b/src/components/settings/EditMinorSetting/EditMinorSetting.test.tsx new file mode 100644 index 0000000..bd53705 --- /dev/null +++ b/src/components/settings/EditMinorSetting/EditMinorSetting.test.tsx @@ -0,0 +1,46 @@ +import type { Doc, Id } from "@convex/_generated/dataModel"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useMutation } from "convex/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EditMinorSetting } from "./EditMinorSetting"; + +describe("EditMinorSetting", () => { + const mockUser: Doc<"users"> = { + _id: "user123" as Id<"users">, + isMinor: false, + _creationTime: 123, + role: "user", + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders correctly with initial state", () => { + render(); + + expect(screen.getByText("Under 18")).toBeInTheDocument(); + expect( + screen.getByText( + "Are you under 18 years old or applying on behalf of someone who is?", + ), + ).toBeInTheDocument(); + expect(screen.getByRole("switch", { name: "Is minor" })).not.toBeChecked(); + }); + + it("toggles the switch and calls updateIsMinor mutation", async () => { + const user = userEvent.setup(); + const updateIsMinorMock = vi.fn(); + (useMutation as ReturnType).mockReturnValue( + updateIsMinorMock, + ); + + render(); + + const freeSwitch = screen.getByRole("switch", { name: "Is minor" }); + await user.click(freeSwitch); + + expect(updateIsMinorMock).toHaveBeenCalledWith({ isMinor: true }); + }); +}); diff --git a/src/components/settings/EditNameSetting/EditNameSetting.test.tsx b/src/components/settings/EditNameSetting/EditNameSetting.test.tsx new file mode 100644 index 0000000..6be7cce --- /dev/null +++ b/src/components/settings/EditNameSetting/EditNameSetting.test.tsx @@ -0,0 +1,105 @@ +import type { Doc, Id } from "@convex/_generated/dataModel"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useMutation } from "convex/react"; +import { toast } from "sonner"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EditNameSetting } from "./EditNameSetting"; + +describe("EditNameSetting", () => { + const mockUser: Doc<"users"> = { + _id: "123" as Id<"users">, + name: "John Doe", + role: "user", + _creationTime: 123, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders correct username if exists", () => { + render(); + expect(screen.getByText("John Doe")).toBeInTheDocument(); + }); + + it("renders 'Set name' if name is not set", () => { + render(); + expect( + screen.getByRole("button", { name: "Set name" }), + ).toBeInTheDocument(); + }); + + it("populates correct username when modal is opened", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "John Doe" })); + expect(screen.getByRole("textbox")).toHaveValue("John Doe"); + }); + + it("displays an error when the name is too long", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "John Doe" })); + const input = screen.getByLabelText("Name"); + + await user.type(input, "a".repeat(101)); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect( + await screen.findByText("Name must be less than 100 characters."), + ).toBeInTheDocument(); + }); + + it("submits the form successfully", async () => { + const user = userEvent.setup(); + + const updateName = vi.fn(); + (useMutation as ReturnType).mockReturnValue(updateName); + render(); + await user.click(screen.getByRole("button", { name: "John Doe" })); + + const input = screen.getByLabelText("Name"); + await user.clear(input); + await user.type(input, "Jane Doe"); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => + expect(updateName).toHaveBeenCalledWith({ name: "Jane Doe" }), + ); + expect(toast.success).toHaveBeenCalledWith("Name updated."); + }); + + it("displays an error when the form submission fails", async () => { + const user = userEvent.setup(); + + const updateName = vi + .fn() + .mockRejectedValue(new Error("Failed to update name")); + (useMutation as ReturnType).mockReturnValue(updateName); + render(); + await user.click(screen.getByRole("button", { name: "John Doe" })); + + const input = screen.getByLabelText("Name"); + await user.clear(input); + await user.type(input, "Jane Doe"); + await user.click(screen.getByRole("button", { name: "Save" })); + expect( + await screen.findByText("Failed to update name. Please try again."), + ).toBeInTheDocument(); + }); + + it("closes the modal without saving when the cancel button is clicked", async () => { + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole("button", { name: "John Doe" })); + const input = screen.getByLabelText("Name"); + await user.clear(input); + await user.type(input, "Jane Doe"); + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(screen.queryByText("Edit name")).not.toBeInTheDocument(); + expect(screen.getByText("John Doe")).toBeInTheDocument(); + }); +}); diff --git a/src/components/settings/EditNameSetting/EditNameSetting.tsx b/src/components/settings/EditNameSetting/EditNameSetting.tsx index 8b1df2d..38a3ce4 100644 --- a/src/components/settings/EditNameSetting/EditNameSetting.tsx +++ b/src/components/settings/EditNameSetting/EditNameSetting.tsx @@ -38,7 +38,7 @@ const EditNameModal = ({ setError(undefined); if (name.length > 100) { - setError("Name must be less than 100 characters"); + setError("Name must be less than 100 characters."); return; } diff --git a/src/components/settings/EditResidenceSetting/EditResidenceSetting.test.tsx b/src/components/settings/EditResidenceSetting/EditResidenceSetting.test.tsx new file mode 100644 index 0000000..70931b1 --- /dev/null +++ b/src/components/settings/EditResidenceSetting/EditResidenceSetting.test.tsx @@ -0,0 +1,89 @@ +import type { Doc, Id } from "@convex/_generated/dataModel"; +import { JURISDICTIONS } from "@convex/constants"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useMutation } from "convex/react"; +import { toast } from "sonner"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EditResidenceSetting } from "./EditResidenceSetting"; + +describe("EditResidenceSetting", () => { + const mockUser: Doc<"users"> = { + _id: "user123" as Id<"users">, + _creationTime: 123, + role: "user", + residence: "CA", + }; + + const mockSetResidence = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + (useMutation as unknown as ReturnType).mockReturnValue( + mockSetResidence, + ); + }); + + it("renders correct jurisdiction if it exists", () => { + render(); + expect(screen.getByText(JURISDICTIONS.CA)).toBeInTheDocument(); + }); + + it("renders 'Set residence' if residence is not set", () => { + render( + , + ); + expect( + screen.getByRole("button", { name: "Set residence" }), + ).toBeInTheDocument(); + }); + + it("populates correct jurisdiction when modal is opened", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: JURISDICTIONS.CA })); + expect( + screen.getByRole("button", { name: `${JURISDICTIONS.CA} State` }), + ).toBeInTheDocument(); + }); + + it("updates residence and submits the form", async () => { + const user = userEvent.setup(); + mockSetResidence.mockResolvedValueOnce(undefined); + + render(); + await user.click(screen.getByRole("button", { name: JURISDICTIONS.CA })); + const stateSelect = screen.getByLabelText("State"); + + await user.click(stateSelect); + + await user.click(screen.getByRole("option", { name: JURISDICTIONS.NY })); + + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(mockSetResidence).toHaveBeenCalledWith({ + residence: "NY", + }); + + expect(toast.success).toHaveBeenCalledWith("Residence updated."); + }); + + it("displays an error message if the update fails", async () => { + const user = userEvent.setup(); + mockSetResidence.mockRejectedValueOnce(new Error("Update failed")); + + render(); + + await user.click(screen.getByRole("button", { name: JURISDICTIONS.CA })); + const stateSelect = screen.getByLabelText("State"); + await user.click(stateSelect); + + await user.click(screen.getByRole("option", { name: JURISDICTIONS.NY })); + + await user.click(screen.getByRole("button", { name: "Save" })); + + expect( + screen.getByText("Failed to update residence. Please try again."), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/settings/EditThemeSetting/EditThemeSetting.test.tsx b/src/components/settings/EditThemeSetting/EditThemeSetting.test.tsx new file mode 100644 index 0000000..8853a03 --- /dev/null +++ b/src/components/settings/EditThemeSetting/EditThemeSetting.test.tsx @@ -0,0 +1,49 @@ +import { useTheme } from "@/utils/useTheme"; +import { THEMES } from "@convex/constants"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EditThemeSetting } from "./EditThemeSetting"; + +describe("EditThemeSetting", () => { + const mockUpdateTheme = vi.fn(); + const mockSetNextTheme = vi.fn(); + const mockThemeSelection = new Set(["light"]); + + beforeEach(() => { + vi.clearAllMocks(); + (useTheme as unknown as ReturnType).mockReturnValue({ + theme: "light", + themeSelection: mockThemeSelection, + setTheme: vi.fn((theme) => { + const selectedTheme = [...theme][0]; + mockUpdateTheme({ theme: selectedTheme }); + mockSetNextTheme(selectedTheme); + }), + }); + }); + + it("renders component correctly with correct initial theme", () => { + render(); + expect(screen.getByText("Theme")).toBeInTheDocument(); + expect(screen.getByText("Adjust your display.")).toBeInTheDocument(); + for (const theme of Object.values(THEMES)) { + expect(screen.getByText(theme.label)).toBeInTheDocument(); + } + const lightThemeButton = screen.getByRole("radio", { + name: THEMES.light.label, + }); + expect(lightThemeButton).toHaveAttribute("aria-checked", "true"); + }); + + it("calls setTheme when a theme is selected", async () => { + const user = userEvent.setup(); + render(); + const darkThemeButton = screen.getByRole("radio", { + name: THEMES.dark.label, + }); + await user.click(darkThemeButton); + expect(mockUpdateTheme).toHaveBeenCalledWith({ theme: "dark" }); + expect(mockSetNextTheme).toHaveBeenCalledWith("dark"); + }); +}); diff --git a/tests/vitest.setup.ts b/tests/vitest.setup.ts index 2bd3841..6681f6f 100644 --- a/tests/vitest.setup.ts +++ b/tests/vitest.setup.ts @@ -13,3 +13,13 @@ vi.mock("sonner", () => ({ error: vi.fn(), }, })); + +// Mock the auth hook +vi.mock("@convex-dev/auth/react", () => ({ + useAuthActions: vi.fn(), +})); + +// Mock the useTheme hook +vi.mock("@/utils/useTheme", () => ({ + useTheme: vi.fn(), +}));