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
9 changes: 6 additions & 3 deletions tests/api/info/_mockInfoTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { IRequestTransport } from "@bloxwap/hyperliquid";
export interface MockInfoCall {
endpoint: "info" | "exchange";
payload: unknown;
signal?: AbortSignal;
}

/** An {@linkcode IRequestTransport} that serves canned responses for offline tests. */
Expand All @@ -23,9 +24,11 @@ export class MockInfoTransport implements IRequestTransport {

constructor(readonly handler: (payload: Record<string, unknown>) => unknown) {}

request<T>(endpoint: "info" | "exchange", payload: unknown, _signal?: AbortSignal): Promise<T> {
this.calls.push({ endpoint, payload });
return Promise.resolve(this.handler(payload as Record<string, unknown>) as T);
async request<T>(endpoint: "info" | "exchange", payload: unknown, signal?: AbortSignal): Promise<T> {
this.calls.push({ endpoint, payload, signal });
// `async` so a throwing handler rejects the returned promise, the way a real transport's
// failures surface — never a synchronous throw out of `request`.
return this.handler(payload as Record<string, unknown>) as T;
}
}

Expand Down
136 changes: 136 additions & 0 deletions tests/api/info/_offlineMethodTests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* Shared offline battery for Info API methods over {@linkcode MockInfoTransport}.
*
* Every Info method body is the same shape — validate params against the request schema, send one
* `info` request, return the transport's response — so each method's test file runs this battery:
* the exact wire payload for every valid-params combo, a {@linkcode ValidationError} and zero
* requests for invalid params, response/error/signal passthrough, and identical behavior through
* the {@linkcode InfoClient} wrapper.
*
* @module
*/

import { describe, expect, test } from "bun:test";
import { InfoClient, ValidationError } from "@bloxwap/hyperliquid";
import { MockInfoTransport } from "./_mockInfoTransport.ts";

/** A valid-params case for {@linkcode runOfflineMethodTests}. */
export interface OfflineMethodCase {
/** Params handed to the method. */
params: Record<string, unknown>;
/** Expected wire payload after request-schema validation; defaults to `{ type: <name>, ...params }`. */
payload?: Record<string, unknown>;
}

/** How the method (and its InfoClient wrapper) takes its arguments. */
export type OfflineMethodSignature =
/** `(params, signal?)` */
| "params"
/** `(signal?)` — parameterless endpoint */
| "none"
/** `(params?, signal?)` or `(signal?)` — params optional via an overload */
| "overloaded";

// The battery drives every method shape through one signature; each call site passes the right
// arguments for its declared `signature` mode.
type AnyInfoMethod = (config: { transport: MockInfoTransport }, ...args: any[]) => Promise<unknown>;

/**
* Registers the offline battery for one Info API method as `describe("<name> (offline request)")`.
*
* @param options.name Method name; also the request `type` and the InfoClient method name.
* @param options.method The standalone method function.
* @param options.signature How the method takes its arguments (see {@linkcode OfflineMethodSignature}).
* @param options.cases Valid-params cases; ignored for `signature: "none"` (one bare request is tested).
* @param options.invalidParams Params that must fail request-schema validation; omit when no input can be invalid.
*/
export function runOfflineMethodTests(options: {
name: string;
method: AnyInfoMethod;
signature: OfflineMethodSignature;
cases?: OfflineMethodCase[];
invalidParams?: Record<string, unknown>[];
}): void {
const { name, method, signature, cases = [{ params: {} }], invalidParams = [] } = options;
const payloadOf = (c: OfflineMethodCase): Record<string, unknown> => c.payload ?? { type: name, ...c.params };

const callMethod = (transport: MockInfoTransport, c: OfflineMethodCase, signal?: AbortSignal): Promise<unknown> =>
signature === "none" ? method({ transport }, signal) : method({ transport }, c.params, signal);

const callClient = (client: InfoClient, c: OfflineMethodCase, signal?: AbortSignal): Promise<unknown> => {
const fn = (client as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>)[name];
return signature === "none" ? fn.call(client, signal) : fn.call(client, c.params, signal);
};

describe(`${name} (offline request)`, () => {
test("sends one request with the exact payload and returns the transport's response", async () => {
for (const c of cases) {
const response = { sentinel: name };
const transport = new MockInfoTransport(() => response);
const signal = new AbortController().signal;

const result = await callMethod(transport, c, signal);

expect(result).toBe(response); // the method returns the transport's response untouched
expect(transport.calls).toEqual([{ endpoint: "info", payload: payloadOf(c), signal }]);
}
});

if (invalidParams.length > 0) {
test("rejects invalid params before any request", () => {
for (const params of invalidParams) {
const transport = new MockInfoTransport(() => ({}));

expect(() => method({ transport }, params)).toThrow(ValidationError);

expect(transport.calls).toHaveLength(0); // validation happens before sending
}
});
}

test("propagates transport errors", async () => {
const error = new Error("transport boom");
const transport = new MockInfoTransport(() => {
throw error;
});

await expect(callMethod(transport, cases[0])).rejects.toBe(error);
});

test("is exposed on InfoClient with identical behavior", async () => {
for (const c of cases) {
const response = { sentinel: name };
const transport = new MockInfoTransport(() => response);
const client = new InfoClient({ transport });
const signal = new AbortController().signal;

const result = await callClient(client, c, signal);

expect(result).toBe(response);
expect(transport.calls).toEqual([{ endpoint: "info", payload: payloadOf(c), signal }]);
}
});

if (signature === "overloaded") {
test("accepts a bare AbortSignal or no argument in place of params (function and client)", async () => {
for (const viaClient of [false, true]) {
for (const arg of ["signal", "absent"] as const) {
const transport = new MockInfoTransport(() => null);
const client = new InfoClient({ transport });
const fn = (client as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>)[name];
const signal = new AbortController().signal;

if (arg === "signal") {
await (viaClient ? fn.call(client, signal) : method({ transport }, signal));
expect(transport.calls).toEqual([{ endpoint: "info", payload: { type: name }, signal }]);
} else {
await (viaClient ? fn.call(client) : method({ transport }));
expect(transport.calls).toEqual([{ endpoint: "info", payload: { type: name } }]);
expect(transport.calls[0].signal).toBeUndefined();
}
}
}
});
}
});
}
18 changes: 17 additions & 1 deletion tests/api/info/activeAssetData.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type ActiveAssetDataParameters, ActiveAssetDataRequest } from "@bloxwap/hyperliquid/api/info";
import { activeAssetData, type ActiveAssetDataParameters, ActiveAssetDataRequest } from "@bloxwap/hyperliquid/api/info";
import { runOfflineMethodTests } from "./_offlineMethodTests.ts";
import * as v from "valibot";
import { schemaCoverage } from "../_utils/schemaCoverage.ts";
import { typeToJsonSchema } from "../_utils/typeToJsonSchema.ts";
Expand All @@ -23,3 +24,18 @@ runTest({
schemaCoverage(responseSchema, data);
},
});

// ============================================================
// Offline: request construction, passthrough, and InfoClient wrapper
// ============================================================

runOfflineMethodTests({
name: "activeAssetData",
method: activeAssetData,
signature: "params",
cases: [{ params: { user: "0x0000000000000000000000000000000000000001", coin: "ETH" } }],
invalidParams: [
{ user: "0x123", coin: "ETH" },
{ user: "0x0000000000000000000000000000000000000001", coin: 123 },
],
});
12 changes: 12 additions & 0 deletions tests/api/info/allBorrowLendReserveStates.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { allBorrowLendReserveStates } from "@bloxwap/hyperliquid/api/info";
import { runOfflineMethodTests } from "./_offlineMethodTests.ts";
import { schemaCoverage } from "../_utils/schemaCoverage.ts";
import { typeToJsonSchema } from "../_utils/typeToJsonSchema.ts";
import { runTest } from "./_t.ts";
Expand All @@ -13,3 +15,13 @@ runTest({
schemaCoverage(responseSchema, data);
},
});

// ============================================================
// Offline: request construction, passthrough, and InfoClient wrapper
// ============================================================

runOfflineMethodTests({
name: "allBorrowLendReserveStates",
method: allBorrowLendReserveStates,
signature: "none",
});
15 changes: 14 additions & 1 deletion tests/api/info/allMids.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type AllMidsParameters, AllMidsRequest } from "@bloxwap/hyperliquid/api/info";
import { allMids, type AllMidsParameters, AllMidsRequest } from "@bloxwap/hyperliquid/api/info";
import { runOfflineMethodTests } from "./_offlineMethodTests.ts";
import * as v from "valibot";
import { schemaCoverage } from "../_utils/schemaCoverage.ts";
import { typeToJsonSchema } from "../_utils/typeToJsonSchema.ts";
Expand All @@ -20,3 +21,15 @@ runTest({
schemaCoverage(responseSchema, data);
},
});

// ============================================================
// Offline: request construction, passthrough, and InfoClient wrapper
// ============================================================

runOfflineMethodTests({
name: "allMids",
method: allMids,
signature: "overloaded",
cases: [{ params: { dex: "test" } }],
invalidParams: [{ dex: 123 }],
});
12 changes: 12 additions & 0 deletions tests/api/info/allPerpMetas.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { allPerpMetas } from "@bloxwap/hyperliquid/api/info";
import { runOfflineMethodTests } from "./_offlineMethodTests.ts";
import { schemaCoverage } from "../_utils/schemaCoverage.ts";
import { typeToJsonSchema } from "../_utils/typeToJsonSchema.ts";
import { runTest } from "./_t.ts";
Expand All @@ -13,3 +15,13 @@ runTest({
schemaCoverage(responseSchema, data);
},
});

// ============================================================
// Offline: request construction, passthrough, and InfoClient wrapper
// ============================================================

runOfflineMethodTests({
name: "allPerpMetas",
method: allPerpMetas,
signature: "none",
});
19 changes: 18 additions & 1 deletion tests/api/info/approvedBuilders.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { type ApprovedBuildersParameters, ApprovedBuildersRequest } from "@bloxwap/hyperliquid/api/info";
import {
approvedBuilders,
type ApprovedBuildersParameters,
ApprovedBuildersRequest,
} from "@bloxwap/hyperliquid/api/info";
import { runOfflineMethodTests } from "./_offlineMethodTests.ts";
import * as v from "valibot";
import { schemaCoverage } from "../_utils/schemaCoverage.ts";
import { typeToJsonSchema } from "../_utils/typeToJsonSchema.ts";
Expand All @@ -23,3 +28,15 @@ runTest({
schemaCoverage(responseSchema, data);
},
});

// ============================================================
// Offline: request construction, passthrough, and InfoClient wrapper
// ============================================================

runOfflineMethodTests({
name: "approvedBuilders",
method: approvedBuilders,
signature: "params",
cases: [{ params: { user: "0x0000000000000000000000000000000000000001" } }],
invalidParams: [{ user: "0x123" }, {}],
});
19 changes: 18 additions & 1 deletion tests/api/info/borrowLendReserveState.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { type BorrowLendReserveStateParameters, BorrowLendReserveStateRequest } from "@bloxwap/hyperliquid/api/info";
import {
borrowLendReserveState,
type BorrowLendReserveStateParameters,
BorrowLendReserveStateRequest,
} from "@bloxwap/hyperliquid/api/info";
import { runOfflineMethodTests } from "./_offlineMethodTests.ts";
import * as v from "valibot";
import { schemaCoverage } from "../_utils/schemaCoverage.ts";
import { typeToJsonSchema } from "../_utils/typeToJsonSchema.ts";
Expand All @@ -20,3 +25,15 @@ runTest({
schemaCoverage(responseSchema, data);
},
});

// ============================================================
// Offline: request construction, passthrough, and InfoClient wrapper
// ============================================================

runOfflineMethodTests({
name: "borrowLendReserveState",
method: borrowLendReserveState,
signature: "params",
cases: [{ params: { token: 1 } }],
invalidParams: [{ token: -1 }, { token: "abc" }, {}],
});
19 changes: 18 additions & 1 deletion tests/api/info/borrowLendUserState.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { type BorrowLendUserStateParameters, BorrowLendUserStateRequest } from "@bloxwap/hyperliquid/api/info";
import {
borrowLendUserState,
type BorrowLendUserStateParameters,
BorrowLendUserStateRequest,
} from "@bloxwap/hyperliquid/api/info";
import { runOfflineMethodTests } from "./_offlineMethodTests.ts";
import * as v from "valibot";
import { schemaCoverage } from "../_utils/schemaCoverage.ts";
import { typeToJsonSchema } from "../_utils/typeToJsonSchema.ts";
Expand All @@ -20,3 +25,15 @@ runTest({
schemaCoverage(responseSchema, data);
},
});

// ============================================================
// Offline: request construction, passthrough, and InfoClient wrapper
// ============================================================

runOfflineMethodTests({
name: "borrowLendUserState",
method: borrowLendUserState,
signature: "params",
cases: [{ params: { user: "0x0000000000000000000000000000000000000001" } }],
invalidParams: [{ user: "0x123" }, {}],
});
27 changes: 26 additions & 1 deletion tests/api/info/candleSnapshot.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type CandleSnapshotParameters, CandleSnapshotRequest } from "@bloxwap/hyperliquid/api/info";
import { candleSnapshot, type CandleSnapshotParameters, CandleSnapshotRequest } from "@bloxwap/hyperliquid/api/info";
import { runOfflineMethodTests } from "./_offlineMethodTests.ts";
import { schemaCoverage } from "../_utils/schemaCoverage.ts";
import { typeToJsonSchema } from "../_utils/typeToJsonSchema.ts";
import { valibotToJsonSchema } from "../_utils/valibotToJsonSchema.ts";
Expand Down Expand Up @@ -38,3 +39,27 @@ runTest({
schemaCoverage(responseSchema, data);
},
});

// ============================================================
// Offline: request construction, passthrough, and InfoClient wrapper
// ============================================================

runOfflineMethodTests({
name: "candleSnapshot",
method: candleSnapshot,
signature: "params",
cases: [
{
params: { coin: "ETH", interval: "1h", startTime: 1000 },
payload: { type: "candleSnapshot", req: { coin: "ETH", interval: "1h", startTime: 1000 } },
},
{
params: { coin: "ETH", interval: "1h", startTime: 1000, endTime: 2000 },
payload: { type: "candleSnapshot", req: { coin: "ETH", interval: "1h", startTime: 1000, endTime: 2000 } },
},
],
invalidParams: [
{ coin: 1, interval: "1h", startTime: 1000 },
{ coin: "ETH", interval: "7m", startTime: 1000 },
],
});
17 changes: 17 additions & 0 deletions tests/api/info/candleSnapshotAll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { describe, expect, test } from "bun:test";
import { InfoClient } from "@bloxwap/hyperliquid";
import { candleSnapshotAll } from "@bloxwap/hyperliquid/api/info";
import { MockInfoTransport, scriptedPages } from "./_mockInfoTransport.ts";

Expand Down Expand Up @@ -54,4 +55,20 @@ describe("candleSnapshotAll", () => {
expect(transport.calls).toHaveLength(2);
expect(result).toHaveLength(PAGE);
});

test("is exposed on InfoClient with identical behavior", async () => {
const transport = new MockInfoTransport(scriptedPages([candlePage(0, PAGE), candlePage(PAGE * 60_000, 7)]));
const client = new InfoClient({ transport });
const signal = new AbortController().signal;

const result = await client.candleSnapshotAll(
{ coin: "ETH", interval: "1m", startTime: 0 },
{ maxPages: 5 },
signal,
);

expect(result).toHaveLength(5_007);
expect(requestedStartTimes(transport)).toEqual([0, (PAGE - 1) * 60_000]);
expect(transport.calls[0].signal).toBe(signal);
});
});
Loading
Loading