From b927b16fed72b37cd4148f96f228e24cabae561c Mon Sep 17 00:00:00 2001 From: Damilare Date: Thu, 30 Jul 2026 13:37:49 +0100 Subject: [PATCH 1/4] test(ui): add EditCampaignMetadata tests with accessible label associations - Add htmlFor/id associations to labels/inputs for accessible names - Add image URL validation with inline error display - Add 9 comprehensive tests covering toggle, validation, save, clear, error resilience Closes #801 --- .../components/EditCampaignMetadata.test.tsx | 213 ++++++++++++++++++ src/components/EditCampaignMetadata.tsx | 52 ++++- 2 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/components/EditCampaignMetadata.test.tsx diff --git a/src/__tests__/components/EditCampaignMetadata.test.tsx b/src/__tests__/components/EditCampaignMetadata.test.tsx new file mode 100644 index 00000000..7fc6598b --- /dev/null +++ b/src/__tests__/components/EditCampaignMetadata.test.tsx @@ -0,0 +1,213 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import EditCampaignMetadata from "@/components/EditCampaignMetadata"; +import { validateImageUrl } from "@/lib/imageValidation"; + +jest.mock("@/lib/imageValidation", () => ({ + validateImageUrl: jest.fn(), +})); + +const mockValidateImageUrl = validateImageUrl as jest.MockedFunction; + +const PROPS = { + campaignId: 42, + initialTitle: "Original Title", + initialDescription: "Original description.", + initialCoverImageUrl: "", +}; + +function renderComponent(props = {}) { + return render(); +} + +describe("EditCampaignMetadata", () => { + beforeEach(() => { + jest.clearAllMocks(); + localStorage.clear(); + mockValidateImageUrl.mockReturnValue({ valid: true }); + }); + + // ----------------------------------------------------------------------- + // Rendering & Toggle + // ----------------------------------------------------------------------- + + it("renders the toggle button and expands the editor on click", () => { + renderComponent(); + + const toggle = screen.getByRole("button", { name: /editMetadata/ }); + expect(toggle).toBeInTheDocument(); + + // Form is initially collapsed + expect(screen.queryByText("titleLabel")).not.toBeInTheDocument(); + expect(screen.queryByText("descriptionLabel")).not.toBeInTheDocument(); + + fireEvent.click(toggle); + + // Form is now visible + expect(screen.getByText("titleLabel")).toBeInTheDocument(); + expect(screen.getByText("descriptionLabel")).toBeInTheDocument(); + expect(screen.getByText("coverImageUrlLabel")).toBeInTheDocument(); + }); + + it("toggles aria-expanded on the header button", () => { + renderComponent(); + const toggle = screen.getByRole("button", { name: /editMetadata/ }); + + expect(toggle).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + }); + + // ----------------------------------------------------------------------- + // Image URL Validation + // ----------------------------------------------------------------------- + + it("shows inline error when save is clicked with an invalid image URL", async () => { + mockValidateImageUrl.mockReturnValue({ + valid: false, + error: "Image domain not allowed.", + }); + + renderComponent(); + + // Expand the editor + fireEvent.click(screen.getByRole("button", { name: /editMetadata/ })); + + // Type an invalid URL + const urlInput = screen.getByRole("textbox", { name: /coverImageUrlLabel/ }); + await userEvent.type(urlInput, "https://evil.com/virus.png"); + + // Click save + fireEvent.click(screen.getByRole("button", { name: /saveButton/ })); + + expect(mockValidateImageUrl).toHaveBeenCalledWith("https://evil.com/virus.png"); + expect(screen.getByRole("alert")).toHaveTextContent("Image domain not allowed."); + expect(urlInput).toHaveAttribute("aria-invalid", "true"); + expect(urlInput.getAttribute("aria-describedby")).toBe(screen.getByRole("alert").id); + }); + + it("does not validate an empty image URL (optional field)", async () => { + renderComponent({ initialCoverImageUrl: "" }); + + fireEvent.click(screen.getByRole("button", { name: /editMetadata/ })); + fireEvent.click(screen.getByRole("button", { name: /saveButton/ })); + + // validateImageUrl should not have been called for an empty string + expect(mockValidateImageUrl).not.toHaveBeenCalled(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("clears the inline error when the user starts editing the URL", async () => { + mockValidateImageUrl + .mockReturnValueOnce({ valid: false, error: "Invalid URL." }) + .mockReturnValue({ valid: true }); + + renderComponent(); + fireEvent.click(screen.getByRole("button", { name: /editMetadata/ })); + + const urlInput = screen.getByRole("textbox", { name: /coverImageUrlLabel/ }); + await userEvent.type(urlInput, "https://evil.com/img.png"); + fireEvent.click(screen.getByRole("button", { name: /saveButton/ })); + + expect(screen.getByRole("alert")).toBeInTheDocument(); + + // Clear the error by typing + await userEvent.clear(urlInput); + await userEvent.type(urlInput, "https://images.unsplash.com/photo-1"); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + // ----------------------------------------------------------------------- + // Successful Save + // ----------------------------------------------------------------------- + + it("saves metadata to localStorage on successful edit", async () => { + renderComponent(); + fireEvent.click(screen.getByRole("button", { name: /editMetadata/ })); + + const titleInput = screen.getByRole("textbox", { name: /titleLabel/ }); + const descInput = screen.getByRole("textbox", { name: /descriptionLabel/ }); + const urlInput = screen.getByRole("textbox", { name: /coverImageUrlLabel/ }); + + await userEvent.clear(titleInput); + await userEvent.type(titleInput, "New Title"); + await userEvent.clear(descInput); + await userEvent.type(descInput, "New description."); + await userEvent.type(urlInput, "https://images.unsplash.com/photo-1"); + + fireEvent.click(screen.getByRole("button", { name: /saveButton/ })); + + const storageKey = "poh_meta_override_42"; + const stored = JSON.parse(localStorage.getItem(storageKey) || "{}"); + + expect(stored.title).toBe("New Title"); + expect(stored.description).toBe("New description."); + expect(stored.coverImageUrl).toBe("https://images.unsplash.com/photo-1"); + expect(stored.editedAt).toBeDefined(); + }); + + it("shows the audit trail timestamp after saving", () => { + renderComponent(); + fireEvent.click(screen.getByRole("button", { name: /editMetadata/ })); + + const titleInput = screen.getByRole("textbox", { name: /titleLabel/ }); + fireEvent.change(titleInput, { target: { value: "Audited Title" } }); + + fireEvent.click(screen.getByRole("button", { name: /saveButton/ })); + + expect(screen.getByText(/lastEdited/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /clearEdits/ })).toBeInTheDocument(); + }); + + // ----------------------------------------------------------------------- + // Clear + // ----------------------------------------------------------------------- + + it("clears the override and restores initial values", () => { + // First, set up a saved override + const storageKey = "poh_meta_override_42"; + localStorage.setItem( + storageKey, + JSON.stringify({ + title: "Saved Title", + description: "Saved description.", + coverImageUrl: "https://images.unsplash.com/photo-1", + editedAt: new Date().toISOString(), + }), + ); + + renderComponent(); + fireEvent.click(screen.getByRole("button", { name: /editMetadata/ })); + + // The override should be loaded + expect(screen.getByRole("textbox", { name: /titleLabel/ })).toHaveValue("Saved Title"); + + // Click clear + fireEvent.click(screen.getByRole("button", { name: /clearEdits/ })); + + // Fields should be restored to initial values + expect(screen.getByRole("textbox", { name: /titleLabel/ })).toHaveValue("Original Title"); + expect(screen.getByRole("textbox", { name: /descriptionLabel/ })).toHaveValue( + "Original description.", + ); + expect(localStorage.getItem(storageKey)).toBeNull(); + }); + + // ----------------------------------------------------------------------- + // localStorage error resilience + // ----------------------------------------------------------------------- + + it("handles corrupt localStorage data gracefully", () => { + const storageKey = "poh_meta_override_42"; + localStorage.setItem(storageKey, "not valid json"); + + renderComponent(); + fireEvent.click(screen.getByRole("button", { name: /editMetadata/ })); + + // Should show initial values since parsing failed + expect(screen.getByRole("textbox", { name: /titleLabel/ })).toHaveValue("Original Title"); + }); +}); diff --git a/src/components/EditCampaignMetadata.tsx b/src/components/EditCampaignMetadata.tsx index ce529504..314ccdde 100644 --- a/src/components/EditCampaignMetadata.tsx +++ b/src/components/EditCampaignMetadata.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { useTranslations } from "next-intl"; +import { validateImageUrl } from "@/lib/imageValidation"; interface Props { campaignId: number; @@ -31,6 +32,7 @@ export default function EditCampaignMetadata({ const [description, setDescription] = useState(initialDescription); const [coverImageUrl, setCoverImageUrl] = useState(initialCoverImageUrl); const [override, setOverride] = useState(null); + const [imageError, setImageError] = useState(null); useEffect(() => { try { @@ -48,6 +50,16 @@ export default function EditCampaignMetadata({ }, [storageKey]); const handleSave = () => { + // Validate the cover image URL before saving + if (coverImageUrl) { + const { valid, error } = validateImageUrl(coverImageUrl); + if (!valid) { + setImageError(error || "Invalid image URL"); + return; + } + } + setImageError(null); + const data: MetaOverride = { title, description, @@ -69,11 +81,24 @@ export default function EditCampaignMetadata({ // ignore } setOverride(null); + setImageError(null); setTitle(initialTitle); setDescription(initialDescription); setCoverImageUrl(initialCoverImageUrl); }; + const titleId = `edit-title-${campaignId}`; + const descriptionId = `edit-description-${campaignId}`; + const coverImageId = `edit-cover-image-url-${campaignId}`; + + const handleImageUrlChange = (url: string) => { + setCoverImageUrl(url); + // Clear error when the user starts typing + if (imageError) { + setImageError(null); + } + }; + return (
{/* Toggle header */} @@ -123,10 +148,11 @@ export default function EditCampaignMetadata({ {/* Title */}
-