diff --git a/frontend/src/app/settings/settings-content.test.tsx b/frontend/src/app/settings/settings-content.test.tsx new file mode 100644 index 00000000..ea0291fd --- /dev/null +++ b/frontend/src/app/settings/settings-content.test.tsx @@ -0,0 +1,156 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { PropsWithChildren } from "react"; + +const toastError = vi.fn(); +const toastSuccess = vi.fn(); +const toastBase = vi.fn(); + +const mockSession = { + publicKey: "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7", + network: "TESTNET", + walletName: "Freighter", +}; + +vi.mock("@/context/wallet-context", () => ({ + useWallet: () => ({ + session: mockSession, + disconnect: vi.fn(), + isHydrated: true, + }), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock("next/link", () => ({ + default: ({ children, ...rest }: PropsWithChildren>) => ( + {children} + ), +})); + +vi.mock("react-hot-toast", () => { + const fn = (...args: unknown[]) => toastBase(...args); + fn.success = (...args: unknown[]) => toastSuccess(...args); + fn.error = (...args: unknown[]) => toastError(...args); + return { default: fn }; +}); + +vi.mock("@/lib/api/_shared", () => ({ + getApiBaseUrl: () => "http://localhost:4000", +})); + +import SettingsContent from "./settings-content"; + +describe("SettingsContent clipboard copy", () => { + const originalClipboard = navigator.clipboard; + + beforeEach(() => { + toastError.mockClear(); + toastSuccess.mockClear(); + toastBase.mockClear(); + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new Error("network disabled in tests")), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + Object.defineProperty(navigator, "clipboard", { + value: originalClipboard, + configurable: true, + writable: true, + }); + }); + + it("shows an error toast instead of failing silently when copying the wallet address is denied", async () => { + // userEvent.setup() installs its own clipboard stub, so it must run + // before we install ours or it will clobber this mock. + const user = userEvent.setup(); + const writeText = vi.fn().mockRejectedValue(new Error("Permission denied")); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + writable: true, + }); + + render(); + + await user.click(screen.getByRole("button", { name: /copy wallet address/i })); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith(mockSession.publicKey); + }); + await waitFor(() => { + expect(toastError).toHaveBeenCalledWith("Failed to copy to clipboard"); + }); + expect(toastSuccess).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: /address copied$/i })).not.toBeInTheDocument(); + }); + + it("shows a success toast when copying the wallet address succeeds", async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + writable: true, + }); + + render(); + + await user.click(screen.getByRole("button", { name: /copy wallet address/i })); + + await waitFor(() => { + expect(toastSuccess).toHaveBeenCalledWith("Address copied to clipboard"); + }); + expect(toastError).not.toHaveBeenCalled(); + expect(await screen.findByRole("button", { name: /^address copied$/i })).toBeInTheDocument(); + }); + + it("shows an error toast instead of failing silently when copying the contract address is denied", async () => { + // userEvent.setup() installs its own clipboard stub, so it must run + // before we install ours or it will clobber this mock. + const user = userEvent.setup(); + const writeText = vi.fn().mockRejectedValue(new Error("Permission denied")); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + writable: true, + }); + + render(); + + await user.click(screen.getByRole("button", { name: /copy contract address/i })); + + await waitFor(() => { + expect(writeText).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(toastError).toHaveBeenCalledWith("Failed to copy to clipboard"); + }); + expect(toastSuccess).not.toHaveBeenCalled(); + }); + + it("shows a success toast when copying the contract address succeeds", async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + writable: true, + }); + + render(); + + await user.click(screen.getByRole("button", { name: /copy contract address/i })); + + await waitFor(() => { + expect(toastSuccess).toHaveBeenCalledWith("Contract address copied"); + }); + expect(toastError).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/settings/settings-content.tsx b/frontend/src/app/settings/settings-content.tsx index 2cd68d13..f8d9c431 100644 --- a/frontend/src/app/settings/settings-content.tsx +++ b/frontend/src/app/settings/settings-content.tsx @@ -9,6 +9,7 @@ import Link from "next/link"; import { formatNetwork } from "@/lib/wallet"; import toast from "react-hot-toast"; import { getApiBaseUrl } from "@/lib/api/_shared"; +import { copyToClipboard } from "@/lib/clipboard"; type DisplayCurrency = "USD" | "EUR" | "GBP" | "XLM" | "USDC"; type AmountFormat = "full" | "compact"; @@ -76,10 +77,12 @@ export default function SettingsContent() { }; const copyAddress = async () => { - if (session?.publicKey) { - await navigator.clipboard.writeText(session.publicKey); + if (!session?.publicKey) return; + const success = await copyToClipboard(session.publicKey, { + successMessage: "Address copied to clipboard", + }); + if (success) { setCopied(true); - toast.success("Address copied to clipboard"); setTimeout(() => setCopied(false), 1500); } }; @@ -395,10 +398,11 @@ export default function SettingsContent() {
{shortenPublicKey(CONTRACT_ADDRESS)}
diff --git a/frontend/src/components/wallet/WalletButton.test.tsx b/frontend/src/components/wallet/WalletButton.test.tsx new file mode 100644 index 00000000..08431b5a --- /dev/null +++ b/frontend/src/components/wallet/WalletButton.test.tsx @@ -0,0 +1,107 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const useWalletMock = vi.fn(); +const toastError = vi.fn(); +const toastSuccess = vi.fn(); + +vi.mock("@/context/wallet-context", () => ({ + useWallet: () => useWalletMock(), +})); + +vi.mock("react-hot-toast", () => ({ + default: { + success: (...args: unknown[]) => toastSuccess(...args), + error: (...args: unknown[]) => toastError(...args), + }, +})); + +import { WalletButton } from "./WalletButton"; + +const SESSION = { + walletId: "freighter" as const, + walletName: "Freighter", + publicKey: "GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOP", + connectedAt: new Date().toISOString(), + network: "Testnet", + mocked: false, +}; + +describe("WalletButton clipboard copy", () => { + const originalClipboard = navigator.clipboard; + + beforeEach(() => { + toastError.mockClear(); + toastSuccess.mockClear(); + useWalletMock.mockReturnValue({ + status: "connected", + session: SESSION, + disconnect: vi.fn(), + isHydrated: true, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + Object.defineProperty(navigator, "clipboard", { + value: originalClipboard, + configurable: true, + writable: true, + }); + }); + + it("shows an error toast instead of failing silently when the clipboard write is denied", async () => { + // userEvent.setup() installs its own clipboard stub, so it must run + // before we install ours or it will clobber this mock. + const user = userEvent.setup(); + const writeText = vi.fn().mockRejectedValue(new Error("Permission denied")); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + writable: true, + }); + + render(); + + // Open the wallet chip dropdown that contains the "Copy" button. + await user.click(screen.getByTitle(SESSION.publicKey)); + + const copyButton = await screen.findByRole("button", { name: /copy/i }); + await user.click(copyButton); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith(SESSION.publicKey); + }); + + await waitFor(() => { + expect(toastError).toHaveBeenCalledWith("Failed to copy to clipboard"); + }); + expect(toastSuccess).not.toHaveBeenCalled(); + + // Local "copied" feedback must not fire on failure. + expect(screen.queryByRole("button", { name: /copied!/i })).not.toBeInTheDocument(); + }); + + it("shows a success toast and toggles the copied label when the write succeeds", async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + writable: true, + }); + + render(); + + await user.click(screen.getByTitle(SESSION.publicKey)); + const copyButton = await screen.findByRole("button", { name: /copy/i }); + await user.click(copyButton); + + await waitFor(() => { + expect(toastSuccess).toHaveBeenCalledWith("Address copied to clipboard"); + }); + expect(toastError).not.toHaveBeenCalled(); + expect(await screen.findByRole("button", { name: /copied!/i })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/wallet/WalletButton.tsx b/frontend/src/components/wallet/WalletButton.tsx index 0109cab6..d7b401db 100644 --- a/frontend/src/components/wallet/WalletButton.tsx +++ b/frontend/src/components/wallet/WalletButton.tsx @@ -19,6 +19,7 @@ import { shortenPublicKey, isExpectedNetwork, } from "@/lib/wallet"; +import { copyToClipboard } from "@/lib/clipboard"; import { Skeleton } from "@/components/ui/Skeleton"; import { WalletModal } from "./WalletModal"; @@ -68,12 +69,12 @@ export function WalletButton() { const handleCopy = async () => { if (!session?.publicKey) return; - try { - await navigator.clipboard.writeText(session.publicKey); + const success = await copyToClipboard(session.publicKey, { + successMessage: "Address copied to clipboard", + }); + if (success) { setCopied(true); setTimeout(() => setCopied(false), 2000); - } catch { - // Clipboard API may be blocked in some environments } }; diff --git a/frontend/src/lib/clipboard.test.ts b/frontend/src/lib/clipboard.test.ts new file mode 100644 index 00000000..6babc9ff --- /dev/null +++ b/frontend/src/lib/clipboard.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const toastSuccess = vi.fn(); +const toastError = vi.fn(); + +vi.mock("react-hot-toast", () => ({ + default: { + success: (...args: unknown[]) => toastSuccess(...args), + error: (...args: unknown[]) => toastError(...args), + }, +})); + +import { copyToClipboard } from "./clipboard"; + +describe("copyToClipboard", () => { + const originalClipboard = navigator.clipboard; + + beforeEach(() => { + toastSuccess.mockClear(); + toastError.mockClear(); + }); + + afterEach(() => { + Object.defineProperty(navigator, "clipboard", { + value: originalClipboard, + configurable: true, + writable: true, + }); + }); + + it("writes text, shows a success toast, and resolves true on success", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + writable: true, + }); + + const result = await copyToClipboard("GABC123"); + + expect(writeText).toHaveBeenCalledWith("GABC123"); + expect(toastSuccess).toHaveBeenCalledWith("Copied to clipboard"); + expect(toastError).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("uses a custom success message when provided", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + writable: true, + }); + + await copyToClipboard("GABC123", { successMessage: "Address copied to clipboard" }); + + expect(toastSuccess).toHaveBeenCalledWith("Address copied to clipboard"); + }); + + it("shows an error toast and resolves false when writeText rejects (e.g. permission denied)", async () => { + const writeText = vi.fn().mockRejectedValue(new Error("Permission denied")); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + writable: true, + }); + + const result = await copyToClipboard("GABC123"); + + expect(result).toBe(false); + expect(toastError).toHaveBeenCalledWith("Failed to copy to clipboard"); + expect(toastSuccess).not.toHaveBeenCalled(); + }); + + it("does not throw when writeText rejects", async () => { + const writeText = vi.fn().mockRejectedValue(new Error("nope")); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + writable: true, + }); + + await expect(copyToClipboard("text")).resolves.not.toThrow(); + }); + + it("uses a custom error message when provided", async () => { + const writeText = vi.fn().mockRejectedValue(new Error("nope")); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + writable: true, + }); + + await copyToClipboard("text", { errorMessage: "Could not copy address" }); + + expect(toastError).toHaveBeenCalledWith("Could not copy address"); + }); + + it("shows an error toast and resolves false when navigator.clipboard is unavailable", async () => { + Object.defineProperty(navigator, "clipboard", { + value: undefined, + configurable: true, + writable: true, + }); + + const result = await copyToClipboard("GABC123"); + + expect(result).toBe(false); + expect(toastError).toHaveBeenCalledWith("Failed to copy to clipboard"); + }); +}); diff --git a/frontend/src/lib/clipboard.ts b/frontend/src/lib/clipboard.ts new file mode 100644 index 00000000..0d00c321 --- /dev/null +++ b/frontend/src/lib/clipboard.ts @@ -0,0 +1,47 @@ +/** + * lib/clipboard.ts + * + * Shared clipboard helper with consistent toast feedback and error handling. + * + * `navigator.clipboard.writeText` can reject (or be entirely unavailable — + * e.g. insecure context, denied permission, unsupported browser). This + * wrapper normalizes both cases into a single non-throwing call so callers + * never need their own try/catch. + */ + +import toast from "react-hot-toast"; + +export interface CopyToClipboardOptions { + /** Message shown in the success toast. Defaults to "Copied to clipboard". */ + successMessage?: string; + /** Message shown in the error toast. Defaults to "Failed to copy to clipboard". */ + errorMessage?: string; +} + +/** + * Copies `text` to the clipboard, surfacing a success or error toast. + * + * Never throws — resolves to `true` on success and `false` on failure + * (including when the Clipboard API is unavailable). + */ +export async function copyToClipboard( + text: string, + options?: CopyToClipboardOptions, +): Promise { + try { + if ( + typeof navigator === "undefined" || + !navigator.clipboard || + typeof navigator.clipboard.writeText !== "function" + ) { + throw new Error("Clipboard API unavailable"); + } + + await navigator.clipboard.writeText(text); + toast.success(options?.successMessage ?? "Copied to clipboard"); + return true; + } catch { + toast.error(options?.errorMessage ?? "Failed to copy to clipboard"); + return false; + } +}