diff --git a/.github/workflows/live-tests.yml b/.github/workflows/live-tests.yml new file mode 100644 index 0000000..60906d2 --- /dev/null +++ b/.github/workflows/live-tests.yml @@ -0,0 +1,38 @@ +name: Live API Tests + +# Runs the LIVE contract tests (tests/live/**) against the real OilPriceAPI. +# These require the OILPRICEAPI_TEST_KEY repo secret. The job is gated so it +# only runs when the secret is present, and the live suite itself skips +# gracefully when the key env var is absent — so external forks (which cannot +# read the secret) are never blocked or failed. + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + live: + name: Live API contract tests + runs-on: ubuntu-latest + # Only run when the secret is available (it is empty on forked-PR contexts). + if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install dependencies + run: npm ci + + - name: Run live tests + # Guard at the step level too: skip if the secret is empty so the job + # is a no-op (green) rather than a failure when no key is configured. + if: ${{ secrets.OILPRICEAPI_TEST_KEY != '' }} + run: npm run test:live + env: + OILPRICEAPI_TEST_KEY: ${{ secrets.OILPRICEAPI_TEST_KEY }} diff --git a/package.json b/package.json index cc9c305..813ea23 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oilpriceapi", - "version": "0.9.0", + "version": "0.9.1", "description": "Official Node.js SDK for Oil Price API - Real-time and historical oil & commodity prices", "type": "module", "main": "./dist/cjs/index.js", diff --git a/src/resources/futures.ts b/src/resources/futures.ts index af5bac7..96f7653 100644 --- a/src/resources/futures.ts +++ b/src/resources/futures.ts @@ -9,23 +9,73 @@ import type { OilPriceAPI } from "../client.js"; import { ValidationError } from "../errors.js"; /** - * Futures contract price data + * A single futures contract month within a {@link FuturesPrice} response. + * + * Returned at the top level under `front_month` and for every entry in + * `contracts[]`. The latest traded price is `last_price`. + */ +export interface FuturesContractMonth { + /** Contract code (e.g. "BRENT_FUTURES_2026_08"). */ + code?: string; + /** Contract month in YYYY-MM form (e.g. "2026-08"). */ + contract_month?: string; + /** Latest traded/settlement price for this contract month. */ + last_price?: number; + /** Currency code (e.g. "USD"). */ + currency?: string; + /** Opening price (may be returned as a string by the API). */ + open?: number | string; + /** Closing price (may be returned as a string by the API). */ + close?: number | string; + /** Session high. */ + high?: number | string; + /** Session low. */ + low?: number | string; + /** Any additional fields the API returns for a contract month. */ + [key: string]: unknown; +} + +/** + * Futures contract price data. + * + * `GET /v1/futures/{slug}` returns a TOP-LEVEL object (there is NO + * `{ status, data }` envelope). The latest price lives at + * `front_month.last_price`, with the full term structure in `contracts[]`. + * + * Legacy flat fields (`contract`, `price`, `currency`, `timestamp`) are kept + * optional for backward compatibility, but real responses populate + * `front_month` / `contracts` instead. */ export interface FuturesPrice { - /** Contract symbol */ - contract: string; - /** Current price */ - price: number; - /** Formatted price string */ + /** Commodity identifier (e.g. "BRENT_FUTURES"). */ + commodity?: string; + /** Data source (e.g. "ICE"). */ + source?: string; + /** ISO timestamp the data was last updated. */ + updated_at?: string; + /** Settlement date for the prices. */ + settlement_date?: string; + /** Front-month contract — the latest price is `front_month.last_price`. */ + front_month?: FuturesContractMonth; + /** Full forward term structure, one entry per contract month. */ + contracts?: FuturesContractMonth[]; + /** Optional warning when the returned data is stale. */ + data_age_warning?: unknown; + /** Additional metadata returned by the API. */ + metadata?: Record; + + /** @deprecated Legacy flat contract symbol — use `front_month.code`. */ + contract?: string; + /** @deprecated Legacy flat price — use `front_month.last_price`. */ + price?: number; + /** @deprecated Legacy formatted price string. */ formatted?: string; - /** Currency code */ - currency: string; - /** Contract expiration date */ + /** @deprecated Legacy currency — use `front_month.currency`. */ + currency?: string; + /** @deprecated Legacy contract expiration date. */ expiration?: string; - /** ISO timestamp when price was recorded */ - timestamp: string; - /** Additional metadata */ - metadata?: Record; + /** @deprecated Legacy ISO timestamp — use `updated_at`. */ + timestamp?: string; } /** @@ -153,15 +203,25 @@ export interface FuturesCurvePoint { } /** - * Futures curve data + * Futures curve data. + * + * `GET /v1/futures/{slug}/curve` can legitimately return a no-data response of + * the form `{ error: "No futures data available for curve analysis", date }` + * when a curve cannot be built. That is a valid state (not an HTTP error), so + * `curve` is optional and `error` / `date` are surfaced for callers to detect + * the no-data case. */ export interface FuturesCurveData { /** Base contract */ - contract: string; + contract?: string; /** ISO timestamp when curve was generated */ - timestamp: string; - /** Array of curve points */ - curve: FuturesCurvePoint[]; + timestamp?: string; + /** Array of curve points (absent in the no-data response) */ + curve?: FuturesCurvePoint[]; + /** Present in the no-data response: "No futures data available for curve analysis". */ + error?: string; + /** Date associated with the no-data response. */ + date?: string; } /** @@ -213,9 +273,10 @@ export interface FuturesSpreadHistory { /** * Slugs for the supported ICE / gas / carbon futures contract families. * - * These map to the `GET /v1/futures/{slug}/...` endpoints. Each family - * supports `/latest`, `/historical`, `/ohlc`, `/intraday`, `/spreads`, - * `/curve`, and `/spread-history`. + * These map to the `GET /v1/futures/{slug}` (latest) endpoint plus the + * `GET /v1/futures/{slug}/...` sub-resources. Latest is the bare slug path + * (there is NO `/latest` suffix). Each family also supports `/historical`, + * `/ohlc`, `/intraday`, `/spreads`, `/curve`, and `/spread-history`. */ export type FuturesContractFamilySlug = | "ice-brent" @@ -268,16 +329,35 @@ export const FUTURES_CONTRACTS = { * path segment used by the typed family helpers. */ export const FUTURES_FAMILY_SLUGS: Record = { - [FUTURES_CONTRACTS.BRENT]: "ice-brent", - [FUTURES_CONTRACTS.WTI]: "ice-wti", - [FUTURES_CONTRACTS.GASOIL]: "ice-gasoil", - [FUTURES_CONTRACTS.NATURAL_GAS]: "natural-gas", - [FUTURES_CONTRACTS.TTF_GAS]: "ttf-gas", - [FUTURES_CONTRACTS.LNG_JKM]: "lng-jkm", - [FUTURES_CONTRACTS.EUA_CARBON]: "eua-carbon", - [FUTURES_CONTRACTS.UK_CARBON]: "uk-carbon", + [FUTURES_CONTRACTS.BRENT]: "ice-brent", // BZ + [FUTURES_CONTRACTS.WTI]: "ice-wti", // CL + [FUTURES_CONTRACTS.GASOIL]: "ice-gasoil", // G + QS: "ice-gasoil", // ICE Gasoil also trades under the QS ticker prefix + [FUTURES_CONTRACTS.NATURAL_GAS]: "natural-gas", // NG + [FUTURES_CONTRACTS.TTF_GAS]: "ttf-gas", // TTF + [FUTURES_CONTRACTS.LNG_JKM]: "lng-jkm", // JKM + [FUTURES_CONTRACTS.EUA_CARBON]: "eua-carbon", // EUA + [FUTURES_CONTRACTS.UK_CARBON]: "uk-carbon", // UKA }; +/** + * Resolve a futures contract code (e.g. `"BZ"`, `"QS"`) or an already-valid + * family slug (e.g. `"ice-brent"`) to its `/v1/futures/{slug}` path segment. + * + * Matching is case-insensitive for codes. Returns `null` if the input maps to + * neither a known code nor a known family slug. + */ +export function resolveFuturesFamilySlug(codeOrSlug: string): FuturesContractFamilySlug | null { + const trimmed = codeOrSlug.trim(); + // Direct code match (case-insensitive — codes are upper-case). + const byCode = FUTURES_FAMILY_SLUGS[trimmed.toUpperCase()]; + if (byCode) return byCode; + // Already a valid family slug? + const lower = trimmed.toLowerCase(); + const isSlug = Object.values(FUTURES_FAMILY_SLUGS).includes(lower as FuturesContractFamilySlug); + return isSlug ? (lower as FuturesContractFamilySlug) : null; +} + /** * Typed helper for a single futures contract family (e.g., ICE Brent, Gasoil). * @@ -301,9 +381,12 @@ export class FuturesContractFamily { /** * Get the latest price for this contract family. + * + * Latest is served from the bare slug path `GET /v1/futures/{slug}` — + * there is NO `/latest` suffix (that path 404s). */ async latest(): Promise { - return this.client["request"](`/v1/futures/${this.slug}/latest`, {}); + return this.client["request"](`/v1/futures/${this.slug}`, {}); } /** @@ -387,16 +470,13 @@ export class FuturesContractFamily { * * const client = new OilPriceAPI({ apiKey: 'your_key' }); * - * // Get latest price - * const latest = await client.futures.latest('CL.1'); + * // Get the latest curve by contract code (resolves to GET /v1/futures/ice-wti) + * const latest = await client.futures.latest('CL'); * console.log(`${latest.contract}: $${latest.price}`); * - * // Get OHLC data - * const ohlc = await client.futures.ohlc('CL.1', '2024-01-15'); - * console.log(`High: $${ohlc.high}, Low: $${ohlc.low}`); - * - * // Get futures curve - * const curve = await client.futures.curve('CL'); + * // Typed family helpers are the most ergonomic option: + * const brent = await client.futures.brent().latest(); + * const curve = await client.futures.brent().curve(); * curve.curve.forEach(point => { * console.log(`${point.months_out}mo: $${point.price}`); * }); @@ -406,18 +486,28 @@ export class FuturesResource { constructor(private client: OilPriceAPI) {} /** - * Get latest price for a futures contract + * Get the latest curve/quote for a futures contract family. * - * @param contract - Contract symbol (e.g., "CL.1", "BZ.2") - * @returns Latest futures price data + * Accepts an ergonomic contract code (e.g. `"BZ"`, `"CL"`, `"QS"`) or a + * family slug (e.g. `"ice-brent"`). The code is resolved to its family slug + * and the request is sent to `GET /v1/futures/{slug}` — the bare slug path, + * with NO `/latest` suffix (the suffixed path 404s). * - * @throws {NotFoundError} If contract not found + * Supported codes: BZ (Brent), CL (WTI), G/QS (Gasoil), NG (Natural Gas), + * TTF, JKM, EUA, UKA. Slugs: ice-brent, ice-wti, ice-gasoil, natural-gas, + * ttf-gas, lng-jkm, eua-carbon, uk-carbon. + * + * @param contract - Contract code (e.g. "BZ") or family slug (e.g. "ice-brent"). + * @returns Latest futures price/curve data + * + * @throws {ValidationError} If the code/slug is empty or unrecognized. * @throws {OilPriceAPIError} If API request fails * * @example * ```typescript - * const price = await client.futures.latest('CL.1'); - * console.log(`WTI Front Month: $${price.price}`); + * import { FUTURES_CONTRACTS } from 'oilpriceapi'; + * const price = await client.futures.latest(FUTURES_CONTRACTS.BRENT); // "BZ" + * const wti = await client.futures.latest('ice-wti'); * ``` */ async latest(contract: string): Promise { @@ -425,7 +515,17 @@ export class FuturesResource { throw new ValidationError("Contract symbol must be a non-empty string"); } - return this.client["request"](`/v1/futures/${contract}`, {}); + const slug = resolveFuturesFamilySlug(contract); + if (!slug) { + throw new ValidationError( + `Unknown futures contract "${contract}". Use a contract code ` + + `(BZ, CL, G, QS, NG, TTF, JKM, EUA, UKA) or a family slug ` + + `(ice-brent, ice-wti, ice-gasoil, natural-gas, ttf-gas, lng-jkm, ` + + `eua-carbon, uk-carbon).`, + ); + } + + return this.client["request"](`/v1/futures/${slug}`, {}); } /** diff --git a/src/version.ts b/src/version.ts index 946b30c..305660b 100644 --- a/src/version.ts +++ b/src/version.ts @@ -7,7 +7,7 @@ * - X-Client-Version header * - Package.json (should match) */ -export const SDK_VERSION = "0.9.0"; +export const SDK_VERSION = "0.9.1"; /** * SDK identifier used in User-Agent and X-Api-Client headers diff --git a/tests/live/futures.test.ts b/tests/live/futures.test.ts new file mode 100644 index 0000000..4ebb05a --- /dev/null +++ b/tests/live/futures.test.ts @@ -0,0 +1,124 @@ +/** + * LIVE contract tests for the futures latest endpoints. + * + * These hit the REAL authenticated API over the network and verify the + * futures-path fix (v0.9.1): latest is served from the bare slug path + * `GET /v1/futures/{slug}` (NO `/latest` suffix, which 404s). + * + * Requires a real API key in `process.env.OILPRICEAPI_TEST_KEY`. When the key + * is absent (offline runs, external forks) the suite is SKIPPED gracefully so + * it never fails CI for contributors without the secret. + * + * The API rate limit is 1 request/second, so live requests are spaced out and + * the suite keeps to a handful of calls to avoid 429s. + * + * Run explicitly with `npm run test:live`. + */ +import { describe, it, expect, beforeAll } from "vitest"; +import { OilPriceAPI } from "../../src/client.js"; +import type { FuturesCurveData, FuturesPrice } from "../../src/resources/futures.js"; + +const API_KEY = process.env.OILPRICEAPI_TEST_KEY; + +// Skip the entire suite (rather than fail) when no key is available. +const describeLive = API_KEY ? describe : describe.skip; + +/** Space requests to respect the 1 req/sec rate limit. */ +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const RATE_LIMIT_DELAY_MS = 1100; + +/** + * Assert the latest payload is the real TOP-LEVEL futures response shape. + * + * `GET /v1/futures/{slug}` returns a top-level object (NO `{ status, data }` + * envelope). The latest price lives at `front_month.last_price`, with the full + * term structure in `contracts[]`. We require a numeric front-month price in a + * sane range, and fall back to the first contract / a legacy flat `price` for + * resilience. + */ +function expectSaneFuturesPayload(res: FuturesPrice | Record) { + expect(res).toBeDefined(); + expect(typeof res).toBe("object"); + + const obj = res as Record; + + // Primary contract: front_month.last_price (the documented latest price). + const frontMonth = obj.front_month as Record | undefined; + const contracts = obj.contracts as Array> | undefined; + const candidate = + (frontMonth && typeof frontMonth.last_price === "number" + ? (frontMonth.last_price as number) + : undefined) ?? + (contracts && contracts.length > 0 && typeof contracts[0].last_price === "number" + ? (contracts[0].last_price as number) + : undefined) ?? + (typeof obj.price === "number" ? obj.price : undefined); + + expect(candidate, "expected a numeric front_month.last_price in the futures payload").toBeTypeOf( + "number", + ); + // Energy futures trade in a wide but bounded range across commodities. + expect(candidate as number).toBeGreaterThan(0); + expect(candidate as number).toBeLessThan(100000); +} + +/** + * Assert the curve payload is EITHER real curve data OR the documented + * no-data response. `GET /v1/futures/{slug}/curve` can legitimately return + * `{ error: "No futures data available for curve analysis", date: "..." }` + * when no curve can be built — that is a valid state, not a failure. + */ +function expectSaneCurveOrNoData(res: FuturesCurveData | Record) { + expect(res).toBeDefined(); + expect(typeof res).toBe("object"); + + const obj = res as Record; + + // No-data state: accept the documented error response without failing. + if (typeof obj.error === "string") { + expect(obj.error).toMatch(/no futures data available/i); + return; + } + + // Otherwise we expect real curve data: an array of points with prices. + const curve = obj.curve as Array> | undefined; + expect(Array.isArray(curve), "expected a `curve` array when not a no-data response").toBe(true); + if (curve && curve.length > 0) { + const point = curve[0]; + const price = + typeof point.price === "number" + ? point.price + : typeof point.last_price === "number" + ? (point.last_price as number) + : undefined; + expect(price, "expected a numeric price in the first curve point").toBeTypeOf("number"); + expect(price as number).toBeGreaterThan(0); + expect(price as number).toBeLessThan(100000); + } +} + +describeLive("LIVE futures latest endpoints (v0.9.1 path fix)", () => { + let client: OilPriceAPI; + + beforeAll(() => { + client = new OilPriceAPI({ apiKey: API_KEY as string, retries: 1 }); + }); + + it("client.futures.brent().latest() returns the top-level response with front_month.last_price (GET /v1/futures/ice-brent)", async () => { + const res = await client.futures.brent().latest(); + expectSaneFuturesPayload(res); + await sleep(RATE_LIMIT_DELAY_MS); + }); + + it("client.futures.brent().curve() returns curve data OR the documented no-data response (tolerant)", async () => { + const res = await client.futures.brent().curve(); + expectSaneCurveOrNoData(res); + await sleep(RATE_LIMIT_DELAY_MS); + }); + + // Note: the shared CI test key is rate-limited to ~1 req/sec. We keep the live + // suite to 2 spaced calls (brent latest + curve) to stay reliable; the top-level + // latest() + code→slug mapping (e.g. 'CL'→ice-wti) is covered by unit tests in + // tests/resources/futures.test.ts. Adding a 3rd live call here intermittently + // trips the rate limit (the SDK's retry-on-429 then times out the test). +}); diff --git a/tests/resources/futures-family.test.ts b/tests/resources/futures-family.test.ts index 6c17cb7..962e2ef 100644 --- a/tests/resources/futures-family.test.ts +++ b/tests/resources/futures-family.test.ts @@ -4,6 +4,7 @@ import { FUTURES_CONTRACTS, FUTURES_FAMILY_SLUGS, FuturesContractFamily, + resolveFuturesFamilySlug, } from "../../src/resources/futures.js"; /** @@ -66,14 +67,15 @@ describe("Futures contract families (issue #1)", () => { }); describe("family endpoint coverage", () => { - it("latest() hits /v1/futures/{slug}/latest", async () => { + it("latest() hits the bare slug path /v1/futures/{slug} (NO /latest suffix)", async () => { const spy = vi .spyOn(client as any, "request") .mockResolvedValue({ contract: "ice-gasoil", price: 800, currency: "USD", timestamp: "t" }); await client.futures.gasoil().latest(); - expect(spy).toHaveBeenCalledWith("/v1/futures/ice-gasoil/latest", {}); + // The correct latest endpoint is the bare slug; `/latest` 404s. + expect(spy).toHaveBeenCalledWith("/v1/futures/ice-gasoil", {}); }); it("historical() passes date params", async () => { @@ -139,4 +141,77 @@ describe("Futures contract families (issue #1)", () => { expect(spy).toHaveBeenCalledWith("/v1/futures/eua-carbon/spread-history", {}); }); }); + + describe("resolveFuturesFamilySlug()", () => { + it.each([ + ["BZ", "ice-brent"], + ["CL", "ice-wti"], + ["G", "ice-gasoil"], + ["QS", "ice-gasoil"], // Gasoil also trades under the QS ticker prefix + ["NG", "natural-gas"], + ["TTF", "ttf-gas"], + ["JKM", "lng-jkm"], + ["EUA", "eua-carbon"], + ["UKA", "uk-carbon"], + ])("resolves code %s -> %s", (code, slug) => { + expect(resolveFuturesFamilySlug(code)).toBe(slug); + }); + + it("resolves codes case-insensitively", () => { + expect(resolveFuturesFamilySlug("bz")).toBe("ice-brent"); + expect(resolveFuturesFamilySlug("qs")).toBe("ice-gasoil"); + }); + + it("passes through already-valid family slugs", () => { + expect(resolveFuturesFamilySlug("ice-brent")).toBe("ice-brent"); + expect(resolveFuturesFamilySlug("ICE-BRENT")).toBe("ice-brent"); + }); + + it("returns null for unknown codes/slugs", () => { + expect(resolveFuturesFamilySlug("ZZZ")).toBeNull(); + expect(resolveFuturesFamilySlug("crude")).toBeNull(); + }); + }); + + describe("top-level futures.latest(code) maps code -> /v1/futures/{slug}", () => { + it.each([ + ["BZ", "ice-brent"], + ["CL", "ice-wti"], + ["G", "ice-gasoil"], + ["QS", "ice-gasoil"], + ["NG", "natural-gas"], + ["TTF", "ttf-gas"], + ["JKM", "lng-jkm"], + ["EUA", "eua-carbon"], + ["UKA", "uk-carbon"], + ])("latest('%s') -> GET /v1/futures/%s (no /latest suffix)", async (code, slug) => { + const spy = vi + .spyOn(client as any, "request") + .mockResolvedValue({ contract: slug, price: 1, currency: "USD", timestamp: "t" }); + + await client.futures.latest(code); + + expect(spy).toHaveBeenCalledWith(`/v1/futures/${slug}`, {}); + }); + + it("accepts a family slug directly", async () => { + const spy = vi + .spyOn(client as any, "request") + .mockResolvedValue({ contract: "ice-wti", price: 1, currency: "USD", timestamp: "t" }); + + await client.futures.latest("ice-wti"); + + expect(spy).toHaveBeenCalledWith("/v1/futures/ice-wti", {}); + }); + + it("throws ValidationError for an unknown contract", async () => { + await expect(client.futures.latest("NOPE")).rejects.toThrow(/Unknown futures contract/); + }); + + it("throws ValidationError for an empty contract", async () => { + await expect(client.futures.latest("")).rejects.toThrow( + "Contract symbol must be a non-empty string", + ); + }); + }); }); diff --git a/tests/resources/futures.test.ts b/tests/resources/futures.test.ts index 5171da7..b8bcf30 100644 --- a/tests/resources/futures.test.ts +++ b/tests/resources/futures.test.ts @@ -19,26 +19,47 @@ describe("FuturesResource", () => { }); describe("latest()", () => { - it("should fetch latest futures price", async () => { + it("should fetch latest futures price by contract code (maps to bare slug path)", async () => { + // The real API returns a TOP-LEVEL object (NO { status, data } envelope); + // the latest price lives at front_month.last_price, with the full term + // structure in contracts[]. const mockPrice: FuturesPrice = { - contract: "CL.1", - price: 75.5, - formatted: "$75.50", - currency: "USD", - expiration: "2024-03-20", - timestamp: "2024-01-15T10:00:00Z", + commodity: "WTI_FUTURES", + source: "ICE", + updated_at: "2024-01-15T10:00:00Z", + settlement_date: "2024-01-15", + front_month: { + code: "WTI_FUTURES_2024_03", + contract_month: "2024-03", + last_price: 75.5, + currency: "USD", + open: "75.0", + close: "75.5", + high: "76.5", + low: "74.5", + }, + contracts: [ + { contract_month: "2024-03", last_price: 75.5, currency: "USD" }, + { contract_month: "2024-04", last_price: 75.75, currency: "USD" }, + ], + metadata: {}, }; - const requestSpy = vi - .spyOn(client as any, "request") - .mockResolvedValue(mockPrice); + const requestSpy = vi.spyOn(client as any, "request").mockResolvedValue(mockPrice); - const result = await client.futures.latest("CL.1"); + // "CL" resolves to the ice-wti family; latest is the bare slug path + // (GET /v1/futures/ice-wti) — there is NO /latest suffix. + const result = await client.futures.latest("CL"); - expect(requestSpy).toHaveBeenCalledWith("/v1/futures/CL.1", {}); + expect(requestSpy).toHaveBeenCalledWith("/v1/futures/ice-wti", {}); expect(result).toEqual(mockPrice); - expect(result.contract).toBe("CL.1"); - expect(result.price).toBe(75.5); + // The real latest price is surfaced at front_month.last_price. + expect(result.front_month?.last_price).toBe(75.5); + expect(result.contracts).toHaveLength(2); + }); + + it("should throw error for an unknown contract code/slug", async () => { + await expect(client.futures.latest("CL.1")).rejects.toThrow(/Unknown futures contract/); }); it("should throw error for empty contract", async () => { @@ -72,16 +93,11 @@ describe("FuturesResource", () => { }, ]; - const requestSpy = vi - .spyOn(client as any, "request") - .mockResolvedValue(mockPrices); + const requestSpy = vi.spyOn(client as any, "request").mockResolvedValue(mockPrices); const result = await client.futures.historical("CL.1"); - expect(requestSpy).toHaveBeenCalledWith( - "/v1/futures/CL.1/historical", - {}, - ); + expect(requestSpy).toHaveBeenCalledWith("/v1/futures/CL.1/historical", {}); expect(result).toEqual(mockPrices); expect(result).toHaveLength(2); }); @@ -89,9 +105,7 @@ describe("FuturesResource", () => { it("should fetch historical prices with date filters", async () => { const mockPrices: HistoricalFuturesPrice[] = []; - const requestSpy = vi - .spyOn(client as any, "request") - .mockResolvedValue(mockPrices); + const requestSpy = vi.spyOn(client as any, "request").mockResolvedValue(mockPrices); await client.futures.historical("CL.1", { startDate: "2024-01-01", @@ -132,9 +146,7 @@ describe("FuturesResource", () => { open_interest: 250000, }; - const requestSpy = vi - .spyOn(client as any, "request") - .mockResolvedValue(mockOHLC); + const requestSpy = vi.spyOn(client as any, "request").mockResolvedValue(mockOHLC); const result = await client.futures.ohlc("CL.1"); @@ -152,9 +164,7 @@ describe("FuturesResource", () => { close: 75.75, }; - const requestSpy = vi - .spyOn(client as any, "request") - .mockResolvedValue(mockOHLC); + const requestSpy = vi.spyOn(client as any, "request").mockResolvedValue(mockOHLC); await client.futures.ohlc("CL.1", "2024-01-15"); @@ -176,9 +186,7 @@ describe("FuturesResource", () => { ], }; - const requestSpy = vi - .spyOn(client as any, "request") - .mockResolvedValue(mockData); + const requestSpy = vi.spyOn(client as any, "request").mockResolvedValue(mockData); const result = await client.futures.intraday("CL.1"); @@ -204,9 +212,7 @@ describe("FuturesResource", () => { timestamp: "2024-01-15T10:00:00Z", }; - const requestSpy = vi - .spyOn(client as any, "request") - .mockResolvedValue(mockSpread); + const requestSpy = vi.spyOn(client as any, "request").mockResolvedValue(mockSpread); const result = await client.futures.spreads("CL.1", "CL.2"); @@ -257,9 +263,7 @@ describe("FuturesResource", () => { ], }; - const requestSpy = vi - .spyOn(client as any, "request") - .mockResolvedValue(mockCurve); + const requestSpy = vi.spyOn(client as any, "request").mockResolvedValue(mockCurve); const result = await client.futures.curve("CL"); @@ -268,6 +272,22 @@ describe("FuturesResource", () => { expect(result.curve).toHaveLength(3); }); + it("should surface the documented no-data response without throwing", async () => { + // /curve can legitimately return a no-data response (not an HTTP error). + const noData = { + error: "No futures data available for curve analysis", + date: "2024-01-15", + }; + + const requestSpy = vi.spyOn(client as any, "request").mockResolvedValue(noData); + + const result = await client.futures.curve("CL"); + + expect(requestSpy).toHaveBeenCalledWith("/v1/futures/CL/curve", {}); + expect(result.error).toBe("No futures data available for curve analysis"); + expect(result.curve).toBeUndefined(); + }); + it("should throw error for empty contract", async () => { await expect(client.futures.curve("")).rejects.toThrow( "Contract symbol must be a non-empty string", @@ -286,9 +306,7 @@ describe("FuturesResource", () => { ], }; - const requestSpy = vi - .spyOn(client as any, "request") - .mockResolvedValue(mockData); + const requestSpy = vi.spyOn(client as any, "request").mockResolvedValue(mockData); const result = await client.futures.continuous("CL"); @@ -303,9 +321,7 @@ describe("FuturesResource", () => { prices: [], }; - const requestSpy = vi - .spyOn(client as any, "request") - .mockResolvedValue(mockData); + const requestSpy = vi.spyOn(client as any, "request").mockResolvedValue(mockData); await client.futures.continuous("CL", 2);