diff --git a/tests/api/explorer/_mockTransport.ts b/tests/api/explorer/_mockTransport.ts new file mode 100644 index 00000000..75b2d1ac --- /dev/null +++ b/tests/api/explorer/_mockTransport.ts @@ -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) => unknown = () => ({})) {} + + request(endpoint: "explorer", payload: unknown, signal?: AbortSignal): Promise { + this.calls.push({ endpoint, payload, signal }); + if (this.error !== undefined) return Promise.reject(this.error); + return Promise.resolve(this.handler(payload as Record) as T); + } +} + +/** One recorded subscription. */ +export interface MockSubscribeCall { + channel: string; + payload: unknown; + listener: (data: CustomEvent) => 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( + channel: string, + payload: unknown, + listener: (data: CustomEvent) => void, + options?: MockSubscribeCall["options"], + ): Promise { + this.calls.push({ channel, payload, listener: listener as (data: CustomEvent) => 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 })); + } +} diff --git a/tests/api/explorer/blockDetails.test.ts b/tests/api/explorer/blockDetails.test.ts index d7477745..54258e9c 100644 --- a/tests/api/explorer/blockDetails.test.ts +++ b/tests/api/explorer/blockDetails.test.ts @@ -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"; @@ -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"); + }); +}); diff --git a/tests/api/explorer/client.test.ts b/tests/api/explorer/client.test.ts new file mode 100644 index 00000000..58517810 --- /dev/null +++ b/tests/api/explorer/client.test.ts @@ -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); + }); +}); diff --git a/tests/api/explorer/explorerBlock.test.ts b/tests/api/explorer/explorerBlock.test.ts index 53e05374..b315f30e 100644 --- a/tests/api/explorer/explorerBlock.test.ts +++ b/tests/api/explorer/explorerBlock.test.ts @@ -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"); + }); +}); diff --git a/tests/api/explorer/explorerTxs.test.ts b/tests/api/explorer/explorerTxs.test.ts index cb049f53..b55c3646 100644 --- a/tests/api/explorer/explorerTxs.test.ts +++ b/tests/api/explorer/explorerTxs.test.ts @@ -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"); + }); +}); diff --git a/tests/api/explorer/txDetails.test.ts b/tests/api/explorer/txDetails.test.ts index 33746fbb..66034e1b 100644 --- a/tests/api/explorer/txDetails.test.ts +++ b/tests/api/explorer/txDetails.test.ts @@ -1,4 +1,4 @@ -import { type TxDetailsParameters, TxDetailsRequest } from "@bloxwap/hyperliquid/api/explorer"; +import { type TxDetailsParameters, TxDetailsRequest, txDetails } from "@bloxwap/hyperliquid/api/explorer"; import * as v from "valibot"; import { schemaCoverage } from "../_utils/schemaCoverage.ts"; import { typeToJsonSchema } from "../_utils/typeToJsonSchema.ts"; @@ -23,3 +23,60 @@ 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"; + +const HASH = "0x4de9f1f5d912c23d8fbb0411f01bfe0000eb9f3ccb3fec747cb96e75e8944b06"; + +describe("txDetails (offline)", () => { + test("sends the validated request to the explorer endpoint", async () => { + const response = { type: "txDetails", tx: { hash: HASH } }; + const transport = new MockExplorerTransport(() => response); + + const result = await txDetails({ transport }, { hash: HASH }); + + assertEquals(transport.calls.length, 1); + assertEquals(transport.calls[0].endpoint, "explorer"); + assertEquals(transport.calls[0].payload, { type: "txDetails", hash: HASH }); + assertStrictEquals(result, response); + }); + + test("passes the abort signal to the transport", async () => { + const transport = new MockExplorerTransport(); + const controller = new AbortController(); + + await txDetails({ transport }, { hash: HASH }, 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(() => txDetails({ transport }, { hash: "0x1234" }), ValidationError); // too short + await assertRejects(() => txDetails({ transport }, { hash: "not-hex" }), ValidationError); // not hex + + assertEquals(transport.calls.length, 0); + }); + + test("an error response throws ApiRequestError", async () => { + const transport = new MockExplorerTransport(() => ({ type: "error", message: "tx not found" })); + + await assertRejects(() => txDetails({ transport }, { hash: HASH }), ApiRequestError, "tx not found"); + }); + + test("transport errors propagate", async () => { + const transport = new MockExplorerTransport(); + transport.error = new TransportError("connection lost"); + + await assertRejects(() => txDetails({ transport }, { hash: HASH }), TransportError, "connection lost"); + }); +}); diff --git a/tests/api/explorer/userDetails.test.ts b/tests/api/explorer/userDetails.test.ts index c6afea79..8fd2d620 100644 --- a/tests/api/explorer/userDetails.test.ts +++ b/tests/api/explorer/userDetails.test.ts @@ -1,4 +1,4 @@ -import { type UserDetailsParameters, UserDetailsRequest } from "@bloxwap/hyperliquid/api/explorer"; +import { type UserDetailsParameters, UserDetailsRequest, userDetails } from "@bloxwap/hyperliquid/api/explorer"; import * as v from "valibot"; import { schemaCoverage } from "../_utils/schemaCoverage.ts"; import { typeToJsonSchema } from "../_utils/typeToJsonSchema.ts"; @@ -21,3 +21,68 @@ 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"; + +const USER = "0x9150749c4cec13dc7c1555d0d664f08d4d81be83"; + +describe("userDetails (offline)", () => { + test("sends the validated request to the explorer endpoint", async () => { + const response = { type: "userDetails", txs: [] }; + const transport = new MockExplorerTransport(() => response); + + const result = await userDetails({ transport }, { user: USER }); + + assertEquals(transport.calls.length, 1); + assertEquals(transport.calls[0].endpoint, "explorer"); + assertEquals(transport.calls[0].payload, { type: "userDetails", user: USER }); + assertStrictEquals(result, response); + }); + + test("lowercases a mixed-case address", async () => { + const transport = new MockExplorerTransport(); + + await userDetails({ transport }, { user: "0x9150749C4cec13Dc7c1555D0d664F08d4d81Be83" }); + + assertEquals(transport.calls[0].payload, { type: "userDetails", user: USER }); + }); + + test("passes the abort signal to the transport", async () => { + const transport = new MockExplorerTransport(); + const controller = new AbortController(); + + await userDetails({ transport }, { user: USER }, 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(() => userDetails({ transport }, { user: "0x1234" }), ValidationError); // too short + await assertRejects(() => userDetails({ transport }, { user: "not-an-address" }), ValidationError); // not hex + + assertEquals(transport.calls.length, 0); + }); + + test("an error response throws ApiRequestError", async () => { + const transport = new MockExplorerTransport(() => ({ type: "error", message: "user not found" })); + + await assertRejects(() => userDetails({ transport }, { user: USER }), ApiRequestError, "user not found"); + }); + + test("transport errors propagate", async () => { + const transport = new MockExplorerTransport(); + transport.error = new TransportError("connection lost"); + + await assertRejects(() => userDetails({ transport }, { user: USER }), TransportError, "connection lost"); + }); +});