From 44ff81d6995f367899f274a722d6fa87a9bfc0f8 Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Sun, 26 Jul 2026 21:05:40 -0700 Subject: [PATCH] test(explorer): bring src/api/explorer to 100% line coverage Add offline tests for every explorer method and the ExplorerClient wrapper, backed by new mock transports that record endpoint, payload, and signal, serve canned responses, and support error injection. Per method: exact request payload on the explorer endpoint, invalid params rejected with ValidationError before any request, response / error / abort-signal passthrough, and ApiRequestError on server error envelopes. For the two subscriptions: duck-channel wiring, listener detail forwarding, onError passthrough, and unsubscribe. Coverage (offline, HL_OFFLINE=1): - src/api/explorer/_methods: 56.9% (66/116) -> 100% (116/116) - src/api/explorer/client.ts: 33.3% (13/39) -> 100% (34/34) Tests only; no src changes. --- tests/api/explorer/_mockTransport.ts | 80 +++++++++++++++++ tests/api/explorer/blockDetails.test.ts | 66 +++++++++++++- tests/api/explorer/client.test.ts | 104 +++++++++++++++++++++++ tests/api/explorer/explorerBlock.test.ts | 53 ++++++++++++ tests/api/explorer/explorerTxs.test.ts | 55 ++++++++++++ tests/api/explorer/txDetails.test.ts | 59 ++++++++++++- tests/api/explorer/userDetails.test.ts | 67 ++++++++++++++- 7 files changed, 481 insertions(+), 3 deletions(-) create mode 100644 tests/api/explorer/_mockTransport.ts create mode 100644 tests/api/explorer/client.test.ts 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"); + }); +});