@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
319 lines (260 loc) • 9.03 kB
text/typescript
/**
* @jest-environment jsdom
*/
import { genAccount } from "@ledgerhq/ledger-wallet-framework/mocks/account";
import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets/currencies";
import { InvalidAddress } from "@ledgerhq/errors";
import { getMainAccount } from "@ledgerhq/live-common/account/index";
import { getAccountBridge } from "@ledgerhq/live-common/bridge/index";
import type { Account } from "@ledgerhq/types-live";
import { renderHook, waitFor } from "@testing-library/react";
import { BigNumber } from "bignumber.js";
import { useBridgeRecipientValidation } from "../useBridgeRecipientValidation";
jest.mock("@ledgerhq/live-common/bridge/index");
jest.mock("@ledgerhq/live-common/account/index");
const mockedGetMainAccount = jest.mocked(getMainAccount);
const mockedGetAccountBridge = jest.mocked(getAccountBridge);
const createMockAccount = (): Account => {
const account = genAccount("mock_account");
return {
...account,
id: "mock_account_id",
freshAddress: "source_address",
balance: new BigNumber(100000000),
spendableBalance: new BigNumber(100000000),
currency: getCryptoCurrencyById("bitcoin"),
};
};
const mockAccount = createMockAccount();
const mockBridge = {
createTransaction: jest.fn(),
updateTransaction: jest.fn(),
prepareTransaction: jest.fn(),
getTransactionStatus: jest.fn(),
sync: jest.fn(),
receive: jest.fn(),
estimateMaxSpendable: jest.fn(),
signOperation: jest.fn(),
broadcast: jest.fn(),
getPreloadStrategy: jest.fn(),
checkValidRecipient: jest.fn(),
signRawOperation: jest.fn(),
validateAddress: jest.fn(),
getSerializedAddressParameters: jest.fn(),
};
describe("useBridgeRecipientValidation", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
mockedGetMainAccount.mockReturnValue(mockAccount);
mockedGetAccountBridge.mockReturnValue(mockBridge);
mockBridge.createTransaction.mockReturnValue({ recipient: "" });
mockBridge.updateTransaction.mockImplementation((tx, updates) => ({ ...tx, ...updates }));
mockBridge.prepareTransaction.mockResolvedValue({ recipient: "valid_address" });
mockBridge.getTransactionStatus.mockResolvedValue({
errors: {},
warnings: {},
});
});
afterEach(() => {
jest.useRealTimers();
});
it("returns empty state initially", () => {
const { result } = renderHook(() =>
useBridgeRecipientValidation({
recipient: "",
account: null,
}),
);
expect(result.current.errors).toEqual({});
expect(result.current.warnings).toEqual({});
expect(result.current.isLoading).toBe(false);
expect(result.current.status).toBeNull();
});
it("validates recipient address successfully", async () => {
const { result } = renderHook(() =>
useBridgeRecipientValidation({
recipient: "valid_address",
account: mockAccount,
}),
);
expect(result.current.isLoading).toBe(true);
jest.advanceTimersByTime(300);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(mockBridge.createTransaction).toHaveBeenCalled();
expect(mockBridge.updateTransaction).toHaveBeenCalledWith(expect.anything(), {
recipient: "valid_address",
});
expect(mockBridge.prepareTransaction).toHaveBeenCalled();
expect(mockBridge.getTransactionStatus).toHaveBeenCalled();
});
it("returns recipient error when bridge detects invalid address", async () => {
const recipientError = new InvalidAddress();
mockBridge.getTransactionStatus.mockResolvedValue({
errors: { recipient: recipientError },
warnings: {},
});
const { result } = renderHook(() =>
useBridgeRecipientValidation({
recipient: "invalid_address",
account: mockAccount,
}),
);
jest.advanceTimersByTime(300);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.errors.recipient).toBe(recipientError);
});
it("returns sender error when detected", async () => {
const senderError = new Error("Insufficient balance");
mockBridge.getTransactionStatus.mockResolvedValue({
errors: { sender: senderError },
warnings: {},
});
const { result } = renderHook(() =>
useBridgeRecipientValidation({
recipient: "valid_address",
account: mockAccount,
}),
);
jest.advanceTimersByTime(300);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.errors.sender).toBe(senderError);
});
it("returns warnings when detected", async () => {
const recipientWarning = new Error("Low balance warning");
mockBridge.getTransactionStatus.mockResolvedValue({
errors: {},
warnings: { recipient: recipientWarning },
});
const { result } = renderHook(() =>
useBridgeRecipientValidation({
recipient: "valid_address",
account: mockAccount,
}),
);
jest.advanceTimersByTime(300);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.warnings.recipient).toBe(recipientWarning);
});
it("does not validate when enabled is false", async () => {
const { result } = renderHook(() =>
useBridgeRecipientValidation({
recipient: "valid_address",
account: mockAccount,
enabled: false,
}),
);
jest.advanceTimersByTime(300);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(mockBridge.createTransaction).not.toHaveBeenCalled();
});
it("resets state when recipient is cleared", async () => {
const { result, rerender } = renderHook(
({ recipient }) =>
useBridgeRecipientValidation({
recipient,
account: mockAccount,
}),
{ initialProps: { recipient: "valid_address" } },
);
jest.advanceTimersByTime(300);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
rerender({ recipient: "" });
expect(result.current.errors).toEqual({});
expect(result.current.warnings).toEqual({});
expect(result.current.isLoading).toBe(false);
});
it("debounces validation calls", async () => {
const { rerender } = renderHook(
({ recipient }) =>
useBridgeRecipientValidation({
recipient,
account: mockAccount,
}),
{ initialProps: { recipient: "addr1" } },
);
rerender({ recipient: "addr2" });
rerender({ recipient: "addr3" });
jest.advanceTimersByTime(300);
await waitFor(() => {
expect(mockBridge.createTransaction).toHaveBeenCalledTimes(1);
});
});
it("handles validation errors gracefully", async () => {
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
mockBridge.prepareTransaction.mockRejectedValue(new Error("Network error"));
const { result } = renderHook(() =>
useBridgeRecipientValidation({
recipient: "valid_address",
account: mockAccount,
}),
);
jest.advanceTimersByTime(300);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.errors).toEqual({});
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Bridge recipient validation failed:",
expect.any(Error),
);
consoleErrorSpy.mockRestore();
});
it("cancels pending validation when recipient changes", async () => {
const { rerender } = renderHook(
({ recipient }) =>
useBridgeRecipientValidation({
recipient,
account: mockAccount,
}),
{ initialProps: { recipient: "addr1" } },
);
jest.advanceTimersByTime(150);
rerender({ recipient: "addr2" });
jest.advanceTimersByTime(300);
await waitFor(() => {
expect(mockBridge.updateTransaction).toHaveBeenLastCalledWith(expect.anything(), {
recipient: "addr2",
});
});
});
it("returns null status when account is null", () => {
const { result } = renderHook(() =>
useBridgeRecipientValidation({
recipient: "valid_address",
account: null,
}),
);
expect(result.current.status).toBeNull();
// When account is null, the hook doesn't validate, so isLoading remains false
// However, the initial state might set isLoading to true briefly
expect(result.current.errors).toEqual({});
expect(result.current.warnings).toEqual({});
});
it("handles parent account correctly", async () => {
const parentAccount = { ...mockAccount, id: "parent_account_id" };
renderHook(() =>
useBridgeRecipientValidation({
recipient: "valid_address",
account: mockAccount,
parentAccount,
}),
);
jest.advanceTimersByTime(300);
await waitFor(() => {
expect(getMainAccount).toHaveBeenCalledWith(mockAccount, parentAccount);
});
});
});