From ec35c279ca1163c09d04316a913f52ad83d81e3b Mon Sep 17 00:00:00 2001 From: infinityplusone Date: Wed, 21 Jan 2026 08:26:03 -0500 Subject: [PATCH] Implement Webhooks v2 support with event type discovery, subscription management, and test delivery. Update HTTP client to support PATCH, PUT, and DELETE methods. Enhance documentation and tests for new features. --- .gitignore | 2 + CHANGELOG.md | 10 ++ README.md | 2 +- docs/API_REFERENCE.md | 130 +++++++++++++++++++- src/client.ts | 108 +++++++++++++++++ src/models/Webhooks.ts | 95 +++++++++++++++ src/models/index.ts | 14 +++ src/utils/http.ts | 14 ++- tests/unit/client.test.ts | 247 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 618 insertions(+), 4 deletions(-) create mode 100644 src/models/Webhooks.ts diff --git a/.gitignore b/.gitignore index 385df2a..1601c49 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ dist/ coverage/ .build/ .tmp/ +.npm-cache/ # Editor / OS .DS_Store @@ -26,3 +27,4 @@ coverage/ # Other ROADMAP.md +yoni/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cd4c84..73fd992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to `@makegov/tango-node` will be documented in this file. This project follows [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +- Webhooks v2 client support: event type discovery, subscription CRUD, endpoint management, test delivery, and sample payload helpers. (refs `makegov/tango#1275`) + +### Changed + +- HTTP client now supports PATCH/PUT/DELETE for non-GET endpoints. + ## [0.1.0] - 2025-11-21 - Initial Node.js port of the Tango Python SDK. diff --git a/README.md b/README.md index 6b64e4a..301af48 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A modern Node.js SDK for the [Tango API](https://tango.makegov.com), featuring d - **Dynamic Response Shaping** – Ask Tango for exactly the fields you want using a simple shape syntax. - **Type-Safe by Design** – Shape strings are validated against Tango schemas and mapped to generated TypeScript types. -- **Comprehensive API Coverage** – Agencies, business types, entities, contracts, forecasts, opportunities, notices, and grants. +- **Comprehensive API Coverage** – Agencies, business types, entities, contracts, forecasts, opportunities, notices, grants, and webhooks. - **Flexible Data Access** – Plain JavaScript objects backed by runtime validation and parsing, materialized via the dynamic model pipeline. - **Modern Node** – Built for Node 18+ with native `fetch` and ESM-first design. - **Tested Against the Real API** – Integration tests (mirroring the Python SDK) keep behavior aligned. diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 0c1aca1..e0a0ea9 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -34,7 +34,7 @@ const resp = await client.listAgencies({ page: 1, limit: 25 }); | `page` | `number` | Page number (default 1). | | `limit` | `number` | Max results per page (default 25, max 100). | -#### Returns +#### Returns (Agencies) `PaginatedResponse` @@ -109,7 +109,7 @@ page: number, limit: number ``` -#### Returns +#### Returns (Contracts) `PaginatedResponse` materialized according to the requested shape. Date/datetime fields are parsed, decimals normalized to strings, nested recipients, agencies, and locations are objects. @@ -167,6 +167,132 @@ Search SAM.gov opportunities with shaping. --- +## Webhooks (v2) + +Webhook APIs let **Large / Enterprise** users manage subscription filters for outbound Tango webhooks. + +### `listWebhookEventTypes()` + +Discover supported `event_type` values and subject types. + +```ts +const info = await client.listWebhookEventTypes(); +``` + +### `listWebhookSubscriptions(options?)` + +```ts +const subs = await client.listWebhookSubscriptions({ page: 1, pageSize: 25 }); +``` + +Notes: + +- Uses `page` + `page_size` (not `limit`) for pagination on this endpoint. + +### `getWebhookSubscription(id)` + +```ts +const sub = await client.getWebhookSubscription("SUBSCRIPTION_UUID"); +``` + +### `createWebhookSubscription({ subscriptionName, payload })` + +```ts +await client.createWebhookSubscription({ + subscriptionName: "Track specific vendors", + payload: { + records: [ + { event_type: "awards.new_award", subject_type: "entity", subject_ids: ["UEI123ABC"] }, + { event_type: "awards.new_transaction", subject_type: "entity", subject_ids: ["UEI123ABC"] }, + ], + }, +}); +``` + +Notes: + +- Prefer v2 fields: `subject_type` + `subject_ids`. +- Legacy compatibility: `resource_ids` is accepted as an alias for `subject_ids` (don’t send both). +- Catch-all: `subject_ids: []` means “all subjects” for that record and is **Enterprise-only**. Large tier users must list specific IDs. + +### `updateWebhookSubscription(id, patch)` + +```ts +await client.updateWebhookSubscription("SUBSCRIPTION_UUID", { + subscriptionName: "Updated name", +}); +``` + +### `deleteWebhookSubscription(id)` + +```ts +await client.deleteWebhookSubscription("SUBSCRIPTION_UUID"); +``` + +### Webhook endpoints + +In production, MakeGov provisions the initial endpoint for you. These methods are most useful for dev/self-service. + +```ts +const endpoints = await client.listWebhookEndpoints({ page: 1, limit: 25 }); +const endpoint = await client.getWebhookEndpoint("ENDPOINT_UUID"); +``` + +```ts +// Create (one endpoint per user) +const created = await client.createWebhookEndpoint({ callbackUrl: "https://example.com/tango/webhooks" }); + +// Update +await client.updateWebhookEndpoint(created.id, { isActive: false }); + +// Delete +await client.deleteWebhookEndpoint(created.id); +``` + +### `testWebhookDelivery(options?)` + +Send an immediate test webhook to your configured endpoint. + +```ts +const result = await client.testWebhookDelivery(); +``` + +### `getWebhookSamplePayload(options?)` + +Fetch Tango-shaped sample deliveries (and sample subscription request bodies). + +```ts +const sample = await client.getWebhookSamplePayload({ eventType: "awards.new_award" }); +``` + +### Deliveries / redelivery + +The API does not currently expose a public `/api/webhooks/deliveries/` or redelivery endpoint. Use: + +- `testWebhookDelivery()` for connectivity checks +- `getWebhookSamplePayload()` for building handlers + subscription payloads + +### Receiving webhooks (signature verification) + +Every delivery includes an HMAC signature header: + +- `X-Tango-Signature: sha256=` + +Compute the digest over the **raw request body bytes** using your shared secret. + +```ts +import crypto from "node:crypto"; + +export function verifyTangoWebhookSignature(secret: string, rawBody: Buffer, signatureHeader: string | null): boolean { + if (!signatureHeader) return false; + const sig = signatureHeader.startsWith("sha256=") ? signatureHeader.slice("sha256=".length) : signatureHeader; + const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); + return crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(sig, "hex")); +} +``` + +--- + ## Error Types All thrown by async methods: diff --git a/src/client.ts b/src/client.ts index 6fef460..334d6b7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -6,6 +6,14 @@ import type { ShapeSpec } from "./shapes/types.js"; import { HttpClient } from "./utils/http.js"; import { unflattenResponse } from "./utils/unflatten.js"; import { PaginatedResponse, TangoClientOptions } from "./types.js"; +import type { + WebhookEndpoint, + WebhookEventTypesResponse, + WebhookSamplePayloadResponse, + WebhookSubscription, + WebhookSubscriptionPayload, + WebhookTestDeliveryResult, +} from "./models/Webhooks.js"; type AnyRecord = Record; @@ -99,6 +107,11 @@ export interface ListEntitiesOptions extends ListOptionsBase { [key: string]: unknown; } +export interface ListWebhookSubscriptionsOptions { + page?: number; + pageSize?: number; +} + export class TangoClient { private readonly http: HttpClient; private readonly shapeParser: ShapeParser; @@ -403,6 +416,101 @@ export class TangoClient { return paginated; } + // --------------------------------------------------------------------------- + // Webhooks (v2) + // --------------------------------------------------------------------------- + + async listWebhookEventTypes(): Promise { + return await this.http.get("/api/webhooks/event-types/"); + } + + async listWebhookSubscriptions(options: ListWebhookSubscriptionsOptions = {}): Promise> { + const { page = 1, pageSize } = options; + const params: AnyRecord = { page }; + if (pageSize !== undefined) params.page_size = pageSize; + + const data = await this.http.get("/api/webhooks/subscriptions/", params); + return buildPaginatedResponse(data); + } + + async getWebhookSubscription(id: string): Promise { + if (!id) throw new TangoValidationError("Webhook subscription id is required"); + return await this.http.get(`/api/webhooks/subscriptions/${encodeURIComponent(id)}/`); + } + + async createWebhookSubscription(options: { subscriptionName: string; payload: WebhookSubscriptionPayload }): Promise { + const { subscriptionName, payload } = options; + if (!subscriptionName) throw new TangoValidationError("Webhook subscriptionName is required"); + return await this.http.post("/api/webhooks/subscriptions/", { + subscription_name: subscriptionName, + payload, + }); + } + + async updateWebhookSubscription( + id: string, + options: { subscriptionName?: string; payload?: WebhookSubscriptionPayload }, + ): Promise { + if (!id) throw new TangoValidationError("Webhook subscription id is required"); + const body: AnyRecord = {}; + if (options.subscriptionName !== undefined) body.subscription_name = options.subscriptionName; + if (options.payload !== undefined) body.payload = options.payload; + return await this.http.patch(`/api/webhooks/subscriptions/${encodeURIComponent(id)}/`, body); + } + + async deleteWebhookSubscription(id: string): Promise { + if (!id) throw new TangoValidationError("Webhook subscription id is required"); + await this.http.delete(`/api/webhooks/subscriptions/${encodeURIComponent(id)}/`); + } + + async listWebhookEndpoints(options: { page?: number; limit?: number } = {}): Promise> { + const { page = 1, limit = 25 } = options; + const params: AnyRecord = { page, limit: Math.min(limit, 100) }; + const data = await this.http.get("/api/webhooks/endpoints/", params); + + // Endpoints are commonly paginated like other Tango resources, but keep this resilient. + if (Array.isArray(data)) { + return { count: data.length, next: null, previous: null, pageMetadata: null, results: data as WebhookEndpoint[] }; + } + return buildPaginatedResponse(data); + } + + async getWebhookEndpoint(id: string): Promise { + if (!id) throw new TangoValidationError("Webhook endpoint id is required"); + return await this.http.get(`/api/webhooks/endpoints/${encodeURIComponent(id)}/`); + } + + async createWebhookEndpoint(options: { callbackUrl: string; isActive?: boolean }): Promise { + const { callbackUrl, isActive = true } = options; + if (!callbackUrl) throw new TangoValidationError("Webhook callbackUrl is required"); + return await this.http.post("/api/webhooks/endpoints/", { callback_url: callbackUrl, is_active: isActive }); + } + + async updateWebhookEndpoint(id: string, options: { callbackUrl?: string; isActive?: boolean }): Promise { + if (!id) throw new TangoValidationError("Webhook endpoint id is required"); + const body: AnyRecord = {}; + if (options.callbackUrl !== undefined) body.callback_url = options.callbackUrl; + if (options.isActive !== undefined) body.is_active = options.isActive; + return await this.http.patch(`/api/webhooks/endpoints/${encodeURIComponent(id)}/`, body); + } + + async deleteWebhookEndpoint(id: string): Promise { + if (!id) throw new TangoValidationError("Webhook endpoint id is required"); + await this.http.delete(`/api/webhooks/endpoints/${encodeURIComponent(id)}/`); + } + + async testWebhookDelivery(options: { endpointId?: string } = {}): Promise { + const body: AnyRecord = {}; + if (options.endpointId) body.endpoint_id = options.endpointId; + return await this.http.post("/api/webhooks/endpoints/test-delivery/", body); + } + + async getWebhookSamplePayload(options: { eventType?: string } = {}): Promise { + const params: AnyRecord = {}; + if (options.eventType) params.event_type = options.eventType; + return await this.http.get("/api/webhooks/endpoints/sample-payload/", params); + } + private parseShape(shape: string | null | undefined, flat: boolean, flatLists: boolean): ShapeSpec | null { if (!shape) return null; return this.shapeParser.parseWithFlags(shape, flat, flatLists); diff --git a/src/models/Webhooks.ts b/src/models/Webhooks.ts new file mode 100644 index 0000000..9a0b520 --- /dev/null +++ b/src/models/Webhooks.ts @@ -0,0 +1,95 @@ +export interface WebhookSubscriptionPayloadRecord { + event_type: string; + subject_type?: string | null; + subject_ids?: string[]; + // Legacy compatibility (v1) + resource_ids?: string[]; +} + +export interface WebhookSubscriptionPayload { + records: WebhookSubscriptionPayloadRecord[]; +} + +export interface WebhookSubscription { + id: string; + endpoint?: string; + subscription_name: string; + payload: WebhookSubscriptionPayload | null; + created_at: string; +} + +export interface WebhookEndpoint { + id: string; + name: string; + callback_url: string; + secret?: string; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface WebhookEventType { + event_type: string; + default_subject_type: string; + description: string; + schema_version: number; +} + +export interface WebhookSubjectTypeDefinition { + subject_type: string; + description: string; + id_format: string; + status: string; +} + +export interface WebhookEventTypesResponse { + event_types: WebhookEventType[]; + subject_types: string[]; + subject_type_definitions: WebhookSubjectTypeDefinition[]; +} + +export interface WebhookTestDeliveryResult { + success: boolean; + status_code?: number; + response_time_ms?: number; + endpoint_url?: string; + message?: string; + error?: string; + response_body?: string; + test_payload?: Record; +} + +export interface WebhookSampleSubject { + subject_type: string; + subject_id: string; +} + +export interface WebhookSampleDelivery { + timestamp: string; + events: Array>; +} + +export interface WebhookSamplePayloadSingleResponse { + event_type: string; + sample_delivery: WebhookSampleDelivery; + sample_subjects: WebhookSampleSubject[]; + sample_subscription_requests: Record; + signature_header: string; + note: string; +} + +export interface WebhookSamplePayloadAllResponse { + samples: Record< + string, + { + sample_delivery: WebhookSampleDelivery; + sample_subjects: WebhookSampleSubject[]; + sample_subscription_requests: Record; + } + >; + usage: string; + signature_header: string; + note: string; +} + +export type WebhookSamplePayloadResponse = WebhookSamplePayloadSingleResponse | WebhookSamplePayloadAllResponse; diff --git a/src/models/index.ts b/src/models/index.ts index 8301670..d7f3e17 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -8,3 +8,17 @@ export type { Location } from "./Location.js"; export type { Notice } from "./Notice.js"; export type { Opportunity } from "./Opportunity.js"; export type { RecipientProfile } from "./RecipientProfile.js"; +export type { + WebhookEndpoint, + WebhookEventType, + WebhookEventTypesResponse, + WebhookSamplePayloadAllResponse, + WebhookSamplePayloadResponse, + WebhookSamplePayloadSingleResponse, + WebhookSampleSubject, + WebhookSubscription, + WebhookSubscriptionPayload, + WebhookSubscriptionPayloadRecord, + WebhookSubjectTypeDefinition, + WebhookTestDeliveryResult, +} from "./Webhooks.js"; diff --git a/src/utils/http.ts b/src/utils/http.ts index 02be821..20315aa 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -9,7 +9,7 @@ export interface HttpClientOptions { } export interface RequestOptions { - method: "GET" | "POST"; + method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; path: string; query?: Record; body?: unknown; @@ -195,4 +195,16 @@ export class HttpClient { post(path: string, body?: unknown): Promise { return this.request({ method: "POST", path, body }); } + + patch(path: string, body?: unknown): Promise { + return this.request({ method: "PATCH", path, body }); + } + + put(path: string, body?: unknown): Promise { + return this.request({ method: "PUT", path, body }); + } + + delete(path: string, query?: Record): Promise { + return this.request({ method: "DELETE", path, query }); + } } diff --git a/tests/unit/client.test.ts b/tests/unit/client.test.ts index bfbac8d..817e9e9 100644 --- a/tests/unit/client.test.ts +++ b/tests/unit/client.test.ts @@ -291,4 +291,251 @@ describe("TangoClient", () => { expect(contract.recipient?.display_name).toBe("Acme"); expect((contract as any).total_contract_value).toBe("123.45"); }); + + it("supports webhooks v2 endpoints (event types, subscriptions, test delivery, sample payload)", async () => { + const calls: { url: string; init: RequestInit }[] = []; + + const fetchImpl = async (url: string | URL, init?: RequestInit): Promise => { + calls.push({ url: String(url), init: init ?? {} }); + const parsed = new URL(String(url)); + const method = String(init?.method ?? "GET").toUpperCase(); + + if (parsed.pathname === "/api/webhooks/event-types/" && method === "GET") { + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + event_types: [{ event_type: "awards.new_award", default_subject_type: "entity", description: "", schema_version: 1 }], + subject_types: ["entity"], + subject_type_definitions: [{ subject_type: "entity", description: "Entity UEI", id_format: "UEI", status: "active" }], + }); + }, + }; + } + + if (parsed.pathname === "/api/webhooks/subscriptions/" && method === "GET") { + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + count: 1, + next: null, + previous: null, + results: [{ id: "sub-1", subscription_name: "My sub", payload: { records: [] }, created_at: "2026-01-01T00:00:00Z" }], + }); + }, + }; + } + + if (parsed.pathname === "/api/webhooks/subscriptions/" && method === "POST") { + return { + ok: true, + status: 201, + async text() { + return JSON.stringify({ id: "sub-1", subscription_name: "My sub", payload: { records: [] }, created_at: "2026-01-01T00:00:00Z" }); + }, + }; + } + + if (parsed.pathname === "/api/webhooks/subscriptions/sub-1/" && method === "PATCH") { + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ id: "sub-1", subscription_name: "Updated", payload: { records: [] }, created_at: "2026-01-01T00:00:00Z" }); + }, + }; + } + + if (parsed.pathname === "/api/webhooks/subscriptions/sub-1/" && method === "DELETE") { + return { + ok: true, + status: 204, + async text() { + return ""; + }, + }; + } + + if (parsed.pathname === "/api/webhooks/endpoints/test-delivery/" && method === "POST") { + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ success: true, status_code: 200, message: "ok" }); + }, + }; + } + + if (parsed.pathname === "/api/webhooks/endpoints/sample-payload/" && method === "GET") { + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + event_type: "awards.new_award", + sample_delivery: { timestamp: "2026-01-01T00:00:00Z", events: [{ event_type: "awards.new_award" }] }, + sample_subjects: [{ subject_type: "entity", subject_id: "UEI123" }], + sample_subscription_requests: {}, + signature_header: "X-Tango-Signature: sha256=", + note: "sample", + }); + }, + }; + } + + if (parsed.pathname === "/api/webhooks/endpoints/" && method === "GET") { + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + count: 1, + next: null, + previous: null, + results: [ + { + id: "ep-1", + name: "yoni", + callback_url: "https://example.com/tango/webhooks", + is_active: true, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, + ], + }); + }, + }; + } + + if (parsed.pathname === "/api/webhooks/endpoints/" && method === "POST") { + return { + ok: true, + status: 201, + async text() { + return JSON.stringify({ + id: "ep-1", + name: "yoni", + callback_url: "https://example.com/tango/webhooks", + secret: "secret", + is_active: true, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }); + }, + }; + } + + if (parsed.pathname === "/api/webhooks/endpoints/ep-1/" && method === "PATCH") { + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + id: "ep-1", + name: "yoni", + callback_url: "https://example.com/tango/webhooks", + is_active: false, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + }); + }, + }; + } + + if (parsed.pathname === "/api/webhooks/endpoints/ep-1/" && method === "DELETE") { + return { + ok: true, + status: 204, + async text() { + return ""; + }, + }; + } + + throw new Error(`Unexpected request: ${method} ${parsed.pathname}`); + }; + + const client = new TangoClient({ apiKey: "test", baseUrl: "https://example.test", fetchImpl }); + + const eventTypes = await client.listWebhookEventTypes(); + expect(eventTypes.event_types[0].event_type).toBe("awards.new_award"); + + const subs = await client.listWebhookSubscriptions({ page: 2, pageSize: 25 }); + expect(subs.count).toBe(1); + expect(subs.results[0].subscription_name).toBe("My sub"); + + await client.createWebhookSubscription({ subscriptionName: "My sub", payload: { records: [] } }); + await client.updateWebhookSubscription("sub-1", { subscriptionName: "Updated" }); + await client.deleteWebhookSubscription("sub-1"); + + const testResult = await client.testWebhookDelivery(); + expect(testResult.success).toBe(true); + + const sample = await client.getWebhookSamplePayload({ eventType: "awards.new_award" }); + expect((sample as any).event_type).toBe("awards.new_award"); + + const endpoints = await client.listWebhookEndpoints({ page: 2, limit: 10 }); + expect(endpoints.count).toBe(1); + expect(endpoints.results[0].name).toBe("yoni"); + + const created = await client.createWebhookEndpoint({ callbackUrl: "https://example.com/tango/webhooks" }); + expect(created.secret).toBe("secret"); + + const updated = await client.updateWebhookEndpoint(created.id, { isActive: false }); + expect(updated.is_active).toBe(false); + + await client.deleteWebhookEndpoint(created.id); + + const listSubsCall = calls.find( + (c) => new URL(c.url).pathname === "/api/webhooks/subscriptions/" && String(c.init.method ?? "GET").toUpperCase() === "GET", + ); + expect(listSubsCall).toBeTruthy(); + const listSubsQuery = new URL(listSubsCall!.url).searchParams; + expect(listSubsQuery.get("page")).toBe("2"); + expect(listSubsQuery.get("page_size")).toBe("25"); + + const sampleCall = calls.find((c) => new URL(c.url).pathname === "/api/webhooks/endpoints/sample-payload/"); + expect(sampleCall).toBeTruthy(); + const sampleQuery = new URL(sampleCall!.url).searchParams; + expect(sampleQuery.get("event_type")).toBe("awards.new_award"); + + const createCall = calls.find( + (c) => new URL(c.url).pathname === "/api/webhooks/subscriptions/" && String(c.init.method).toUpperCase() === "POST", + ); + expect(createCall).toBeTruthy(); + const createBody = JSON.parse(String(createCall!.init.body ?? "{}")); + expect(createBody.subscription_name).toBe("My sub"); + expect(createBody.payload).toEqual({ records: [] }); + + const listEndpointsCall = calls.find( + (c) => new URL(c.url).pathname === "/api/webhooks/endpoints/" && String(c.init.method ?? "GET").toUpperCase() === "GET", + ); + expect(listEndpointsCall).toBeTruthy(); + const listEndpointsQuery = new URL(listEndpointsCall!.url).searchParams; + expect(listEndpointsQuery.get("page")).toBe("2"); + expect(listEndpointsQuery.get("limit")).toBe("10"); + + const createEndpointCall = calls.find( + (c) => new URL(c.url).pathname === "/api/webhooks/endpoints/" && String(c.init.method).toUpperCase() === "POST", + ); + expect(createEndpointCall).toBeTruthy(); + const createEndpointBody = JSON.parse(String(createEndpointCall!.init.body ?? "{}")); + expect(createEndpointBody.callback_url).toBe("https://example.com/tango/webhooks"); + expect(createEndpointBody.is_active).toBe(true); + + const updateEndpointCall = calls.find( + (c) => new URL(c.url).pathname === "/api/webhooks/endpoints/ep-1/" && String(c.init.method).toUpperCase() === "PATCH", + ); + expect(updateEndpointCall).toBeTruthy(); + const updateEndpointBody = JSON.parse(String(updateEndpointCall!.init.body ?? "{}")); + expect(updateEndpointBody.is_active).toBe(false); + + const deleteEndpointCall = calls.find( + (c) => new URL(c.url).pathname === "/api/webhooks/endpoints/ep-1/" && String(c.init.method).toUpperCase() === "DELETE", + ); + expect(deleteEndpointCall).toBeTruthy(); + }); });