diff --git a/.env.example b/.env.example index 80c7407..b90aba9 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,11 @@ MAKE_ORGANIZATION="12345" # Numeric team ID (used by most integration tests, e.g. scenarios, connections, executions) MAKE_TEAM="12345" +# Dedicated non-confidential scheduled webhook whose inactive scenario leaves deliveries queued; +# token needs hooks:read + hooks:write +MAKE_HOOK_INCOMINGS_HOOK_ID="" +MAKE_HOOK_INCOMINGS_WEBHOOK_URL="" + # Existing SDK app name for SDK integration tests; if unset, a temporary app is created and deleted MAKE_APP_NAME="" diff --git a/README.md b/README.md index 1b79918..fb74296 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ const make = new Make('your-api-key', 'eu2.make.com', { - **Scenario Labels** - Team-scoped tags assignable to scenarios - **Functions** - Custom JavaScript functions for scenarios - **Hooks** - Webhooks and mailhooks for external integrations +- **Hook Incoming Queue** - Inspect and clear items queued for a webhook awaiting processing - **Incomplete Executions** - Failed or incomplete scenario runs - **Keys** - API keys and secrets - **On-Prem Agents** - On-prem bridge agents running on customer infrastructure @@ -212,6 +213,7 @@ All tools are organized into the following categories: - `folders` - `functions` - `hooks` +- `hook-incomings` - `incomplete-executions` - `keys` - `scenario-labels` @@ -287,6 +289,17 @@ MAKE_TEAM="" MAKE_ORGANIZATION="" ``` +Required only for Hook Incoming Queue integration tests: + +``` +MAKE_HOOK_INCOMINGS_HOOK_ID="" +MAKE_HOOK_INCOMINGS_WEBHOOK_URL="" +``` + +Use a dedicated non-confidential scheduled webhook whose scenario is inactive so deliveries remain queued. The API +token needs `hooks:read` and `hooks:write`; the tests preserve pre-existing queue items and never create or delete the +hook. + Required for connected-system **create** integration (field names from `getAppConfig`): ``` diff --git a/package-lock.json b/package-lock.json index bc0e78f..bcb712a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@makehq/sdk", - "version": "1.6.14", + "version": "1.6.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@makehq/sdk", - "version": "1.6.14", + "version": "1.6.15", "license": "MIT", "devDependencies": { "@eslint/js": "^9.22.0", diff --git a/package.json b/package.json index 9361bae..83267a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@makehq/sdk", - "version": "1.6.14", + "version": "1.6.15", "description": "Make TypeScript SDK", "license": "MIT", "author": "Make", diff --git a/src/endpoints/hook-incomings.tools.ts b/src/endpoints/hook-incomings.tools.ts new file mode 100644 index 0000000..5a48d3e --- /dev/null +++ b/src/endpoints/hook-incomings.tools.ts @@ -0,0 +1,196 @@ +import type { Make } from '../make.js'; +import type { MakeTool } from '../tools.js'; +import type { DeleteHookIncomingsOptions, ListHookIncomingsOptions } from './hook-incomings.js'; + +export const tools: MakeTool[] = [ + { + name: 'hook-incomings_list', + title: 'List webhook queue items', + description: + "List items currently waiting in a webhook's processing queue — payloads the webhook received but hasn't handed off to a scenario yet (e.g. because the scenario isn't active).", + category: 'hook-incomings', + scope: 'hooks:read', + scopeId: 'hookId', + identifier: 'hookId', + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + inputSchema: { + type: 'object', + properties: { + hookId: { type: 'number', description: 'The hook ID to list queued items for' }, + from: { + type: 'integer', + description: 'Only include items queued at or after this Unix timestamp (ms)', + }, + to: { + type: 'integer', + description: 'Only include items queued at or before this Unix timestamp (ms)', + }, + pg: { + type: 'object', + description: 'Pagination and sorting options', + properties: { + sortBy: { + type: 'string', + enum: ['created'], + description: 'Sort queue items by their creation time', + }, + sortDir: { + type: 'string', + enum: ['asc', 'desc'], + description: 'Sort direction', + }, + offset: { type: 'integer', minimum: 0, description: 'Number of items to skip' }, + limit: { + type: 'integer', + minimum: 1, + maximum: 10_000, + description: 'Maximum number of items to return (up to 10,000)', + }, + }, + additionalProperties: false, + }, + }, + required: ['hookId'], + }, + examples: [{ hookId: 11 }], + execute: async ( + make: Make, + args: { hookId: number; from?: number; to?: number; pg?: ListHookIncomingsOptions['pg'] }, + ) => { + return await make.hooks.incomings.list(args.hookId, { + from: args.from, + to: args.to, + pg: args.pg, + }); + }, + }, + { + name: 'hook-incomings_stats', + title: 'Get webhook queue stats', + description: "Get the current size and limit of a webhook's processing queue.", + category: 'hook-incomings', + scope: 'hooks:read', + scopeId: 'hookId', + identifier: 'hookId', + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + inputSchema: { + type: 'object', + properties: { + hookId: { type: 'number', description: 'The hook ID to get queue stats for' }, + }, + required: ['hookId'], + }, + examples: [{ hookId: 11 }], + execute: async (make: Make, args: { hookId: number }) => { + return await make.hooks.incomings.stats(args.hookId); + }, + }, + { + name: 'hook-incomings_get', + title: 'Get webhook queue item detail', + description: + 'Get the full detail of a single queued item, including its payload when available. Confidential hooks omit payload data.', + category: 'hook-incomings', + scope: 'hooks:read', + scopeId: 'hookId', + identifier: 'hookId', + resourceId: 'incomingId', + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + inputSchema: { + type: 'object', + properties: { + hookId: { type: 'number', description: 'The hook ID the queue item belongs to' }, + incomingId: { + type: 'string', + pattern: '^[0-9a-f]{32}$', + description: 'The 32-character lowercase hexadecimal ID of the queue item to retrieve', + }, + }, + required: ['hookId', 'incomingId'], + }, + examples: [{ hookId: 11, incomingId: '7a567f385d1a4f5ab7bff89162b7605e' }], + execute: async (make: Make, args: { hookId: number; incomingId: string }) => { + return await make.hooks.incomings.get(args.hookId, args.incomingId); + }, + }, + { + name: 'hook-incomings_delete', + title: 'Delete webhook queue items', + description: + "Delete items from a webhook's processing queue. Specify `ids` to delete specific items, or `all: true` (with `confirmed: true`) to clear the entire queue. An item currently being processed cannot be deleted.", + category: 'hook-incomings', + scope: 'hooks:write', + scopeId: 'hookId', + identifier: 'hookId', + annotations: { + readOnlyHint: false, + destructiveHint: true, + openWorldHint: false, + }, + inputSchema: { + type: 'object', + properties: { + hookId: { type: 'number', description: 'The hook ID to delete queue items for' }, + ids: { + type: 'array', + items: { type: 'string' }, + minItems: 1, + description: 'IDs of the queue items to delete', + }, + exceptIds: { + type: 'array', + items: { type: 'string' }, + description: 'When used with `all`, IDs of queue items to keep instead of deleting', + }, + all: { type: 'boolean', description: 'Delete every item in the queue' }, + confirmed: { + type: 'boolean', + description: 'Required (and must be `true`) when `all` is used, to confirm the bulk deletion', + }, + }, + required: ['hookId'], + oneOf: [ + { + type: 'object', + properties: { + hookId: { type: 'number' }, + ids: { type: 'array', items: { type: 'string' }, minItems: 1 }, + }, + required: ['ids'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + hookId: { type: 'number' }, + exceptIds: { type: 'array', items: { type: 'string' } }, + all: { type: 'boolean', const: true }, + confirmed: { type: 'boolean', const: true }, + }, + required: ['all', 'confirmed'], + additionalProperties: false, + }, + ], + }, + examples: [ + { hookId: 11, ids: ['d1efa5318a034d36ad7cbeac543573cf'] }, + { hookId: 11, all: true, confirmed: true }, + ], + execute: async (make: Make, args: { hookId: number } & DeleteHookIncomingsOptions) => { + const { hookId, ...options } = args; + return await make.hooks.incomings.delete(hookId, options); + }, + }, +]; diff --git a/src/endpoints/hook-incomings.ts b/src/endpoints/hook-incomings.ts new file mode 100644 index 0000000..d096624 --- /dev/null +++ b/src/endpoints/hook-incomings.ts @@ -0,0 +1,261 @@ +import type { FetchFunction, JSONValue } from '../types.js'; + +const HOOK_INCOMING_ID_PATTERN = /^[0-9a-f]{32}$/; +const MAX_HOOK_INCOMINGS_LIMIT = 10_000; + +/** + * A single item in a webhook's processing queue. + * Queue items accumulate when a webhook receives data it can't immediately + * hand off to a running scenario (e.g. because the scenario is inactive). + */ +export type HookIncoming = { + /** Unique identifier of the queue item */ + id: string; + /** Scope the item was queued under (e.g. 'hook') */ + scope: string; + /** Size of the queued payload in bytes */ + size: number; + /** ISO 8601 timestamp of when the item was queued */ + created: string; +}; + +/** + * Pagination supported by the webhook processing queue endpoint. + */ +type HookIncomingsPagination = { + /** Queue items can only be sorted by their creation time */ + sortBy: 'created'; + /** Sort direction (ascending or descending) */ + sortDir: 'asc' | 'desc'; + /** Number of queue items to skip */ + offset: number; + /** Maximum number of queue items to return, up to 10,000 */ + limit: number; +}; + +/** + * Options for listing a webhook's queued incoming items. + */ +export type ListHookIncomingsOptions = { + /** Only include items queued at or after this Unix timestamp (ms) */ + from?: number; + /** Only include items queued at or before this Unix timestamp (ms) */ + to?: number; + /** Pagination options supported by the webhook queue */ + pg?: Partial; +}; + +/** + * Response format for listing a webhook's queued items. + */ +type ListHookIncomingsResponse = { + /** Queue items matching the query */ + incomings: HookIncoming[]; + /** Pagination information */ + pg: Partial; +}; + +/** + * Queue size and limit for a webhook. + */ +export type HookIncomingStats = { + /** Number of items currently in the queue */ + queue: number; + /** Maximum number of items the queue can hold */ + limit: number; + /** Whether the queue is enabled */ + enabled: boolean; +}; + +/** + * Response format for getting a webhook's queue stats. + */ +type GetHookIncomingStatsResponse = { + /** The queue stats */ + incomingStat: HookIncomingStats; +}; + +/** + * Detail of a single webhook queue item, including its payload. + */ +export type HookIncomingDetail = HookIncoming & { + /** The queued payload. Omitted when the hook is confidential. */ + data?: JSONValue; + /** Whether the hook is confidential, in which case the payload is withheld */ + isHookConfidential?: boolean; +}; + +/** + * Response format for getting a webhook queue item's detail. + */ +type GetHookIncomingResponse = { + /** The requested queue item, including its payload */ + incoming: HookIncomingDetail; +}; + +/** + * Options for deleting items from a webhook's queue. + * Either `ids` or `all` must be specified. + */ +export type DeleteHookIncomingsOptions = + | { + /** IDs of the queue items to delete */ + ids: string[]; + /** Not available when deleting specific queue items */ + exceptIds?: never; + /** Not available when deleting specific queue items */ + all?: never; + /** Not available when deleting specific queue items */ + confirmed?: never; + } + | { + /** Not available when deleting the entire queue */ + ids?: never; + /** IDs of queue items to keep instead of deleting */ + exceptIds?: string[]; + /** Delete every item in the queue */ + all: true; + /** Confirm the bulk deletion */ + confirmed: true; + }; + +/** + * Result of deleting items from a webhook's queue. + */ +export type DeleteHookIncomingsResult = { + /** IDs of the queue items that were actually deleted */ + deletedIds: string[]; + /** Present when some items could not be deleted because they were being processed */ + error?: { + /** Name of the error */ + name: string; + /** Description of the error */ + message: string; + }; +}; + +/** + * Response format for deleting items from a webhook's queue. + */ +type DeleteHookIncomingsResponse = { + /** IDs of the queue items that were actually deleted */ + incomings: string[]; + /** Present when some items could not be deleted because they were being processed */ + error?: { + name: string; + message: string; + }; +}; + +/** + * Class providing methods for working with a Make webhook's processing queue. + * Items accumulate here when a webhook receives data it can't immediately + * hand off to a running scenario. + */ +export class HookIncomings { + readonly #fetch: FetchFunction; + + /** + * Create a new HookIncomings instance. + * @param fetch Function for making API requests + */ + constructor(fetch: FetchFunction) { + this.#fetch = fetch; + } + + /** + * List items currently queued for a webhook. + * @param hookId The hook ID to list queued items for + * @param options Optional filtering and pagination parameters + * @returns Promise with the list of queued items + * @throws {TypeError} If a timestamp is not an integer or any pagination option is unsupported + */ + async list(hookId: number, options?: ListHookIncomingsOptions): Promise { + if (options?.from !== undefined && !Number.isInteger(options.from)) { + throw new TypeError('`from` must be an integer Unix timestamp in milliseconds when specified'); + } + if (options?.to !== undefined && !Number.isInteger(options.to)) { + throw new TypeError('`to` must be an integer Unix timestamp in milliseconds when specified'); + } + if (options?.pg?.sortBy !== undefined && options.pg.sortBy !== 'created') { + throw new TypeError('`pg.sortBy` must be `created` when specified'); + } + if (options?.pg?.sortDir !== undefined && options.pg.sortDir !== 'asc' && options.pg.sortDir !== 'desc') { + throw new TypeError('`pg.sortDir` must be `asc` or `desc` when specified'); + } + if ( + options?.pg?.limit !== undefined && + (!Number.isInteger(options.pg.limit) || options.pg.limit < 1 || options.pg.limit > MAX_HOOK_INCOMINGS_LIMIT) + ) { + throw new TypeError('`pg.limit` must be an integer between 1 and 10000 when specified'); + } + if (options?.pg?.offset !== undefined && (!Number.isInteger(options.pg.offset) || options.pg.offset < 0)) { + throw new TypeError('`pg.offset` must be a non-negative integer when specified'); + } + + return ( + await this.#fetch(`/hooks/${hookId}/incomings`, { + query: { + from: options?.from, + to: options?.to, + pg: options?.pg, + }, + }) + ).incomings; + } + + /** + * Get queue size and limit for a webhook. + * @param hookId The hook ID to get queue stats for + * @returns Promise with the queue stats + */ + async stats(hookId: number): Promise { + return (await this.#fetch(`/hooks/${hookId}/incomings/stats`)).incomingStat; + } + + /** + * Get detail of a single queued item, including its payload when available. Confidential hooks omit payload data. + * @param hookId The hook ID the queue item belongs to + * @param incomingId The 32-character lowercase hexadecimal ID of the queue item to retrieve + * @returns Promise with the queue item detail + * @throws {TypeError} If `incomingId` is not a valid queue item ID + */ + async get(hookId: number, incomingId: string): Promise { + if (!HOOK_INCOMING_ID_PATTERN.test(incomingId)) { + throw new TypeError('`incomingId` must be a 32-character lowercase hexadecimal string'); + } + + const encodedIncomingId = encodeURIComponent(incomingId); + return (await this.#fetch(`/hooks/${hookId}/incomings/${encodedIncomingId}`)).incoming; + } + + /** + * Delete items from a webhook's queue. + * @param hookId The hook ID to delete queue items for + * @param options Which items to delete + * @returns Promise with the IDs that were deleted and an optional partial-failure error + * @throws {TypeError} If the deletion selector is invalid or a bulk deletion is not confirmed + */ + async delete(hookId: number, options: DeleteHookIncomingsOptions): Promise { + const deletesSpecificItems = options.ids !== undefined; + const deletesAllItems = options.all === true; + + if (deletesSpecificItems === deletesAllItems) { + throw new TypeError('Exactly one of `ids` or `all: true` must be specified'); + } + if (deletesSpecificItems && options.ids.length === 0) { + throw new TypeError('`ids` must contain at least one queue item ID'); + } + if (deletesAllItems && options.confirmed !== true) { + throw new TypeError('`confirmed` must be `true` when `all` is used'); + } + + const { confirmed, ...body } = options; + const response = await this.#fetch(`/hooks/${hookId}/incomings`, { + method: 'DELETE', + query: { confirmed }, + body, + }); + return { deletedIds: response.incomings, error: response.error }; + } +} diff --git a/src/endpoints/hooks.ts b/src/endpoints/hooks.ts index a35353f..c29d75f 100644 --- a/src/endpoints/hooks.ts +++ b/src/endpoints/hooks.ts @@ -1,4 +1,5 @@ import type { FetchFunction, JSONValue, Pagination } from '../types.js'; +import { HookIncomings } from './hook-incomings.js'; /** * Represents a Make webhook or mailhook. @@ -147,12 +148,18 @@ export type ListHooksOptions = { export class Hooks { readonly #fetch: FetchFunction; + /** + * Access to a webhook's processing queue (list, stats, detail, delete). + */ + public readonly incomings: HookIncomings; + /** * Create a new Hooks instance. * @param fetch Function for making API requests */ constructor(fetch: FetchFunction) { this.#fetch = fetch; + this.incomings = new HookIncomings(fetch); } /** diff --git a/src/index.ts b/src/index.ts index eaa815e..8194fa8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -112,6 +112,15 @@ export type { ListFunctionsOptions, } from './endpoints/functions.js'; export type { Hook, Hooks, CreateHookBody, UpdateHookBody, ListHooksOptions, HookPing } from './endpoints/hooks.js'; +export type { + HookIncoming, + HookIncomingDetail, + HookIncomingStats, + HookIncomings, + ListHookIncomingsOptions, + DeleteHookIncomingsOptions, + DeleteHookIncomingsResult, +} from './endpoints/hook-incomings.js'; export type { IncompleteExecution, IncompleteExecutions, diff --git a/src/tools.ts b/src/tools.ts index 4ff05d3..c4dc531 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -20,6 +20,7 @@ import { tools as UsersTools } from './endpoints/users.tools.js'; import { tools as FunctionsTools } from './endpoints/functions.tools.js'; import { tools as ExecutionsTools } from './endpoints/executions.tools.js'; import { tools as HooksTools } from './endpoints/hooks.tools.js'; +import { tools as HookIncomingsTools } from './endpoints/hook-incomings.tools.js'; import { tools as DevicesTools } from './endpoints/devices.tools.js'; import { tools as KeysTools } from './endpoints/keys.tools.js'; import { tools as FoldersTools } from './endpoints/folders.tools.js'; @@ -42,11 +43,11 @@ import { tools as CustomRolesTools } from './endpoints/custom-roles.tools.js'; */ export type JSONSchema = { /** - * The type of the schema (object, string, number, boolean, array, etc.). + * The type of the schema (object, string, number, integer, boolean, array, etc.). * Optional when the schema is expressed purely through composition * (`oneOf`/`anyOf`/`allOf`) or a `const` value. */ - type?: 'object' | 'string' | 'number' | 'boolean' | 'array' | 'null'; + type?: 'object' | 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'null'; /** Properties definition for object types */ properties?: Record; /** Schemas for properties whose names match a regex pattern (object types) */ @@ -225,6 +226,7 @@ export const MakeTools = [ ...ScenarioLabelsTools, ...FunctionsTools, ...HooksTools, + ...HookIncomingsTools, ...DevicesTools, ...DataStructuresTools, ...ConnectionsTools, diff --git a/test/hook-incomings-tools.spec.ts b/test/hook-incomings-tools.spec.ts new file mode 100644 index 0000000..4a62461 --- /dev/null +++ b/test/hook-incomings-tools.spec.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from '@jest/globals'; +import { Make } from '../src/make.js'; +import { MakeTools } from '../src/tools.js'; +import { mockFetch } from './test.utils.js'; + +import * as listMock from './mocks/hook-incomings/list.json'; +import * as statsMock from './mocks/hook-incomings/stats.json'; +import * as getMock from './mocks/hook-incomings/get.json'; +import * as deleteMock from './mocks/hook-incomings/delete.json'; + +const MAKE_API_KEY = 'api-key'; +const MAKE_ZONE = 'make.local'; +const HOOK_ID = 11; + +function getTool(name: string) { + const tool = MakeTools.find(entry => entry.name === name); + if (!tool) { + throw new Error(`Missing MCP tool: ${name}`); + } + return tool; +} + +describe('MCP tools: hook-incomings', () => { + const make = new Make(MAKE_API_KEY, MAKE_ZONE); + + it('Should execute hook-incomings_list', async () => { + mockFetch(`GET https://make.local/api/v2/hooks/${HOOK_ID}/incomings`, listMock); + + const tool = getTool('hook-incomings_list'); + const result = await tool.execute(make, { hookId: HOOK_ID }); + + expect(result).toStrictEqual(listMock.incomings); + }); + + it('Should expose and forward hook-incomings_list pagination', async () => { + const pg = { limit: 10, offset: 20, sortBy: 'created', sortDir: 'desc' } as const; + mockFetch( + `GET https://make.local/api/v2/hooks/${HOOK_ID}/incomings?pg%5Blimit%5D=10&pg%5Boffset%5D=20&pg%5BsortBy%5D=created&pg%5BsortDir%5D=desc`, + listMock, + ); + + const tool = getTool('hook-incomings_list'); + const result = await tool.execute(make, { hookId: HOOK_ID, pg }); + + expect(tool.inputSchema.properties?.pg).toEqual( + expect.objectContaining({ + type: 'object', + properties: expect.objectContaining({ + limit: expect.objectContaining({ type: 'integer', minimum: 1, maximum: 10_000 }), + offset: expect.objectContaining({ type: 'integer', minimum: 0 }), + sortBy: expect.objectContaining({ type: 'string', enum: ['created'] }), + sortDir: expect.objectContaining({ type: 'string', enum: ['asc', 'desc'] }), + }), + }), + ); + expect(result).toStrictEqual(listMock.incomings); + }); + + it('Should constrain hook-incomings_list timestamps to integers', () => { + const tool = getTool('hook-incomings_list'); + + expect(tool.inputSchema.properties?.from).toEqual(expect.objectContaining({ type: 'integer' })); + expect(tool.inputSchema.properties?.to).toEqual(expect.objectContaining({ type: 'integer' })); + }); + + it('Should execute hook-incomings_stats', async () => { + mockFetch(`GET https://make.local/api/v2/hooks/${HOOK_ID}/incomings/stats`, statsMock); + + const tool = getTool('hook-incomings_stats'); + const result = await tool.execute(make, { hookId: HOOK_ID }); + + expect(result).toStrictEqual(statsMock.incomingStat); + }); + + it('Should execute hook-incomings_get', async () => { + mockFetch(`GET https://make.local/api/v2/hooks/${HOOK_ID}/incomings/8d88f6f5b0484908890ef11fe7e5bf63`, getMock); + + const tool = getTool('hook-incomings_get'); + const result = await tool.execute(make, { hookId: HOOK_ID, incomingId: '8d88f6f5b0484908890ef11fe7e5bf63' }); + + expect(result).toStrictEqual(getMock.incoming); + }); + + it('Should constrain hook-incomings_get to queue item IDs', () => { + const tool = getTool('hook-incomings_get'); + + expect(tool.inputSchema.properties?.incomingId?.pattern).toBe('^[0-9a-f]{32}$'); + }); + + it('Should explain that confidential hooks omit queued payload data', () => { + const tool = getTool('hook-incomings_get'); + + expect(tool.description).toContain('Confidential hooks omit payload data'); + }); + + it('Should execute hook-incomings_delete', async () => { + const ids = ['d1efa5318a034d36ad7cbeac543573cf', '29d9a7410dff494ab739036f6c332335']; + + mockFetch(`DELETE https://make.local/api/v2/hooks/${HOOK_ID}/incomings`, deleteMock, req => { + expect(req.body).toStrictEqual({ ids }); + }); + + const tool = getTool('hook-incomings_delete'); + const result = await tool.execute(make, { hookId: HOOK_ID, ids }); + + expect(result).toStrictEqual({ deletedIds: deleteMock.incomings, error: undefined }); + }); + + it('Should describe the valid hook-incomings_delete input variants', () => { + const tool = getTool('hook-incomings_delete'); + const idsVariant = tool.inputSchema.oneOf?.find(schema => schema.required?.includes('ids')); + + expect(tool.inputSchema.oneOf).toEqual( + expect.arrayContaining([ + expect.objectContaining({ required: ['ids'] }), + expect.objectContaining({ + required: ['all', 'confirmed'], + properties: expect.objectContaining({ + all: expect.objectContaining({ const: true }), + confirmed: expect.objectContaining({ const: true }), + }), + }), + ]), + ); + expect(tool.inputSchema.properties?.ids?.minItems).toBe(1); + expect(idsVariant?.properties?.ids?.minItems).toBe(1); + }); +}); diff --git a/test/hook-incomings.integration.test.ts b/test/hook-incomings.integration.test.ts new file mode 100644 index 0000000..8128ade --- /dev/null +++ b/test/hook-incomings.integration.test.ts @@ -0,0 +1,171 @@ +import 'dotenv/config'; +import { randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; +import { Make } from '../src/make.js'; +import type { HookIncoming, HookIncomingDetail } from '../src/endpoints/hook-incomings.js'; + +const MAKE_API_KEY = String(process.env.MAKE_API_KEY || ''); +const MAKE_ZONE = String(process.env.MAKE_ZONE || ''); +const MAKE_HOOK_INCOMINGS_HOOK_ID = Number(process.env.MAKE_HOOK_INCOMINGS_HOOK_ID || 0); +const MAKE_HOOK_INCOMINGS_WEBHOOK_URL = String(process.env.MAKE_HOOK_INCOMINGS_WEBHOOK_URL || ''); + +const POLL_INTERVAL_MS = 1000; +const POLL_TIMEOUT_MS = 30_000; +const LIST_OPTIONS = { pg: { limit: 10_000, offset: 0 } } as const; +const MARKER_FIELD = 'makeSdkHookIncomingsTestMarker'; +const TEST_MARKERS = [`${randomUUID()}-specific`, `${randomUUID()}-bulk`] as const; +const TEST_MARKER_SET: ReadonlySet = new Set(TEST_MARKERS); + +const requirements = [ + ['MAKE_API_KEY', Boolean(MAKE_API_KEY)], + ['MAKE_ZONE', Boolean(MAKE_ZONE)], + ['MAKE_HOOK_INCOMINGS_HOOK_ID', Number.isInteger(MAKE_HOOK_INCOMINGS_HOOK_ID) && MAKE_HOOK_INCOMINGS_HOOK_ID > 0], + ['MAKE_HOOK_INCOMINGS_WEBHOOK_URL', Boolean(MAKE_HOOK_INCOMINGS_WEBHOOK_URL)], +] as const; +const missingRequirements = requirements.filter(([, ready]) => !ready).map(([name]) => name); +const integrationReady = missingRequirements.length === 0; +const skipHint = integrationReady ? '' : ` — skipped; set ${missingRequirements.join(', ')} in .env`; + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function payloadMarker(data: HookIncomingDetail['data']): string | undefined { + if (typeof data !== 'object' || data === null || Array.isArray(data)) return undefined; + + const marker = data[MARKER_FIELD]; + return typeof marker === 'string' ? marker : undefined; +} + +(integrationReady ? describe : describe.skip)(`Integration: Hook incomings${skipHint}`, () => { + const make = new Make(MAKE_API_KEY, MAKE_ZONE); + + let baselineIds = new Set(); + let testIdsByMarker = new Map(); + + async function listAll(): Promise { + return await make.hooks.incomings.list(MAKE_HOOK_INCOMINGS_HOOK_ID, LIST_OPTIONS); + } + + async function findTestIds(): Promise> { + const incomings = await listAll(); + const candidates = incomings.filter(incoming => !baselineIds.has(incoming.id)); + const found = new Map(); + + for (const candidate of candidates) { + const detail = await make.hooks.incomings.get(MAKE_HOOK_INCOMINGS_HOOK_ID, candidate.id); + const marker = payloadMarker(detail.data); + + if (marker !== undefined && TEST_MARKER_SET.has(marker)) { + found.set(marker, candidate.id); + } + } + + return found; + } + + async function waitForTestIds(): Promise> { + const deadline = Date.now() + POLL_TIMEOUT_MS; + + while (Date.now() < deadline) { + const found = await findTestIds(); + if (found.size === TEST_MARKERS.length) return found; + await sleep(POLL_INTERVAL_MS); + } + + throw new Error(`Timed out waiting for ${TEST_MARKERS.length} marked webhook deliveries to enter the queue`); + } + + function requireTestId(marker: (typeof TEST_MARKERS)[number]): string { + const id = testIdsByMarker.get(marker); + if (!id) throw new Error(`Missing queued webhook delivery for marker ${marker}`); + return id; + } + + beforeAll(async () => { + baselineIds = new Set((await listAll()).map(incoming => incoming.id)); + + for (const marker of TEST_MARKERS) { + const response = await fetch(MAKE_HOOK_INCOMINGS_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ [MARKER_FIELD]: marker }), + }); + + if (!response.ok) { + throw new Error(`Webhook fixture rejected a test delivery with HTTP ${response.status}`); + } + } + + testIdsByMarker = await waitForTestIds(); + }, POLL_TIMEOUT_MS + 10_000); + + afterAll(async () => { + const current = await listAll(); + const currentIds = new Set(current.map(incoming => incoming.id)); + const discovered = await findTestIds(); + const remainingTestIds = new Set( + [...testIdsByMarker.values(), ...discovered.values()].filter(id => currentIds.has(id)), + ); + + if (remainingTestIds.size > 0) { + await make.hooks.incomings.delete(MAKE_HOOK_INCOMINGS_HOOK_ID, { + ids: [...remainingTestIds], + }); + } + }, POLL_TIMEOUT_MS); + + it('Should list the two queued fixture deliveries', async () => { + const incomings = await listAll(); + + expect(incomings.map(incoming => incoming.id)).toEqual(expect.arrayContaining([...testIdsByMarker.values()])); + }); + + it('Should report queue stats including the fixture deliveries', async () => { + const stats = await make.hooks.incomings.stats(MAKE_HOOK_INCOMINGS_HOOK_ID); + + expect(stats.enabled).toBe(true); + expect(stats.queue).toBeGreaterThanOrEqual(baselineIds.size + TEST_MARKERS.length); + expect(stats.limit).toBeGreaterThanOrEqual(stats.queue); + }); + + it('Should get each fixture delivery with its payload data', async () => { + const details = await Promise.all( + TEST_MARKERS.map(marker => make.hooks.incomings.get(MAKE_HOOK_INCOMINGS_HOOK_ID, requireTestId(marker))), + ); + + expect(details.map(detail => payloadMarker(detail.data))).toStrictEqual(TEST_MARKERS); + for (const detail of details) { + expect(detail.isHookConfidential).not.toBe(true); + } + }); + + it('Should delete a specific fixture delivery using IDs in the request body', async () => { + const id = requireTestId(TEST_MARKERS[0]); + const result = await make.hooks.incomings.delete(MAKE_HOOK_INCOMINGS_HOOK_ID, { ids: [id] }); + const remainingIds = new Set((await listAll()).map(incoming => incoming.id)); + + expect(result.deletedIds).toContain(id); + expect(remainingIds.has(id)).toBe(false); + expect([...baselineIds].every(baselineId => remainingIds.has(baselineId))).toBe(true); + }); + + it('Should bulk-delete only the other fixture delivery with confirmation', async () => { + const id = requireTestId(TEST_MARKERS[1]); + const currentBeforeDelete = await listAll(); + const idsToPreserve = currentBeforeDelete.map(incoming => incoming.id).filter(currentId => currentId !== id); + + expect(currentBeforeDelete.some(incoming => incoming.id === id)).toBe(true); + + const result = await make.hooks.incomings.delete(MAKE_HOOK_INCOMINGS_HOOK_ID, { + all: true, + confirmed: true, + exceptIds: idsToPreserve, + }); + const remainingIds = new Set((await listAll()).map(incoming => incoming.id)); + + expect(result.deletedIds).toStrictEqual([id]); + expect(remainingIds.has(id)).toBe(false); + expect(idsToPreserve.every(preservedId => remainingIds.has(preservedId))).toBe(true); + }); +}); diff --git a/test/hook-incomings.spec.ts b/test/hook-incomings.spec.ts new file mode 100644 index 0000000..faf5153 --- /dev/null +++ b/test/hook-incomings.spec.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from '@jest/globals'; +import { Make } from '../src/make.js'; +import type { DeleteHookIncomingsOptions, ListHookIncomingsOptions } from '../src/endpoints/hook-incomings.js'; +import { mockFetch } from './test.utils.js'; + +import * as listMock from './mocks/hook-incomings/list.json'; +import * as statsMock from './mocks/hook-incomings/stats.json'; +import * as getMock from './mocks/hook-incomings/get.json'; +import * as getConfidentialMock from './mocks/hook-incomings/get-confidential.json'; +import * as deleteMock from './mocks/hook-incomings/delete.json'; +import * as deletePartialErrorMock from './mocks/hook-incomings/delete-partial-error.json'; + +const MAKE_API_KEY = 'api-key'; +const MAKE_ZONE = 'make.local'; +const HOOK_ID = 11; + +describe('Endpoints: HookIncomings', () => { + const make = new Make(MAKE_API_KEY, MAKE_ZONE); + + it('Should list queued items for a hook', async () => { + mockFetch(`GET https://make.local/api/v2/hooks/${HOOK_ID}/incomings`, listMock); + + const result = await make.hooks.incomings.list(HOOK_ID); + + expect(result).toStrictEqual(listMock.incomings); + }); + + it('Should forward from/to and pagination options when listing', async () => { + mockFetch( + `GET https://make.local/api/v2/hooks/${HOOK_ID}/incomings?from=1000&to=2000&pg%5Blimit%5D=10&pg%5Boffset%5D=0`, + listMock, + ); + + const result = await make.hooks.incomings.list(HOOK_ID, { + from: 1000, + to: 2000, + pg: { limit: 10, offset: 0 }, + }); + + expect(result).toStrictEqual(listMock.incomings); + }); + + it.each([ + { field: 'from', options: { from: 1000.5 } }, + { field: 'to', options: { to: 2000.5 } }, + ])('Should reject a fractional $field list timestamp', async ({ field, options }) => { + await expect(make.hooks.incomings.list(HOOK_ID, options)).rejects.toThrow( + `\`${field}\` must be an integer Unix timestamp in milliseconds when specified`, + ); + }); + + it('Should reject an unsupported list sort field', async () => { + const options = { pg: { sortBy: 'id' } } as unknown as ListHookIncomingsOptions; + + await expect(make.hooks.incomings.list(HOOK_ID, options)).rejects.toThrow( + '`pg.sortBy` must be `created` when specified', + ); + }); + + it('Should reject an unsupported list sort direction', async () => { + const options = { pg: { sortDir: 'sideways' } } as unknown as ListHookIncomingsOptions; + + await expect(make.hooks.incomings.list(HOOK_ID, options)).rejects.toThrow( + '`pg.sortDir` must be `asc` or `desc` when specified', + ); + }); + + it.each([0, 1.5, 10_001])('Should reject an unsupported list limit: %s', async limit => { + await expect(make.hooks.incomings.list(HOOK_ID, { pg: { limit } })).rejects.toThrow( + '`pg.limit` must be an integer between 1 and 10000 when specified', + ); + }); + + it.each([-1, 1.5])('Should reject an unsupported list offset: %s', async offset => { + await expect(make.hooks.incomings.list(HOOK_ID, { pg: { offset } })).rejects.toThrow( + '`pg.offset` must be a non-negative integer when specified', + ); + }); + + it('Should get queue stats for a hook', async () => { + mockFetch(`GET https://make.local/api/v2/hooks/${HOOK_ID}/incomings/stats`, statsMock); + + const result = await make.hooks.incomings.stats(HOOK_ID); + + expect(result).toStrictEqual(statsMock.incomingStat); + }); + + it('Should get detail of a queued item', async () => { + mockFetch(`GET https://make.local/api/v2/hooks/${HOOK_ID}/incomings/8d88f6f5b0484908890ef11fe7e5bf63`, getMock); + + const result = await make.hooks.incomings.get(HOOK_ID, '8d88f6f5b0484908890ef11fe7e5bf63'); + + expect(result).toStrictEqual(getMock.incoming); + }); + + it.each(['stats', '../../../users/me'])('Should reject malformed queue item ID %s', async incomingId => { + await expect(make.hooks.incomings.get(HOOK_ID, incomingId)).rejects.toThrow( + '`incomingId` must be a 32-character lowercase hexadecimal string', + ); + }); + + it('Should omit the payload for a confidential hook', async () => { + mockFetch( + `GET https://make.local/api/v2/hooks/${HOOK_ID}/incomings/7a567f385d1a4f5ab7bff89162b7605e`, + getConfidentialMock, + ); + + const result = await make.hooks.incomings.get(HOOK_ID, '7a567f385d1a4f5ab7bff89162b7605e'); + + expect(result).toStrictEqual(getConfidentialMock.incoming); + expect(result.data).toBeUndefined(); + }); + + it('Should delete specific queue items', async () => { + const ids = ['d1efa5318a034d36ad7cbeac543573cf', '29d9a7410dff494ab739036f6c332335']; + + mockFetch(`DELETE https://make.local/api/v2/hooks/${HOOK_ID}/incomings`, deleteMock, req => { + expect(req.body).toStrictEqual({ ids }); + }); + + const result = await make.hooks.incomings.delete(HOOK_ID, { ids }); + + expect(result).toStrictEqual({ deletedIds: deleteMock.incomings, error: undefined }); + }); + + it('Should surface a partial-failure error alongside whatever was deleted', async () => { + const ids = ['02731358e5ab4022aff040015a1f1a57', 'dcf18b685e5c4095b9ee24cea09146d3']; + + mockFetch(`DELETE https://make.local/api/v2/hooks/${HOOK_ID}/incomings`, deletePartialErrorMock, req => { + expect(req.body).toStrictEqual({ ids }); + }); + + const result = await make.hooks.incomings.delete(HOOK_ID, { ids }); + + expect(result.deletedIds).toStrictEqual(deletePartialErrorMock.incomings); + expect(result.error).toStrictEqual(deletePartialErrorMock.error); + }); + + it('Should confirm deletion of the entire queue', async () => { + mockFetch(`DELETE https://make.local/api/v2/hooks/${HOOK_ID}/incomings?confirmed=true`, deleteMock, req => { + expect(req.body).toStrictEqual({ all: true }); + }); + + await make.hooks.incomings.delete(HOOK_ID, { all: true, confirmed: true }); + }); + + it('Should reject deletion without a selector', async () => { + await expect(make.hooks.incomings.delete(HOOK_ID, {} as DeleteHookIncomingsOptions)).rejects.toThrow( + 'Exactly one of `ids` or `all: true` must be specified', + ); + }); + + it('Should reject an empty list of deletion IDs', async () => { + await expect(make.hooks.incomings.delete(HOOK_ID, { ids: [] })).rejects.toThrow( + '`ids` must contain at least one queue item ID', + ); + }); + + it('Should require explicit confirmation when deleting the entire queue', async () => { + await expect(make.hooks.incomings.delete(HOOK_ID, { all: true } as DeleteHookIncomingsOptions)).rejects.toThrow( + '`confirmed` must be `true` when `all` is used', + ); + }); +}); diff --git a/test/mocks/hook-incomings/delete-partial-error.json b/test/mocks/hook-incomings/delete-partial-error.json new file mode 100644 index 0000000..e9686e6 --- /dev/null +++ b/test/mocks/hook-incomings/delete-partial-error.json @@ -0,0 +1,7 @@ +{ + "incomings": ["02731358e5ab4022aff040015a1f1a57"], + "error": { + "name": "APIError", + "message": "Some of the incoming messages could not be deleted because they are being processed right now." + } +} diff --git a/test/mocks/hook-incomings/delete.json b/test/mocks/hook-incomings/delete.json new file mode 100644 index 0000000..25b1459 --- /dev/null +++ b/test/mocks/hook-incomings/delete.json @@ -0,0 +1,3 @@ +{ + "incomings": ["d1efa5318a034d36ad7cbeac543573cf", "29d9a7410dff494ab739036f6c332335"] +} diff --git a/test/mocks/hook-incomings/get-confidential.json b/test/mocks/hook-incomings/get-confidential.json new file mode 100644 index 0000000..5b9e8a7 --- /dev/null +++ b/test/mocks/hook-incomings/get-confidential.json @@ -0,0 +1,9 @@ +{ + "incoming": { + "id": "7a567f385d1a4f5ab7bff89162b7605e", + "scope": "hook", + "size": 31, + "created": "2020-03-05T14:48:10.537Z", + "isHookConfidential": true + } +} diff --git a/test/mocks/hook-incomings/get.json b/test/mocks/hook-incomings/get.json new file mode 100644 index 0000000..3ecf3b5 --- /dev/null +++ b/test/mocks/hook-incomings/get.json @@ -0,0 +1,11 @@ +{ + "incoming": { + "id": "8d88f6f5b0484908890ef11fe7e5bf63", + "scope": "hook", + "size": 11, + "created": "2020-03-05T14:52:01.359Z", + "data": { + "name": "test" + } + } +} diff --git a/test/mocks/hook-incomings/list.json b/test/mocks/hook-incomings/list.json new file mode 100644 index 0000000..3cc0167 --- /dev/null +++ b/test/mocks/hook-incomings/list.json @@ -0,0 +1,22 @@ +{ + "incomings": [ + { + "id": "a17c1163d7e04d258fce6bac2c8bd3d6", + "scope": "hook", + "size": 1, + "created": "2021-02-03T09:59:36.260Z" + }, + { + "id": "73b56c93f1ff49fe880eeab4fe4c029b", + "scope": "hook", + "size": 1, + "created": "2021-02-03T09:59:38.594Z" + } + ], + "pg": { + "sortBy": "created", + "limit": 10000, + "sortDir": "asc", + "offset": 0 + } +} diff --git a/test/mocks/hook-incomings/stats.json b/test/mocks/hook-incomings/stats.json new file mode 100644 index 0000000..6b675b3 --- /dev/null +++ b/test/mocks/hook-incomings/stats.json @@ -0,0 +1,7 @@ +{ + "incomingStat": { + "queue": 2, + "limit": 10000, + "enabled": true + } +}