Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions frontend/src/app/settings/settings-content.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>) => (
<a {...rest}>{children}</a>
),
}));

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(<SettingsContent />);

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(<SettingsContent />);

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(<SettingsContent />);

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(<SettingsContent />);

await user.click(screen.getByRole("button", { name: /copy contract address/i }));

await waitFor(() => {
expect(toastSuccess).toHaveBeenCalledWith("Contract address copied");
});
expect(toastError).not.toHaveBeenCalled();
});
});
18 changes: 11 additions & 7 deletions frontend/src/app/settings/settings-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}
};
Expand Down Expand Up @@ -395,10 +398,11 @@ export default function SettingsContent() {
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-white dark:text-black">{shortenPublicKey(CONTRACT_ADDRESS)}</span>
<button
onClick={() => {
navigator.clipboard.writeText(CONTRACT_ADDRESS);
toast.success("Contract address copied");
}}
onClick={() =>
copyToClipboard(CONTRACT_ADDRESS, {
successMessage: "Contract address copied",
})
}
aria-label="Copy contract address"
className="opacity-60 hover:opacity-100 transition"
>
Expand Down
109 changes: 109 additions & 0 deletions frontend/src/components/dashboard/StreamDetailsModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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 { Stream } from "@/lib/dashboard";

const toastError = vi.fn();
const toastSuccess = vi.fn();

vi.mock("react-hot-toast", () => ({
default: {
success: (...args: unknown[]) => toastSuccess(...args),
error: (...args: unknown[]) => toastError(...args),
},
}));

import { StreamDetailsModal } from "./StreamDetailsModal";

const STREAM: Stream = {
id: "stream-1",
recipient: "GRECIPIENT1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ12345",
amount: 100,
token: "USDC",
status: "Active",
deposited: 100,
withdrawn: 25,
date: "2026-01-01",
ratePerSecond: 0.001,
lastUpdateTime: Date.now(),
isActive: true,
};

describe("StreamDetailsModal clipboard copy", () => {
const originalClipboard = navigator.clipboard;

beforeEach(() => {
toastError.mockClear();
toastSuccess.mockClear();
});

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(
<StreamDetailsModal
stream={STREAM}
onClose={vi.fn()}
onCancelClick={vi.fn()}
onTopUpClick={vi.fn()}
/>,
);

await user.click(screen.getByRole("button", { name: /copy recipient address/i }));

await waitFor(() => {
expect(writeText).toHaveBeenCalledWith(STREAM.recipient);
});

await waitFor(() => {
expect(toastError).toHaveBeenCalledWith("Failed to copy to clipboard");
});
expect(toastSuccess).not.toHaveBeenCalled();
});

it("shows a success toast when the clipboard write succeeds", async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
writable: true,
});

render(
<StreamDetailsModal
stream={STREAM}
onClose={vi.fn()}
onCancelClick={vi.fn()}
onTopUpClick={vi.fn()}
/>,
);

await user.click(screen.getByRole("button", { name: /copy recipient address/i }));

await waitFor(() => {
expect(toastSuccess).toHaveBeenCalledWith("Recipient address copied");
});
expect(toastError).not.toHaveBeenCalled();
expect(
await screen.findByRole("button", { name: /recipient address copied/i }),
).toBeInTheDocument();
});
});
29 changes: 24 additions & 5 deletions frontend/src/components/dashboard/StreamDetailsModal.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"use client";

import React from "react";
import React, { useState } from "react";
import { Button } from "@/components/ui/Button";
import { useModalDialog } from "@/hooks/useModalDialog";
import { copyToClipboard } from "@/lib/clipboard";
import type { Stream } from "@/lib/dashboard";

interface StreamDetailsModalProps {
Expand All @@ -19,10 +20,21 @@ export const StreamDetailsModal: React.FC<StreamDetailsModalProps> = ({
onTopUpClick,
}) => {
const dialogRef = useModalDialog({ onClose });
const [recipientCopied, setRecipientCopied] = useState(false);

const progress = stream.deposited > 0 ? Math.min(100, Math.max(0, (stream.withdrawn / stream.deposited) * 100)) : 0;
const remaining = stream.deposited - stream.withdrawn;

const handleCopyRecipient = async () => {
const success = await copyToClipboard(stream.recipient, {
successMessage: "Recipient address copied",
});
if (success) {
setRecipientCopied(true);
setTimeout(() => setRecipientCopied(false), 1500);
}
};

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md"
Expand Down Expand Up @@ -62,12 +74,19 @@ export const StreamDetailsModal: React.FC<StreamDetailsModalProps> = ({
<div className="flex items-center gap-2">
<code className="text-sm text-accent truncate">{stream.recipient}</code>
<button
onClick={() => navigator.clipboard.writeText(stream.recipient)}
onClick={handleCopyRecipient}
aria-label={recipientCopied ? "Recipient address copied" : "Copy recipient address"}
className="text-slate-500 hover:text-accent transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
{recipientCopied ? (
<svg className="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
</div>
</div>
Expand Down
Loading
Loading