Skip to content
Merged
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
105 changes: 92 additions & 13 deletions frontend/__tests__/hooks/useCreateMarket.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
import { renderHook, act, waitFor } from "@testing-library/react";
import { scValToNative } from "@stellar/stellar-sdk";
import { useCreateMarket } from "@/hooks/useCreateMarket";
import type { CreateMarketFormData } from "@/components/CreateMarketForm";
import * as stellar from "@/lib/stellar";
import * as api from "@/lib/api";
import { useWallet } from "@/hooks/useWallet";

jest.mock("@/lib/stellar");
jest.mock("@/hooks/useWallet");
jest.mock("@/hooks/useWallet", () => ({ useWallet: jest.fn() }));
jest.mock("@/lib/config", () => ({
get MARKET_FACTORY_CONTRACT_ID() {
return mockFactoryId;
},
}));
jest.mock("@/lib/api", () => ({ createMarketMeta: jest.fn() }));
jest.mock("@/lib/stellar", () => ({
...jest.requireActual("@/lib/stellar"),
buildSorobanInvocation: jest.fn(),
submitTransaction: jest.fn(),
decodeScVal: jest.fn(),
}));

// Read lazily by the config mock, so a test can switch the factory off.
let mockFactoryId: string | undefined = "CFACTORYCONTRACTADDRESS";

const FORM_DATA: CreateMarketFormData = {
fighterAName: "Ali",
Expand All @@ -23,20 +39,22 @@ const FORM_DATA: CreateMarketFormData = {

beforeEach(() => {
jest.clearAllMocks();
mockFactoryId = "CFACTORYCONTRACTADDRESS";
(useWallet as jest.Mock).mockReturnValue({
address: "GUSER123",
signTransaction: jest.fn().mockResolvedValue("signed-xdr"),
});
});

test("happy path: returns new market_id on success", async () => {
(stellar.buildSorobanInvocation as jest.Mock).mockResolvedValue("unsigned-xdr");
(stellar.submitTransaction as jest.Mock).mockResolvedValue({
txHash: "hash123",
ledger: 100,
returnValue: { type: "Bytes", value: "mkt-new-hex" },
});
(stellar.decodeScVal as jest.Mock).mockReturnValue("mkt-new-hex");
(stellar.decodeScVal as jest.Mock).mockResolvedValue("mkt-new-hex");
(api.createMarketMeta as jest.Mock).mockResolvedValue({ success: true });
});

test("happy path: returns new market_id on success", async () => {
(stellar.buildSorobanInvocation as jest.Mock).mockResolvedValue("unsigned-xdr");

const { result } = renderHook(() => useCreateMarket());
let id: string | undefined;
Expand All @@ -50,6 +68,73 @@ test("happy path: returns new market_id on success", async () => {
expect(result.current.isLoading).toBe(false);
});

test("builds create_market against the configured factory with typed arguments", async () => {
(stellar.buildSorobanInvocation as jest.Mock).mockResolvedValue("unsigned-xdr");

const { result } = renderHook(() => useCreateMarket());

await act(async () => {
await result.current.createMarket(FORM_DATA);
});

const call = (stellar.buildSorobanInvocation as jest.Mock).mock.calls[0][0];
expect(call.contractId).toBe("CFACTORYCONTRACTADDRESS");
expect(call.method).toBe("create_market");
expect(call.signerAddress).toBe("GUSER123");

const [caller, fighterA, fighterB, oracle, lockTime, endTime] = call.args;
expect(caller).toBe("GUSER123");
expect([fighterA, fighterB, oracle]).toEqual(["Ali", "Foreman", "GORACLE"]);
expect(lockTime.switch().name).toBe("scvU64");
expect(scValToNative(lockTime)).toBe(BigInt(Date.parse(FORM_DATA.bettingEndsAt) / 1000));
expect(endTime.switch().name).toBe("scvU64");
expect(scValToNative(endTime)).toBe(BigInt(Date.parse(FORM_DATA.scheduledAt) / 1000));
});

test("sends fighter metadata and the tx hash to the backend after confirmation", async () => {
(stellar.buildSorobanInvocation as jest.Mock).mockResolvedValue("unsigned-xdr");

const { result } = renderHook(() => useCreateMarket());

await act(async () => {
await result.current.createMarket(FORM_DATA);
});

expect(api.createMarketMeta).toHaveBeenCalledWith({ ...FORM_DATA, txHash: "hash123" });
});

test("still resolves with the market_id when the metadata sync fails", async () => {
(stellar.buildSorobanInvocation as jest.Mock).mockResolvedValue("unsigned-xdr");
(api.createMarketMeta as jest.Mock).mockRejectedValue(new Error("backend down"));
const consoleError = jest.spyOn(console, "error").mockImplementation(() => {});

const { result } = renderHook(() => useCreateMarket());
let id: string | undefined;

await act(async () => {
id = await result.current.createMarket(FORM_DATA);
});

expect(id).toBe("mkt-new-hex");
expect(result.current.error).toBeNull();
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});

test("throws when no factory contract is configured", async () => {
mockFactoryId = undefined;

const { result } = renderHook(() => useCreateMarket());

await act(async () => {
await expect(result.current.createMarket(FORM_DATA)).rejects.toThrow(
"Market factory contract is not configured"
);
});

expect(stellar.buildSorobanInvocation).not.toHaveBeenCalled();
});

test("error path: sets error on submission failure", async () => {
const testError = new Error("Submission failed");
(stellar.buildSorobanInvocation as jest.Mock).mockRejectedValue(testError);
Expand Down Expand Up @@ -81,12 +166,6 @@ test("edge case: isLoading is true during request, false after completion", asyn
await waitFor(() => expect(result.current.isLoading).toBe(true));

await act(async () => {
(stellar.submitTransaction as jest.Mock).mockResolvedValue({
txHash: "hash123",
ledger: 100,
returnValue: "mkt-123",
});
(stellar.decodeScVal as jest.Mock).mockReturnValue("mkt-123");
resolve("unsigned-xdr");
});

Expand Down
135 changes: 135 additions & 0 deletions frontend/__tests__/lib/stellar.invocation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import {
Account,
Address,
Keypair,
Operation,
StrKey,
Transaction,
TransactionBuilder,
scValToNative,
} from "@stellar/stellar-sdk";
import {
buildSorobanInvocation,
NETWORK_PASSPHRASE,
submitTransaction,
TX_EXPIRED_MESSAGE,
TX_TIMEOUT_SECONDS,
} from "@/lib/stellar";

const mockServer = {
getAccount: jest.fn(),
simulateTransaction: jest.fn(),
sendTransaction: jest.fn(),
getTransaction: jest.fn(),
};

jest.mock("@stellar/stellar-sdk", () => {
const actual = jest.requireActual("@stellar/stellar-sdk");

return {
...actual,
SorobanRpc: {
...actual.SorobanRpc,
Server: jest.fn(() => mockServer),
assembleTransaction: (tx: { toEnvelope: () => any }) => ({
operations: tx.toEnvelope().v1().tx().operations(),
}),
Api: { ...actual.SorobanRpc.Api, isSimulationSuccess: () => true },
},
};
});

const SIGNER = Keypair.random().publicKey();
const CONTRACT_ID = StrKey.encodeContract(Buffer.alloc(32, 1));

beforeEach(() => {
jest.clearAllMocks();
mockServer.getAccount.mockResolvedValue(new Account(SIGNER, "1"));
mockServer.simulateTransaction.mockResolvedValue({ minResourceFee: "100" });
});

async function buildAndDecode(args: unknown[]) {
const xdr = await buildSorobanInvocation({
contractId: CONTRACT_ID,
method: "place_bet",
args,
signerAddress: SIGNER,
});

return TransactionBuilder.fromXDR(xdr, NETWORK_PASSPHRASE) as Transaction;
}

describe("buildSorobanInvocation encoding", () => {
it("encodes a single invoke-contract operation targeting the given contract", async () => {
const tx = await buildAndDecode([]);

expect(tx.operations).toHaveLength(1);
expect(tx.operations[0].type).toBe("invokeHostFunction");

const invoke = (tx.operations[0] as Operation.InvokeHostFunction).func.invokeContract();
expect(Address.fromScAddress(invoke.contractAddress()).toString()).toBe(CONTRACT_ID);
});

it("uses the method name as the invoked function name", async () => {
const tx = await buildAndDecode([]);

const invoke = (tx.operations[0] as Operation.InvokeHostFunction).func.invokeContract();
expect(invoke.functionName().toString()).toBe("place_bet");
});

it("rejects a contract id that is not a C-strkey before touching the network", async () => {
await expect(
buildSorobanInvocation({
contractId: "market-1",
method: "place_bet",
args: [],
signerAddress: SIGNER,
})
).rejects.toThrow("Invalid contract address: market-1");

expect(mockServer.getAccount).not.toHaveBeenCalled();
});

it("converts each argument into the matching ScVal type", async () => {
const tx = await buildAndDecode([SIGNER, 100n]);

const invoke = (tx.operations[0] as Operation.InvokeHostFunction).func.invokeContract();
const [addressArg, amountArg] = invoke.args();

expect(addressArg.switch().name).toBe("scvAddress");
expect(Address.fromScVal(addressArg).toString()).toBe(SIGNER);
expect(scValToNative(amountArg)).toBe(100n);
});
});

describe("transaction validity window", () => {
it("sets maxTime to the bounded timeout from the moment of building", async () => {
const before = Math.floor(Date.now() / 1000);
const tx = await buildAndDecode([]);
const after = Math.floor(Date.now() / 1000);

const maxTime = Number(tx.timeBounds?.maxTime);
expect(maxTime).toBeGreaterThanOrEqual(before + TX_TIMEOUT_SECONDS);
expect(maxTime).toBeLessThanOrEqual(after + TX_TIMEOUT_SECONDS);
});

it("maps a txTooLate rejection to the friendly expiry message", async () => {
const signedXdr = await buildSorobanInvocation({
contractId: CONTRACT_ID,
method: "place_bet",
args: [],
signerAddress: SIGNER,
});

mockServer.sendTransaction.mockResolvedValue({
status: "ERROR",
errorResult: { result: () => ({ switch: () => ({ name: "txTooLate" }) }) },
});

const onStatus = jest.fn();
await expect(submitTransaction(signedXdr, onStatus)).rejects.toThrow(TX_EXPIRED_MESSAGE);
expect(onStatus).toHaveBeenCalledWith(
expect.objectContaining({ status: "failed", error: TX_EXPIRED_MESSAGE })
);
});
});
51 changes: 51 additions & 0 deletions frontend/__tests__/useClaimRefund.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { renderHook, act } from "@testing-library/react";
import { useClaimRefund } from "@/hooks/useClaimRefund";
import { useWallet } from "@/hooks/useWallet";
import * as stellar from "@/lib/stellar";
import * as api from "@/lib/api";

jest.mock("@/hooks/useWallet", () => ({ useWallet: jest.fn() }));
jest.mock("@/lib/stellar");
jest.mock("@/lib/api", () => ({ fetchMarketById: jest.fn(), fetchMarketBets: jest.fn() }));

const mockUseWallet = useWallet as jest.Mock;
const mockBuildSorobanInvocation = stellar.buildSorobanInvocation as jest.Mock;
const mockFetchMarketById = api.fetchMarketById as jest.Mock;
const mockFetchMarketBets = api.fetchMarketBets as jest.Mock;

const ADDRESS = "GBETTOR";
const CONTRACT_ADDRESS = "CMARKETCONTRACTADDRESS";

beforeEach(() => {
jest.clearAllMocks();
mockUseWallet.mockReturnValue({
address: ADDRESS,
isNetworkMismatched: false,
signTransaction: jest.fn().mockResolvedValue("signed-xdr"),
});
mockFetchMarketById.mockResolvedValue({
id: "market-1",
contractAddress: CONTRACT_ADDRESS,
status: "Cancelled",
outcome: null,
});
mockFetchMarketBets.mockResolvedValue([{ id: "bet-1" }]);
mockBuildSorobanInvocation.mockResolvedValue("unsigned-xdr");
(stellar.submitTransaction as jest.Mock).mockResolvedValue({ txHash: "h", ledger: 1, returnValue: null });
(stellar.decodeScVal as jest.Mock).mockResolvedValue(50n);
});

it("builds claim_refund against the market contract address", async () => {
const { result } = renderHook(() => useClaimRefund());

await act(async () => {
await result.current.claimRefund("bet-1", "market-1");
});

expect(mockBuildSorobanInvocation).toHaveBeenCalledWith({
contractId: CONTRACT_ADDRESS,
method: "claim_refund",
args: ["bet-1"],
signerAddress: ADDRESS,
});
});
2 changes: 1 addition & 1 deletion frontend/components/BetForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export function BetForm({ market, onBetPlaced }: BetFormProps): JSX.Element {
const [estimate, setEstimate] = useState<FeeEstimate | null>(null);
const [isEstimating, setIsEstimating] = useState(false);

const { placeBet, isLoading } = usePlaceBet(market.id);
const { placeBet, isLoading } = usePlaceBet(market.id, market.contractAddress);

const isLocked = market.status !== "Open";
const numericAmount = parseFloat(amount);
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/BettingInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ export function BettingInterface({ market, onBetPlaced }: BettingInterfaceProps)
const [debouncedAmount, setDebouncedAmount] = useState<string>("");
const [sheetOpen, setSheetOpen] = useState(false);

const { placeBet, isLoading } = usePlaceBet(market.id);
const { placeBet, isLoading } = usePlaceBet(market.id, market.contractAddress);
const { addToast: showToast } = useToast();

const sheetRef = useRef<HTMLDivElement>(null);
Expand Down
Loading