Skip to content
Open
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
154 changes: 154 additions & 0 deletions src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";

class TestFailError extends Error {
constructor(message: string) {
super(message);
this.name = "FailError";
}
}

const contextClientCtor = mock((options: unknown) => options);

class MockContextClient {
constructor(public readonly options: unknown) {
contextClientCtor(options);
}
}

const loadConfig = mock((): Record<string, string> => ({}));
const fail = mock((message: string) => {
throw new TestFailError(message);
});

mock.module("context-markets", () => ({
ContextClient: MockContextClient,
}));

mock.module("../config.js", () => ({
loadConfig,
}));

mock.module("../format.js", () => ({
fail,
}));

const { readClient, tradingClient } = await import("../client.js");

const ORIGINAL_ENV = {
CONTEXT_API_KEY: process.env.CONTEXT_API_KEY,
CONTEXT_PRIVATE_KEY: process.env.CONTEXT_PRIVATE_KEY,
CONTEXT_RPC_URL: process.env.CONTEXT_RPC_URL,
CONTEXT_BASE_URL: process.env.CONTEXT_BASE_URL,
CONTEXT_CHAIN: process.env.CONTEXT_CHAIN,
};

function clearContextEnv(): void {
delete process.env.CONTEXT_API_KEY;
delete process.env.CONTEXT_PRIVATE_KEY;
delete process.env.CONTEXT_RPC_URL;
delete process.env.CONTEXT_BASE_URL;
delete process.env.CONTEXT_CHAIN;
}

function restoreEnv(): void {
clearContextEnv();
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
if (value !== undefined) {
process.env[key] = value;
}
}
}

describe("client", () => {
beforeEach(() => {
clearContextEnv();
contextClientCtor.mockClear();
loadConfig.mockClear();
loadConfig.mockReturnValue({});
fail.mockClear();
});

afterEach(() => {
restoreEnv();
});

test("readClient uses the api key from flags", () => {
const client = readClient({
"api-key": "flag-api-key",
"rpc-url": "https://rpc.example",
"base-url": "https://api.example",
chain: "testnet",
});

expect(client).toBeInstanceOf(MockContextClient);
expect(contextClientCtor).toHaveBeenCalledWith({
apiKey: "flag-api-key",
rpcUrl: "https://rpc.example",
baseUrl: "https://api.example",
chain: "testnet",
});
});

test("readClient throws when no api key can be resolved", () => {
expect(() => readClient()).toThrow(TestFailError);
expect(fail).toHaveBeenCalledWith(
"Missing CONTEXT_API_KEY. Set via --api-key flag, CONTEXT_API_KEY env, or ~/.config/context/config.env",
);
});

test("tradingClient uses api key and private key from flags", () => {
const client = tradingClient({
"api-key": "flag-api-key",
"private-key": "0xabc123",
"rpc-url": "https://rpc.example",
"base-url": "https://api.example",
chain: "mainnet",
});

expect(client).toBeInstanceOf(MockContextClient);
expect(contextClientCtor).toHaveBeenCalledWith({
apiKey: "flag-api-key",
rpcUrl: "https://rpc.example",
baseUrl: "https://api.example",
chain: "mainnet",
signer: {
privateKey: "0xabc123",
},
});
});

test("tradingClient throws when the private key is missing", () => {
process.env.CONTEXT_API_KEY = "env-api-key";

expect(() => tradingClient()).toThrow(TestFailError);
expect(fail).toHaveBeenCalledWith(
"A private key is required for trading operations.",
{
hint:
"Set CONTEXT_PRIVATE_KEY env var, pass --private-key <key>, or run `context setup`",
},
);
});

test("falls back to the config file for credentials and chain settings", () => {
loadConfig.mockReturnValue({
CONTEXT_API_KEY: "config-api-key",
CONTEXT_PRIVATE_KEY: "0xconfig-private-key",
CONTEXT_RPC_URL: "https://config-rpc.example",
CONTEXT_BASE_URL: "https://config-api.example",
CONTEXT_CHAIN: "testnet",
});

tradingClient();

expect(contextClientCtor).toHaveBeenCalledWith({
apiKey: "config-api-key",
rpcUrl: "https://config-rpc.example",
baseUrl: "https://config-api.example",
chain: "testnet",
signer: {
privateKey: "0xconfig-private-key",
},
});
});
});
195 changes: 195 additions & 0 deletions src/__tests__/commands/account.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { beforeEach, describe, expect, spyOn, test } from "bun:test";
import { FailError, commandHarness } from "../helpers.js";

const { default: handleAccount } = await import("../../commands/account.js");

describe("account commands", () => {
beforeEach(() => {
commandHarness.reset();
});

test("status fetches account status and portfolio balance in parallel", async () => {
const status = {
address: "0xabc",
ethBalance: "1000000000000000000",
usdcAllowance: true,
isOperatorApproved: true,
isReady: true,
};
const balance = {
usdc: {
balance: 20_000_000,
settlementBalance: 8_000_000,
walletBalance: 12_000_000,
},
};
commandHarness.client.account.status.mockResolvedValue(status);
commandHarness.client.portfolio.balance.mockResolvedValue(balance);

await commandHarness.callCommand(handleAccount, {
subcommand: "status",
});

expect(commandHarness.tradingClient).toHaveBeenCalled();
expect(commandHarness.client.account.status).toHaveBeenCalled();
expect(commandHarness.client.portfolio.balance).toHaveBeenCalledWith();
expect(commandHarness.outCalls[0]?.data).toEqual(status);
});

test("setup calls ctx.account.setup", async () => {
const result = { success: true };
commandHarness.client.account.setup.mockResolvedValue(result);

await commandHarness.callCommand(handleAccount, {
subcommand: "setup",
});

expect(commandHarness.client.account.setup).toHaveBeenCalled();
expect(commandHarness.outCalls[0]?.data).toEqual(result);
});

test("mint-test-usdc passes an optional amount", async () => {
const result = { amount: 250, txHash: "0xabc" };
commandHarness.client.account.mintTestUsdc.mockResolvedValue(result);

await commandHarness.callCommand(handleAccount, {
subcommand: "mint-test-usdc",
flags: {
amount: "250",
},
});

expect(commandHarness.client.account.mintTestUsdc).toHaveBeenCalledWith(250);
expect(commandHarness.outCalls[0]?.data).toEqual(result);
});

test("deposit parses the amount and reshapes the response", async () => {
commandHarness.client.account.deposit.mockResolvedValue({
txHash: "0xdeposit",
});

await commandHarness.callCommand(handleAccount, {
subcommand: "deposit",
positional: ["100.5"],
});

expect(commandHarness.client.account.deposit).toHaveBeenCalledWith(100.5);
expect(commandHarness.outCalls[0]?.data).toEqual({
status: "deposited",
amount_usdc: 100.5,
tx_hash: "0xdeposit",
});
});

test("withdraw parses the amount and reshapes the response", async () => {
commandHarness.client.account.withdraw.mockResolvedValue("0xwithdraw");

await commandHarness.callCommand(handleAccount, {
subcommand: "withdraw",
positional: ["25"],
});

expect(commandHarness.client.account.withdraw).toHaveBeenCalledWith(25);
expect(commandHarness.outCalls[0]?.data).toEqual({
status: "withdrawn",
amount_usdc: 25,
tx_hash: "0xwithdraw",
});
});

test("mint-complete-sets calls ctx.account.mintCompleteSets", async () => {
commandHarness.client.account.mintCompleteSets.mockResolvedValue("0xmint");

await commandHarness.callCommand(handleAccount, {
subcommand: "mint-complete-sets",
positional: ["market-1", "5"],
});

expect(commandHarness.client.account.mintCompleteSets).toHaveBeenCalledWith(
"market-1",
5,
);
expect(commandHarness.outCalls[0]?.data).toEqual({
status: "minted",
market_id: "market-1",
amount: 5,
tx_hash: "0xmint",
});
});

test("burn-complete-sets honors --credit-internal=false", async () => {
commandHarness.client.account.burnCompleteSets.mockResolvedValue("0xburn");

await commandHarness.callCommand(handleAccount, {
subcommand: "burn-complete-sets",
positional: ["market-1", "3"],
flags: {
"credit-internal": "false",
},
});

expect(commandHarness.client.account.burnCompleteSets).toHaveBeenCalledWith(
"market-1",
3,
false,
);
expect(commandHarness.outCalls[0]?.data).toEqual({
status: "burned",
market_id: "market-1",
amount: 3,
credit_internal: false,
tx_hash: "0xburn",
});
});

test("gasless-approve calls ctx.account.gaslessSetup", async () => {
const result = { success: true };
commandHarness.client.account.gaslessSetup.mockResolvedValue(result);

await commandHarness.callCommand(handleAccount, {
subcommand: "gasless-approve",
});

expect(commandHarness.client.account.gaslessSetup).toHaveBeenCalled();
expect(commandHarness.outCalls[0]).toEqual({ data: result, config: undefined });
});

test("gasless-deposit parses the amount", async () => {
const result = { success: true, txHash: "0xgasless" };
commandHarness.client.account.gaslessDeposit.mockResolvedValue(result);

await commandHarness.callCommand(handleAccount, {
subcommand: "gasless-deposit",
positional: ["15"],
});

expect(commandHarness.client.account.gaslessDeposit).toHaveBeenCalledWith(15);
expect(commandHarness.outCalls[0]).toEqual({ data: result, config: undefined });
});

test("invalid deposit amounts throw FailError", async () => {
await expect(
commandHarness.callCommand(handleAccount, {
subcommand: "deposit",
positional: ["0"],
}),
).rejects.toBeInstanceOf(FailError);

expect(commandHarness.failCalls[0]).toEqual({
message: "Deposit amount must be a positive number",
details: { received: "0" },
});
});

test("help does not call the sdk", async () => {
const logSpy = spyOn(console, "log").mockImplementation(() => {});

await commandHarness.callCommand(handleAccount, {
subcommand: "help",
});

expect(commandHarness.tradingClient).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalled();
logSpy.mockRestore();
});
});
Loading