mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat(Confluence Node): Add Create Page operation (no-changelog) (#36008)
This commit is contained in:
@@ -76,6 +76,30 @@ export const pageRLC: INodeProperties = {
|
||||
|
||||
export type ConfluenceBodyFormat = 'storage' | 'atlas_doc_format' | 'plainText';
|
||||
|
||||
export const bodyFormatOption: INodeProperties = {
|
||||
displayName: 'Body Format',
|
||||
name: 'bodyFormat',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Atlas Doc Format',
|
||||
value: 'atlas_doc_format',
|
||||
description: 'The ADF JSON representation',
|
||||
},
|
||||
{
|
||||
name: 'Plain Text',
|
||||
value: 'plainText',
|
||||
description: 'Text extracted from the ADF body (dynamic macros carry no text)',
|
||||
},
|
||||
{
|
||||
name: 'Storage',
|
||||
value: 'storage',
|
||||
description: 'The raw storage-format XHTML',
|
||||
},
|
||||
],
|
||||
default: 'storage',
|
||||
};
|
||||
|
||||
export const spaceRLC: INodeProperties = {
|
||||
displayName: 'Space',
|
||||
name: 'space',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import * as page from './page';
|
||||
import { CONFLUENCE_CREDENTIAL_NAME } from '../transport';
|
||||
|
||||
export const confluenceNodeDescription: INodeTypeDescription = {
|
||||
@@ -10,6 +11,7 @@ export const confluenceNodeDescription: INodeTypeDescription = {
|
||||
icon: 'file:confluence.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}',
|
||||
description: 'Interact with the Confluence Cloud API',
|
||||
defaults: {
|
||||
name: 'Confluence',
|
||||
@@ -24,5 +26,20 @@ export const confluenceNodeDescription: INodeTypeDescription = {
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Page',
|
||||
value: 'page',
|
||||
},
|
||||
],
|
||||
default: 'page',
|
||||
},
|
||||
...page.description,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { IDataObject, IExecuteFunctions, INodeProperties } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import type { ConfluenceBodyFormat } from '../common';
|
||||
import { bodyFormatOption } from '../common';
|
||||
|
||||
export interface ConfluenceBodyEnvelope extends IDataObject {
|
||||
representation: 'storage' | 'atlas_doc_format';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function bodyProperties(operations: string[]): INodeProperties[] {
|
||||
const show = { resource: ['page'], operation: operations };
|
||||
return [
|
||||
{
|
||||
...bodyFormatOption,
|
||||
default: 'plainText',
|
||||
description: 'How the page content below is interpreted',
|
||||
displayOptions: { show },
|
||||
// Same values as the shared selector; write-oriented descriptions
|
||||
options: [
|
||||
{
|
||||
name: 'Atlas Doc Format',
|
||||
value: 'atlas_doc_format',
|
||||
description: 'Raw Atlassian Document Format JSON document',
|
||||
},
|
||||
{
|
||||
name: 'Plain Text',
|
||||
value: 'plainText',
|
||||
description: 'Text is wrapped in paragraph blocks; no markup needed',
|
||||
},
|
||||
{
|
||||
name: 'Storage',
|
||||
value: 'storage',
|
||||
description: 'Confluence storage-format XHTML, e.g. <h2>Title</h2><p>Text</p>',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Body',
|
||||
name: 'bodyPlainText',
|
||||
type: 'string',
|
||||
typeOptions: { rows: 4 },
|
||||
default: '',
|
||||
description:
|
||||
'Page content as plain text; each line becomes a paragraph. Blank lines and leading whitespace are removed.',
|
||||
displayOptions: { show: { ...show, bodyFormat: ['plainText'] } },
|
||||
},
|
||||
{
|
||||
displayName: 'Body (Storage HTML)',
|
||||
name: 'bodyStorage',
|
||||
type: 'string',
|
||||
typeOptions: { rows: 4 },
|
||||
default: '',
|
||||
placeholder: '<h2>Heading</h2><p>Text</p>',
|
||||
description: 'Page content in Confluence storage format',
|
||||
displayOptions: { show: { ...show, bodyFormat: ['storage'] } },
|
||||
},
|
||||
{
|
||||
displayName: 'Body (ADF JSON)',
|
||||
name: 'bodyAdf',
|
||||
type: 'json',
|
||||
default: '',
|
||||
placeholder: '{ "type": "doc", "version": 1, "content": [] }',
|
||||
description: 'Page content as an Atlassian Document Format document',
|
||||
displayOptions: { show: { ...show, bodyFormat: ['atlas_doc_format'] } },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const HTML_ESCAPES: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
};
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/[&<>"]/g, (char) => HTML_ESCAPES[char]);
|
||||
}
|
||||
|
||||
export function buildBodyEnvelope(
|
||||
format: ConfluenceBodyFormat,
|
||||
content: unknown,
|
||||
): ConfluenceBodyEnvelope {
|
||||
switch (format) {
|
||||
case 'plainText': {
|
||||
if (typeof content === 'object' && content !== null) {
|
||||
throw new Error(
|
||||
'Plain text body must be text, got an object. Use the ADF JSON format for document objects.',
|
||||
);
|
||||
}
|
||||
const text = content === null || content === undefined ? '' : String(content);
|
||||
const paragraphs = text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line !== '')
|
||||
.map((line) => `<p>${escapeHtml(line)}</p>`);
|
||||
return { representation: 'storage', value: paragraphs.join('') };
|
||||
}
|
||||
|
||||
case 'storage': {
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error('Storage (HTML) body must be a string of Confluence storage-format markup');
|
||||
}
|
||||
return { representation: 'storage', value: content };
|
||||
}
|
||||
|
||||
case 'atlas_doc_format': {
|
||||
let parsed: unknown = content;
|
||||
if (typeof content === 'string') {
|
||||
if (content.trim() === '') {
|
||||
throw new Error('ADF JSON body is empty. Provide an ADF document object.');
|
||||
}
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
throw new Error('ADF JSON body is not valid JSON');
|
||||
}
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('ADF JSON body must be a JSON object, e.g. { "type": "doc", ... }');
|
||||
}
|
||||
return { representation: 'atlas_doc_format', value: JSON.stringify(parsed) };
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported body format "${format as string}"`);
|
||||
}
|
||||
}
|
||||
|
||||
export function readBodyEnvelope(
|
||||
ctx: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): ConfluenceBodyEnvelope {
|
||||
const format = ctx.getNodeParameter('bodyFormat', itemIndex, 'plainText') as ConfluenceBodyFormat;
|
||||
const fieldByFormat: Record<ConfluenceBodyFormat, string> = {
|
||||
plainText: 'bodyPlainText',
|
||||
storage: 'bodyStorage',
|
||||
atlas_doc_format: 'bodyAdf',
|
||||
};
|
||||
const content = ctx.getNodeParameter(fieldByFormat[format], itemIndex, '');
|
||||
try {
|
||||
return buildBodyEnvelope(format, content);
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(ctx.getNode(), (error as Error).message, { itemIndex });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeParameterResourceLocator,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { bodyProperties, readBodyEnvelope } from './bodyEnvelope';
|
||||
import { pageRLC, spaceRLC } from '../common';
|
||||
import { confluenceApiRequest } from '../../transport';
|
||||
|
||||
const showOnCreate = { resource: ['page'], operation: ['create'] };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
...spaceRLC,
|
||||
required: true,
|
||||
description: 'The space to create the page in',
|
||||
displayOptions: { show: showOnCreate },
|
||||
},
|
||||
{
|
||||
displayName: 'Title',
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'e.g. Weekly Report',
|
||||
description: 'The title of the new page',
|
||||
displayOptions: { show: showOnCreate },
|
||||
},
|
||||
...bodyProperties(['create']),
|
||||
{
|
||||
...pageRLC,
|
||||
displayName: 'Parent Page',
|
||||
name: 'parentPage',
|
||||
required: false,
|
||||
description:
|
||||
'The page to create the new page under. Leave empty to create under the space homepage.',
|
||||
// By Title needs a title-to-ID resolver the create path does not have yet
|
||||
modes: (pageRLC.modes ?? []).filter((mode) => mode.name !== 'title'),
|
||||
displayOptions: {
|
||||
show: showOnCreate,
|
||||
// The API rejects root-level + parentId; hiding makes the combination unrepresentable
|
||||
hide: { '/options.rootLevel': [true] },
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Option',
|
||||
default: {},
|
||||
displayOptions: { show: showOnCreate },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Create as Draft',
|
||||
name: 'createAsDraft',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to create the page as a draft instead of publishing it',
|
||||
},
|
||||
{
|
||||
displayName: 'Private',
|
||||
name: 'private',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether only the creating user can view and edit the page. The creator is the connected account, which needs permission to restrict content in the space.',
|
||||
},
|
||||
{
|
||||
displayName: 'Root Level',
|
||||
name: 'rootLevel',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to create the page at the space root, outside the space homepage tree. Cannot be combined with a parent page.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<IDataObject | IDataObject[]> {
|
||||
const spaceId = this.getNodeParameter('space', itemIndex, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
const options = this.getNodeParameter('options', itemIndex, {});
|
||||
|
||||
const rawTitle: unknown = this.getNodeParameter('title', itemIndex, '');
|
||||
// Objects coerce to '' so validation rejects them instead of titling the page '[object Object]'
|
||||
const title =
|
||||
typeof rawTitle === 'string'
|
||||
? rawTitle.trim()
|
||||
: rawTitle === null || rawTitle === undefined || typeof rawTitle === 'object'
|
||||
? ''
|
||||
: String(rawTitle).trim();
|
||||
|
||||
if (!spaceId) {
|
||||
throw new NodeOperationError(this.getNode(), 'Space is required', { itemIndex });
|
||||
}
|
||||
if (!title) {
|
||||
throw new NodeOperationError(this.getNode(), 'Title is required', { itemIndex });
|
||||
}
|
||||
|
||||
const body: IDataObject = {
|
||||
spaceId,
|
||||
status: options.createAsDraft ? 'draft' : 'current',
|
||||
title,
|
||||
body: readBodyEnvelope(this, itemIndex),
|
||||
};
|
||||
|
||||
if (!options.rootLevel) {
|
||||
const parentRef = this.getNodeParameter('parentPage', itemIndex, '') as
|
||||
| INodeParameterResourceLocator
|
||||
| string;
|
||||
const rawParentValue =
|
||||
typeof parentRef === 'object' && parentRef !== null ? parentRef.value : parentRef;
|
||||
// The field is optional: an empty By URL value means "no parent", so only
|
||||
// extract (and regex-validate) once something is set
|
||||
if (String(rawParentValue ?? '').trim() !== '') {
|
||||
const parentId = this.getNodeParameter('parentPage', itemIndex, '', {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
if (parentId) body.parentId = parentId;
|
||||
}
|
||||
}
|
||||
|
||||
const qs: IDataObject = {};
|
||||
if (options.private) qs.private = true;
|
||||
if (options.rootLevel) qs['root-level'] = true;
|
||||
|
||||
try {
|
||||
return await confluenceApiRequest.call(this, 'POST', '/wiki/api/v2/pages', body, qs);
|
||||
} catch (error) {
|
||||
// Private creation applies a content restriction under the hood, and Atlassian
|
||||
// masks the failing permission/scope check as 404 on this endpoint
|
||||
if (options.private && error instanceof NodeApiError && error.httpCode === '404') {
|
||||
throw new NodeOperationError(this.getNode(), 'Could not create the page as private', {
|
||||
itemIndex,
|
||||
description:
|
||||
'Atlassian reports this as "not found", but it usually means the restriction step was refused: the connected user needs the "Add/Delete restrictions" permission in the space, and the credential\'s OAuth app must allow the content-restriction scopes. Try again without the Private option, or check the space permissions.',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as create from './create.operation';
|
||||
|
||||
export { create };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['page'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Create',
|
||||
value: 'create',
|
||||
description: 'Create a new page in a space',
|
||||
action: 'Create a page',
|
||||
},
|
||||
],
|
||||
default: 'create',
|
||||
},
|
||||
...create.description,
|
||||
];
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { IDataObject, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import * as page from './page';
|
||||
|
||||
/**
|
||||
* Compile-checked contract for operation modules. The router body (owned by ENT-125)
|
||||
* calls `<resource>.<operation>.execute.call(this, i)` once per item, SharePoint v2
|
||||
* shape; the other op tickets (ENT-126/319/127/305/327/306) implement against this.
|
||||
* Compile-checked contract for operation modules. The router calls
|
||||
* `<resource>.<operation>.execute.call(this, i)` once per item.
|
||||
*/
|
||||
export type ConfluenceOperation = (
|
||||
this: IExecuteFunctions,
|
||||
@@ -12,16 +13,41 @@ export type ConfluenceOperation = (
|
||||
) => Promise<IDataObject | IDataObject[]>;
|
||||
|
||||
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
// Fallbacks: the shell ships properties: [], so these parameters don't exist yet
|
||||
const items = this.getInputData();
|
||||
const resource = this.getNodeParameter('resource', 0, '');
|
||||
const operation = this.getNodeParameter('operation', 0, '');
|
||||
|
||||
switch (resource) {
|
||||
// Op tickets (ENT-125/126/319/127/305/327/306) add their resource cases here
|
||||
default:
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The operation "${resource}:${operation}" is not supported`,
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
let responseData: IDataObject | IDataObject[];
|
||||
|
||||
switch (`${resource}:${operation}`) {
|
||||
case 'page:create':
|
||||
responseData = await page.create.execute.call(this, i);
|
||||
break;
|
||||
default:
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The operation "${resource}:${operation}" is not supported`,
|
||||
);
|
||||
}
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray(responseData),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
returnData.push.apply(returnData, executionData);
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
returnData.push({ json: { error: message }, pairedItem: { item: i } });
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
|
||||
@@ -7,12 +7,19 @@ import { Confluence } from '../Confluence.node';
|
||||
describe('Confluence Node', () => {
|
||||
const node = new Confluence();
|
||||
|
||||
it('should ship gated: hidden, no properties, not usable as a tool', () => {
|
||||
it('should ship gated: hidden and not usable as a tool', () => {
|
||||
expect(node.description.hidden).toBe(true);
|
||||
expect(node.description.properties).toEqual([]);
|
||||
expect(node.description.usableAsTool).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should expose the page resource with the create operation', () => {
|
||||
const resource = node.description.properties.find((p) => p.name === 'resource');
|
||||
expect(resource?.options).toEqual([expect.objectContaining({ value: 'page' })]);
|
||||
|
||||
const operation = node.description.properties.find((p) => p.name === 'operation');
|
||||
expect(operation?.options).toEqual([expect.objectContaining({ value: 'create' })]);
|
||||
});
|
||||
|
||||
it('should reference the confluenceCloudOAuth2Api credential by name', () => {
|
||||
expect(node.description.credentials).toEqual([
|
||||
{ name: 'confluenceCloudOAuth2Api', required: true },
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { buildBodyEnvelope, readBodyEnvelope } from '../../../actions/page/bodyEnvelope';
|
||||
import { mockExecuteCtx } from '../../shared';
|
||||
|
||||
describe('buildBodyEnvelope', () => {
|
||||
describe('plainText', () => {
|
||||
it.each([
|
||||
['wraps a single line in a paragraph block', 'Hello world', '<p>Hello world</p>'],
|
||||
[
|
||||
'wraps each non-blank line in its own paragraph',
|
||||
'First\nSecond\n\n \nThird',
|
||||
'<p>First</p><p>Second</p><p>Third</p>',
|
||||
],
|
||||
[
|
||||
'escapes markup so text cannot inject storage format',
|
||||
'<script>1 & 2 > "0"</script>',
|
||||
'<p><script>1 & 2 > "0"</script></p>',
|
||||
],
|
||||
['produces an empty value for empty input', '', ''],
|
||||
['coerces number expression results instead of dropping them', 42, '<p>42</p>'],
|
||||
['coerces boolean expression results instead of dropping them', true, '<p>true</p>'],
|
||||
])('%s', (_name, input, value) => {
|
||||
expect(buildBodyEnvelope('plainText', input)).toEqual({ representation: 'storage', value });
|
||||
});
|
||||
|
||||
it('rejects object content instead of creating an empty page', () => {
|
||||
expect(() => buildBodyEnvelope('plainText', { some: 'object' })).toThrow(
|
||||
'must be text, got an object',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storage', () => {
|
||||
it('passes storage markup through verbatim', () => {
|
||||
const html = '<h2>Title</h2><table><tr><td>x</td></tr></table>';
|
||||
expect(buildBodyEnvelope('storage', html)).toEqual({
|
||||
representation: 'storage',
|
||||
value: html,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-string content', () => {
|
||||
expect(() => buildBodyEnvelope('storage', { html: '<p>x</p>' })).toThrow(
|
||||
'Storage (HTML) body must be a string',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('atlas_doc_format', () => {
|
||||
const doc = { type: 'doc', version: 1, content: [] };
|
||||
|
||||
it.each([
|
||||
['serializes an already-parsed document object', doc],
|
||||
['parses and re-serializes a JSON string', JSON.stringify(doc)],
|
||||
])('%s', (_name, input) => {
|
||||
expect(buildBodyEnvelope('atlas_doc_format', input)).toEqual({
|
||||
representation: 'atlas_doc_format',
|
||||
value: JSON.stringify(doc),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['invalid JSON', '{not json', 'not valid JSON'],
|
||||
['an empty string', ' ', 'empty'],
|
||||
['an array document', JSON.stringify([1, 2]), 'must be a JSON object'],
|
||||
['a null document', 'null', 'must be a JSON object'],
|
||||
['a scalar document', '"text"', 'must be a JSON object'],
|
||||
])('rejects %s', (_name, input, message) => {
|
||||
expect(() => buildBodyEnvelope('atlas_doc_format', input)).toThrow(message);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('readBodyEnvelope', () => {
|
||||
it('reads the field matching the selected format', () => {
|
||||
const ctx = mockExecuteCtx({
|
||||
bodyFormat: 'storage',
|
||||
bodyStorage: '<p>from storage field</p>',
|
||||
bodyPlainText: 'from the wrong field',
|
||||
});
|
||||
expect(readBodyEnvelope(ctx, 0).value).toBe('<p>from storage field</p>');
|
||||
});
|
||||
|
||||
it('defaults to plain text', () => {
|
||||
const ctx = mockExecuteCtx({ bodyPlainText: 'Hello' });
|
||||
expect(readBodyEnvelope(ctx, 0)).toEqual({ representation: 'storage', value: '<p>Hello</p>' });
|
||||
});
|
||||
|
||||
it('wraps envelope errors in a NodeOperationError carrying the item index', () => {
|
||||
const ctx = mockExecuteCtx({ bodyFormat: 'atlas_doc_format', bodyAdf: '{broken' });
|
||||
try {
|
||||
readBodyEnvelope(ctx, 3);
|
||||
throw new Error('expected readBodyEnvelope to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(NodeOperationError);
|
||||
expect((error as NodeOperationError).context.itemIndex).toBe(3);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import { execute } from '../../../actions/page/create.operation';
|
||||
import { confluenceApiRequest } from '../../../transport';
|
||||
import { mockExecuteCtx, testNode } from '../../shared';
|
||||
|
||||
vi.mock('../../../transport', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
confluenceApiRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
const apiRequest = confluenceApiRequest as unknown as Mock;
|
||||
|
||||
const baseParams: Record<string, unknown> = {
|
||||
space: { mode: 'list', value: '111' },
|
||||
title: 'My Page',
|
||||
bodyFormat: 'plainText',
|
||||
bodyPlainText: 'Hello',
|
||||
parentPage: '',
|
||||
options: {},
|
||||
};
|
||||
|
||||
describe('page:create', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiRequest.mockResolvedValue({ id: '222', title: 'My Page' });
|
||||
});
|
||||
|
||||
it('posts the assembled envelope to the v2 pages endpoint', async () => {
|
||||
const result = await execute.call(mockExecuteCtx(baseParams), 0);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/wiki/api/v2/pages',
|
||||
{
|
||||
spaceId: '111',
|
||||
status: 'current',
|
||||
title: 'My Page',
|
||||
body: { representation: 'storage', value: '<p>Hello</p>' },
|
||||
},
|
||||
{},
|
||||
);
|
||||
expect(result).toEqual({ id: '222', title: 'My Page' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'includes parentId when a parent page is set',
|
||||
{ parentPage: { mode: 'id', value: '98304' } },
|
||||
{ parentId: '98304' },
|
||||
{},
|
||||
],
|
||||
[
|
||||
'sends root-level and drops the parent when Root Level is on',
|
||||
{ parentPage: { mode: 'id', value: '98304' }, options: { rootLevel: true } },
|
||||
{},
|
||||
{ 'root-level': true },
|
||||
],
|
||||
[
|
||||
'sends the private query param when Private is on',
|
||||
{ options: { private: true } },
|
||||
{},
|
||||
{ private: true },
|
||||
],
|
||||
[
|
||||
'creates a draft when Create as Draft is on',
|
||||
{ options: { createAsDraft: true } },
|
||||
{ status: 'draft' },
|
||||
{},
|
||||
],
|
||||
['trims the title', { title: ' Padded ' }, { title: 'Padded' }, {}],
|
||||
['coerces a non-string title from an expression', { title: 32 }, { title: '32' }, {}],
|
||||
[
|
||||
'combines Private, Root Level, and Create as Draft',
|
||||
{
|
||||
parentPage: { mode: 'id', value: '98304' },
|
||||
options: { private: true, rootLevel: true, createAsDraft: true },
|
||||
},
|
||||
{ status: 'draft' },
|
||||
{ private: true, 'root-level': true },
|
||||
],
|
||||
])('%s', async (_name, overrides, expectedBody, expectedQs) => {
|
||||
await execute.call(mockExecuteCtx({ ...baseParams, ...overrides }), 0);
|
||||
|
||||
const [, , body, qs] = apiRequest.mock.calls[0];
|
||||
expect(body).toMatchObject(expectedBody);
|
||||
if (!('parentId' in expectedBody)) expect(body).not.toHaveProperty('parentId');
|
||||
expect(qs).toEqual(expectedQs);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['space', { ...baseParams, space: { mode: 'list', value: '' } }, 'Space is required'],
|
||||
['title', { ...baseParams, title: ' ' }, 'Title is required'],
|
||||
])('rejects a missing %s without calling the API', async (_field, params, message) => {
|
||||
const ctx = mockExecuteCtx(params);
|
||||
|
||||
await expect(execute.call(ctx, 0)).rejects.toThrow(NodeOperationError);
|
||||
await expect(execute.call(ctx, 0)).rejects.toThrow(message);
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an object title instead of creating a page named [object Object]', async () => {
|
||||
const ctx = mockExecuteCtx({ ...baseParams, title: { some: 'object' } });
|
||||
|
||||
await expect(execute.call(ctx, 0)).rejects.toThrow('Title is required');
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Atlassian masks the failing restriction step behind a bare 404; the node
|
||||
// replaces it with guidance only when Private was the requested option
|
||||
it('maps a 404 with Private on to an actionable error', async () => {
|
||||
apiRequest.mockRejectedValue(
|
||||
new NodeApiError(testNode, { message: 'not found' }, { httpCode: '404' }),
|
||||
);
|
||||
const ctx = mockExecuteCtx({ ...baseParams, options: { private: true } });
|
||||
|
||||
const error = await execute
|
||||
.call(ctx, 0)
|
||||
.then(() => null)
|
||||
.catch((thrown: NodeOperationError) => thrown);
|
||||
|
||||
expect(error).toBeInstanceOf(NodeOperationError);
|
||||
expect(error?.message).toBe('Could not create the page as private');
|
||||
expect(error?.description).toContain('Add/Delete restrictions');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a 404 without Private',
|
||||
{},
|
||||
new NodeApiError(testNode, { message: 'x' }, { httpCode: '404' }),
|
||||
],
|
||||
[
|
||||
'a non-404 with Private on',
|
||||
{ private: true },
|
||||
new NodeApiError(testNode, { message: 'x' }, { httpCode: '400' }),
|
||||
],
|
||||
])('passes %s through unchanged', async (_name, options, thrown) => {
|
||||
apiRequest.mockRejectedValue(thrown);
|
||||
|
||||
await expect(execute.call(mockExecuteCtx({ ...baseParams, options }), 0)).rejects.toBe(thrown);
|
||||
});
|
||||
|
||||
it('treats an empty By URL parent as "no parent" instead of failing extraction', async () => {
|
||||
await execute.call(
|
||||
mockExecuteCtx({ ...baseParams, parentPage: { mode: 'url', value: '' } }),
|
||||
0,
|
||||
);
|
||||
|
||||
const [, , body] = apiRequest.mock.calls[0];
|
||||
expect(body).not.toHaveProperty('parentId');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import { router } from '../../actions/router';
|
||||
import { confluenceApiRequest } from '../../transport';
|
||||
import { mockExecuteCtx } from '../shared';
|
||||
|
||||
vi.mock('../../transport', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
confluenceApiRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
const apiRequest = confluenceApiRequest as unknown as Mock;
|
||||
|
||||
const createParams: Record<string, unknown> = {
|
||||
resource: 'page',
|
||||
operation: 'create',
|
||||
space: { mode: 'list', value: '111' },
|
||||
title: 'My Page',
|
||||
bodyFormat: 'plainText',
|
||||
bodyPlainText: 'Hello',
|
||||
parentPage: '',
|
||||
options: {},
|
||||
};
|
||||
|
||||
describe('Confluence router', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiRequest.mockResolvedValue({ id: '222', title: 'My Page' });
|
||||
});
|
||||
|
||||
it('dispatches page:create per item and pairs outputs to their inputs', async () => {
|
||||
const result = await router.call(mockExecuteCtx(createParams, 2));
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{ json: { id: '222', title: 'My Page' }, pairedItem: { item: 0 } },
|
||||
{ json: { id: '222', title: 'My Page' }, pairedItem: { item: 1 } },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
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);
|
||||
apiRequest.mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce({ id: '333' });
|
||||
|
||||
expect(await router.call(ctx)).toEqual([
|
||||
[
|
||||
{ json: { error: 'boom' }, pairedItem: { item: 0 } },
|
||||
{ json: { id: '333' }, pairedItem: { item: 1 } },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('rethrows when continue-on-fail is off', async () => {
|
||||
const ctx = mockExecuteCtx(createParams);
|
||||
ctx.continueOnFail.mockReturnValue(false);
|
||||
apiRequest.mockRejectedValue(new Error('boom'));
|
||||
|
||||
await expect(router.call(ctx)).rejects.toThrow('boom');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['constructor', 'create'],
|
||||
['__proto__', 'create'],
|
||||
['page', 'constructor'],
|
||||
['page', 'hasOwnProperty'],
|
||||
])('rejects inherited-property lookups (%s:%s) as unsupported', async (resource, operation) => {
|
||||
const ctx = mockExecuteCtx({ ...createParams, resource, operation });
|
||||
|
||||
await expect(router.call(ctx)).rejects.toThrow(NodeOperationError);
|
||||
await expect(router.call(ctx)).rejects.toThrow(
|
||||
`The operation "${resource}:${operation}" is not supported`,
|
||||
);
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended';
|
||||
|
||||
export const testNode: INode = {
|
||||
id: 'test',
|
||||
name: 'Confluence',
|
||||
type: 'n8n-nodes-base.confluence',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
export function mockExecuteCtx(
|
||||
params: Record<string, unknown>,
|
||||
items = 1,
|
||||
): DeepMockProxy<IExecuteFunctions> {
|
||||
const ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.getInputData.mockReturnValue(Array.from({ length: items }, () => ({ json: {} })));
|
||||
ctx.getNodeParameter.mockImplementation(
|
||||
(name: string, _i?: number, fallback?: unknown, options?: { extractValue?: boolean }) => {
|
||||
const value = name in params ? params[name] : fallback;
|
||||
// Mimic extractValue unwrapping a resource locator to its value, so a
|
||||
// call site that forgets to ask for extraction fails the assertions.
|
||||
if (options?.extractValue && value && typeof value === 'object' && 'value' in value) {
|
||||
return (value as { value: unknown }).value as never;
|
||||
}
|
||||
return value as never;
|
||||
},
|
||||
);
|
||||
ctx.getNode.mockReturnValue(testNode);
|
||||
ctx.helpers.returnJsonArray.mockImplementation((data) =>
|
||||
(Array.isArray(data) ? data : [data]).map((json) => ({ json })),
|
||||
);
|
||||
ctx.helpers.constructExecutionMetaData.mockImplementation(
|
||||
(data, { itemData }) => data.map((entry) => ({ ...entry, pairedItem: itemData })) as never,
|
||||
);
|
||||
return ctx;
|
||||
}
|
||||
@@ -108,6 +108,54 @@ describe('confluenceApiRequest', () => {
|
||||
expect(error?.messages).toContain('boom');
|
||||
});
|
||||
|
||||
it("surfaces Atlassian's v2 error envelope instead of the generic status message", async () => {
|
||||
mockHttpRequestWithAuthentication
|
||||
.mockResolvedValueOnce(accessibleResources)
|
||||
.mockRejectedValueOnce({
|
||||
message: 'Request failed with status code 404',
|
||||
response: {
|
||||
status: 404,
|
||||
data: {
|
||||
errors: [
|
||||
{
|
||||
status: 404,
|
||||
code: 'NOT_FOUND',
|
||||
title: 'Page not found',
|
||||
detail: 'No page with this ID exists',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const error = await confluenceApiRequest
|
||||
.call(ctx, 'GET', '/wiki/api/v2/pages/1')
|
||||
.then(() => null)
|
||||
.catch((thrown: NodeApiError) => thrown);
|
||||
|
||||
expect(error).toBeInstanceOf(NodeApiError);
|
||||
expect(error?.message).toBe('Page not found');
|
||||
expect(error?.description).toBe('No page with this ID exists');
|
||||
});
|
||||
|
||||
it('falls back to the generic wrap when the envelope carries no usable title', async () => {
|
||||
mockHttpRequestWithAuthentication
|
||||
.mockResolvedValueOnce(accessibleResources)
|
||||
.mockRejectedValueOnce({
|
||||
message: 'boom',
|
||||
response: { status: 500, data: { errors: [{ title: '' }] } },
|
||||
});
|
||||
|
||||
const error = await confluenceApiRequest
|
||||
.call(ctx, 'GET', '/wiki/api/v2/pages')
|
||||
.then(() => null)
|
||||
.catch((thrown: NodeApiError) => thrown);
|
||||
|
||||
expect(error).toBeInstanceOf(NodeApiError);
|
||||
expect(error?.httpCode).toBe('500');
|
||||
expect(error?.messages).toContain('boom');
|
||||
});
|
||||
|
||||
it('surfaces the cloudId lookup error when no site matches', async () => {
|
||||
ctx.getCredentials.mockResolvedValue({ domain: 'https://missing.atlassian.net' });
|
||||
|
||||
|
||||
@@ -52,6 +52,20 @@ export async function confluenceApiRequest(
|
||||
options,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new NodeApiError(this.getNode(), error as JsonObject);
|
||||
// Atlassian's v2 error envelope sits in response.data.errors; without this,
|
||||
// NodeApiError stops at Axios's generic "Request failed with status code N"
|
||||
const envelope = (error as { response?: { data?: { errors?: unknown } } }).response?.data
|
||||
?.errors;
|
||||
const first = Array.isArray(envelope)
|
||||
? (envelope[0] as { title?: unknown; detail?: unknown })
|
||||
: undefined;
|
||||
const title = typeof first?.title === 'string' && first.title !== '' ? first.title : undefined;
|
||||
const detail =
|
||||
typeof first?.detail === 'string' && first.detail !== '' ? first.detail : undefined;
|
||||
throw new NodeApiError(
|
||||
this.getNode(),
|
||||
error as JsonObject,
|
||||
title ? { message: title, description: detail } : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user