diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..93f9e6e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '[BUG] ' +labels: 'bug' +assignees: '' + +--- + +## Description +A clear and concise description of what the bug is. + +## Steps to Reproduce +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '...' +3. Scroll down to '...' +4. See error + +## Expected Behavior +A clear and concise description of what you expected to happen. + +## Actual Behavior +A clear and concise description of what actually happened. + +## Screenshots +If applicable, add screenshots to help explain your problem. + +## Environment +- Browser: [e.g. Chrome, Firefox, Safari] +- OS: [e.g. Windows, macOS, Linux] +- Network/Testnet: [e.g. Testnet, Futurenet] + +## Additional Context +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..cbaf9fb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '[FEATURE] ' +labels: 'enhancement' +assignees: '' + +--- + +## Description +A clear and concise description of the feature you'd like to see. + +## Is your feature request related to a problem? +A clear and concise description of any related problem. Ex. I'm always frustrated when [...] + +## Proposed Solution +A clear and concise description of what you want to happen. + +## Alternatives Considered +A clear and concise description of any alternative solutions or features you've considered. + +## Additional Context +Add any other context or screenshots about the feature request here. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f9b7b16 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 RWA-ToolKit + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/components/issuer/panels/CompliancePanel.test.tsx b/components/issuer/panels/CompliancePanel.test.tsx new file mode 100644 index 0000000..ac1205a --- /dev/null +++ b/components/issuer/panels/CompliancePanel.test.tsx @@ -0,0 +1,743 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { Keypair } from "@stellar/stellar-sdk"; +import type { AssetDetail } from "@/types"; +import { useTx } from "@/hooks/useTx"; +import { useAllowlist } from "@/hooks/useCompliance"; +import { CompliancePanel } from "./CompliancePanel"; + +jest.mock("@/hooks/useTx", () => ({ + useTx: jest.fn(), +})); + +jest.mock("@/hooks/useCompliance", () => ({ + useAllowlist: jest.fn(), +})); + +jest.mock("@/lib/contracts", () => ({ + compliance: { + addToAllowlist: jest.fn(), + suspend: jest.fn(), + remove: jest.fn(), + blockJurisdiction: jest.fn(), + unblockJurisdiction: jest.fn(), + }, +})); + +jest.mock("@/components/compliance/ComplianceBadge", () => ({ + ComplianceBadge: ({ status }: { status: string }) => {status}, +})); + +jest.mock("@/components/ui/CopyButton", () => ({ + CopyButton: ({ value }: { value: string }) => ( + + ), +})); + +jest.mock("@/components/ui/TxProgress", () => ({ + TxProgress: ({ + phase, + hash, + error, + onDismiss, + successMessage, + }: { + phase: string; + hash: string | null; + error: string | null; + onDismiss: () => void; + successMessage: string; + }) => ( +
+

Phase: {phase}

+ {hash &&

Hash: {hash}

} + {error &&

Error: {error}

} +

{successMessage}

+ +
+ ), +})); + +jest.mock("@/components/ui/Spinner", () => ({ + Spinner: ({ size }: { size?: number }) => , +})); + +const mockUseTx = useTx as jest.MockedFunction; +const mockUseAllowlist = useAllowlist as jest.MockedFunction; + +const RECIPIENT = Keypair.random().publicKey(); +const CONTRACT_ADDRESS = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; + +const createMockAsset = (overrides: Partial = {}): AssetDetail => ({ + id: 1n, + tokenContract: "CTOKEN1", + issuer: "GISSUER", + name: "Test Asset", + assetType: "real_estate", + valuation: 1_000_000_00n, + createdAt: 50000, + active: true, + metadata: { + name: "Test Asset", + symbol: "TEST", + assetType: "real_estate", + totalSupply: 1_000_000n, + decimals: 7, + admin: "GISSUER", + complianceContract: "CCOMPLIANCE", + assetDescription: "Test asset", + valuation: 1_000_000_00n, + paused: false, + }, + ...overrides, +}); + +function setupIdleTx() { + mockUseTx.mockReturnValue({ + phase: "idle", + hash: null, + error: null, + pending: false, + run: jest.fn(async (fn) => { + await fn({} as any); + return true; + }), + reset: jest.fn(), + }); +} + +function setupEmptyAllowlist() { + mockUseAllowlist.mockReturnValue({ + data: [], + loading: false, + error: null, + refetch: jest.fn(), + }); +} + +describe("CompliancePanel", () => { + afterEach(() => jest.clearAllMocks()); + + describe("AddToAllowlistCard", () => { + it("renders the address approval form", () => { + setupIdleTx(); + setupEmptyAllowlist(); + render(); + + expect(screen.getByLabelText("Address")).toBeInTheDocument(); + expect(screen.getByLabelText("Jurisdiction")).toBeInTheDocument(); + expect(screen.getByLabelText("Expires at ledger")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Approve address" }) + ).toBeInTheDocument(); + }); + + it("rejects an invalid address", async () => { + setupIdleTx(); + setupEmptyAllowlist(); + render(); + + fireEvent.change(screen.getByLabelText("Address"), { + target: { value: "not-an-address" }, + }); + fireEvent.change(screen.getByLabelText("Jurisdiction"), { + target: { value: "US" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Approve address" })); + + expect( + screen.getByText("Enter a valid Stellar address (G… or C…).") + ).toBeInTheDocument(); + }); + + it("accepts a valid G… address", async () => { + const mockRun = jest.fn(async (fn) => { + await fn({} as any); + return true; + }); + mockUseTx.mockReturnValue({ + phase: "idle", + hash: null, + error: null, + pending: false, + run: mockRun, + reset: jest.fn(), + }); + setupEmptyAllowlist(); + + render(); + + fireEvent.change(screen.getByLabelText("Address"), { + target: { value: RECIPIENT }, + }); + fireEvent.change(screen.getByLabelText("Jurisdiction"), { + target: { value: "US" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Approve address" })); + + expect(mockRun).toHaveBeenCalled(); + }); + + it("accepts a valid C… contract address", async () => { + const mockRun = jest.fn(async (fn) => { + await fn({} as any); + return true; + }); + mockUseTx.mockReturnValue({ + phase: "idle", + hash: null, + error: null, + pending: false, + run: mockRun, + reset: jest.fn(), + }); + setupEmptyAllowlist(); + + render(); + + fireEvent.change(screen.getByLabelText("Address"), { + target: { value: CONTRACT_ADDRESS }, + }); + fireEvent.change(screen.getByLabelText("Jurisdiction"), { + target: { value: "US" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Approve address" })); + + expect(mockRun).toHaveBeenCalled(); + }); + + it("rejects a jurisdiction code that is too short", async () => { + setupIdleTx(); + setupEmptyAllowlist(); + render(); + + fireEvent.change(screen.getByLabelText("Address"), { + target: { value: RECIPIENT }, + }); + fireEvent.change(screen.getByLabelText("Jurisdiction"), { + target: { value: "U" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Approve address" })); + + expect( + screen.getByText("Enter a 2–3 character ISO jurisdiction code (e.g. US, DE, KE).") + ).toBeInTheDocument(); + }); + + it("rejects a jurisdiction code that is too long", async () => { + setupIdleTx(); + setupEmptyAllowlist(); + render(); + + fireEvent.change(screen.getByLabelText("Address"), { + target: { value: RECIPIENT }, + }); + fireEvent.change(screen.getByLabelText("Jurisdiction"), { + target: { value: "USAA" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Approve address" })); + + expect( + screen.getByText("Enter a 2–3 character ISO jurisdiction code (e.g. US, DE, KE).") + ).toBeInTheDocument(); + }); + + it("rejects a non-numeric expiry ledger", async () => { + setupIdleTx(); + setupEmptyAllowlist(); + render(); + + fireEvent.change(screen.getByLabelText("Address"), { + target: { value: RECIPIENT }, + }); + fireEvent.change(screen.getByLabelText("Jurisdiction"), { + target: { value: "US" }, + }); + fireEvent.change(screen.getByLabelText("Expires at ledger"), { + target: { value: "not-a-number" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Approve address" })); + + expect( + screen.getByText("Expiry ledger must be a non-negative integer (0 = never expires).") + ).toBeInTheDocument(); + }); + + it("rejects a negative expiry ledger", async () => { + setupIdleTx(); + setupEmptyAllowlist(); + render(); + + fireEvent.change(screen.getByLabelText("Address"), { + target: { value: RECIPIENT }, + }); + fireEvent.change(screen.getByLabelText("Jurisdiction"), { + target: { value: "US" }, + }); + fireEvent.change(screen.getByLabelText("Expires at ledger"), { + target: { value: "-1" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Approve address" })); + + expect( + screen.getByText("Expiry ledger must be a non-negative integer (0 = never expires).") + ).toBeInTheDocument(); + }); + + it("accepts 0 as expiry ledger (never expires)", async () => { + const mockRun = jest.fn(async (fn) => { + await fn({} as any); + return true; + }); + mockUseTx.mockReturnValue({ + phase: "idle", + hash: null, + error: null, + pending: false, + run: mockRun, + reset: jest.fn(), + }); + setupEmptyAllowlist(); + + render(); + + fireEvent.change(screen.getByLabelText("Address"), { + target: { value: RECIPIENT }, + }); + fireEvent.change(screen.getByLabelText("Jurisdiction"), { + target: { value: "US" }, + }); + fireEvent.change(screen.getByLabelText("Expires at ledger"), { + target: { value: "0" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Approve address" })); + + expect(mockRun).toHaveBeenCalled(); + }); + + it("disables form while transaction is pending", () => { + mockUseTx.mockReturnValue({ + phase: "signing", + hash: null, + error: null, + pending: true, + run: jest.fn(), + reset: jest.fn(), + }); + setupEmptyAllowlist(); + + render(); + + expect(screen.getByLabelText("Address")).toBeDisabled(); + expect(screen.getByLabelText("Jurisdiction")).toBeDisabled(); + expect(screen.getByLabelText("Expires at ledger")).toBeDisabled(); + }); + + it("calls onChanged callback when approval succeeds", async () => { + const mockRun = jest.fn(async (fn) => { + await fn({} as any); + return true; + }); + mockUseTx.mockReturnValue({ + phase: "idle", + hash: null, + error: null, + pending: false, + run: mockRun, + reset: jest.fn(), + }); + setupEmptyAllowlist(); + + const onChanged = jest.fn(); + render(); + + fireEvent.change(screen.getByLabelText("Address"), { + target: { value: RECIPIENT }, + }); + fireEvent.change(screen.getByLabelText("Jurisdiction"), { + target: { value: "US" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Approve address" })); + + expect(onChanged).toHaveBeenCalled(); + }); + + it("converts jurisdiction to uppercase", async () => { + const mockRun = jest.fn(async (fn) => { + await fn({} as any); + return true; + }); + mockUseTx.mockReturnValue({ + phase: "idle", + hash: null, + error: null, + pending: false, + run: mockRun, + reset: jest.fn(), + }); + setupEmptyAllowlist(); + + render(); + + const jurisdictionInput = screen.getByLabelText("Jurisdiction") as HTMLInputElement; + fireEvent.change(jurisdictionInput, { + target: { value: "us" }, + }); + + expect(jurisdictionInput.value).toBe("US"); + }); + }); + + describe("AllowlistManageCard", () => { + it("shows loading spinner when allowlist is loading", () => { + setupIdleTx(); + mockUseAllowlist.mockReturnValue({ + data: null, + loading: true, + error: null, + refetch: jest.fn(), + }); + + render(); + + expect(screen.getByTestId("spinner")).toBeInTheDocument(); + }); + + it("shows empty state when allowlist is empty", () => { + setupIdleTx(); + setupEmptyAllowlist(); + + render(); + + expect(screen.getByText("No addresses on the allowlist yet.")).toBeInTheDocument(); + }); + + it("renders allowlist entries", () => { + setupIdleTx(); + mockUseAllowlist.mockReturnValue({ + data: [ + { + address: RECIPIENT, + status: "Approved", + jurisdiction: "US", + }, + ], + loading: false, + error: null, + refetch: jest.fn(), + }); + + render(); + + expect(screen.getByText("Approved")).toBeInTheDocument(); + expect(screen.getByText("US")).toBeInTheDocument(); + }); + + it("shows Suspend button for approved addresses", () => { + setupIdleTx(); + mockUseAllowlist.mockReturnValue({ + data: [ + { + address: RECIPIENT, + status: "Approved", + jurisdiction: "US", + }, + ], + loading: false, + error: null, + refetch: jest.fn(), + }); + + render(); + + expect(screen.getByRole("button", { name: "Suspend" })).toBeInTheDocument(); + }); + + it("shows Re-approve button for suspended addresses", () => { + setupIdleTx(); + mockUseAllowlist.mockReturnValue({ + data: [ + { + address: RECIPIENT, + status: "Suspended", + jurisdiction: "US", + }, + ], + loading: false, + error: null, + refetch: jest.fn(), + }); + + render(); + + expect(screen.getByRole("button", { name: "Re-approve" })).toBeInTheDocument(); + }); + + it("always shows Remove button", () => { + setupIdleTx(); + mockUseAllowlist.mockReturnValue({ + data: [ + { + address: RECIPIENT, + status: "Approved", + jurisdiction: "US", + }, + ], + loading: false, + error: null, + refetch: jest.fn(), + }); + + render(); + + const removeButtons = screen.getAllByRole("button", { name: "Remove" }); + expect(removeButtons.length).toBeGreaterThan(0); + }); + + it("disables action buttons while transaction is pending", () => { + mockUseTx.mockReturnValue({ + phase: "signing", + hash: null, + error: null, + pending: true, + run: jest.fn(), + reset: jest.fn(), + }); + mockUseAllowlist.mockReturnValue({ + data: [ + { + address: RECIPIENT, + status: "Approved", + jurisdiction: "US", + }, + ], + loading: false, + error: null, + refetch: jest.fn(), + }); + + render(); + + expect(screen.getByRole("button", { name: "Suspend" })).toBeDisabled(); + }); + + it("calls onChanged callback when action succeeds", async () => { + const mockRun = jest.fn(async (fn) => { + await fn({} as any); + return true; + }); + mockUseTx.mockReturnValue({ + phase: "idle", + hash: null, + error: null, + pending: false, + run: mockRun, + reset: jest.fn(), + }); + mockUseAllowlist.mockReturnValue({ + data: [ + { + address: RECIPIENT, + status: "Approved", + jurisdiction: "US", + }, + ], + loading: false, + error: null, + refetch: jest.fn(), + }); + + const onChanged = jest.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Suspend" })); + + expect(onChanged).toHaveBeenCalled(); + }); + }); + + describe("JurisdictionCard", () => { + it("renders block and unblock jurisdiction forms", () => { + setupIdleTx(); + setupEmptyAllowlist(); + + render(); + + expect(screen.getByLabelText("Block jurisdiction")).toBeInTheDocument(); + expect(screen.getByLabelText("Unblock jurisdiction")).toBeInTheDocument(); + }); + + it("rejects a jurisdiction code that is too short in block form", async () => { + setupIdleTx(); + setupEmptyAllowlist(); + render(); + + const blockInput = screen.getByLabelText("Block jurisdiction"); + fireEvent.change(blockInput, { target: { value: "U" } }); + + const blockButtons = screen.getAllByRole("button", { name: "Block" }); + fireEvent.click(blockButtons[0]); + + expect(screen.getByText("Enter a 2–3 character ISO code.")).toBeInTheDocument(); + }); + + it("accepts a valid jurisdiction code in block form", async () => { + const mockRun = jest.fn(async (fn) => { + await fn({} as any); + return true; + }); + mockUseTx.mockReturnValue({ + phase: "idle", + hash: null, + error: null, + pending: false, + run: mockRun, + reset: jest.fn(), + }); + setupEmptyAllowlist(); + + render(); + + const blockInput = screen.getByLabelText("Block jurisdiction"); + fireEvent.change(blockInput, { target: { value: "KP" } }); + + const blockButtons = screen.getAllByRole("button", { name: "Block" }); + fireEvent.click(blockButtons[0]); + + expect(mockRun).toHaveBeenCalled(); + }); + + it("accepts a valid jurisdiction code in unblock form", async () => { + const mockRun = jest.fn(async (fn) => { + await fn({} as any); + return true; + }); + mockUseTx.mockReturnValue({ + phase: "idle", + hash: null, + error: null, + pending: false, + run: mockRun, + reset: jest.fn(), + }); + setupEmptyAllowlist(); + + render(); + + const unblockInput = screen.getByLabelText("Unblock jurisdiction"); + fireEvent.change(unblockInput, { target: { value: "US" } }); + + const unblockButtons = screen.getAllByRole("button", { name: "Unblock" }); + fireEvent.click(unblockButtons[0]); + + expect(mockRun).toHaveBeenCalled(); + }); + + it("converts jurisdiction codes to uppercase", () => { + setupIdleTx(); + setupEmptyAllowlist(); + render(); + + const blockInput = screen.getByLabelText("Block jurisdiction") as HTMLInputElement; + fireEvent.change(blockInput, { target: { value: "kp" } }); + + expect(blockInput.value).toBe("KP"); + }); + + it("disables block button while transaction is pending", () => { + mockUseTx.mockReturnValue({ + phase: "signing", + hash: null, + error: null, + pending: true, + run: jest.fn(), + reset: jest.fn(), + }); + setupEmptyAllowlist(); + + render(); + + const blockButtons = screen.getAllByRole("button", { name: "Block" }); + expect(blockButtons[0]).toBeDisabled(); + }); + + it("disables unblock button while transaction is pending", () => { + mockUseTx.mockReturnValue({ + phase: "signing", + hash: null, + error: null, + pending: true, + run: jest.fn(), + reset: jest.fn(), + }); + setupEmptyAllowlist(); + + render(); + + const unblockButtons = screen.getAllByRole("button", { name: "Unblock" }); + expect(unblockButtons[0]).toBeDisabled(); + }); + + it("calls onChanged callback when block succeeds", async () => { + const mockRun = jest.fn(async (fn) => { + await fn({} as any); + return true; + }); + mockUseTx.mockReturnValue({ + phase: "idle", + hash: null, + error: null, + pending: false, + run: mockRun, + reset: jest.fn(), + }); + setupEmptyAllowlist(); + + const onChanged = jest.fn(); + render(); + + const blockInput = screen.getByLabelText("Block jurisdiction"); + fireEvent.change(blockInput, { target: { value: "KP" } }); + + const blockButtons = screen.getAllByRole("button", { name: "Block" }); + fireEvent.click(blockButtons[0]); + + expect(onChanged).toHaveBeenCalled(); + }); + + it("calls onChanged callback when unblock succeeds", async () => { + const mockRun = jest.fn(async (fn) => { + await fn({} as any); + return true; + }); + mockUseTx.mockReturnValue({ + phase: "idle", + hash: null, + error: null, + pending: false, + run: mockRun, + reset: jest.fn(), + }); + setupEmptyAllowlist(); + + const onChanged = jest.fn(); + render(); + + const unblockInput = screen.getByLabelText("Unblock jurisdiction"); + fireEvent.change(unblockInput, { target: { value: "US" } }); + + const unblockButtons = screen.getAllByRole("button", { name: "Unblock" }); + fireEvent.click(unblockButtons[0]); + + expect(onChanged).toHaveBeenCalled(); + }); + }); +}); diff --git a/components/issuer/panels/TokenPanel.test.tsx b/components/issuer/panels/TokenPanel.test.tsx new file mode 100644 index 0000000..12d5964 --- /dev/null +++ b/components/issuer/panels/TokenPanel.test.tsx @@ -0,0 +1,565 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { Keypair } from "@stellar/stellar-sdk"; +import type { AssetDetail } from "@/types"; +import { useTx } from "@/hooks/useTx"; +import { TokenPanel } from "./TokenPanel"; + +jest.mock("@/hooks/useTx", () => ({ + useTx: jest.fn(), +})); + +jest.mock("@/lib/contracts", () => ({ + assetToken: { + mint: jest.fn(), + pause: jest.fn(), + unpause: jest.fn(), + }, +})); + +jest.mock("@/components/ui/TxProgress", () => ({ + TxProgress: ({ + phase, + hash, + error, + onDismiss, + successMessage, + }: { + phase: string; + hash: string | null; + error: string | null; + onDismiss: () => void; + successMessage: string; + }) => ( +
+

Phase: {phase}

+ {hash &&

Hash: {hash}

} + {error &&

Error: {error}

} +

{successMessage}

+ +
+ ), +})); + +const mockUseTx = useTx as jest.MockedFunction; + +const RECIPIENT = Keypair.random().publicKey(); + +const createMockAsset = (overrides: Partial = {}): AssetDetail => ({ + id: 1n, + tokenContract: "CTOKEN1", + issuer: "GISSUER", + name: "Test Asset", + assetType: "real_estate", + valuation: 1_000_000_00n, + createdAt: 50000, + active: true, + metadata: { + name: "Test Asset", + symbol: "TEST", + assetType: "real_estate", + totalSupply: 1_000_000n, + decimals: 7, + admin: "GISSUER", + complianceContract: "CCOMPLIANCE", + assetDescription: "Test asset", + valuation: 1_000_000_00n, + paused: false, + }, + ...overrides, +}); + +function setupIdleTx() { + mockUseTx.mockReturnValue({ + phase: "idle", + hash: null, + error: null, + pending: false, + run: jest.fn(async (fn) => { + await fn({} as any); + return true; + }), + reset: jest.fn(), + }); +} + +describe("TokenPanel", () => { + afterEach(() => jest.clearAllMocks()); + + describe("MintCard", () => { + it("renders the mint form with address and amount fields", () => { + setupIdleTx(); + render(); + + expect(screen.getByLabelText("Recipient address")).toBeInTheDocument(); + expect(screen.getByLabelText("Amount")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Mint" })).toBeInTheDocument(); + }); + + it("displays the token symbol in the amount field", () => { + setupIdleTx(); + render( + + ); + + expect(screen.getByText("GOLD")).toBeInTheDocument(); + }); + + it("displays the current token supply", () => { + setupIdleTx(); + const asset = createMockAsset({ + metadata: { + ...createMockAsset().metadata, + totalSupply: 5_000_000n, + decimals: 7, + symbol: "TEST", + }, + }); + render(); + + expect(screen.getByText(/Current supply: 500 TEST/)).toBeInTheDocument(); + }); + + it("rejects an invalid recipient address", async () => { + setupIdleTx(); + render(); + + fireEvent.change(screen.getByLabelText("Recipient address"), { + target: { value: "not-a-stellar-address" }, + }); + fireEvent.change(screen.getByLabelText("Amount"), { + target: { value: "100" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Mint" })); + + expect( + screen.getByText("Enter a valid Stellar address (G… or C…).") + ).toBeInTheDocument(); + }); + + it("accepts a valid G… address", async () => { + setupIdleTx(); + const mockRun = jest.fn(async (fn) => { + await fn({} as any); + return true; + }); + mockUseTx.mockReturnValue({ + phase: "idle", + hash: null, + error: null, + pending: false, + run: mockRun, + reset: jest.fn(), + }); + + render(); + + fireEvent.change(screen.getByLabelText("Recipient address"), { + target: { value: RECIPIENT }, + }); + fireEvent.change(screen.getByLabelText("Amount"), { + target: { value: "100" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Mint" })); + + expect(mockRun).toHaveBeenCalled(); + }); + + it("accepts a valid C… contract address", async () => { + setupIdleTx(); + const mockRun = jest.fn(async (fn) => { + await fn({} as any); + return true; + }); + mockUseTx.mockReturnValue({ + phase: "idle", + hash: null, + error: null, + pending: false, + run: mockRun, + reset: jest.fn(), + }); + + render(); + + fireEvent.change(screen.getByLabelText("Recipient address"), { + target: { value: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4" }, + }); + fireEvent.change(screen.getByLabelText("Amount"), { + target: { value: "100" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Mint" })); + + expect(mockRun).toHaveBeenCalled(); + }); + + it("rejects a zero or negative amount", async () => { + setupIdleTx(); + render(); + + fireEvent.change(screen.getByLabelText("Recipient address"), { + target: { value: RECIPIENT }, + }); + fireEvent.change(screen.getByLabelText("Amount"), { + target: { value: "0" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Mint" })); + + expect( + screen.getByText("Amount must be greater than zero.") + ).toBeInTheDocument(); + }); + + it("disables form inputs while a transaction is pending", () => { + mockUseTx.mockReturnValue({ + phase: "signing", + hash: null, + error: null, + pending: true, + run: jest.fn(), + reset: jest.fn(), + }); + + render(); + + expect(screen.getByLabelText("Recipient address")).toBeDisabled(); + expect(screen.getByLabelText("Amount")).toBeDisabled(); + }); + + it("shows TxProgress when transaction is not idle", () => { + mockUseTx.mockReturnValue({ + phase: "confirming", + hash: "abc123", + error: null, + pending: true, + run: jest.fn(), + reset: jest.fn(), + }); + + render(); + + expect(screen.getByTestId("tx-progress")).toBeInTheDocument(); + expect(screen.getByText(/Phase: confirming/)).toBeInTheDocument(); + }); + + it("clears the form after successful mint", async () => { + const mockRun = jest.fn(async (fn) => { + await fn({} as any); + return true; + }); + mockUseTx.mockReturnValue({ + phase: "idle", + hash: null, + error: null, + pending: false, + run: mockRun, + reset: jest.fn(), + }); + + const onMinted = jest.fn(); + render(); + + fireEvent.change(screen.getByLabelText("Recipient address"), { + target: { value: RECIPIENT }, + }); + fireEvent.change(screen.getByLabelText("Amount"), { + target: { value: "100" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Mint" })); + + expect(onMinted).toHaveBeenCalled(); + }); + + it("calls onMinted callback when mint succeeds", async () => { + const mockRun = jest.fn(async (fn) => { + await fn({} as any); + return true; + }); + mockUseTx.mockReturnValue({ + phase: "idle", + hash: null, + error: null, + pending: false, + run: mockRun, + reset: jest.fn(), + }); + + const onMinted = jest.fn(); + render(); + + fireEvent.change(screen.getByLabelText("Recipient address"), { + target: { value: RECIPIENT }, + }); + fireEvent.change(screen.getByLabelText("Amount"), { + target: { value: "100" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Mint" })); + + expect(onMinted).toHaveBeenCalled(); + }); + }); + + describe("PauseCard", () => { + it("renders pause button when token is not paused", () => { + setupIdleTx(); + render( + + ); + + expect(screen.getByRole("button", { name: "Pause transfers" })).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Unpause transfers" }) + ).not.toBeInTheDocument(); + }); + + it("renders unpause button when token is paused", () => { + setupIdleTx(); + render( + + ); + + expect( + screen.getByRole("button", { name: "Unpause transfers" }) + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Pause transfers" }) + ).not.toBeInTheDocument(); + }); + + it("displays pause warning when token is paused", () => { + setupIdleTx(); + render( + + ); + + expect( + screen.getByText("Transfers are currently paused.") + ).toBeInTheDocument(); + }); + + it("does not display pause warning when token is not paused", () => { + setupIdleTx(); + render( + + ); + + expect( + screen.queryByText("Transfers are currently paused.") + ).not.toBeInTheDocument(); + }); + + it("disables pause button while transaction is pending", () => { + mockUseTx.mockReturnValue({ + phase: "signing", + hash: null, + error: null, + pending: true, + run: jest.fn(), + reset: jest.fn(), + }); + + render( + + ); + + expect( + screen.getByRole("button", { name: "Pause transfers" }) + ).toBeDisabled(); + }); + + it("disables unpause button while transaction is pending", () => { + mockUseTx.mockReturnValue({ + phase: "signing", + hash: null, + error: null, + pending: true, + run: jest.fn(), + reset: jest.fn(), + }); + + render( + + ); + + expect( + screen.getByRole("button", { name: "Unpause transfers" }) + ).toBeDisabled(); + }); + + it("shows TxProgress when pause transaction is not idle", () => { + mockUseTx.mockReturnValue({ + phase: "confirming", + hash: "def456", + error: null, + pending: true, + run: jest.fn(), + reset: jest.fn(), + }); + + render( + + ); + + expect(screen.getByTestId("tx-progress")).toBeInTheDocument(); + }); + + it("calls onPauseToggled callback when pause succeeds", async () => { + const mockRun = jest.fn(async (fn) => { + await fn({} as any); + return true; + }); + mockUseTx.mockReturnValue({ + phase: "idle", + hash: null, + error: null, + pending: false, + run: mockRun, + reset: jest.fn(), + }); + + const onPauseToggled = jest.fn(); + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Pause transfers" })); + + expect(onPauseToggled).toHaveBeenCalled(); + }); + + it("calls onPauseToggled callback when unpause succeeds", async () => { + const mockRun = jest.fn(async (fn) => { + await fn({} as any); + return true; + }); + mockUseTx.mockReturnValue({ + phase: "idle", + hash: null, + error: null, + pending: false, + run: mockRun, + reset: jest.fn(), + }); + + const onPauseToggled = jest.fn(); + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Unpause transfers" })); + + expect(onPauseToggled).toHaveBeenCalled(); + }); + + it("uses btn-primary styling for pause button", () => { + setupIdleTx(); + render( + + ); + + const pauseButton = screen.getByRole("button", { name: "Pause transfers" }); + expect(pauseButton).toHaveClass("btn-secondary"); + }); + + it("uses btn-primary styling for unpause button", () => { + setupIdleTx(); + render( + + ); + + const unpauseButton = screen.getByRole("button", { name: "Unpause transfers" }); + expect(unpauseButton).toHaveClass("btn-primary"); + }); + }); +});