diff --git a/src/commands/transactions/appeal.ts b/src/commands/transactions/appeal.ts new file mode 100644 index 00000000..e2532c60 --- /dev/null +++ b/src/commands/transactions/appeal.ts @@ -0,0 +1,39 @@ +import {TransactionHash} from "genlayer-js/types"; +import {BaseAction} from "../../lib/actions/BaseAction"; + +export interface AppealOptions { + rpc?: string; +} + +export class AppealAction extends BaseAction { + constructor() { + super(); + } + + async appeal({ + txId, + rpc, + }: { + txId: TransactionHash; + rpc?: string; + }): Promise { + const client = await this.getClient(rpc); + await client.initializeConsensusSmartContract(); + this.startSpinner(`Appealing transaction ${txId}...`); + + try { + const hash = await client.appealTransaction({ + txId, + }); + + const result = await client.waitForTransactionReceipt({ + hash, + retries: 100, + interval: 5000, + }); + this.succeedSpinner("Appeal operation successfully executed", result); + } catch (error) { + this.failSpinner("Error during appeal operation", error); + } + } +} \ No newline at end of file diff --git a/src/commands/transactions/index.ts b/src/commands/transactions/index.ts new file mode 100644 index 00000000..8a2d50fa --- /dev/null +++ b/src/commands/transactions/index.ts @@ -0,0 +1,16 @@ +import {Command} from "commander"; +import {TransactionHash} from "genlayer-js/types"; +import {AppealAction, AppealOptions} from "./appeal"; + +export function initializeTransactionsCommands(program: Command) { + program + .command("appeal ") + .description("Appeal a transaction by its hash") + .option("--rpc ", "RPC URL for the network") + .action(async (txId: TransactionHash, options: AppealOptions) => { + const appealAction = new AppealAction(); + await appealAction.appeal({txId, ...options}); + }); + + return program; +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 1e101f8f..8cd83bf4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import {initializeValidatorCommands} from "../src/commands/validators"; import {initializeUpdateCommands} from "../src/commands/update"; import {initializeScaffoldCommands} from "../src/commands/scaffold"; import {initializeNetworkCommands} from "../src/commands/network"; +import {initializeTransactionsCommands} from "../src/commands/transactions"; export function initializeCLI() { program.version(version).description(CLI_DESCRIPTION); @@ -21,6 +22,7 @@ export function initializeCLI() { initializeValidatorCommands(program); initializeScaffoldCommands(program); initializeNetworkCommands(program); + initializeTransactionsCommands(program); program.parse(process.argv); } diff --git a/tests/actions/appeal.test.ts b/tests/actions/appeal.test.ts new file mode 100644 index 00000000..3caf7e7d --- /dev/null +++ b/tests/actions/appeal.test.ts @@ -0,0 +1,99 @@ +import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import {createClient, createAccount} from "genlayer-js"; +import type {TransactionHash} from "genlayer-js/types"; +import {AppealAction} from "../../src/commands/transactions/appeal"; + +vi.mock("genlayer-js"); + +describe("AppealAction", () => { + let appealAction: AppealAction; + const mockClient = { + appealTransaction: vi.fn(), + waitForTransactionReceipt: vi.fn(), + initializeConsensusSmartContract: vi.fn(), + }; + + const mockPrivateKey = "mocked_private_key"; + const mockTxId = "0x1234567890123456789012345678901234567890123456789012345678901234" as TransactionHash; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createClient).mockReturnValue(mockClient as any); + vi.mocked(createAccount).mockReturnValue({privateKey: mockPrivateKey} as any); + appealAction = new AppealAction(); + vi.spyOn(appealAction as any, "getPrivateKey").mockResolvedValue(mockPrivateKey); + + vi.spyOn(appealAction as any, "startSpinner").mockImplementation(() => {}); + vi.spyOn(appealAction as any, "succeedSpinner").mockImplementation(() => {}); + vi.spyOn(appealAction as any, "failSpinner").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("calls appealTransaction successfully", async () => { + const mockReceipt = {status: "success"}; + + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); + + await appealAction.appeal({ + txId: mockTxId, + }); + + expect(mockClient.appealTransaction).toHaveBeenCalledWith({ + txId: mockTxId, + }); + expect(appealAction["succeedSpinner"]).toHaveBeenCalledWith( + "Appeal operation successfully executed", + mockReceipt, + ); + }); + + test("handles appealTransaction errors", async () => { + vi.mocked(mockClient.appealTransaction).mockRejectedValue(new Error("Mocked appeal error")); + + await appealAction.appeal({txId: mockTxId}); + + expect(appealAction["failSpinner"]).toHaveBeenCalledWith( + "Error during appeal operation", + expect.any(Error), + ); + }); + + test("uses custom RPC URL for appeal operations", async () => { + const rpcUrl = "https://custom-rpc-url.com"; + const mockReceipt = {status: "success"}; + + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); + + await appealAction.appeal({ + txId: mockTxId, + rpc: rpcUrl, + }); + + expect(createClient).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: rpcUrl, + }), + ); + expect(mockClient.appealTransaction).toHaveBeenCalledWith({ + txId: mockTxId, + }); + expect(appealAction["succeedSpinner"]).toHaveBeenCalledWith( + "Appeal operation successfully executed", + mockReceipt, + ); + }); + + test("initializes consensus smart contract before appeal", async () => { + const mockReceipt = {status: "success"}; + + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); + + await appealAction.appeal({txId: mockTxId}); + + expect(mockClient.initializeConsensusSmartContract).toHaveBeenCalledTimes(1); + expect(mockClient.appealTransaction).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/commands/appeal.test.ts b/tests/commands/appeal.test.ts new file mode 100644 index 00000000..db7c51f6 --- /dev/null +++ b/tests/commands/appeal.test.ts @@ -0,0 +1,58 @@ +import {Command} from "commander"; +import {AppealAction} from "../../src/commands/transactions/appeal"; +import {vi, describe, beforeEach, afterEach, test, expect} from "vitest"; +import {initializeTransactionsCommands} from "../../src/commands/transactions"; + +vi.mock("../../src/commands/transactions/appeal"); + +describe("appeal command", () => { + let program: Command; + const mockTxId = "0x1234567890123456789012345678901234567890123456789012345678901234"; + + beforeEach(() => { + program = new Command(); + initializeTransactionsCommands(program); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("AppealAction.appeal is called with default options", async () => { + program.parse(["node", "test", "appeal", mockTxId]); + expect(AppealAction).toHaveBeenCalledTimes(1); + expect(AppealAction.prototype.appeal).toHaveBeenCalledWith({ + txId: mockTxId, + }); + }); + + test("AppealAction.appeal is called with custom RPC URL", async () => { + program.parse([ + "node", + "test", + "appeal", + mockTxId, + "--rpc", + "https://custom-rpc-url-for-appeal.com", + ]); + expect(AppealAction).toHaveBeenCalledTimes(1); + expect(AppealAction.prototype.appeal).toHaveBeenCalledWith({ + txId: mockTxId, + rpc: "https://custom-rpc-url-for-appeal.com", + }); + }); + + test("AppealAction is instantiated when the appeal command is executed", async () => { + program.parse(["node", "test", "appeal", mockTxId]); + expect(AppealAction).toHaveBeenCalledTimes(1); + }); + + test("throws error for unrecognized options", async () => { + const appealCommand = program.commands.find(cmd => cmd.name() === "appeal"); + appealCommand?.exitOverride(); + expect(() => + program.parse(["node", "test", "appeal", mockTxId, "--invalid-option"]), + ).toThrowError("error: unknown option '--invalid-option'"); + }); +}); \ No newline at end of file diff --git a/tests/index.test.ts b/tests/index.test.ts index bd81447a..3e25aa8e 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -41,6 +41,10 @@ vi.mock("../src/commands/network", () => ({ initializeNetworkCommands: vi.fn(), })); +vi.mock("../src/commands/transactions", () => ({ + initializeTransactionsCommands: vi.fn(), +})); + describe("CLI", () => { it("should initialize CLI", () => { expect(initializeCLI).not.toThrow();