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
80 changes: 80 additions & 0 deletions tests/api/explorer/_mockTransport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Mock transports for offline Explorer API tests.
*
* `MockExplorerTransport` answers `explorer` requests from a scripted handler instead of the
* network and records every call (endpoint, payload, signal), so tests can assert the exact
* request a method built. `MockExplorerSubscriptionTransport` records subscriptions the same
* way and lets a test dispatch events into the recorded listener. Both support error injection.
*
* @module
*/

import type { IRequestTransport, ISubscription, ISubscriptionTransport, TransportError } from "@bloxwap/hyperliquid";

/** One recorded request, after request-schema validation (so `type` is always set). */
export interface MockExplorerCall {
endpoint: "explorer";
payload: unknown;
signal?: AbortSignal;
}

/** An {@linkcode IRequestTransport} over the `explorer` endpoint that serves canned responses for offline tests. */
export class MockExplorerTransport implements IRequestTransport<"explorer"> {
readonly isTestnet = true;
readonly calls: MockExplorerCall[] = [];

/** When set, requests reject with this error instead of resolving. */
error: unknown;

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

request<T>(endpoint: "explorer", payload: unknown, signal?: AbortSignal): Promise<T> {
this.calls.push({ endpoint, payload, signal });
if (this.error !== undefined) return Promise.reject(this.error);
return Promise.resolve(this.handler(payload as Record<string, unknown>) as T);
}
}

/** One recorded subscription. */
export interface MockSubscribeCall {
channel: string;
payload: unknown;
listener: (data: CustomEvent<unknown>) => void;
options?: {
signal?: AbortSignal;
onError?: (error: TransportError) => void;
};
}

/** An {@linkcode ISubscriptionTransport} that records subscriptions for offline tests. */
export class MockExplorerSubscriptionTransport implements ISubscriptionTransport {
readonly calls: MockSubscribeCall[] = [];

/** When set, `subscribe` rejects with this error instead of resolving. */
error: unknown;

/** Number of times the returned subscription handles were unsubscribed. */
unsubscribeCount = 0;

subscribe<T>(
channel: string,
payload: unknown,
listener: (data: CustomEvent<T>) => void,
options?: MockSubscribeCall["options"],
): Promise<ISubscription> {
this.calls.push({ channel, payload, listener: listener as (data: CustomEvent<unknown>) => void, options });
if (this.error !== undefined) return Promise.reject(this.error);
return Promise.resolve({
unsubscribe: () => {
this.unsubscribeCount++;
return Promise.resolve();
},
});
}

/** Feeds `detail` to the listener recorded for `channel`, wrapped in a `CustomEvent`. */
dispatch(channel: string, detail: unknown): void {
const call = this.calls.find((c) => c.channel === channel);
call?.listener(new CustomEvent(channel, { detail }));
}
}
66 changes: 65 additions & 1 deletion tests/api/explorer/blockDetails.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type BlockDetailsParameters, BlockDetailsRequest } from "@bloxwap/hyperliquid/api/explorer";
import { type BlockDetailsParameters, BlockDetailsRequest, blockDetails } from "@bloxwap/hyperliquid/api/explorer";
import * as v from "valibot";
import { schemaCoverage } from "../_utils/schemaCoverage.ts";
import { typeToJsonSchema } from "../_utils/typeToJsonSchema.ts";
Expand All @@ -20,3 +20,67 @@ runRequestTest({
schemaCoverage(responseSchema, data);
},
});

// ============================================================
// Offline: exact payload, validation, and passthrough against a mock transport
// ============================================================

import { describe, test } from "bun:test";
import { assertEquals, assertRejects, assertStrictEquals } from "@jsr/std__assert";
import { TransportError, ValidationError } from "@bloxwap/hyperliquid";
import { ApiRequestError } from "@bloxwap/hyperliquid/api/explorer";
import { MockExplorerTransport } from "./_mockTransport.ts";

describe("blockDetails (offline)", () => {
test("sends the validated request to the explorer endpoint", async () => {
const response = { type: "blockDetails", blockDetails: { height: 123 } };
const transport = new MockExplorerTransport(() => response);

const result = await blockDetails({ transport }, { height: 123 });

assertEquals(transport.calls.length, 1);
assertEquals(transport.calls[0].endpoint, "explorer");
assertEquals(transport.calls[0].payload, { type: "blockDetails", height: 123 });
assertStrictEquals(result, response);
});

test("accepts the height as a decimal string", async () => {
const transport = new MockExplorerTransport();

await blockDetails({ transport }, { height: "123" });

assertEquals(transport.calls[0].payload, { type: "blockDetails", height: 123 });
});

test("passes the abort signal to the transport", async () => {
const transport = new MockExplorerTransport();
const controller = new AbortController();

await blockDetails({ transport }, { height: 1 }, controller.signal);

assertStrictEquals(transport.calls[0].signal, controller.signal);
});

test("rejects invalid params before any request is sent", async () => {
const transport = new MockExplorerTransport();

await assertRejects(() => blockDetails({ transport }, { height: -1 }), ValidationError);
await assertRejects(() => blockDetails({ transport }, { height: 1.5 }), ValidationError);
await assertRejects(() => blockDetails({ transport }, { height: "abc" }), ValidationError);

assertEquals(transport.calls.length, 0);
});

test("an error response throws ApiRequestError", async () => {
const transport = new MockExplorerTransport(() => ({ type: "error", message: "invalid block height: 0" }));

await assertRejects(() => blockDetails({ transport }, { height: 1 }), ApiRequestError, "invalid block height: 0");
});

test("transport errors propagate", async () => {
const transport = new MockExplorerTransport();
transport.error = new TransportError("connection lost");

await assertRejects(() => blockDetails({ transport }, { height: 1 }), TransportError, "connection lost");
});
});
104 changes: 104 additions & 0 deletions tests/api/explorer/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Offline tests for the {@linkcode ExplorerClient} wrapper: every method must delegate to its
* standalone function counterpart with the client's stored config.
* @module
*/

import { describe, test } from "bun:test";
import { assertEquals, assertRejects, assertStrictEquals } from "@jsr/std__assert";
import { ExplorerClient, ValidationError } from "@bloxwap/hyperliquid";
import type { ExplorerBlockEvent, ExplorerTxsEvent } from "@bloxwap/hyperliquid/api/explorer";
import { MockExplorerSubscriptionTransport, MockExplorerTransport } from "./_mockTransport.ts";

const HASH = "0x4de9f1f5d912c23d8fbb0411f01bfe0000eb9f3ccb3fec747cb96e75e8944b06";
const USER = "0x9150749c4cec13dc7c1555d0d664f08d4d81be83";

describe("ExplorerClient (offline)", () => {
test("stores the constructor config", () => {
const transport = new MockExplorerTransport();
const client = new ExplorerClient({ transport });

assertStrictEquals(client.config_.transport, transport);
});

test("blockDetails delegates to the explorer endpoint", async () => {
const response = { type: "blockDetails", blockDetails: { height: 123 } };
const transport = new MockExplorerTransport(() => response);
const client = new ExplorerClient({ transport });
const controller = new AbortController();

const result = await client.blockDetails({ height: 123 }, controller.signal);

assertEquals(transport.calls[0].endpoint, "explorer");
assertEquals(transport.calls[0].payload, { type: "blockDetails", height: 123 });
assertStrictEquals(transport.calls[0].signal, controller.signal);
assertStrictEquals(result, response);
});

test("txDetails delegates to the explorer endpoint", async () => {
const response = { type: "txDetails", tx: { hash: HASH } };
const transport = new MockExplorerTransport(() => response);
const client = new ExplorerClient({ transport });

const result = await client.txDetails({ hash: HASH });

assertEquals(transport.calls[0].payload, { type: "txDetails", hash: HASH });
assertStrictEquals(result, response);
});

test("userDetails delegates to the explorer endpoint", async () => {
const response = { type: "userDetails", txs: [] };
const transport = new MockExplorerTransport(() => response);
const client = new ExplorerClient({ transport });

const result = await client.userDetails({ user: USER });

assertEquals(transport.calls[0].payload, { type: "userDetails", user: USER });
assertStrictEquals(result, response);
});

test("invalid params reject before any request is sent", async () => {
const transport = new MockExplorerTransport();
const client = new ExplorerClient({ transport });

await assertRejects(() => client.blockDetails({ height: -1 }), ValidationError);

assertEquals(transport.calls.length, 0);
});

test("explorerBlock wires the listener to the duck channel", async () => {
const transport = new MockExplorerSubscriptionTransport();
const client = new ExplorerClient({ transport });
const received: ExplorerBlockEvent[] = [];
const onError = () => {};

await client.explorerBlock((data) => received.push(data), onError);

assertEquals(transport.calls[0].channel, "explorerBlock_");
assertEquals(transport.calls[0].payload, { type: "explorerBlock" });
assertStrictEquals(transport.calls[0].options?.onError, onError);

transport.dispatch("explorerBlock_", []);
assertEquals(received, [[]]);
});

test("explorerTxs wires the listener to the duck channel", async () => {
const transport = new MockExplorerSubscriptionTransport();
const client = new ExplorerClient({ transport });
const received: ExplorerTxsEvent[] = [];

const sub = await client.explorerTxs((data) => received.push(data));

assertEquals(transport.calls[0].channel, "explorerTxs_");
assertEquals(transport.calls[0].payload, { type: "explorerTxs" });

const detail: ExplorerTxsEvent = [
{ action: { type: "order" }, block: 1, error: null, hash: "0xabc", time: 1, user: "0xdef" },
];
transport.dispatch("explorerTxs_", detail);
assertEquals(received, [detail]);

await sub.unsubscribe();
assertEquals(transport.unsubscribeCount, 1);
});
});
53 changes: 53 additions & 0 deletions tests/api/explorer/explorerBlock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,56 @@ runSubscriptionTest({
schemaCoverage(responseSchema, data);
},
});

// ============================================================
// Offline: channel, payload, and listener wiring against a mock subscription transport
// ============================================================

import { describe, test } from "bun:test";
import { assertEquals, assertRejects, assertStrictEquals } from "@jsr/std__assert";
import { TransportError } from "@bloxwap/hyperliquid";
import { explorerBlock } from "@bloxwap/hyperliquid/api/explorer";
import { MockExplorerSubscriptionTransport } from "./_mockTransport.ts";

describe("explorerBlock (offline)", () => {
test("subscribes to the duck channel with the validated payload", async () => {
const transport = new MockExplorerSubscriptionTransport();
const onError = () => {};

const sub = await explorerBlock({ transport }, () => {}, onError);

assertEquals(transport.calls.length, 1);
assertEquals(transport.calls[0].channel, "explorerBlock_");
assertEquals(transport.calls[0].payload, { type: "explorerBlock" });
assertStrictEquals(transport.calls[0].options?.onError, onError);
assertEquals(typeof sub.unsubscribe, "function");
});

test("forwards event detail to the listener", async () => {
const transport = new MockExplorerSubscriptionTransport();
const received: ExplorerBlockEvent[] = [];

await explorerBlock({ transport }, (data) => received.push(data));

const detail: ExplorerBlockEvent = [{ blockTime: 1, hash: "0xabc", height: 1, numTxs: 0, proposer: "0xdef" }];
transport.dispatch("explorerBlock_", detail);

assertEquals(received, [detail]);
});

test("the returned subscription unsubscribes", async () => {
const transport = new MockExplorerSubscriptionTransport();

const sub = await explorerBlock({ transport }, () => {});
await sub.unsubscribe();

assertEquals(transport.unsubscribeCount, 1);
});

test("subscribe failures reject the promise", async () => {
const transport = new MockExplorerSubscriptionTransport();
transport.error = new TransportError("connection lost");

await assertRejects(() => explorerBlock({ transport }, () => {}), TransportError, "connection lost");
});
});
55 changes: 55 additions & 0 deletions tests/api/explorer/explorerTxs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,58 @@ runSubscriptionTest({
schemaCoverage(responseSchema, data, ["#/items/properties/error/defined"]);
},
});

// ============================================================
// Offline: channel, payload, and listener wiring against a mock subscription transport
// ============================================================

import { describe, test } from "bun:test";
import { assertEquals, assertRejects, assertStrictEquals } from "@jsr/std__assert";
import { TransportError } from "@bloxwap/hyperliquid";
import { explorerTxs } from "@bloxwap/hyperliquid/api/explorer";
import { MockExplorerSubscriptionTransport } from "./_mockTransport.ts";

describe("explorerTxs (offline)", () => {
test("subscribes to the duck channel with the validated payload", async () => {
const transport = new MockExplorerSubscriptionTransport();
const onError = () => {};

const sub = await explorerTxs({ transport }, () => {}, onError);

assertEquals(transport.calls.length, 1);
assertEquals(transport.calls[0].channel, "explorerTxs_");
assertEquals(transport.calls[0].payload, { type: "explorerTxs" });
assertStrictEquals(transport.calls[0].options?.onError, onError);
assertEquals(typeof sub.unsubscribe, "function");
});

test("forwards event detail to the listener", async () => {
const transport = new MockExplorerSubscriptionTransport();
const received: ExplorerTxsEvent[] = [];

await explorerTxs({ transport }, (data) => received.push(data));

const detail: ExplorerTxsEvent = [
{ action: { type: "order" }, block: 1, error: null, hash: "0xabc", time: 1, user: "0xdef" },
];
transport.dispatch("explorerTxs_", detail);

assertEquals(received, [detail]);
});

test("the returned subscription unsubscribes", async () => {
const transport = new MockExplorerSubscriptionTransport();

const sub = await explorerTxs({ transport }, () => {});
await sub.unsubscribe();

assertEquals(transport.unsubscribeCount, 1);
});

test("subscribe failures reject the promise", async () => {
const transport = new MockExplorerSubscriptionTransport();
transport.error = new TransportError("connection lost");

await assertRejects(() => explorerTxs({ transport }, () => {}), TransportError, "connection lost");
});
});
Loading