From 9fbd9fbc6574a47965b6d4148c7efbd477b772dc Mon Sep 17 00:00:00 2001 From: Ilfat Mindubaev Date: Tue, 25 Aug 2026 07:58:40 +0000 Subject: [PATCH] feat(Confluence Node): Add Page Get Many by Label operation (#36785) Co-authored-by: Claude Fable 5 --- .../nodes/Confluence/actions/common.ts | 88 +++++- .../Confluence/actions/page/get.operation.ts | 48 +-- .../actions/page/getManyByLabel.operation.ts | 105 +++++++ .../nodes/Confluence/actions/page/index.ts | 10 +- .../nodes/Confluence/actions/router.ts | 3 + .../nodes/Confluence/methods/listSearch.ts | 77 ++++- .../Confluence/test/Confluence.node.test.ts | 1 + .../test/actions/page/get.operation.test.ts | 19 ++ .../page/getManyByLabel.operation.test.ts | 297 ++++++++++++++++++ .../Confluence/test/actions/router.test.ts | 28 ++ .../test/methods/listSearch.test.ts | 124 +++++++- 11 files changed, 738 insertions(+), 62 deletions(-) create mode 100644 packages/nodes-base/nodes/Confluence/actions/page/getManyByLabel.operation.ts create mode 100644 packages/nodes-base/nodes/Confluence/test/actions/page/getManyByLabel.operation.test.ts diff --git a/packages/nodes-base/nodes/Confluence/actions/common.ts b/packages/nodes-base/nodes/Confluence/actions/common.ts index 823426c8eea..41580dc2b17 100644 --- a/packages/nodes-base/nodes/Confluence/actions/common.ts +++ b/packages/nodes-base/nodes/Confluence/actions/common.ts @@ -5,7 +5,7 @@ import type { INodeParameterResourceLocator, INodeProperties, } from 'n8n-workflow'; -import { NodeOperationError } from 'n8n-workflow'; +import { jsonParse, NodeOperationError } from 'n8n-workflow'; import { CONFLUENCE_CREDENTIAL_NAME, confluenceApiRequest } from '../transport'; @@ -79,6 +79,41 @@ export const pageRLC: INodeProperties = { ], }; +export const labelRLC: INodeProperties = { + displayName: 'Label', + name: 'label', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + description: 'The label to operate on', + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getLabels', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: 'e.g. 123456', + validation: [ + { + type: 'regex', + properties: { + regex: '^[0-9]+$', + errorMessage: 'The label ID must be numeric', + }, + }, + ], + }, + ], +}; + export type ConfluenceBodyFormat = 'storage' | 'atlas_doc_format' | 'plainText'; export const bodyFormatOption: INodeProperties = { @@ -196,6 +231,57 @@ export async function resolveSpaceKey( return space.key; } +// Text extraction, not rendering: concatenate ADF text nodes, newline at block boundaries +const ADF_BLOCK_TYPES = new Set([ + 'blockquote', + 'bulletList', + 'codeBlock', + 'heading', + 'listItem', + 'orderedList', + 'panel', + 'paragraph', + 'rule', + 'table', + 'tableRow', + 'taskItem', + 'taskList', +]); + +function adfToPlainText(node: IDataObject): string { + if (node.type === 'text') return typeof node.text === 'string' ? node.text : ''; + if (node.type === 'hardBreak') return '\n'; + const content = Array.isArray(node.content) ? (node.content as IDataObject[]) : []; + let inner = ''; + for (const child of content) { + inner += adfToPlainText(child); + if (node.type === 'tableRow') inner += ' '; + } + return ADF_BLOCK_TYPES.has(node.type as string) ? `${inner}\n` : inner; +} + +/** Replaces a page's ADF body with plain text extracted from it. No server-side + * plain-text format exists, so callers request `atlas_doc_format` and shape here. */ +export function shapeBody(page: IDataObject, bodyFormat: ConfluenceBodyFormat): IDataObject { + if (bodyFormat !== 'plainText') return page; + const adf = (page.body as IDataObject | undefined)?.atlas_doc_format as IDataObject | undefined; + let value = ''; + if (typeof adf?.value === 'string' && adf.value !== '') { + const doc = jsonParse(adf.value, { fallbackValue: null }) ?? {}; + // The walk can still throw on valid-JSON shapes it can't take (e.g. null nodes); + // a page with an unreadable body should yield an empty value, not fail the item + try { + value = adfToPlainText(doc) + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); + } catch { + value = ''; + } + } + return { ...page, body: { plainText: { representation: 'plain_text', value } } }; +} + export type NextPageParam = { key: 'cursor' | 'start'; value: string }; export function extractNextPageParam(response: IDataObject): NextPageParam | undefined { 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 270404c8327..6f6b9d4173d 100644 --- a/packages/nodes-base/nodes/Confluence/actions/page/get.operation.ts +++ b/packages/nodes-base/nodes/Confluence/actions/page/get.operation.ts @@ -10,6 +10,7 @@ import { optionalSpaceRLC, pageRLC, resolvePageId, + shapeBody, } from '../common'; import type { ConfluenceOperation } from '../router'; @@ -80,53 +81,6 @@ export const description: INodeProperties[] = [ }, ]; -// Text extraction, not rendering: concatenate ADF text nodes, newline at block boundaries -const ADF_BLOCK_TYPES = new Set([ - 'blockquote', - 'bulletList', - 'codeBlock', - 'heading', - 'listItem', - 'orderedList', - 'panel', - 'paragraph', - 'rule', - 'table', - 'tableRow', - 'taskItem', - 'taskList', -]); - -function adfToPlainText(node: IDataObject): string { - if (node.type === 'text') return typeof node.text === 'string' ? node.text : ''; - if (node.type === 'hardBreak') return '\n'; - const content = Array.isArray(node.content) ? (node.content as IDataObject[]) : []; - let inner = ''; - for (const child of content) { - inner += adfToPlainText(child); - if (node.type === 'tableRow') inner += ' '; - } - return ADF_BLOCK_TYPES.has(node.type as string) ? `${inner}\n` : inner; -} - -function shapeBody(page: IDataObject, bodyFormat: ConfluenceBodyFormat): IDataObject { - if (bodyFormat !== 'plainText') return page; - const adf = (page.body as IDataObject | undefined)?.atlas_doc_format as IDataObject | undefined; - let value = ''; - if (typeof adf?.value === 'string' && adf.value !== '') { - try { - const doc = JSON.parse(adf.value) as IDataObject; - value = adfToPlainText(doc) - .replace(/[ \t]+\n/g, '\n') - .replace(/\n{3,}/g, '\n\n') - .trim(); - } catch { - value = ''; - } - } - return { ...page, body: { plainText: { representation: 'plain_text', value } } }; -} - /** * Discovery phase: flattened tree records from `/pages/{id}/descendants` (no bodies). * Records at the endpoint's max depth may have unreached children, so the walk diff --git a/packages/nodes-base/nodes/Confluence/actions/page/getManyByLabel.operation.ts b/packages/nodes-base/nodes/Confluence/actions/page/getManyByLabel.operation.ts new file mode 100644 index 00000000000..2b1aa1db4bb --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/actions/page/getManyByLabel.operation.ts @@ -0,0 +1,105 @@ +import type { IDataObject, IExecuteFunctions, INodeProperties } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import { returnAllOrLimit } from '@utils/descriptions'; +import { updateDisplayOptions } from '@utils/utilities'; + +import { confluenceApiRequest } from '../../transport'; +import type { ConfluenceBodyFormat } from '../common'; +import { + PAGE_LIMIT, + bodyFormatOption, + extractNextCursor, + labelRLC, + optionalSpaceRLC, + parsePositiveInt, + shapeBody, +} from '../common'; +import type { ConfluenceOperation } from '../router'; + +const properties: INodeProperties[] = [ + { + ...labelRLC, + description: 'The label whose pages to fetch', + }, + { + ...optionalSpaceRLC, + description: + 'Only returns pages in this space. Leave empty or pick "All Spaces" to return pages from all spaces.', + }, + ...returnAllOrLimit, + bodyFormatOption, +]; + +const displayOptions = { + show: { + resource: ['page'], + operation: ['getManyByLabel'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export const execute: ConfluenceOperation = async function ( + this: IExecuteFunctions, + itemIndex: number, +) { + const labelId = String( + this.getNodeParameter('label', itemIndex, '', { extractValue: true }) as string, + ).trim(); + if (labelId === '') { + throw new NodeOperationError(this.getNode(), "The 'Label' parameter is empty", { itemIndex }); + } + + const spaceId = String( + this.getNodeParameter('space', itemIndex, '', { extractValue: true }) as string, + ).trim(); + + const returnAll = this.getNodeParameter('returnAll', itemIndex, false); + const limit = returnAll + ? Infinity + : parsePositiveInt.call( + this, + this.getNodeParameter('limit', itemIndex, 100), + 'Limit', + itemIndex, + ); + + const bodyFormat = this.getNodeParameter( + 'bodyFormat', + itemIndex, + 'storage', + ) as ConfluenceBodyFormat; + // No server-side plain-text format exists; it is derived from ADF in shapeBody + const requestedFormat = bodyFormat === 'plainText' ? 'atlas_doc_format' : bodyFormat; + + const pages: IDataObject[] = []; + let cursor: string | undefined; + const seenCursors = new Set(); + do { + const qs: IDataObject = { + 'body-format': requestedFormat, + limit: Math.min(limit - pages.length, PAGE_LIMIT), + }; + if (spaceId !== '') qs['space-id'] = spaceId; + if (cursor !== undefined) qs.cursor = cursor; + + const response = await confluenceApiRequest.call( + this, + 'GET', + `/wiki/api/v2/labels/${encodeURIComponent(labelId)}/pages`, + {}, + qs, + ); + const results = Array.isArray(response.results) ? (response.results as IDataObject[]) : []; + pages.push.apply(pages, results); + + const next = extractNextCursor(response); + // A next link revisiting any earlier page would loop forever under Return All + if (next === undefined || seenCursors.has(next)) break; + seenCursors.add(next); + cursor = next; + } while (pages.length < limit); + + return pages.slice(0, limit).map((page) => shapeBody(page, bodyFormat)); +}; diff --git a/packages/nodes-base/nodes/Confluence/actions/page/index.ts b/packages/nodes-base/nodes/Confluence/actions/page/index.ts index 17c7b99dfd8..839368c6619 100644 --- a/packages/nodes-base/nodes/Confluence/actions/page/index.ts +++ b/packages/nodes-base/nodes/Confluence/actions/page/index.ts @@ -4,9 +4,10 @@ import * as append from './append.operation'; import * as create from './create.operation'; import * as del from './delete.operation'; import * as get from './get.operation'; +import * as getManyByLabel from './getManyByLabel.operation'; import * as update from './update.operation'; -export { append, create, del as delete, get, update }; +export { append, create, del as delete, get, getManyByLabel, update }; export const description: INodeProperties[] = [ { @@ -44,6 +45,12 @@ export const description: INodeProperties[] = [ description: 'Retrieve a page, optionally with its full sub-tree', action: 'Get a page', }, + { + name: 'Get Many by Label', + value: 'getManyByLabel', + description: 'Retrieve all pages carrying a label', + action: 'Get many pages by label', + }, { name: 'Update', value: 'update', @@ -57,5 +64,6 @@ export const description: INodeProperties[] = [ ...create.description, ...del.description, ...get.description, + ...getManyByLabel.description, ...update.description, ]; diff --git a/packages/nodes-base/nodes/Confluence/actions/router.ts b/packages/nodes-base/nodes/Confluence/actions/router.ts index ce3ef2dae63..759c6d2aa3c 100644 --- a/packages/nodes-base/nodes/Confluence/actions/router.ts +++ b/packages/nodes-base/nodes/Confluence/actions/router.ts @@ -47,6 +47,9 @@ export async function router(this: IExecuteFunctions): Promise string, filter?: string, paginationToken?: string, ): Promise { @@ -27,30 +36,74 @@ export async function searchSpaces( const results: INodeListSearchItems[] = []; let cursor = paginationToken; - // No server-side text filter on the v2 spaces list; fetch ahead so matches - // beyond the first page stay discoverable for (let fetched = 0; fetched < MAX_FILTERED_SEARCH_PAGES; fetched++) { - const qs: IDataObject = { limit: SEARCH_PAGE_SIZE, sort: 'name', status: 'current' }; + const qs: IDataObject = { ...baseQs, limit: SEARCH_PAGE_SIZE }; if (cursor !== undefined) qs.cursor = cursor; - const response = await confluenceApiRequest.call(this, 'GET', '/wiki/api/v2/spaces', {}, qs); + const response = await confluenceApiRequest.call(this, 'GET', endpoint, {}, qs); const entries = Array.isArray(response.results) ? (response.results as IDataObject[]) : []; - for (const space of entries) { - if (typeof space.id !== 'string' && typeof space.id !== 'number') continue; - if (typeof space.name !== 'string') continue; - if (filterLower !== '' && !space.name.toLowerCase().includes(filterLower)) continue; - const key = typeof space.key === 'string' && space.key !== '' ? ` (${space.key})` : ''; - results.push({ name: `${space.name}${key}`, value: String(space.id) }); + let lastName: string | undefined; + let exactFound = false; + for (const entry of entries) { + if (typeof entry.name !== 'string') continue; + lastName = entry.name.toLowerCase(); + if (typeof entry.id !== 'string' && typeof entry.id !== 'number') continue; + if (filterLower !== '' && !lastName.includes(filterLower)) continue; + if (lastName === filterLower) exactFound = true; + results.push({ name: toDisplayName(entry.name, entry), value: String(entry.id) }); } cursor = extractNextCursor(response); - if (cursor === undefined || filterLower === '' || results.length > 0) break; + if (cursor === undefined || filterLower === '' || exactFound) break; + // The list is name-sorted: don't stop on partial matches while an exact match may still lie ahead + const exactMayLieAhead = lastName !== undefined && lastName < filterLower; + if (results.length > 0 && !exactMayLieAhead) break; } return { results, paginationToken: cursor }; } +export async function searchSpaces( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + return await searchByName.call( + this, + '/wiki/api/v2/spaces', + { sort: 'name', status: 'current' }, + (name, space) => { + const key = typeof space.key === 'string' && space.key !== '' ? ` (${space.key})` : ''; + return `${name}${key}`; + }, + filter, + paginationToken, + ); +} + +export async function getLabels( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + return await searchByName.call( + this, + '/wiki/api/v2/labels', + { sort: 'name' }, + (name, label) => { + // Non-global labels (my/team/system) share names with global ones; the prefix disambiguates + const prefix = + typeof label.prefix === 'string' && label.prefix !== '' && label.prefix !== 'global' + ? ` (${label.prefix})` + : ''; + return `${name}${prefix}`; + }, + filter, + paginationToken, + ); +} + export async function searchSpacesWithAll( this: ILoadOptionsFunctions, filter?: string, 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 f1d7af20e62..c060bb4006b 100644 --- a/packages/nodes-base/nodes/Confluence/test/Confluence.node.test.ts +++ b/packages/nodes-base/nodes/Confluence/test/Confluence.node.test.ts @@ -31,6 +31,7 @@ describe('Confluence Node', () => { expect.objectContaining({ value: 'create' }), expect.objectContaining({ value: 'delete' }), expect.objectContaining({ value: 'get' }), + expect.objectContaining({ value: 'getManyByLabel' }), expect.objectContaining({ value: 'update' }), ]); }); 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 aa293286fe2..7b9466ae750 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 @@ -219,6 +219,25 @@ describe('Confluence page:get operation', () => { expect(body.plainText.value).toBe('A\n B'); }); + it('falls back to an empty value when valid-JSON ADF has nodes the walk cannot take', async () => { + apiRequest.mockResolvedValueOnce({ + id: '123', + body: { + atlas_doc_format: { value: JSON.stringify({ type: 'doc', content: [null] }) }, + }, + }); + const ctx = createContext({ + page: { mode: 'id', value: '123' }, + bodyFormat: 'plainText', + }); + + const result = (await execute.call(ctx, 0)) as IDataObject; + + expect(result.body).toEqual({ + plainText: { representation: 'plain_text', value: '' }, + }); + }); + it('falls back to an empty value when the ADF body is malformed', async () => { apiRequest.mockResolvedValueOnce({ id: '123', 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 new file mode 100644 index 00000000000..15eb690f2f4 --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/test/actions/page/getManyByLabel.operation.test.ts @@ -0,0 +1,297 @@ +import type { + IDataObject, + IExecuteFunctions, + IGetNodeParameterOptions, + INode, + INodeParameterResourceLocator, +} from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import { mockDeep } from 'vitest-mock-extended'; + +import { execute } from '../../../actions/page/getManyByLabel.operation'; +import { confluenceApiRequest } from '../../../transport'; + +vi.mock('../../../transport', () => ({ + CONFLUENCE_CREDENTIAL_NAME: 'confluenceCloudOAuth2Api', + confluenceApiRequest: vi.fn(), +})); + +const apiRequest = vi.mocked(confluenceApiRequest); + +const mockNode: INode = { + id: 'test-node', + name: 'Test Confluence Node', + type: 'n8n-nodes-base.confluence', + typeVersion: 1, + position: [0, 0], + parameters: {}, +}; + +function createContext(params: Record) { + const ctx = mockDeep(); + ctx.getNode.mockReturnValue(mockNode); + ctx.getNodeParameter.mockImplementation( + (name: string, _itemIndex?: number, fallback?: unknown, options?: IGetNodeParameterOptions) => { + const value = params[name] ?? fallback; + if (options?.extractValue && value && typeof value === 'object' && 'value' in value) { + return (value as INodeParameterResourceLocator).value as never; + } + return value as never; + }, + ); + return ctx; +} + +describe('Confluence page:getManyByLabel operation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('fetches pages for a label by ID with the storage body format', async () => { + const pages = [ + { id: '1', title: 'Runbook A', body: { storage: { value: '

a

' } } }, + { id: '2', title: 'Runbook B', body: { storage: { value: '

b

' } } }, + ]; + apiRequest.mockResolvedValueOnce({ results: pages }); + const ctx = createContext({ + label: { mode: 'id', value: '777' }, + bodyFormat: 'storage', + }); + + const result = await execute.call(ctx, 0); + + expect(apiRequest).toHaveBeenCalledTimes(1); + expect(apiRequest).toHaveBeenCalledWith( + 'GET', + '/wiki/api/v2/labels/777/pages', + {}, + { 'body-format': 'storage', limit: 100 }, + ); + expect(result).toEqual(pages); + }); + + it('resolves a label picked from the list through extractValue', async () => { + apiRequest.mockResolvedValueOnce({ results: [] }); + const ctx = createContext({ + label: { mode: 'list', value: '777', cachedResultName: 'runbook' }, + }); + + await execute.call(ctx, 0); + + expect(ctx.getNodeParameter).toHaveBeenCalledWith('label', 0, '', { extractValue: true }); + expect(apiRequest).toHaveBeenCalledWith( + 'GET', + '/wiki/api/v2/labels/777/pages', + {}, + expect.objectContaining({ limit: 100 }), + ); + }); + + it('throws when the label reference is empty', async () => { + const ctx = createContext({ label: { mode: 'id', value: ' ' } }); + + await expect(execute.call(ctx, 0)).rejects.toThrow(NodeOperationError); + await expect(execute.call(ctx, 0)).rejects.toThrow("The 'Label' parameter is empty"); + expect(apiRequest).not.toHaveBeenCalled(); + }); + + it('scopes the request to the selected space', async () => { + apiRequest.mockResolvedValueOnce({ results: [] }); + const ctx = createContext({ + label: { mode: 'id', value: '777' }, + space: { mode: 'list', value: '999', cachedResultName: 'Docs Space' }, + }); + + await execute.call(ctx, 0); + + expect(apiRequest).toHaveBeenCalledWith( + 'GET', + '/wiki/api/v2/labels/777/pages', + {}, + expect.objectContaining({ 'space-id': '999' }), + ); + }); + + it('omits the space filter when "All Spaces" is selected', async () => { + apiRequest.mockResolvedValueOnce({ results: [] }); + const ctx = createContext({ + label: { mode: 'id', value: '777' }, + space: { mode: 'list', value: '' }, + }); + + await execute.call(ctx, 0); + + const qs = apiRequest.mock.calls[0][3] as IDataObject; + expect(qs['space-id']).toBeUndefined(); + }); + + describe('pagination', () => { + it('follows the cursor across pages when Return All is on', async () => { + apiRequest + .mockResolvedValueOnce({ + results: [{ id: '1' }, { id: '2' }], + _links: { next: '/wiki/api/v2/labels/777/pages?cursor=abc%3D%3D' }, + }) + .mockResolvedValueOnce({ results: [{ id: '3' }] }); + const ctx = createContext({ + label: { mode: 'id', value: '777' }, + returnAll: true, + }); + + const result = (await execute.call(ctx, 0)) as IDataObject[]; + + expect(apiRequest).toHaveBeenCalledTimes(2); + expect(apiRequest).toHaveBeenNthCalledWith( + 1, + 'GET', + '/wiki/api/v2/labels/777/pages', + {}, + { 'body-format': 'storage', limit: 250 }, + ); + expect(apiRequest).toHaveBeenNthCalledWith( + 2, + 'GET', + '/wiki/api/v2/labels/777/pages', + {}, + { 'body-format': 'storage', limit: 250, cursor: 'abc==' }, + ); + expect(result.map((page) => page.id)).toEqual(['1', '2', '3']); + }); + + it('stops at the limit and only requests the remainder on follow-up pages', async () => { + apiRequest + .mockResolvedValueOnce({ + results: [{ id: '1' }, { id: '2' }], + _links: { next: '/wiki/api/v2/labels/777/pages?cursor=c2' }, + }) + .mockResolvedValueOnce({ + results: [{ id: '3' }, { id: '4' }], + _links: { next: '/wiki/api/v2/labels/777/pages?cursor=c3' }, + }); + const ctx = createContext({ + label: { mode: 'id', value: '777' }, + returnAll: false, + limit: 3, + }); + + const result = (await execute.call(ctx, 0)) as IDataObject[]; + + expect(apiRequest).toHaveBeenCalledTimes(2); + expect((apiRequest.mock.calls[0][3] as IDataObject).limit).toBe(3); + expect((apiRequest.mock.calls[1][3] as IDataObject).limit).toBe(1); + expect(result.map((page) => page.id)).toEqual(['1', '2', '3']); + }); + + it('stops when the API repeats a next cursor instead of refetching forever', async () => { + apiRequest + .mockResolvedValueOnce({ + results: [{ id: '1' }], + _links: { next: '/wiki/api/v2/labels/777/pages?cursor=same' }, + }) + .mockResolvedValueOnce({ + results: [{ id: '2' }], + _links: { next: '/wiki/api/v2/labels/777/pages?cursor=same' }, + }); + const ctx = createContext({ + label: { mode: 'id', value: '777' }, + returnAll: true, + }); + + const result = (await execute.call(ctx, 0)) as IDataObject[]; + + expect(apiRequest).toHaveBeenCalledTimes(2); + expect(result.map((page) => page.id)).toEqual(['1', '2']); + }); + + it('stops when the response has no next cursor even below the limit', async () => { + apiRequest.mockResolvedValueOnce({ results: [{ id: '1' }] }); + const ctx = createContext({ + label: { mode: 'id', value: '777' }, + returnAll: false, + limit: 50, + }); + + const result = (await execute.call(ctx, 0)) as IDataObject[]; + + expect(apiRequest).toHaveBeenCalledTimes(1); + expect(result.map((page) => page.id)).toEqual(['1']); + }); + + it('caps the per-request page size at 250', async () => { + apiRequest.mockResolvedValueOnce({ results: [] }); + const ctx = createContext({ + label: { mode: 'id', value: '777' }, + returnAll: false, + limit: 1000, + }); + + await execute.call(ctx, 0); + + expect((apiRequest.mock.calls[0][3] as IDataObject).limit).toBe(250); + }); + + it('rejects a limit below 1', async () => { + const ctx = createContext({ + label: { mode: 'id', value: '777' }, + returnAll: false, + limit: 0, + }); + + await expect(execute.call(ctx, 0)).rejects.toThrow('Limit must be a number of at least 1'); + expect(apiRequest).not.toHaveBeenCalled(); + }); + }); + + describe('body formats', () => { + it('passes atlas_doc_format through untouched', async () => { + const pages = [{ id: '1', body: { atlas_doc_format: { value: '{"type":"doc"}' } } }]; + apiRequest.mockResolvedValueOnce({ results: pages }); + const ctx = createContext({ + label: { mode: 'id', value: '777' }, + bodyFormat: 'atlas_doc_format', + }); + + const result = await execute.call(ctx, 0); + + expect(apiRequest).toHaveBeenCalledWith( + 'GET', + '/wiki/api/v2/labels/777/pages', + {}, + expect.objectContaining({ 'body-format': 'atlas_doc_format' }), + ); + expect(result).toEqual(pages); + }); + + it('requests ADF and extracts plain text on every page', async () => { + const adf = JSON.stringify({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'body text' }] }], + }); + apiRequest.mockResolvedValueOnce({ + results: [ + { id: '1', body: { atlas_doc_format: { value: adf } } }, + { id: '2', body: { atlas_doc_format: { value: adf } } }, + ], + }); + const ctx = createContext({ + label: { mode: 'id', value: '777' }, + bodyFormat: 'plainText', + }); + + const result = (await execute.call(ctx, 0)) as IDataObject[]; + + expect(apiRequest).toHaveBeenCalledWith( + 'GET', + '/wiki/api/v2/labels/777/pages', + {}, + expect.objectContaining({ 'body-format': 'atlas_doc_format' }), + ); + expect(result).toHaveLength(2); + for (const page of result) { + expect(page.body).toEqual({ + plainText: { representation: 'plain_text', value: 'body text' }, + }); + } + }); + }); +}); 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 416a36408ec..1e156e2b09f 100644 --- a/packages/nodes-base/nodes/Confluence/test/actions/router.test.ts +++ b/packages/nodes-base/nodes/Confluence/test/actions/router.test.ts @@ -99,6 +99,34 @@ describe('Confluence router', () => { expect(result).toEqual([[{ json: { id: '222', title: 'My Page' }, pairedItem: { item: 0 } }]]); }); + it('dispatches page:getManyByLabel and fans pages out into one item each', async () => { + apiRequest.mockResolvedValue({ results: [{ id: '1' }, { id: '2' }] }); + + const result = await router.call( + mockExecuteCtx({ + resource: 'page', + operation: 'getManyByLabel', + label: { mode: 'id', value: '777' }, + returnAll: false, + limit: 50, + bodyFormat: 'storage', + }), + ); + + expect(apiRequest).toHaveBeenCalledWith( + 'GET', + '/wiki/api/v2/labels/777/pages', + {}, + { 'body-format': 'storage', limit: 50 }, + ); + expect(result).toEqual([ + [ + { json: { id: '1' }, pairedItem: { item: 0 } }, + { json: { id: '2' }, pairedItem: { item: 0 } }, + ], + ]); + }); + it('fans an array response out into one item per page', async () => { apiRequest.mockImplementation(async (_method: string, url: string) => url.endsWith('/descendants') diff --git a/packages/nodes-base/nodes/Confluence/test/methods/listSearch.test.ts b/packages/nodes-base/nodes/Confluence/test/methods/listSearch.test.ts index e33249dc9c8..60143aeb39e 100644 --- a/packages/nodes-base/nodes/Confluence/test/methods/listSearch.test.ts +++ b/packages/nodes-base/nodes/Confluence/test/methods/listSearch.test.ts @@ -2,7 +2,7 @@ import type { ILoadOptionsFunctions } from 'n8n-workflow'; import { mockDeep } from 'vitest-mock-extended'; import { clearSpaceKeyCache } from '../../actions/common'; -import { getPages, searchSpaces, searchSpacesWithAll } from '../../methods/listSearch'; +import { getLabels, getPages, searchSpaces, searchSpacesWithAll } from '../../methods/listSearch'; import { confluenceApiRequest } from '../../transport'; vi.mock('../../transport', () => ({ @@ -320,3 +320,125 @@ describe('Confluence listSearch.searchSpaces', () => { expect(result.paginationToken).toBe('xyz=='); }); }); + +describe('Confluence listSearch.getLabels', () => { + let ctx: ILoadOptionsFunctions; + + beforeEach(() => { + vi.clearAllMocks(); + ctx = mockDeep(); + }); + + it('lists labels sorted by name, marking non-global prefixes', async () => { + apiRequest.mockResolvedValueOnce({ + results: [ + { id: 1, name: 'runbook', prefix: 'global' }, + { name: 'entry without id is skipped' }, + { id: 2, name: 'favourite', prefix: 'my' }, + ], + }); + + const result = await getLabels.call(ctx); + + expect(apiRequest).toHaveBeenCalledWith( + 'GET', + '/wiki/api/v2/labels', + {}, + { limit: 50, sort: 'name' }, + ); + expect(result.results.map(({ name, value }) => [name, value])).toEqual([ + ['runbook', '1'], + ['favourite (my)', '2'], + ]); + expect(result.paginationToken).toBeUndefined(); + }); + + it('filters the typed text client-side, case-insensitively', async () => { + apiRequest.mockResolvedValueOnce({ + results: [ + { id: 1, name: 'runbook', prefix: 'global' }, + { id: 2, name: 'qa-docs', prefix: 'global' }, + ], + }); + + const result = await getLabels.call(ctx, 'RUN'); + + expect(result.results.map(({ name, value }) => [name, value])).toEqual([['runbook', '1']]); + }); + + it('scans past a partial match while an exact match may lie ahead in the name sort', async () => { + apiRequest + .mockResolvedValueOnce({ + results: [{ id: 1, name: 'aqua-qa', prefix: 'global' }], + _links: { next: '/wiki/api/v2/labels?cursor=c2' }, + }) + .mockResolvedValueOnce({ + results: [{ id: 2, name: 'qa', prefix: 'global' }], + _links: { next: '/wiki/api/v2/labels?cursor=c3' }, + }); + + const result = await getLabels.call(ctx, 'qa'); + + expect(apiRequest).toHaveBeenCalledTimes(2); + expect(result.results.map(({ name, value }) => [name, value])).toEqual([ + ['aqua-qa', '1'], + ['qa', '2'], + ]); + }); + + it('stops scanning once the name sort has passed the typed text', async () => { + apiRequest.mockResolvedValueOnce({ + results: [{ id: 1, name: 'runbook', prefix: 'global' }], + _links: { next: '/wiki/api/v2/labels?cursor=c2' }, + }); + + const result = await getLabels.call(ctx, 'run'); + + expect(apiRequest).toHaveBeenCalledTimes(1); + expect(result.results.map(({ name, value }) => [name, value])).toEqual([['runbook', '1']]); + expect(result.paginationToken).toBe('c2'); + }); + + it('keeps fetching pages while a typed filter has no match yet', async () => { + apiRequest + .mockResolvedValueOnce({ + results: [{ id: 1, name: 'runbook', prefix: 'global' }], + _links: { next: '/wiki/api/v2/labels?cursor=c2' }, + }) + .mockResolvedValueOnce({ + results: [{ id: 3, name: 'qa-seed', prefix: 'global' }], + }); + + const result = await getLabels.call(ctx, 'qa-seed'); + + expect(apiRequest).toHaveBeenCalledTimes(2); + expect(apiRequest).toHaveBeenNthCalledWith( + 2, + 'GET', + '/wiki/api/v2/labels', + {}, + expect.objectContaining({ cursor: 'c2' }), + ); + expect(result).toEqual({ + results: [{ name: 'qa-seed', value: '3' }], + paginationToken: undefined, + }); + }); + + it('resumes from the pagination cursor and returns the next one', async () => { + apiRequest.mockResolvedValueOnce({ + results: [{ id: 3, name: 'qa-seed', prefix: 'global' }], + _links: { next: '/wiki/api/v2/labels?cursor=xyz%3D%3D' }, + }); + + const result = await getLabels.call(ctx, undefined, 'abc=='); + + expect(apiRequest).toHaveBeenCalledWith( + 'GET', + '/wiki/api/v2/labels', + {}, + expect.objectContaining({ cursor: 'abc==' }), + ); + expect(result.paginationToken).toBe('xyz=='); + }); +});