feat(Confluence Node): Add Page Get Many by Label operation (#36785)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ilfat Mindubaev
2026-08-25 07:58:40 +00:00
committed by GitHub
parent 7cd37ae874
commit 9fbd9fbc65
11 changed files with 738 additions and 62 deletions
@@ -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<IDataObject | null>(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 {
@@ -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
@@ -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<string>();
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));
};
@@ -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,
];
@@ -47,6 +47,9 @@ export async function router(this: IExecuteFunctions): Promise<INodeExecutionDat
case 'page:get':
responseData = await page.get.execute.call(this, i);
break;
case 'page:getManyByLabel':
responseData = await page.getManyByLabel.execute.call(this, i);
break;
case 'page:update':
responseData = await page.update.execute.call(this, i);
break;
@@ -18,8 +18,17 @@ const SEARCH_PAGE_SIZE = 50;
const MAX_FILTERED_SEARCH_PAGES = 10;
const EMPTY_PAGE: SearchPage = { entries: [], base: '' };
export async function searchSpaces(
/**
* Shared list search over the v2 cursor-paginated lists that have no
* server-side text filter (spaces, labels): the typed text is matched
* client-side against `name`, fetching ahead so matches beyond the first
* page stay discoverable.
*/
async function searchByName(
this: ILoadOptionsFunctions,
endpoint: string,
baseQs: IDataObject,
toDisplayName: (name: string, entry: IDataObject) => string,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
@@ -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<INodeListSearchResult> {
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<INodeListSearchResult> {
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,
@@ -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' }),
]);
});
@@ -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',
@@ -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<string, unknown>) {
const ctx = mockDeep<IExecuteFunctions>();
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: '<p>a</p>' } } },
{ id: '2', title: 'Runbook B', body: { storage: { value: '<p>b</p>' } } },
];
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' },
});
}
});
});
});
@@ -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')
@@ -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<ILoadOptionsFunctions>();
});
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==');
});
});