From de3cc4c156089a958b542b974777482c96d824de Mon Sep 17 00:00:00 2001 From: Karl Waldman Date: Sun, 13 Sep 2026 16:27:45 -0400 Subject: [PATCH] feat(subscriptions): add get, update, pause and resume (#78) Completes the watch lifecycle against /v1/subscriptions. get/update/pause/ resume return the Subscription unwrapped from { subscription }, validate the id and payload before sending, and keep 402/422 recovery details on rawBody. update() refuses a status other than active/paused, which the API answers with HTTP 500 (api#8471). A timed-out PATCH is not replayed. list() no longer turns a missing subscriptions key into []; it and create() raise unexpected_response_shape for a body they cannot map. Tests drive the real client through a fetch mock over fixtures captured from a live lifecycle run on the test key's own account (watch deleted in finally). The live smoke adds the lifecycle, pausing right after create and deleting in finally; it ran against production and passed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo --- README.md | 30 ++ src/index.ts | 1 + src/resources/subscriptions.ts | 279 ++++++++-- tests/fixtures/subscriptions/create.json | 1 + tests/fixtures/subscriptions/get.json | 1 + tests/fixtures/subscriptions/list.json | 1 + tests/fixtures/subscriptions/not-found.json | 1 + tests/fixtures/subscriptions/pause.json | 1 + tests/fixtures/subscriptions/resume.json | 1 + .../subscriptions/update-422-codes.json | 1 + .../subscriptions/update-422-interval.json | 1 + tests/fixtures/subscriptions/update.json | 1 + tests/live/subscriptions.test.ts | 69 ++- tests/resources/subscriptions.test.ts | 494 +++++++++++++----- tests/subscriptions-lifecycle.test-d.ts | 71 +++ 15 files changed, 783 insertions(+), 170 deletions(-) create mode 100644 tests/fixtures/subscriptions/create.json create mode 100644 tests/fixtures/subscriptions/get.json create mode 100644 tests/fixtures/subscriptions/list.json create mode 100644 tests/fixtures/subscriptions/not-found.json create mode 100644 tests/fixtures/subscriptions/pause.json create mode 100644 tests/fixtures/subscriptions/resume.json create mode 100644 tests/fixtures/subscriptions/update-422-codes.json create mode 100644 tests/fixtures/subscriptions/update-422-interval.json create mode 100644 tests/fixtures/subscriptions/update.json create mode 100644 tests/subscriptions-lifecycle.test-d.ts diff --git a/README.md b/README.md index 66a6667..5371581 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,36 @@ for (const permit of permits) { An empty search or history is a valid data state. Do not infer nationwide well-level coverage from the presence of permit data or an SDK method. +## Watches + +`client.subscriptions` manages watches: server-side evaluations of a set of +commodity codes on a fixed interval, read back through a cursor poll. A watch is +not a billing subscription, and pausing or deleting one changes nothing you are +charged. + +```typescript +const watch = await client.subscriptions.create({ + name: "Crude desk", + codes: ["BRENT_CRUDE_USD", "WTI_USD"], + interval: "1h", +}); + +await client.subscriptions.pause(watch.id); +await client.subscriptions.update(watch.id, { codes: ["BRENT_CRUDE_USD"], interval: "15m" }); +await client.subscriptions.resume(watch.id); + +const { events, cursor } = await client.subscriptions.events({ since: 0 }); + +await client.subscriptions.delete(watch.id); +``` + +The plan's watch count and minimum interval are enforced by the API. A create +over either limit fails with HTTP 402 and the upgrade details on `rawBody`; an +update below the minimum interval, or with an unknown code, fails with HTTP 422 +and the per-field reasons on `rawBody.data.details`. A PATCH that times out is +not replayed and carries `ambiguousWrite: true`, so check with `get()` before +resending. + ## CommonJS ```javascript diff --git a/src/index.ts b/src/index.ts index 08a2b00..ad2dc9d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -221,6 +221,7 @@ export type { SubscriptionSource, SubscriptionInterval, CreateSubscriptionParams, + UpdateSubscriptionParams, SubscriptionEvent, SubscriptionEventsResult, SubscriptionEventsOptions, diff --git a/src/resources/subscriptions.ts b/src/resources/subscriptions.ts index 1f945f0..209e741 100644 --- a/src/resources/subscriptions.ts +++ b/src/resources/subscriptions.ts @@ -6,18 +6,25 @@ * Phase 2). Designed for autonomous agents (MCP, schedulers, bots) that want * change notifications without holding an open connection. * - * The poll endpoint (`events`) returns applicable limit guidance from the API; - * callers should use that response metadata to choose a polling interval. + * A watch is not a billing subscription: creating, pausing or deleting one + * changes nothing the account is charged. Each CRUD call counts as one API + * request; the scheduled evaluations and the `events` poll do not. + * + * Shapes verified against a live lifecycle run on 2026-09-13 (#78): every + * single-watch route returns `data: { subscription: {...} }` and `list` + * returns `data: { subscriptions: [...] }`. */ import type { OilPriceAPI } from "../client.js"; -import { ValidationError } from "../errors.js"; +import { OilPriceAPIError, ValidationError } from "../errors.js"; /** * Lifecycle status of a subscription/watch. */ export type SubscriptionStatus = "active" | "paused"; +const STATUSES: readonly SubscriptionStatus[] = ["active", "paused"]; + /** * Attribution source recorded on a watch. Defaults to `"sdk-node"` when created * via this SDK. The API canonicalizes unknown values to `"api"`. @@ -27,8 +34,8 @@ export type SubscriptionSource = string; /** * A persistent agent subscription ("watch"). * - * Returned by {@link SubscriptionsResource.list} and - * {@link SubscriptionsResource.create}. + * Returned by every method except {@link SubscriptionsResource.delete} and + * {@link SubscriptionsResource.events}. */ export interface Subscription { /** Unique watch identifier (UUID). */ @@ -43,7 +50,7 @@ export interface Subscription { status: SubscriptionStatus; /** Whether matching events are also delivered via webhook. */ deliver_webhook: boolean; - /** Attribution source (e.g. "sdk-node", "mcp", "api"). */ + /** Attribution source (e.g. "mcp", "api", "dashboard"). */ source: string; /** Attribution tool name, if any. */ tool_name: string | null; @@ -56,7 +63,8 @@ export interface Subscription { } /** - * A friendly interval expression accepted by {@link SubscriptionsResource.create}. + * A friendly interval expression accepted by {@link SubscriptionsResource.create} + * and {@link SubscriptionsResource.update}. * * Either a preset string ("5m", "15m", "1h", "daily") or an explicit number of * seconds. @@ -87,6 +95,23 @@ export interface CreateSubscriptionParams { tool?: string; } +/** + * Fields to change with {@link SubscriptionsResource.update}. At least one is + * required; omitted fields are left unchanged. + */ +export interface UpdateSubscriptionParams { + /** New watch name (at most 120 characters server-side). */ + name?: string; + /** Replacement list of commodity codes. Must be non-empty. */ + codes?: string[]; + /** New cadence; must be at or above the plan's minimum interval. */ + interval?: SubscriptionInterval; + /** Deliver matching events via webhook (requires webhook entitlement). */ + deliverWebhook?: boolean; + /** `active` or `paused`. {@link SubscriptionsResource.pause} and `resume` are equivalent. */ + status?: SubscriptionStatus; +} + /** * A single event emitted by a watch evaluation. * @@ -191,6 +216,70 @@ export function intervalToSeconds(interval: SubscriptionInterval | undefined): n ); } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function describeShape(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "an array"; + if (isRecord(value)) { + const keys = Object.keys(value); + return keys.length === 0 ? "an empty object" : `an object with keys [${keys.join(", ")}]`; + } + return typeof value; +} + +function unexpectedShape(endpoint: string, expected: string, response: unknown): OilPriceAPIError { + return new OilPriceAPIError( + `Unexpected response shape from ${endpoint}: expected ${expected}, received ` + + `${describeShape(response)}. The SDK will not guess at a response it cannot map. ` + + `Please report this at https://github.com/OilpriceAPI/oilpriceapi-node/issues`, + undefined, + "unexpected_response_shape", + { rawBody: response }, + ); +} + +function isSubscription(value: unknown): value is Subscription { + return ( + isRecord(value) && + typeof value.id === "string" && + value.id !== "" && + STATUSES.includes(value.status as SubscriptionStatus) && + Array.isArray(value.codes) + ); +} + +/** Extract the watch from `{ subscription }`, or raise `unexpected_response_shape`. */ +function unwrapSubscription(response: unknown, endpoint: string): Subscription { + if (isRecord(response) && isSubscription(response.subscription)) { + return response.subscription; + } + throw unexpectedShape( + endpoint, + 'a "subscription" object with an id, codes and a status of active or paused', + response, + ); +} + +function requireId(id: unknown): string { + if (typeof id !== "string" || id.trim() === "") { + throw new ValidationError("Subscription ID must be a non-empty string"); + } + return id; +} + +function requireCodes(codes: unknown): string[] { + if (!Array.isArray(codes) || codes.length === 0) { + throw new ValidationError("codes is required and must be a non-empty array of commodity codes"); + } + if (codes.some((c) => typeof c !== "string" || c.trim() === "")) { + throw new ValidationError("every code must be a non-empty string"); + } + return codes as string[]; +} + /** * Agent Subscriptions ("Watches") Resource * @@ -203,22 +292,18 @@ export function intervalToSeconds(interval: SubscriptionInterval | undefined): n * * const client = new OilPriceAPI({ apiKey: 'your_key' }); * - * // Create a watch using the API-supported `5m` interval * const watch = await client.subscriptions.create({ * name: 'Crude desk', * codes: ['BRENT_CRUDE_USD', 'WTI_USD'], - * interval: '5m', + * interval: '1h', * }); * - * // List all watches - * const watches = await client.subscriptions.list(); + * await client.subscriptions.pause(watch.id); + * await client.subscriptions.update(watch.id, { codes: ['BRENT_CRUDE_USD'] }); + * await client.subscriptions.resume(watch.id); * - * // Poll for new events - * let cursor = 0; - * const { events, cursor: next } = await client.subscriptions.events({ since: cursor }); - * cursor = next; + * const { events, cursor } = await client.subscriptions.events({ since: 0 }); * - * // Remove a watch * await client.subscriptions.delete(watch.id); * ``` */ @@ -228,7 +313,11 @@ export class SubscriptionsResource { /** * List all subscriptions/watches for the authenticated user. * - * @returns Array of subscriptions, newest first. + * @returns Array of subscriptions, newest first. Empty only when the API + * returns an empty list. + * + * @throws {OilPriceAPIError} `unexpected_response_shape` if the response has + * no `subscriptions` array. * * @example * ```typescript @@ -237,10 +326,34 @@ export class SubscriptionsResource { * ``` */ async list(): Promise { - const response = await this.client["request"]< - Subscription[] | { subscriptions: Subscription[] } - >("/v1/subscriptions", {}); - return Array.isArray(response) ? response : (response.subscriptions ?? []); + const endpoint = "/v1/subscriptions"; + const response = await this.client["request"](endpoint, {}); + if (isRecord(response) && Array.isArray(response.subscriptions)) { + const watches = response.subscriptions; + if (watches.every(isSubscription)) return watches; + } + throw unexpectedShape(endpoint, 'a "subscriptions" array of watches', response); + } + + /** + * Get one subscription/watch. + * + * @param id - The subscription ID. + * @returns The watch. + * + * @throws {ValidationError} If `id` is not a non-empty string. + * @throws {NotFoundError} If no watch with that ID belongs to the account. + * + * @example + * ```typescript + * const watch = await client.subscriptions.get(id); + * console.log(watch.status, watch.next_run_at); + * ``` + */ + async get(id: string): Promise { + const endpoint = `/v1/subscriptions/${encodeURIComponent(requireId(id))}`; + const response = await this.client["request"](endpoint, {}); + return unwrapSubscription(response, "/v1/subscriptions/:id"); } /** @@ -254,6 +367,8 @@ export class SubscriptionsResource { * @returns The created subscription. * * @throws {ValidationError} If `codes` is empty or `interval` is invalid. + * @throws {OilPriceAPIError} HTTP 402 when the plan's watch count or minimum + * interval is exceeded; the upgrade details are on `rawBody`. * * @example * ```typescript @@ -266,19 +381,16 @@ export class SubscriptionsResource { * ``` */ async create(params: CreateSubscriptionParams): Promise { - if (!params || !Array.isArray(params.codes) || params.codes.length === 0) { + if (!params) { throw new ValidationError( "codes is required and must be a non-empty array of commodity codes", ); } - if (params.codes.some((c) => typeof c !== "string" || c.trim() === "")) { - throw new ValidationError("every code must be a non-empty string"); - } - + const codes = requireCodes(params.codes); const intervalSeconds = intervalToSeconds(params.interval); const body: Record = { - codes: params.codes, + codes, interval_seconds: intervalSeconds, }; if (params.name !== undefined) { @@ -295,13 +407,108 @@ export class SubscriptionsResource { headers["X-OPA-Tool"] = params.tool; } - const response = await this.client["request"]( - "/v1/subscriptions", + const endpoint = "/v1/subscriptions"; + const response = await this.client["request"]( + endpoint, {}, { method: "POST", body, headers }, ); - return "subscription" in response ? response.subscription : response; + return unwrapSubscription(response, endpoint); + } + + /** + * Change a subscription/watch. + * + * Sends only the fields given. A PATCH that times out is not replayed and + * its error carries `ambiguousWrite: true`: check with {@link get} before + * resending. + * + * @param id - The subscription ID. + * @param params - Fields to change; at least one. + * @returns The updated watch. + * + * @throws {ValidationError} For an empty id, no fields, empty codes, an + * invalid interval or a status other than `active` / `paused`. + * @throws {NotFoundError} If no watch with that ID belongs to the account. + * @throws {OilPriceAPIError} HTTP 422 with `rawBody.data.details` when the + * API rejects a value (unknown code, interval below the plan minimum). + * + * @example + * ```typescript + * const watch = await client.subscriptions.update(id, { interval: '15m', name: 'Fast desk' }); + * ``` + */ + async update(id: string, params: UpdateSubscriptionParams): Promise { + const path = `/v1/subscriptions/${encodeURIComponent(requireId(id))}`; + if (typeof params !== "object" || params === null || Array.isArray(params)) { + throw new ValidationError("update requires an object of fields to change"); + } + + const body: Record = {}; + if (params.name !== undefined) body.name = params.name; + if (params.codes !== undefined) body.codes = requireCodes(params.codes); + if (params.interval !== undefined) body.interval_seconds = intervalToSeconds(params.interval); + if (params.deliverWebhook !== undefined) body.deliver_webhook = params.deliverWebhook; + if (params.status !== undefined) { + // Any other value makes the API return HTTP 500 (api#8471). + if (!STATUSES.includes(params.status)) { + throw new ValidationError(`status must be one of ${STATUSES.join(", ")}`); + } + body.status = params.status; + } + + if (Object.keys(body).length === 0) { + throw new ValidationError( + "update requires at least one of name, codes, interval, deliverWebhook or status", + ); + } + + const response = await this.client["request"](path, {}, { method: "PATCH", body }); + return unwrapSubscription(response, "/v1/subscriptions/:id"); + } + + /** + * Pause a subscription/watch. A paused watch is not evaluated and does not + * count toward the plan's active-watch limit. + * + * @param id - The subscription ID. + * @returns The watch, with `status: "paused"`. + * + * @throws {ValidationError} If `id` is not a non-empty string. + * @throws {NotFoundError} If no watch with that ID belongs to the account. + * + * @example + * ```typescript + * await client.subscriptions.pause(id); + * ``` + */ + async pause(id: string): Promise { + const path = `/v1/subscriptions/${encodeURIComponent(requireId(id))}/pause`; + const response = await this.client["request"](path, {}, { method: "POST" }); + return unwrapSubscription(response, "/v1/subscriptions/:id/pause"); + } + + /** + * Resume a paused subscription/watch. The API schedules its next evaluation + * immediately. + * + * @param id - The subscription ID. + * @returns The watch, with `status: "active"` and a fresh `next_run_at`. + * + * @throws {ValidationError} If `id` is not a non-empty string. + * @throws {NotFoundError} If no watch with that ID belongs to the account. + * + * @example + * ```typescript + * const watch = await client.subscriptions.resume(id); + * console.log(`next evaluation at ${watch.next_run_at}`); + * ``` + */ + async resume(id: string): Promise { + const path = `/v1/subscriptions/${encodeURIComponent(requireId(id))}/resume`; + const response = await this.client["request"](path, {}, { method: "POST" }); + return unwrapSubscription(response, "/v1/subscriptions/:id/resume"); } /** @@ -310,6 +517,7 @@ export class SubscriptionsResource { * @param id - The subscription ID to delete. * * @throws {ValidationError} If `id` is not a non-empty string. + * @throws {NotFoundError} If no watch with that ID belongs to the account. * * @example * ```typescript @@ -317,17 +525,18 @@ export class SubscriptionsResource { * ``` */ async delete(id: string): Promise { - if (!id || typeof id !== "string") { - throw new ValidationError("Subscription ID must be a non-empty string"); - } - await this.client["request"](`/v1/subscriptions/${encodeURIComponent(id)}`, {}, { method: "DELETE" }); + await this.client["request"]( + `/v1/subscriptions/${encodeURIComponent(requireId(id))}`, + {}, + { method: "DELETE" }, + ); } /** * Poll for events emitted by your watches. * * Returns events with `seq` greater than the supplied cursor, ordered - * ascending. Current limits are determined by the API and account. + * ascending. Polling does not count against the request quota. * * @param options - Cursor (`since`), optional `watchId`, and `limit`. * @returns The next cursor, a `has_more` flag, and the events. diff --git a/tests/fixtures/subscriptions/create.json b/tests/fixtures/subscriptions/create.json new file mode 100644 index 0000000..4549ac1 --- /dev/null +++ b/tests/fixtures/subscriptions/create.json @@ -0,0 +1 @@ +{"status":"success","data":{"subscription":{"id":"c27641db-012d-4a22-8939-e77fe05d7eb4","name":"node-78-verification-1789330505408","codes":["BRENT_CRUDE_USD"],"interval_seconds":3600,"status":"active","deliver_webhook":false,"source":"api","tool_name":"oilpriceapi-node-78-verification","last_evaluated_at":null,"next_run_at":"2026-09-13T20:15:05Z","created_at":"2026-09-13T20:15:05Z"}}} \ No newline at end of file diff --git a/tests/fixtures/subscriptions/get.json b/tests/fixtures/subscriptions/get.json new file mode 100644 index 0000000..dfbd5f6 --- /dev/null +++ b/tests/fixtures/subscriptions/get.json @@ -0,0 +1 @@ +{"status":"success","data":{"subscription":{"id":"c27641db-012d-4a22-8939-e77fe05d7eb4","name":"node-78-verification-1789330505408","codes":["BRENT_CRUDE_USD"],"interval_seconds":3600,"status":"paused","deliver_webhook":false,"source":"api","tool_name":"oilpriceapi-node-78-verification","last_evaluated_at":null,"next_run_at":"2026-09-13T20:15:05Z","created_at":"2026-09-13T20:15:05Z"}}} \ No newline at end of file diff --git a/tests/fixtures/subscriptions/list.json b/tests/fixtures/subscriptions/list.json new file mode 100644 index 0000000..c7f8993 --- /dev/null +++ b/tests/fixtures/subscriptions/list.json @@ -0,0 +1 @@ +{"status":"success","data":{"subscriptions":[{"id":"b84b24a0-2b28-4eac-835e-db92bab5c0cb","name":"mcp-live-contract-1788524538194","codes":["BRENT_CRUDE_USD"],"interval_seconds":3600,"status":"active","deliver_webhook":false,"source":"api","tool_name":"opa_create_price_subscription","last_evaluated_at":"2026-09-13T19:32:06Z","next_run_at":"2026-09-13T20:32:06Z","created_at":"2026-09-04T12:22:19Z"}]}} \ No newline at end of file diff --git a/tests/fixtures/subscriptions/not-found.json b/tests/fixtures/subscriptions/not-found.json new file mode 100644 index 0000000..0ffc92f --- /dev/null +++ b/tests/fixtures/subscriptions/not-found.json @@ -0,0 +1 @@ +{"error":{"code":"NOT_FOUND","message":"Subscription not found","status":404,"request_id":"3d88cfe9-2b09-470c-a84b-2501738ae7fc","docs":"https://docs.oilpriceapi.com#NOT_FOUND"}} \ No newline at end of file diff --git a/tests/fixtures/subscriptions/pause.json b/tests/fixtures/subscriptions/pause.json new file mode 100644 index 0000000..dfbd5f6 --- /dev/null +++ b/tests/fixtures/subscriptions/pause.json @@ -0,0 +1 @@ +{"status":"success","data":{"subscription":{"id":"c27641db-012d-4a22-8939-e77fe05d7eb4","name":"node-78-verification-1789330505408","codes":["BRENT_CRUDE_USD"],"interval_seconds":3600,"status":"paused","deliver_webhook":false,"source":"api","tool_name":"oilpriceapi-node-78-verification","last_evaluated_at":null,"next_run_at":"2026-09-13T20:15:05Z","created_at":"2026-09-13T20:15:05Z"}}} \ No newline at end of file diff --git a/tests/fixtures/subscriptions/resume.json b/tests/fixtures/subscriptions/resume.json new file mode 100644 index 0000000..edbd45b --- /dev/null +++ b/tests/fixtures/subscriptions/resume.json @@ -0,0 +1 @@ +{"status":"success","data":{"subscription":{"id":"c27641db-012d-4a22-8939-e77fe05d7eb4","name":"node-78-renamed","codes":["BRENT_CRUDE_USD"],"interval_seconds":3600,"status":"active","deliver_webhook":false,"source":"api","tool_name":"oilpriceapi-node-78-verification","last_evaluated_at":null,"next_run_at":"2026-09-13T20:15:16Z","created_at":"2026-09-13T20:15:05Z"}}} \ No newline at end of file diff --git a/tests/fixtures/subscriptions/update-422-codes.json b/tests/fixtures/subscriptions/update-422-codes.json new file mode 100644 index 0000000..5447c1e --- /dev/null +++ b/tests/fixtures/subscriptions/update-422-codes.json @@ -0,0 +1 @@ +{"status":"fail","data":{"error":"VALIDATION_ERROR","message":"Codes contains invalid commodity codes: NOPE_NOT_A_CODE","details":{"codes":["contains invalid commodity codes: NOPE_NOT_A_CODE"]}}} \ No newline at end of file diff --git a/tests/fixtures/subscriptions/update-422-interval.json b/tests/fixtures/subscriptions/update-422-interval.json new file mode 100644 index 0000000..f36262d --- /dev/null +++ b/tests/fixtures/subscriptions/update-422-interval.json @@ -0,0 +1 @@ +{"status":"fail","data":{"error":"VALIDATION_ERROR","message":"Interval seconds is below your plan minimum of 60 seconds","details":{"interval_seconds":["is below your plan minimum of 60 seconds"]}}} \ No newline at end of file diff --git a/tests/fixtures/subscriptions/update.json b/tests/fixtures/subscriptions/update.json new file mode 100644 index 0000000..0e74beb --- /dev/null +++ b/tests/fixtures/subscriptions/update.json @@ -0,0 +1 @@ +{"status":"success","data":{"subscription":{"id":"c27641db-012d-4a22-8939-e77fe05d7eb4","name":"node-78-renamed","codes":["BRENT_CRUDE_USD"],"interval_seconds":3600,"status":"paused","deliver_webhook":false,"source":"api","tool_name":"oilpriceapi-node-78-verification","last_evaluated_at":null,"next_run_at":"2026-09-13T20:15:05Z","created_at":"2026-09-13T20:15:05Z"}}} \ No newline at end of file diff --git a/tests/live/subscriptions.test.ts b/tests/live/subscriptions.test.ts index 3d96c27..a27d9f3 100644 --- a/tests/live/subscriptions.test.ts +++ b/tests/live/subscriptions.test.ts @@ -1,9 +1,20 @@ /** - * LIVE read tests for the #3245 endpoints (market-brief + subscriptions). + * LIVE tests for the #3245 endpoints (market-brief + subscriptions). * - * These hit the REAL authenticated API. They are READ-ONLY (no prod writes): - * - getMarketBrief(["BRENT_CRUDE_USD"]) → 200 with a numeric price - * - subscriptions.list() → 200 (an array) + * These hit the REAL authenticated API. + * - getMarketBrief(["BRENT_CRUDE_USD"]) → 200 with a numeric price (read-only) + * - subscriptions.list() → 200 (an array) (read-only) + * - the #78 lifecycle: create → pause → get → update → resume → pause → delete, + * on the key's OWN account, on a watch this test creates. The watch is paused + * immediately after creation and deleted in `finally`, and the test asserts + * the delete took effect. + * + * A watch is not a billing subscription. Verified in oilpriceapi-api + * `origin/main` before this smoke was written: `SubscriptionsController` + * pause/resume/update only change the `watches` row, and + * `WatchSnapshotWorker` writes a `WatchEvent` without recording an API + * request, so a live watch consumes no request quota between calls. Each + * CRUD call here counts as one normal request. * * Requires a real API key in `process.env.OILPRICEAPI_TEST_KEY`. Absent the key * the suite is SKIPPED so it never fails CI for contributors without the secret. @@ -13,6 +24,7 @@ */ import { describe, it, expect, beforeAll } from "vitest"; import { OilPriceAPI } from "../../src/client.js"; +import { NotFoundError } from "../../src/errors.js"; import { sleep, RATE_LIMIT_DELAY_MS, skipIfRateLimited } from "./helpers.js"; const API_KEY = process.env.OILPRICEAPI_TEST_KEY; @@ -55,4 +67,53 @@ describeLive("LIVE #3245 endpoints (market-brief + subscriptions)", () => { await sleep(RATE_LIMIT_DELAY_MS); } }); + + it("#78 lifecycle: create, pause, get, update, resume, delete — cleaned up in finally", async (ctx) => { + const name = `oilpriceapi-node-live-${Date.now()}`; + let id: string | undefined; + const step = async (fn: () => Promise): Promise => { + const result = await fn(); + await sleep(RATE_LIMIT_DELAY_MS); + return result; + }; + + try { + const created = await step(() => + client.subscriptions.create({ name, codes: ["BRENT_CRUDE_USD"], interval: "1h" }), + ); + id = created.id; + expect(created.status).toBe("active"); + + // Pause before anything else, so the watch is not evaluated while the + // rest of the lifecycle runs. + const paused = await step(() => client.subscriptions.pause(created.id)); + expect(paused.id).toBe(created.id); + expect(paused.status).toBe("paused"); + + const fetched = await step(() => client.subscriptions.get(created.id)); + expect(fetched.name).toBe(name); + expect(fetched.status).toBe("paused"); + + const renamed = await step(() => + client.subscriptions.update(created.id, { name: `${name}-renamed` }), + ); + expect(renamed.name).toBe(`${name}-renamed`); + expect(renamed.status).toBe("paused"); + + const resumed = await step(() => client.subscriptions.resume(created.id)); + expect(resumed.status).toBe("active"); + + const pausedAgain = await step(() => client.subscriptions.pause(created.id)); + expect(pausedAgain.status).toBe("paused"); + } catch (e) { + skipIfRateLimited(e, ctx); + } finally { + if (id) { + await client.subscriptions.delete(id); + await sleep(RATE_LIMIT_DELAY_MS); + await expect(client.subscriptions.get(id)).rejects.toBeInstanceOf(NotFoundError); + await sleep(RATE_LIMIT_DELAY_MS); + } + } + }, 60_000); }); diff --git a/tests/resources/subscriptions.test.ts b/tests/resources/subscriptions.test.ts index eaefbd5..dbd2aee 100644 --- a/tests/resources/subscriptions.test.ts +++ b/tests/resources/subscriptions.test.ts @@ -1,166 +1,398 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { OilPriceAPI } from "../../src/client.js"; +/** + * #78 — subscriptions (watches) through the REAL client and a REAL `fetch` mock. + * + * Fixtures in tests/fixtures/subscriptions/ are verbatim from a live lifecycle + * run against `api.oilpriceapi.com` on 2026-09-13 with the test key's own + * account: create -> pause -> get -> update -> resume -> delete. The watch was + * deleted in `finally` (DELETE 204, then GET 404). + * + * The previous suite spied on the private `request` method, so it could not + * see the envelope production actually sends, and `list()` turned a missing + * `subscriptions` key into an empty array. + */ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { readFileSync } from "node:fs"; import { + OilPriceAPI, + OilPriceAPIError, + NotFoundError, + RateLimitError, + TimeoutError, + ValidationError, intervalToSeconds, - type Subscription, - type SubscriptionEventsResult, -} from "../../src/resources/subscriptions.js"; -import { ValidationError } from "../../src/errors.js"; - -const mockWatch: Subscription = { - id: "11111111-1111-1111-1111-111111111111", - name: "Crude desk", - codes: ["BRENT_CRUDE_USD", "WTI_USD"], - interval_seconds: 300, - status: "active", - deliver_webhook: false, - source: "sdk-node", - tool_name: null, - last_evaluated_at: null, - next_run_at: "2026-06-21T00:05:00Z", - created_at: "2026-06-21T00:00:00Z", -}; - -describe("SubscriptionsResource", () => { - let client: OilPriceAPI; - - beforeEach(() => { - client = new OilPriceAPI({ apiKey: "test_key_123" }); - vi.clearAllMocks(); - }); - - describe("list()", () => { - it("returns subscriptions from the { subscriptions } envelope", async () => { - const spy = vi - .spyOn(client as any, "request") - .mockResolvedValue({ subscriptions: [mockWatch] }); - - const result = await client.subscriptions.list(); - - expect(spy).toHaveBeenCalledWith("/v1/subscriptions", {}); - expect(result).toEqual([mockWatch]); - expect(result).toHaveLength(1); - }); + isQuotaError, +} from "../../src/index.js"; - it("tolerates a bare array response", async () => { - vi.spyOn(client as any, "request").mockResolvedValue([mockWatch]); - const result = await client.subscriptions.list(); - expect(result).toEqual([mockWatch]); - }); +const KEY = "fixture_key_not_a_real_credential"; +const ID = "c27641db-012d-4a22-8939-e77fe05d7eb4"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Json = any; +const fixture = (name: string): Json => + JSON.parse(readFileSync(`tests/fixtures/subscriptions/${name}.json`, "utf8")); - it("returns [] when subscriptions is missing", async () => { - vi.spyOn(client as any, "request").mockResolvedValue({}); - const result = await client.subscriptions.list(); - expect(result).toEqual([]); +interface Wire { + method: string; + pathname: string; + params: Record; + headers: Record; + body: unknown; +} + +function serve(body: unknown, status = 200, headers: Record = {}): Wire[] { + const wire: Wire[] = []; + vi.spyOn(global, "fetch").mockImplementation((async (input: unknown, init: RequestInit) => { + const url = new URL(String(input)); + wire.push({ + method: (init?.method ?? "GET").toUpperCase(), + pathname: url.pathname, + params: Object.fromEntries(url.searchParams), + headers: (init?.headers ?? {}) as Record, + body: init?.body === undefined ? undefined : JSON.parse(String(init.body)), + }); + if (status === 204) return new Response(null, { status }); + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, }); + }) as unknown as typeof fetch); + return wire; +} + +const client = (options: { retries?: number; timeout?: number } = {}) => + new OilPriceAPI({ + apiKey: KEY, + retries: options.retries ?? 0, + retryDelay: 1, + timeout: options.timeout, }); - describe("create()", () => { - it("maps friendly interval, defaults source to sdk-node, and posts", async () => { - const spy = vi.spyOn(client as any, "request").mockResolvedValue({ subscription: mockWatch }); +afterEach(() => { + vi.restoreAllMocks(); +}); - const result = await client.subscriptions.create({ - name: "Crude desk", - codes: ["BRENT_CRUDE_USD", "WTI_USD"], - interval: "5m", - }); +describe("#78 get()", () => { + it("returns the watch from the { subscription } envelope", async () => { + const wire = serve(fixture("get")); + const watch = await client().subscriptions.get(ID); - expect(spy).toHaveBeenCalledWith( - "/v1/subscriptions", - {}, - { - method: "POST", - body: { - name: "Crude desk", - codes: ["BRENT_CRUDE_USD", "WTI_USD"], - interval_seconds: 300, - }, - headers: { "X-OPA-Source": "sdk-node" }, - }, + expect(wire).toHaveLength(1); + expect(wire[0].method).toBe("GET"); + expect(wire[0].pathname).toBe(`/v1/subscriptions/${ID}`); + expect(watch.id).toBe(ID); + expect(watch.status).toBe("paused"); + expect(watch.codes).toEqual(["BRENT_CRUDE_USD"]); + }); + + it("raises NotFoundError with the API's message for an unknown id", async () => { + serve(fixture("not-found"), 404); + const error = await client() + .subscriptions.get(ID) + .catch((e) => e); + + expect(error).toBeInstanceOf(NotFoundError); + expect(error.message).toBe("Subscription not found"); + expect(error.code).toBe("NOT_FOUND"); + }); + + for (const bad of ["", " ", undefined, 42]) { + it(`rejects id ${JSON.stringify(bad)} before sending`, async () => { + const wire = serve(fixture("get")); + await expect(client().subscriptions.get(bad as unknown as string)).rejects.toBeInstanceOf( + ValidationError, ); - expect(result).toEqual(mockWatch); + expect(wire).toHaveLength(0); }); + } - it("forwards source/tool as X-OPA-Source / X-OPA-Tool headers", async () => { - const spy = vi.spyOn(client as any, "request").mockResolvedValue(mockWatch); + it("keeps a traversing id inside its path segment", async () => { + const wire = serve(fixture("get")); + await client().subscriptions.get("../alerts/someone-elses"); - await client.subscriptions.create({ - codes: ["WTI_USD"], - interval: "1h", - source: "mcp", - tool: "my-bot", - deliverWebhook: true, - }); + expect(wire[0].pathname.startsWith("/v1/subscriptions/")).toBe(true); + expect(wire[0].pathname).not.toContain("/v1/alerts/"); + }); +}); - const [, , options] = spy.mock.calls[0]; - expect(options.headers).toEqual({ "X-OPA-Source": "mcp", "X-OPA-Tool": "my-bot" }); - expect(options.body.interval_seconds).toBe(3600); - expect(options.body.deliver_webhook).toBe(true); - }); +describe("#78 update()", () => { + it("PATCHes only the fields given and returns the updated watch", async () => { + const wire = serve(fixture("update")); + const watch = await client().subscriptions.update(ID, { name: "node-78-renamed" }); - it("defaults interval to 5m (300s) when omitted", async () => { - const spy = vi.spyOn(client as any, "request").mockResolvedValue(mockWatch); - await client.subscriptions.create({ codes: ["WTI_USD"] }); - const [, , options] = spy.mock.calls[0]; - expect(options.body.interval_seconds).toBe(300); - }); + expect(wire[0].method).toBe("PATCH"); + expect(wire[0].pathname).toBe(`/v1/subscriptions/${ID}`); + expect(wire[0].body).toEqual({ name: "node-78-renamed" }); + expect(watch.name).toBe("node-78-renamed"); + }); - it("rejects empty codes", async () => { - await expect(client.subscriptions.create({ codes: [] })).rejects.toThrow(ValidationError); + it("maps interval, deliverWebhook, codes and status to the API's fields", async () => { + const wire = serve(fixture("update")); + await client().subscriptions.update(ID, { + interval: "1h", + deliverWebhook: false, + codes: ["WTI_USD", "BRENT_CRUDE_USD"], + status: "active", }); - it("rejects an invalid interval", async () => { - await expect( - client.subscriptions.create({ codes: ["WTI_USD"], interval: "banana" }), - ).rejects.toThrow(ValidationError); + expect(wire[0].body).toEqual({ + interval_seconds: 3600, + deliver_webhook: false, + codes: ["WTI_USD", "BRENT_CRUDE_USD"], + status: "active", }); }); - describe("delete()", () => { - it("issues a DELETE to the subscription path", async () => { - const spy = vi.spyOn(client as any, "request").mockResolvedValue({}); - await client.subscriptions.delete(mockWatch.id); - expect(spy).toHaveBeenCalledWith( - `/v1/subscriptions/${mockWatch.id}`, - {}, - { method: "DELETE" }, - ); - }); + it("rejects an update with no fields before sending", async () => { + const wire = serve(fixture("update")); + await expect(client().subscriptions.update(ID, {})).rejects.toBeInstanceOf(ValidationError); + expect(wire).toHaveLength(0); + }); - it("rejects an empty id", async () => { - await expect(client.subscriptions.delete("")).rejects.toThrow(ValidationError); - }); + it("rejects empty codes before sending", async () => { + const wire = serve(fixture("update")); + await expect(client().subscriptions.update(ID, { codes: [] })).rejects.toBeInstanceOf( + ValidationError, + ); + expect(wire).toHaveLength(0); }); - describe("events()", () => { - const mockResult: SubscriptionEventsResult = { - cursor: 42, - has_more: false, - events: [{ seq: 42, watch_id: mockWatch.id, type: "evaluated", code: "WTI_USD" }], - }; + it("rejects a status other than active/paused before sending", async () => { + // Measured 2026-09-13: PATCH status=sleeping returns HTTP 500 (api#8471). + const wire = serve(fixture("update")); + await expect( + client().subscriptions.update(ID, { status: "sleeping" as unknown as "active" }), + ).rejects.toBeInstanceOf(ValidationError); + expect(wire).toHaveLength(0); + }); + + it("rejects an invalid interval before sending", async () => { + const wire = serve(fixture("update")); + await expect(client().subscriptions.update(ID, { interval: "banana" })).rejects.toBeInstanceOf( + ValidationError, + ); + expect(wire).toHaveLength(0); + }); - it("passes since/watchId/limit as query params", async () => { - const spy = vi.spyOn(client as any, "request").mockResolvedValue(mockResult); + it("keeps the 422 validation details on the error", async () => { + serve(fixture("update-422-interval"), 422); + const error = await client() + .subscriptions.update(ID, { interval: 1 }) + .catch((e) => e); + + expect(error).toBeInstanceOf(OilPriceAPIError); + expect(error.statusCode).toBe(422); + expect(error.rawBody.data.error).toBe("VALIDATION_ERROR"); + expect(error.rawBody.data.details.interval_seconds).toEqual([ + "is below your plan minimum of 60 seconds", + ]); + }); - const result = await client.subscriptions.events({ - since: 10, - watchId: mockWatch.id, - limit: 50, + it("does not replay a PATCH that timed out, and flags it as ambiguous", async () => { + const fetchSpy = vi.spyOn(global, "fetch").mockImplementation((async ( + _input: unknown, + init: RequestInit, + ) => { + const body = new ReadableStream({ + start(controller) { + init?.signal?.addEventListener("abort", () => + controller.error(Object.assign(new Error("aborted"), { name: "AbortError" })), + ); + }, }); + return new Response(body, { status: 200 }); + }) as unknown as typeof fetch); + + const error = await client({ retries: 2, timeout: 100 }) + .subscriptions.update(ID, { name: "x" }) + .catch((e) => e); + + expect(error).toBeInstanceOf(TimeoutError); + expect(error.ambiguousWrite).toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe("#78 pause() and resume()", () => { + it("pause() POSTs to the member route and returns the paused watch", async () => { + const wire = serve(fixture("pause")); + const watch = await client().subscriptions.pause(ID); + + expect(wire[0].method).toBe("POST"); + expect(wire[0].pathname).toBe(`/v1/subscriptions/${ID}/pause`); + expect(wire[0].body).toBeUndefined(); + expect(watch.status).toBe("paused"); + }); + + it("resume() POSTs to the member route and returns the active watch", async () => { + const wire = serve(fixture("resume")); + const watch = await client().subscriptions.resume(ID); - expect(spy).toHaveBeenCalledWith("/v1/subscriptions/events", { - since: "10", - watch_id: mockWatch.id, - limit: "50", + expect(wire[0].method).toBe("POST"); + expect(wire[0].pathname).toBe(`/v1/subscriptions/${ID}/resume`); + expect(watch.status).toBe("active"); + expect(watch.next_run_at).toBe("2026-09-13T20:15:16Z"); + }); + + it("pause() rejects an empty id before sending", async () => { + const wire = serve(fixture("pause")); + await expect(client().subscriptions.pause("")).rejects.toBeInstanceOf(ValidationError); + expect(wire).toHaveLength(0); + }); + + it("resume() surfaces a 404 as NotFoundError", async () => { + serve(fixture("not-found"), 404); + await expect(client().subscriptions.resume(ID)).rejects.toBeInstanceOf(NotFoundError); + }); + + it("pause() surfaces a 429 as RateLimitError", async () => { + serve({ error: { code: "RATE_LIMITED", message: "Too many requests" } }, 429); + await expect(client().subscriptions.pause(ID)).rejects.toBeInstanceOf(RateLimitError); + }); + + it("resume() maps an aborted fetch to TimeoutError", async () => { + vi.spyOn(global, "fetch").mockRejectedValue( + Object.assign(new Error("This operation was aborted"), { name: "AbortError" }), + ); + await expect(client().subscriptions.resume(ID)).rejects.toBeInstanceOf(TimeoutError); + }); +}); + +describe("#78 entitlement limits keep their recovery metadata", () => { + it("a 402 watch-limit on create is a quota error carrying the upgrade block", async () => { + // Shape from AgentUpgradeTriggers#render_agent_upgrade_required on + // origin/main. Not captured live: the test key's tier allows 1,000 watches. + const body = { + status: "fail", + data: { + error: "WATCH_LIMIT", + message: "Your plan allows up to 1 active watches. Upgrade for more.", + limit: 1, + current: 1, + upgrade_trigger: "watch_limit", + upgrade_url: "https://www.oilpriceapi.com/pricing", + upgrade: { url: "https://www.oilpriceapi.com/pricing", next_tier: "developer" }, + }, + }; + serve(body, 402); + const error = await client() + .subscriptions.create({ codes: ["BRENT_CRUDE_USD"], interval: "1h" }) + .catch((e) => e); + + expect(isQuotaError(error)).toBe(true); + expect(error.rawBody.data.upgrade_trigger).toBe("watch_limit"); + expect(error.rawBody.data.limit).toBe(1); + expect(error.rawBody.data.upgrade.next_tier).toBe("developer"); + }); +}); + +describe("#78 malformed 200 raises unexpected_response_shape", () => { + const watchCases: Array<[string, (c: OilPriceAPI) => Promise]> = [ + ["get", (c) => c.subscriptions.get(ID)], + ["update", (c) => c.subscriptions.update(ID, { name: "x" })], + ["pause", (c) => c.subscriptions.pause(ID)], + ["resume", (c) => c.subscriptions.resume(ID)], + ["create", (c) => c.subscriptions.create({ codes: ["BRENT_CRUDE_USD"] })], + ]; + + for (const [name, call] of watchCases) { + for (const [label, data] of [ + ["no subscription key", { id: ID, status: "active" }], + ["subscription without an id", { subscription: { status: "active" } }], + ["subscription with an unknown status", { subscription: { id: ID, status: "sleeping" } }], + ] as const) { + it(`${name}() with ${label}`, async () => { + serve({ status: "success", data }); + const error = await call(client()).catch((e) => e); + + expect(error).toBeInstanceOf(OilPriceAPIError); + expect(error.code).toBe("unexpected_response_shape"); }); - expect(result).toEqual(mockResult); + } + } + + it("list() with no subscriptions key no longer returns []", async () => { + serve({ status: "success", data: {} }); + const error = await client() + .subscriptions.list() + .catch((e) => e); + + expect(error).toBeInstanceOf(OilPriceAPIError); + expect(error.code).toBe("unexpected_response_shape"); + }); + + it("list() with a non-array subscriptions value", async () => { + serve({ status: "success", data: { subscriptions: "none" } }); + const error = await client() + .subscriptions.list() + .catch((e) => e); + + expect(error.code).toBe("unexpected_response_shape"); + }); +}); + +describe("existing methods through the real client", () => { + it("list() returns the watches from the live envelope", async () => { + const body = fixture("list"); + const wire = serve(body); + const watches = await client().subscriptions.list(); + + expect(wire[0].pathname).toBe("/v1/subscriptions"); + expect(watches).toHaveLength(1); + expect(watches[0].id).toBe(body.data.subscriptions[0].id); + }); + + it("list() returns [] only when the API sends an empty list", async () => { + serve({ status: "success", data: { subscriptions: [] } }); + await expect(client().subscriptions.list()).resolves.toEqual([]); + }); + + it("create() maps the interval, sends attribution headers and returns the watch", async () => { + const wire = serve(fixture("create")); + const watch = await client().subscriptions.create({ + name: "Crude desk", + codes: ["BRENT_CRUDE_USD"], + interval: "1h", + tool: "my-bot", }); - it("works with no options (no params)", async () => { - const spy = vi.spyOn(client as any, "request").mockResolvedValue(mockResult); - await client.subscriptions.events(); - expect(spy).toHaveBeenCalledWith("/v1/subscriptions/events", {}); + expect(wire[0].method).toBe("POST"); + expect(wire[0].body).toEqual({ + name: "Crude desk", + codes: ["BRENT_CRUDE_USD"], + interval_seconds: 3600, }); + expect(wire[0].headers["X-OPA-Source"]).toBe("sdk-node"); + expect(wire[0].headers["X-OPA-Tool"]).toBe("my-bot"); + expect(watch.id).toBe(ID); + expect(watch.status).toBe("active"); + }); + + it("create() rejects empty codes and an invalid interval", async () => { + const wire = serve(fixture("create")); + await expect(client().subscriptions.create({ codes: [] })).rejects.toBeInstanceOf( + ValidationError, + ); + await expect( + client().subscriptions.create({ codes: ["WTI_USD"], interval: "banana" }), + ).rejects.toBeInstanceOf(ValidationError); + expect(wire).toHaveLength(0); + }); + + it("delete() sends DELETE and resolves on 204", async () => { + const wire = serve(null, 204); + await expect(client().subscriptions.delete(ID)).resolves.toBeUndefined(); + + expect(wire[0].method).toBe("DELETE"); + expect(wire[0].pathname).toBe(`/v1/subscriptions/${ID}`); + }); + + it("events() passes since/watchId/limit as query params", async () => { + const wire = serve({ status: "success", data: { cursor: 42, has_more: false, events: [] } }); + const result = await client().subscriptions.events({ since: 10, watchId: ID, limit: 50 }); + + expect(wire[0].pathname).toBe("/v1/subscriptions/events"); + expect(wire[0].params).toEqual({ since: "10", watch_id: ID, limit: "50" }); + expect(result.cursor).toBe(42); }); }); diff --git a/tests/subscriptions-lifecycle.test-d.ts b/tests/subscriptions-lifecycle.test-d.ts new file mode 100644 index 0000000..e645b00 --- /dev/null +++ b/tests/subscriptions-lifecycle.test-d.ts @@ -0,0 +1,71 @@ +/** + * #78 — type-level contract for the subscriptions lifecycle methods. + * + * Shapes verified against a live lifecycle run on 2026-09-13 + * (tests/fixtures/subscriptions/): get, update, pause and resume all return + * `data: { subscription: {...} }`, which the SDK unwraps to a `Subscription`. + */ +import { describe, it, expectTypeOf } from "vitest"; +import type { + OilPriceAPI, + Subscription, + SubscriptionStatus, + UpdateSubscriptionParams, +} from "../src/index.js"; + +type Subs = OilPriceAPI["subscriptions"]; +declare const subs: Subs; + +describe("#78 lifecycle method signatures", () => { + it("get/pause/resume take an id and return the watch", () => { + expectTypeOf().parameter(0).toEqualTypeOf(); + expectTypeOf().parameter(0).toEqualTypeOf(); + expectTypeOf().parameter(0).toEqualTypeOf(); + expectTypeOf>>().toEqualTypeOf(); + expectTypeOf>>().toEqualTypeOf(); + expectTypeOf>>().toEqualTypeOf(); + }); + + it("update takes an id and UpdateSubscriptionParams and returns the watch", () => { + expectTypeOf().parameter(0).toEqualTypeOf(); + expectTypeOf().parameter(1).toEqualTypeOf(); + expectTypeOf>>().toEqualTypeOf(); + }); + + it("cannot be called without an id", () => { + // @ts-expect-error — id is required + void subs.get(); + // @ts-expect-error — id is required + void subs.pause(); + // @ts-expect-error — id and params are required + void subs.update(); + }); +}); + +describe("#78 UpdateSubscriptionParams", () => { + it("accepts the fields the API permits", () => { + const params: UpdateSubscriptionParams = { + name: "Crude desk", + codes: ["BRENT_CRUDE_USD"], + interval: "1h", + deliverWebhook: false, + status: "paused", + }; + void params; + expectTypeOf().toEqualTypeOf< + SubscriptionStatus | undefined + >(); + }); + + it("rejects a status the API does not accept", () => { + // @ts-expect-error — the API returns HTTP 500 for any other status (api#8471) + const bad: UpdateSubscriptionParams = { status: "sleeping" }; + void bad; + }); + + it("does not accept the raw wire field names", () => { + // @ts-expect-error — use interval, which the SDK maps to interval_seconds + const bad: UpdateSubscriptionParams = { interval_seconds: 60 }; + void bad; + }); +});