Skip to content

Commit 44c318f

Browse files
authored
Merge pull request #62 from jotel-dev/#52-test-transactionpanel-validation-paths-addressdisplay-keyboard-focus-mock-client-determinism-and-invalid-network
#52 test transactionpanel validation paths addressdisplay keyboard focus mock client determinism and invalid network
2 parents 216682f + 4284673 commit 44c318f

5 files changed

Lines changed: 194 additions & 15 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
2+
import { describe, it, expect, vi, beforeAll } from "vitest";
3+
import { AddressDisplay } from "./AddressDisplay";
4+
5+
// Mock navigator.clipboard safely in JSDOM
6+
const mockWriteText = vi.fn().mockResolvedValue(undefined);
7+
beforeAll(() => {
8+
Object.defineProperty(navigator, "clipboard", {
9+
value: {
10+
writeText: mockWriteText,
11+
},
12+
configurable: true,
13+
writable: true,
14+
});
15+
});
16+
17+
describe("AddressDisplay", () => {
18+
const address = "GBAMQXTQ7IQKPZXJKZJQZJQZJQZJQZJQZJQZJQZJQZJQZJQZJQZJQQQQ";
19+
20+
it("renders truncated address and copy button in tab order with correct aria-label", async () => {
21+
render(<AddressDisplay address={address} />);
22+
23+
// Confirm address text is present (truncated format)
24+
expect(screen.getByText("GBAMQXTQ...ZJQQQQ")).toBeInTheDocument();
25+
26+
// Confirm copy button exists with aria-label
27+
const copyBtn = screen.getByRole("button", { name: "Copy address" });
28+
expect(copyBtn).toBeInTheDocument();
29+
30+
// Confirm it is focusable (in tab order)
31+
expect(copyBtn).not.toHaveAttribute("tabindex", "-1");
32+
33+
// Click to copy and verify clipboard call
34+
await act(async () => {
35+
fireEvent.click(copyBtn);
36+
});
37+
expect(mockWriteText).toHaveBeenCalledWith(address);
38+
39+
// Verify aria-label changes to "Address copied" after copy
40+
expect(screen.getByRole("button", { name: "Address copied" })).toBeInTheDocument();
41+
42+
// Wait for the state to reset back to "Copy address"
43+
await waitFor(() => {
44+
expect(screen.getByRole("button", { name: "Copy address" })).toBeInTheDocument();
45+
}, { timeout: 2500 });
46+
});
47+
48+
it("verifies copy button has visibility class (opacity-50 base opacity)", () => {
49+
render(<AddressDisplay address={address} />);
50+
51+
const copyBtn = screen.getByRole("button", { name: "Copy address" });
52+
53+
// Expect base opacity-50 for visibility (not opacity-0)
54+
expect(copyBtn.className).toContain("opacity-50");
55+
expect(copyBtn.className).not.toContain("opacity-0");
56+
});
57+
});

‎src/components/TransactionPanel.test.tsx‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,47 @@ describe("TransactionPanel", () => {
130130
expect(await screen.findByText("Transaction failed")).toBeInTheDocument();
131131
expect(screen.getByText("Wallet not connected")).toBeInTheDocument();
132132
});
133+
134+
it("shows self-payment warning error if destination is equal to source address", async () => {
135+
(useSorokit as any).mockReturnValue({
136+
address: "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
137+
isConnected: true,
138+
});
139+
140+
render(<TransactionPanel />);
141+
142+
const destInput = screen.getByLabelText("Destination Address");
143+
const amountInput = screen.getByLabelText("Amount (XLM)");
144+
const submitBtn = screen.getByRole("button", { name: "Send Payment" });
145+
146+
// Type source address as destination address
147+
fireEvent.change(destInput, { target: { value: "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC" } });
148+
fireEvent.change(amountInput, { target: { value: "10" } });
149+
150+
// Self-payment error should be displayed
151+
expect(screen.getByText("Cannot send to self")).toBeInTheDocument();
152+
expect(submitBtn).toBeDisabled();
153+
});
154+
155+
it("shows error for amount below minimum threshold", async () => {
156+
render(<TransactionPanel />);
157+
158+
const destInput = screen.getByLabelText("Destination Address");
159+
const amountInput = screen.getByLabelText("Amount (XLM)");
160+
const submitBtn = screen.getByRole("button", { name: "Send Payment" });
161+
162+
const validDest = "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC";
163+
fireEvent.change(destInput, { target: { value: validDest } });
164+
165+
// Type amount below 0.0000001
166+
fireEvent.change(amountInput, { target: { value: "0.00000005" } });
167+
168+
expect(screen.getByText("Amount must be at least 0.0000001 XLM")).toBeInTheDocument();
169+
expect(submitBtn).toBeDisabled();
170+
171+
// Type valid amount
172+
fireEvent.change(amountInput, { target: { value: "0.0000001" } });
173+
expect(screen.queryByText("Amount must be at least 0.0000001 XLM")).not.toBeInTheDocument();
174+
expect(submitBtn).not.toBeDisabled();
175+
});
133176
});

‎src/components/TransactionPanel.tsx‎

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,23 @@ export function TransactionPanel() {
1717
const [dest, setDest] = useState("");
1818
const [destDirty, setDestDirty] = useState(false);
1919
const [amount, setAmount] = useState("");
20+
const [amountDirty, setAmountDirty] = useState(false);
2021
const [memo, setMemo] = useState("");
2122
const [state, setState] = useState<State>("idle");
2223
const [result, setResult] = useState<TxResult | null>(null);
2324
const [error, setError] = useState<string | null>(null);
2425

2526
const isDestValid = /^G[A-Z2-7]{55}$/.test(dest.trim());
27+
const isSelfPayment = dest.trim() === address;
28+
const parsedAmount = parseFloat(amount);
29+
const isAmountValid = !isNaN(parsedAmount) && parsedAmount >= 0.0000001;
30+
2631
const canSubmit =
2732
isConnected &&
2833
isDestValid &&
29-
amount.trim() &&
30-
parseFloat(amount) > 0;
34+
!isSelfPayment &&
35+
amount.trim() !== "" &&
36+
isAmountValid;
3137

3238
async function submit(e: React.FormEvent) {
3339
e.preventDefault();
@@ -59,6 +65,7 @@ export function TransactionPanel() {
5965
setAmount("");
6066
setMemo("");
6167
setDestDirty(false);
68+
setAmountDirty(false);
6269
} catch (e) {
6370
setError(e instanceof Error ? e.message : "Unknown error");
6471
setState("error");
@@ -144,7 +151,15 @@ export function TransactionPanel() {
144151
setDest(e.target.value);
145152
setDestDirty(true);
146153
}}
147-
error={destDirty && !isDestValid ? "Invalid Stellar address" : undefined}
154+
error={
155+
destDirty
156+
? !isDestValid
157+
? "Invalid Stellar address"
158+
: isSelfPayment
159+
? "Cannot send to self"
160+
: undefined
161+
: undefined
162+
}
148163
disabled={state === "loading"}
149164
/>
150165
<Input
@@ -154,7 +169,21 @@ export function TransactionPanel() {
154169
min="0.0000001"
155170
step="0.0000001"
156171
value={amount}
157-
onChange={(e) => setAmount(e.target.value)}
172+
onChange={(e) => {
173+
setAmount(e.target.value);
174+
setAmountDirty(true);
175+
}}
176+
error={
177+
amountDirty
178+
? amount.trim() === ""
179+
? "Amount is required"
180+
: isNaN(parsedAmount) || parsedAmount <= 0
181+
? "Amount must be greater than 0"
182+
: parsedAmount < 0.0000001
183+
? "Amount must be at least 0.0000001 XLM"
184+
: undefined
185+
: undefined
186+
}
158187
disabled={state === "loading"}
159188
/>
160189
<Input
@@ -178,6 +207,7 @@ export function TransactionPanel() {
178207
setResult(null);
179208
setError(null);
180209
setDestDirty(false);
210+
setAmountDirty(false);
181211
}}
182212
>
183213
New Transaction

‎src/lib/mock-client.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
3+
describe("mock-client", () => {
4+
it("verifies MOCK_ADDRESS is a valid Stellar address format", async () => {
5+
// Dynamically import to ensure fresh or standard load
6+
const { createMockClient } = await import("./mock-client");
7+
const client = createMockClient();
8+
const addressRes = await client.wallet.connect();
9+
const address = addressRes.data?.address;
10+
11+
expect(address).toBeDefined();
12+
expect(address).toMatch(/^G[A-Z2-7]{55}$/);
13+
});
14+
15+
it("verifies switchNetwork returns an error for invalid networks", async () => {
16+
const { createMockClient } = await import("./mock-client");
17+
const client = createMockClient();
18+
19+
const res = await client.network.switchNetwork("invalid" as any);
20+
expect(res.data).toBeNull();
21+
expect(res.error).toBe("Invalid network: invalid");
22+
});
23+
24+
it("verifies two separate imports produce identical MOCK_HISTORY transaction hashes (determinism)", async () => {
25+
// First import and fetch history
26+
const mod1 = await import("./mock-client");
27+
const client1 = mod1.createMockClient();
28+
const res1 = await client1.transaction.getHistory("address", 1, 10);
29+
const hashes1 = res1.data?.map(tx => tx.hash);
30+
31+
// Reset modules and re-import
32+
vi.resetModules();
33+
34+
const mod2 = await import("./mock-client");
35+
const client2 = mod2.createMockClient();
36+
const res2 = await client2.transaction.getHistory("address", 1, 10);
37+
const hashes2 = res2.data?.map(tx => tx.hash);
38+
39+
expect(hashes1).toEqual(hashes2);
40+
});
41+
});

‎src/lib/mock-client.ts‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,17 @@ import type {
1414
} from "./client";
1515

1616
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
17-
const randHex = (len: number) =>
18-
Array.from({ length: len }, () =>
19-
Math.floor(Math.random() * 16).toString(16),
20-
).join("");
17+
let seedState = 123456789;
18+
const randHex = (len: number) => {
19+
let result = "";
20+
for (let i = 0; i < len; i++) {
21+
seedState = (seedState * 1664525 + 1013904223) % 4294967296;
22+
result += (seedState % 16).toString(16);
23+
}
24+
return result;
25+
};
2126

22-
const MOCK_ADDRESS = "GBAMQXTQ7IQKPZXJKZJQZJQZJQZJQZJQZJQZJQZJQZJQZJQZJQZJQ";
27+
const MOCK_ADDRESS = "GBAMQXTQ7IQKPZXJKZJQZJQZJQZJQZJQZJQZJQZJQZJQZJQZJQZJQQQQ";
2328

2429
const MOCK_BALANCES: Balance[] = [
2530
{ asset: "XLM", balance: "1042.5000000", assetType: "native" },
@@ -48,10 +53,10 @@ const MOCK_ACCOUNT: AccountData = {
4853
const MOCK_HISTORY: Transaction[] = Array.from({ length: 20 }, (_, i) => ({
4954
hash: randHex(64),
5055
ledger: 48291034 - i * 12,
51-
createdAt: new Date(Date.now() - i * 3600000).toISOString(),
56+
createdAt: new Date(1782248400000 - i * 3600000).toISOString(),
5257
successful: i !== 3 && i !== 11,
53-
operationCount: Math.floor(Math.random() * 3) + 1,
54-
feePaid: (100 + Math.floor(Math.random() * 400)).toString(),
58+
operationCount: (i % 3) + 1,
59+
feePaid: (100 + (i % 4) * 100).toString(),
5560
memo: i % 4 === 0 ? `memo-${i}` : undefined,
5661
}));
5762

@@ -77,9 +82,9 @@ const MOCK_EVENTS: ContractEvent[] = Array.from({ length: 8 }, (_, i) => ({
7782
contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM",
7883
type: ["transfer", "mint", "burn", "approve"][i % 4],
7984
ledger: 48291035 - i * 5,
80-
createdAt: new Date(Date.now() - i * 600000).toISOString(),
85+
createdAt: new Date(1782248400000 - i * 600000).toISOString(),
8186
topics: [`topic_${i}_a`, `topic_${i}_b`],
82-
value: { amount: (Math.random() * 1000).toFixed(7), from: MOCK_ADDRESS },
87+
value: { amount: ((i + 1) * 12.3456789).toFixed(7), from: MOCK_ADDRESS },
8388
}));
8489

8590
const NETWORKS: Record<string, NetworkInfo> = {
@@ -207,7 +212,10 @@ export function createMockClient(): SorokitClient {
207212
},
208213
switchNetwork: async (name) => {
209214
await delay(600);
210-
currentNetwork = NETWORKS[name] ?? NETWORKS.testnet;
215+
if (!NETWORKS[name]) {
216+
return { data: null, error: `Invalid network: ${name}` };
217+
}
218+
currentNetwork = NETWORKS[name];
211219
return { data: currentNetwork, error: null };
212220
},
213221
},

0 commit comments

Comments
 (0)