From 2147b15230f0ceb8b4d5965f4aa01101a67de7fc Mon Sep 17 00:00:00 2001 From: Stephen Wright Date: Tue, 25 Aug 2026 09:50:31 +0000 Subject: [PATCH] feat(Confluence Node): Add CQL search operation (no-changelog) (#36589) --- .../nodes/Confluence/actions/common.ts | 2 +- .../nodes/Confluence/actions/description.ts | 6 + .../Confluence/actions/page/get.operation.ts | 15 +- .../nodes/Confluence/actions/router.ts | 4 + .../nodes/Confluence/actions/search/index.ts | 29 +++ .../actions/search/query.operation.ts | 159 +++++++++++++ .../Confluence/test/Confluence.node.test.ts | 37 +-- .../attachment/getMany.operation.test.ts | 2 +- .../test/actions/page/get.operation.test.ts | 2 +- .../page/getManyByLabel.operation.test.ts | 4 +- .../Confluence/test/actions/router.test.ts | 28 +++ .../actions/search/query.operation.test.ts | 221 ++++++++++++++++++ 12 files changed, 472 insertions(+), 37 deletions(-) create mode 100644 packages/nodes-base/nodes/Confluence/actions/search/index.ts create mode 100644 packages/nodes-base/nodes/Confluence/actions/search/query.operation.ts create mode 100644 packages/nodes-base/nodes/Confluence/test/actions/search/query.operation.test.ts diff --git a/packages/nodes-base/nodes/Confluence/actions/common.ts b/packages/nodes-base/nodes/Confluence/actions/common.ts index 41580dc2b17..049e6d8181c 100644 --- a/packages/nodes-base/nodes/Confluence/actions/common.ts +++ b/packages/nodes-base/nodes/Confluence/actions/common.ts @@ -315,7 +315,7 @@ export function parsePositiveInt( ): number { const value = Number(raw); if (!Number.isFinite(value) || value < 1) { - throw new NodeOperationError(this.getNode(), `${label} must be a number of at least 1`, { + throw new NodeOperationError(this.getNode(), `${label} must be a finite number of at least 1`, { itemIndex, }); } diff --git a/packages/nodes-base/nodes/Confluence/actions/description.ts b/packages/nodes-base/nodes/Confluence/actions/description.ts index f277ffc1f90..179ac92445b 100644 --- a/packages/nodes-base/nodes/Confluence/actions/description.ts +++ b/packages/nodes-base/nodes/Confluence/actions/description.ts @@ -4,6 +4,7 @@ import { NodeConnectionTypes } from 'n8n-workflow'; import * as attachment from './attachment'; import * as page from './page'; +import * as search from './search'; import { CONFLUENCE_CREDENTIAL_NAME } from '../transport'; export const confluenceNodeDescription: INodeTypeDescription = { @@ -42,10 +43,15 @@ export const confluenceNodeDescription: INodeTypeDescription = { name: 'Page', value: 'page', }, + { + name: 'Search', + value: 'search', + }, ], default: 'page', }, ...attachment.description, ...page.description, + ...search.description, ], }; diff --git a/packages/nodes-base/nodes/Confluence/actions/page/get.operation.ts b/packages/nodes-base/nodes/Confluence/actions/page/get.operation.ts index 6f6b9d4173d..046394de12b 100644 --- a/packages/nodes-base/nodes/Confluence/actions/page/get.operation.ts +++ b/packages/nodes-base/nodes/Confluence/actions/page/get.operation.ts @@ -1,5 +1,4 @@ import type { IDataObject, IExecuteFunctions, INodeProperties } from 'n8n-workflow'; -import { NodeOperationError } from 'n8n-workflow'; import { confluenceApiRequest } from '../../transport'; import type { ConfluenceBodyFormat } from '../common'; @@ -9,6 +8,7 @@ import { extractNextCursor, optionalSpaceRLC, pageRLC, + parsePositiveInt, resolvePageId, shapeBody, } from '../common'; @@ -187,13 +187,12 @@ export const execute: ConfluenceOperation = async function ( return shapeBody(page, bodyFormat); } - const rawMaxPages = this.getNodeParameter('maxPages', itemIndex, 100) as number; - if (!Number.isFinite(rawMaxPages) || rawMaxPages < 1) { - throw new NodeOperationError(this.getNode(), 'Max Pages must be a number of at least 1', { - itemIndex, - }); - } - const maxPages = Math.floor(rawMaxPages); + const maxPages = parsePositiveInt.call( + this, + this.getNodeParameter('maxPages', itemIndex, 100), + 'Max Pages', + itemIndex, + ); const descendantIds = await collectDescendantPageIds.call( this, pageId, diff --git a/packages/nodes-base/nodes/Confluence/actions/router.ts b/packages/nodes-base/nodes/Confluence/actions/router.ts index 759c6d2aa3c..7bed1f4adf4 100644 --- a/packages/nodes-base/nodes/Confluence/actions/router.ts +++ b/packages/nodes-base/nodes/Confluence/actions/router.ts @@ -3,6 +3,7 @@ import { NodeOperationError } from 'n8n-workflow'; import * as attachment from './attachment'; import * as page from './page'; +import * as search from './search'; /** * Compile-checked contract for operation modules. The router calls @@ -53,6 +54,9 @@ export async function router(this: IExecuteFunctions): PromiseAtlassian\'s CQL reference for the syntax.', + }, + ...returnAllOrLimit, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add option', + default: {}, + options: [ + { + displayName: 'Additional Expand Fields', + name: 'additionalExpandFields', + type: 'string', + default: '', + placeholder: 'e.g. content.version,content.metadata.labels', + description: 'Comma-separated list of extra fields to expand on each search result', + }, + { + displayName: 'Content Status', + name: 'contentStatuses', + type: 'multiOptions', + default: [], + description: 'Only match content in these statuses', + options: [ + { name: 'Archived', value: 'archived' }, + { name: 'Current', value: 'current' }, + { name: 'Draft', value: 'draft' }, + ], + }, + { + displayName: 'Fetch Full Page Content', + name: 'fetchFullPageContent', + type: 'boolean', + default: false, + description: + 'Whether each result carries its full storage-format body (content.body.storage), fetched on the same request. Only applies to content results such as pages; space and user results have no body.', + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['search'], + operation: ['query'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +interface SearchOptions { + additionalExpandFields?: string; + contentStatuses?: string[]; + fetchFullPageContent?: boolean; +} + +function buildExpand(options: SearchOptions): string { + const fields = (options.additionalExpandFields ?? '') + .split(',') + .map((field) => field.trim()) + .filter((field) => field !== ''); + if (options.fetchFullPageContent === true) fields.unshift('content.body.storage'); + return [...new Set(fields)].join(','); +} + +export const execute: ConfluenceOperation = async function ( + this: IExecuteFunctions, + itemIndex: number, +) { + // Deliberately passed through untouched: in a raw CQL string the node cannot tell + // values from syntax, so escaping here would corrupt valid queries. Where this node + // composes CQL itself it backslash-escapes quotes and backslashes as Confluence's + // CQL reference prescribes (see methods/listSearch.ts). + const cql = String(this.getNodeParameter('cql', itemIndex, '')).trim(); + if (cql === '') { + throw new NodeOperationError(this.getNode(), 'The CQL query must not be empty', { itemIndex }); + } + + const returnAll = this.getNodeParameter('returnAll', itemIndex, false); + const limit = returnAll + ? Infinity + : parsePositiveInt.call( + this, + this.getNodeParameter('limit', itemIndex, 100), + 'Limit', + itemIndex, + ); + + const options = this.getNodeParameter('options', itemIndex, {}) as SearchOptions; + + const qs: IDataObject = { cql }; + const expand = buildExpand(options); + if (expand !== '') qs.expand = expand; + if (options.contentStatuses !== undefined && options.contentStatuses.length > 0) { + qs.cqlcontext = JSON.stringify({ contentStatuses: options.contentStatuses }); + } + + const results: IDataObject[] = []; + let pageParam: NextPageParam | undefined; + const seenPageParams = new Set(); + let emptyPages = 0; + for (;;) { + const pageQs: IDataObject = { + ...qs, + limit: Math.min(limit - results.length, SEARCH_PAGE_SIZE), + }; + if (pageParam !== undefined) pageQs[pageParam.key] = pageParam.value; + + const response = await confluenceApiRequest.call( + this, + 'GET', + '/wiki/rest/api/search', + {}, + pageQs, + ); + const entries = Array.isArray(response.results) ? (response.results as IDataObject[]) : []; + results.push.apply(results, entries); + if (results.length >= limit) break; + + emptyPages = entries.length === 0 ? emptyPages + 1 : 0; + if (emptyPages >= MAX_CONSECUTIVE_EMPTY_PAGES) break; + + const next = extractNextPageParam(response); + // A next link revisiting any earlier page would loop forever under Return All + if (next === undefined || seenPageParams.has(`${next.key}:${next.value}`)) break; + seenPageParams.add(`${next.key}:${next.value}`); + pageParam = next; + } + + return returnAll ? results : results.slice(0, limit); +}; diff --git a/packages/nodes-base/nodes/Confluence/test/Confluence.node.test.ts b/packages/nodes-base/nodes/Confluence/test/Confluence.node.test.ts index c060bb4006b..7f371e8c0ce 100644 --- a/packages/nodes-base/nodes/Confluence/test/Confluence.node.test.ts +++ b/packages/nodes-base/nodes/Confluence/test/Confluence.node.test.ts @@ -1,32 +1,32 @@ -import type { IExecuteFunctions } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow'; -import { mockDeep } from 'vitest-mock-extended'; import { Confluence } from '../Confluence.node'; +import { mockExecuteCtx } from './shared'; describe('Confluence Node', () => { const node = new Confluence(); + const operationOptions = (resource: string) => + node.description.properties.find( + (p) => p.name === 'operation' && p.displayOptions?.show?.resource?.includes(resource), + )?.options; + it('should stay hidden and off the AI-tool surface while operations land', () => { expect(node.description.hidden).toBe(true); expect(node.description.properties.length).toBeGreaterThan(0); expect(node.description.usableAsTool).toBeUndefined(); }); - it('should expose the attachment and page resources with their operations', () => { + it('should expose the attachment, page and search resources with their operations', () => { const resource = node.description.properties.find((p) => p.name === 'resource'); expect(resource?.options).toEqual([ expect.objectContaining({ value: 'attachment' }), expect.objectContaining({ value: 'page' }), + expect.objectContaining({ value: 'search' }), ]); - const operations = node.description.properties.filter((p) => p.name === 'operation'); - const operationsFor = (resourceName: string) => - operations.find((p) => (p.displayOptions?.show?.resource ?? []).includes(resourceName)); - expect(operationsFor('attachment')?.options).toEqual([ - expect.objectContaining({ value: 'getMany' }), - ]); - expect(operationsFor('page')?.options).toEqual([ + expect(operationOptions('attachment')).toEqual([expect.objectContaining({ value: 'getMany' })]); + expect(operationOptions('page')).toEqual([ expect.objectContaining({ value: 'append' }), expect.objectContaining({ value: 'create' }), expect.objectContaining({ value: 'delete' }), @@ -34,6 +34,7 @@ describe('Confluence Node', () => { expect.objectContaining({ value: 'getManyByLabel' }), expect.objectContaining({ value: 'update' }), ]); + expect(operationOptions('search')).toEqual([expect.objectContaining({ value: 'query' })]); }); it('should reference the confluenceCloudOAuth2Api credential by name', () => { @@ -49,21 +50,7 @@ describe('Confluence Node', () => { }); it('should throw a NodeOperationError when executed without an operation', async () => { - const ctx = mockDeep(); - ctx.getInputData.mockReturnValue([{ json: {} }]); - ctx.getNodeParameter.mockImplementation( - (_name: string, _itemIndex?: number, fallback?: unknown) => fallback as never, - ); - ctx.getNode.mockReturnValue({ - id: 'test-node', - name: 'Test Confluence Node', - type: 'n8n-nodes-base.confluence', - typeVersion: 1, - position: [0, 0], - parameters: {}, - }); - - const promise = node.execute.call(ctx); + const promise = node.execute.call(mockExecuteCtx({})); await expect(promise).rejects.toThrow(NodeOperationError); await expect(promise).rejects.toThrow('The operation ":" is not supported'); diff --git a/packages/nodes-base/nodes/Confluence/test/actions/attachment/getMany.operation.test.ts b/packages/nodes-base/nodes/Confluence/test/actions/attachment/getMany.operation.test.ts index 3403044ff05..813ff067327 100644 --- a/packages/nodes-base/nodes/Confluence/test/actions/attachment/getMany.operation.test.ts +++ b/packages/nodes-base/nodes/Confluence/test/actions/attachment/getMany.operation.test.ts @@ -127,7 +127,7 @@ describe('attachment:getMany', () => { it('rejects a non-positive limit from an expression', async () => { const promise = runGetMany({ limit: 0 }); - await expect(promise).rejects.toThrow('Limit must be a number of at least 1'); + await expect(promise).rejects.toThrow('Limit must be a finite number of at least 1'); expect(apiRequest).not.toHaveBeenCalled(); }); diff --git a/packages/nodes-base/nodes/Confluence/test/actions/page/get.operation.test.ts b/packages/nodes-base/nodes/Confluence/test/actions/page/get.operation.test.ts index 7b9466ae750..eed33f46807 100644 --- a/packages/nodes-base/nodes/Confluence/test/actions/page/get.operation.test.ts +++ b/packages/nodes-base/nodes/Confluence/test/actions/page/get.operation.test.ts @@ -547,7 +547,7 @@ describe('Confluence page:get operation', () => { }); await expect(execute.call(ctx, 0)).rejects.toThrow( - 'Max Pages must be a number of at least 1', + 'Max Pages must be a finite number of at least 1', ); }); diff --git a/packages/nodes-base/nodes/Confluence/test/actions/page/getManyByLabel.operation.test.ts b/packages/nodes-base/nodes/Confluence/test/actions/page/getManyByLabel.operation.test.ts index 15eb690f2f4..2a1edc3e12e 100644 --- a/packages/nodes-base/nodes/Confluence/test/actions/page/getManyByLabel.operation.test.ts +++ b/packages/nodes-base/nodes/Confluence/test/actions/page/getManyByLabel.operation.test.ts @@ -237,7 +237,9 @@ describe('Confluence page:getManyByLabel operation', () => { limit: 0, }); - await expect(execute.call(ctx, 0)).rejects.toThrow('Limit must be a number of at least 1'); + await expect(execute.call(ctx, 0)).rejects.toThrow( + 'Limit must be a finite number of at least 1', + ); expect(apiRequest).not.toHaveBeenCalled(); }); }); diff --git a/packages/nodes-base/nodes/Confluence/test/actions/router.test.ts b/packages/nodes-base/nodes/Confluence/test/actions/router.test.ts index 1e156e2b09f..67cdf572ac1 100644 --- a/packages/nodes-base/nodes/Confluence/test/actions/router.test.ts +++ b/packages/nodes-base/nodes/Confluence/test/actions/router.test.ts @@ -31,6 +31,15 @@ const getParams: Record = { includeDescendants: false, }; +const searchParams: Record = { + resource: 'search', + operation: 'query', + cql: 'type = page', + returnAll: false, + limit: 100, + options: {}, +}; + describe('Confluence router', () => { beforeEach(() => { vi.clearAllMocks(); @@ -166,6 +175,25 @@ describe('Confluence router', () => { expect(result).toEqual([[{ json: { id: '222' }, pairedItem: { item: 0 } }]]); }); + it('dispatches search:query and fans the results out into items', async () => { + apiRequest.mockResolvedValue({ results: [{ title: 'A' }, { title: 'B' }] }); + + const result = await router.call(mockExecuteCtx(searchParams)); + + expect(apiRequest).toHaveBeenCalledWith( + 'GET', + '/wiki/rest/api/search', + {}, + { cql: 'type = page', limit: 50 }, + ); + expect(result).toEqual([ + [ + { json: { title: 'A' }, pairedItem: { item: 0 } }, + { json: { title: 'B' }, pairedItem: { item: 0 } }, + ], + ]); + }); + it('emits an error item and continues with later items when continue-on-fail is on', async () => { const ctx = mockExecuteCtx(createParams, 2); ctx.continueOnFail.mockReturnValue(true); diff --git a/packages/nodes-base/nodes/Confluence/test/actions/search/query.operation.test.ts b/packages/nodes-base/nodes/Confluence/test/actions/search/query.operation.test.ts new file mode 100644 index 00000000000..7d2598aee9d --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/test/actions/search/query.operation.test.ts @@ -0,0 +1,221 @@ +import { NodeOperationError } from 'n8n-workflow'; +import type { Mock } from 'vitest'; + +import { execute } from '../../../actions/search/query.operation'; +import { confluenceApiRequest } from '../../../transport'; +import { mockExecuteCtx } from '../../shared'; + +vi.mock('../../../transport', async (importOriginal) => ({ + ...(await importOriginal()), + confluenceApiRequest: vi.fn(), +})); + +const apiRequest = confluenceApiRequest as unknown as Mock; + +const baseParams: Record = { + cql: 'type = page', + returnAll: false, + limit: 100, + options: {}, +}; + +const SEARCH_ENDPOINT = '/wiki/rest/api/search'; + +const runSearch = async (overrides: Record = {}) => + await execute.call(mockExecuteCtx({ ...baseParams, ...overrides }), 0); + +const expectSearchRequest = (qs: unknown) => + expect(apiRequest).toHaveBeenCalledWith('GET', SEARCH_ENDPOINT, {}, qs); + +const expectNthSearchRequest = (nth: number, qs: unknown) => + expect(apiRequest).toHaveBeenNthCalledWith(nth, 'GET', SEARCH_ENDPOINT, {}, qs); + +function searchPage(ids: string[], next?: string) { + return { + results: ids.map((id) => ({ id })), + ...(next === undefined ? {} : { _links: { next } }), + }; +} + +describe('search:query', () => { + beforeEach(() => { + vi.clearAllMocks(); + apiRequest.mockResolvedValue(searchPage([])); + }); + + it('queries the v1 search endpoint with the CQL and page size', async () => { + apiRequest.mockResolvedValue({ results: [{ title: 'Hit' }] }); + + const result = await runSearch(); + + expect(apiRequest).toHaveBeenCalledTimes(1); + expectSearchRequest({ cql: 'type = page', limit: 50 }); + expect(result).toEqual([{ title: 'Hit' }]); + }); + + it('returns an empty array when nothing matches', async () => { + expect(await runSearch()).toEqual([]); + }); + + it('coerces a non-string query from an expression', async () => { + await runSearch({ cql: 32 }); + + expectSearchRequest(expect.objectContaining({ cql: '32' })); + }); + + it('rejects an empty query after trimming', async () => { + const promise = runSearch({ cql: ' ' }); + + await expect(promise).rejects.toThrow(NodeOperationError); + await expect(promise).rejects.toThrow('The CQL query must not be empty'); + expect(apiRequest).not.toHaveBeenCalled(); + }); + + it('rejects a non-positive limit from an expression', async () => { + const promise = runSearch({ limit: 0 }); + + await expect(promise).rejects.toThrow('Limit must be a finite number of at least 1'); + expect(apiRequest).not.toHaveBeenCalled(); + }); + + it('rejects a non-finite limit from an expression', async () => { + const promise = runSearch({ limit: Infinity }); + + await expect(promise).rejects.toThrow('Limit must be a finite number of at least 1'); + expect(apiRequest).not.toHaveBeenCalled(); + }); + + it('returns all pages by following the next cursor with the same query', async () => { + apiRequest + .mockResolvedValueOnce( + searchPage(['1', '2'], '/rest/api/search?cql=type%3Dpage&cursor=abc&limit=50'), + ) + .mockResolvedValueOnce(searchPage(['3'])); + + const result = await runSearch({ returnAll: true }); + + expect(apiRequest).toHaveBeenCalledTimes(2); + expectNthSearchRequest(2, { cql: 'type = page', limit: 50, cursor: 'abc' }); + expect(result).toEqual([{ id: '1' }, { id: '2' }, { id: '3' }]); + }); + + it('falls back to the start offset when the next link carries no cursor', async () => { + apiRequest + .mockResolvedValueOnce( + searchPage(['1'], '/rest/api/search?cql=type%3Dpage&start=25&limit=25'), + ) + .mockResolvedValueOnce(searchPage([])); + + await runSearch({ returnAll: true }); + + expectNthSearchRequest(2, expect.objectContaining({ start: '25' })); + }); + + it('requests only the limit when it is below the page size', async () => { + apiRequest.mockResolvedValue(searchPage(['1', '2'], '/rest/api/search?cursor=abc')); + + const result = await runSearch({ limit: 1 }); + + expect(apiRequest).toHaveBeenCalledTimes(1); + expectSearchRequest({ cql: 'type = page', limit: 1 }); + expect(result).toEqual([{ id: '1' }]); + }); + + it('stops fetching and truncates once the limit is met', async () => { + apiRequest + .mockResolvedValueOnce(searchPage(['1', '2'], '/rest/api/search?cursor=abc')) + .mockResolvedValueOnce(searchPage(['3', '4'], '/rest/api/search?cursor=def')); + + const result = await runSearch({ limit: 3 }); + + expect(apiRequest).toHaveBeenCalledTimes(2); + expectNthSearchRequest(2, { cql: 'type = page', limit: 1, cursor: 'abc' }); + expect(result).toEqual([{ id: '1' }, { id: '2' }, { id: '3' }]); + }); + + it('coerces a numeric-string limit from an expression', async () => { + apiRequest.mockResolvedValue(searchPage(['1', '2', '3'])); + + const result = await runSearch({ limit: '2' }); + + expectSearchRequest(expect.objectContaining({ limit: 2 })); + expect(result).toEqual([{ id: '1' }, { id: '2' }]); + }); + + it('stops when the next link repeats the same cursor', async () => { + apiRequest.mockResolvedValue(searchPage([], '/rest/api/search?cursor=same')); + + const result = await runSearch({ returnAll: true }); + + expect(apiRequest).toHaveBeenCalledTimes(2); + expect(result).toEqual([]); + }); + + it('stops when the next links cycle between earlier cursors', async () => { + apiRequest + .mockResolvedValueOnce(searchPage(['1'], '/rest/api/search?cursor=a')) + .mockResolvedValueOnce(searchPage(['2'], '/rest/api/search?cursor=b')) + .mockResolvedValue(searchPage(['3'], '/rest/api/search?cursor=a')); + + const result = await runSearch({ returnAll: true }); + + expect(apiRequest).toHaveBeenCalledTimes(3); + expect(result).toEqual([{ id: '1' }, { id: '2' }, { id: '3' }]); + }); + + it('stops after a run of consecutive empty pages even when cursors keep changing', async () => { + let page = 0; + apiRequest.mockImplementation(async () => searchPage([], `/rest/api/search?cursor=c${++page}`)); + + const result = await runSearch({ returnAll: true }); + + expect(apiRequest).toHaveBeenCalledTimes(5); + expect(result).toEqual([]); + }); + + it('stops when the next link has no usable parameter', async () => { + apiRequest.mockResolvedValueOnce(searchPage(['1'], '/rest/api/search?next=true')); + + const result = await runSearch({ returnAll: true }); + + expect(apiRequest).toHaveBeenCalledTimes(1); + expect(result).toEqual([{ id: '1' }]); + }); + + it.each([ + ['the full-content toggle', { fetchFullPageContent: true }, 'content.body.storage'], + [ + 'additional expand fields, trimmed', + { additionalExpandFields: ' content.version , space ,' }, + 'content.version,space', + ], + [ + 'both merged without duplicates', + { + fetchFullPageContent: true, + additionalExpandFields: 'content.body.storage,content.version', + }, + 'content.body.storage,content.version', + ], + ])('sends expand for %s', async (_name, options, expand) => { + await runSearch({ options }); + + expectSearchRequest(expect.objectContaining({ expand })); + }); + + it('sends cqlcontext when content statuses are selected', async () => { + await runSearch({ options: { contentStatuses: ['draft', 'archived'] } }); + + expectSearchRequest( + expect.objectContaining({ cqlcontext: '{"contentStatuses":["draft","archived"]}' }), + ); + }); + + it('omits expand and cqlcontext when no options are set', async () => { + await runSearch(); + + const qs = apiRequest.mock.calls[0][3] as Record; + expect(qs).not.toHaveProperty('expand'); + expect(qs).not.toHaveProperty('cqlcontext'); + }); +});