mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
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:
@@ -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',
|
||||
|
||||
+297
@@ -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==');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user