mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat(Confluence Node): Add comment write operations (no-changelog) (#37024)
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IDisplayOptions,
|
||||
IExecuteFunctions,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { bodyProperties, envelopeHasContent, readBodyEnvelope } from './bodyEnvelope';
|
||||
import { confluenceApiRequest } from '../../transport';
|
||||
import { optionalSpaceRLC, pageRLC, resolvePageId } from '../common';
|
||||
import type { ConfluenceOperation } from '../router';
|
||||
|
||||
const showOnAddComment = { resource: ['page'], operation: ['addComment'] };
|
||||
// Replies target the parent comment, so the page pickers disappear once one is set;
|
||||
// \S (not `exists`) keeps them visible for whitespace-only values, which execute treats as absent
|
||||
const hideOnReply: IDisplayOptions['hide'] = { parentCommentId: [{ _cnd: { regex: '\\S' } }] };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Parent Comment ID',
|
||||
name: 'parentCommentId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. 123456',
|
||||
description:
|
||||
'Leave empty to comment directly on a page. Set to reply to an existing footer comment; the page is inferred from the parent.',
|
||||
displayOptions: { show: showOnAddComment },
|
||||
},
|
||||
{
|
||||
...optionalSpaceRLC,
|
||||
displayOptions: { show: showOnAddComment, hide: hideOnReply },
|
||||
},
|
||||
{
|
||||
...pageRLC,
|
||||
description: 'The page to comment on',
|
||||
displayOptions: { show: showOnAddComment, hide: hideOnReply },
|
||||
},
|
||||
...bodyProperties(['addComment'], undefined, 'Comment content'),
|
||||
];
|
||||
|
||||
export const execute: ConfluenceOperation = async function (
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
) {
|
||||
const body = readBodyEnvelope(this, itemIndex);
|
||||
// An empty body is valid for a page, but a comment without content is never intended
|
||||
if (!envelopeHasContent(body)) {
|
||||
throw new NodeOperationError(this.getNode(), 'The comment body is empty', {
|
||||
itemIndex,
|
||||
description: 'Provide the comment content in the Body field.',
|
||||
});
|
||||
}
|
||||
const parentCommentId = String(
|
||||
this.getNodeParameter('parentCommentId', itemIndex, '') ?? '',
|
||||
).trim();
|
||||
|
||||
// The API takes exactly one container: a parent comment (reply, page inferred) or a page
|
||||
const payload: IDataObject = { body };
|
||||
if (parentCommentId !== '') {
|
||||
payload.parentCommentId = parentCommentId;
|
||||
} else {
|
||||
payload.pageId = await resolvePageId.call(this, itemIndex);
|
||||
}
|
||||
|
||||
try {
|
||||
return await confluenceApiRequest.call(this, 'POST', '/wiki/api/v2/footer-comments', payload);
|
||||
} catch (error) {
|
||||
// Atlassian masks permission failures on this endpoint as 404
|
||||
if (error instanceof NodeApiError && error.httpCode === '404') {
|
||||
throw new NodeOperationError(this.getNode(), 'Confluence could not add the comment', {
|
||||
itemIndex,
|
||||
description:
|
||||
parentCommentId === ''
|
||||
? 'The page may not exist, or the connected user may lack view or comment permission in its space (Confluence reports permission failures as "not found").'
|
||||
: 'The parent comment may not exist, or the connected user may lack view or comment permission on its page (Confluence reports permission failures as "not found").',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -9,14 +9,18 @@ export interface ConfluenceBodyEnvelope extends IDataObject {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function bodyProperties(operations: string[], bodyHint?: string): INodeProperties[] {
|
||||
export function bodyProperties(
|
||||
operations: string[],
|
||||
bodyHint?: string,
|
||||
contentNoun = 'Page content',
|
||||
): INodeProperties[] {
|
||||
const show = { resource: ['page'], operation: operations };
|
||||
const hint = bodyHint === undefined ? {} : { hint: bodyHint };
|
||||
return [
|
||||
{
|
||||
...bodyFormatOption,
|
||||
default: 'plainText',
|
||||
description: 'How the page content below is interpreted',
|
||||
description: `How the ${contentNoun.toLowerCase()} below is interpreted`,
|
||||
displayOptions: { show },
|
||||
// Same values as the shared selector; write-oriented descriptions
|
||||
options: [
|
||||
@@ -44,8 +48,7 @@ export function bodyProperties(operations: string[], bodyHint?: string): INodePr
|
||||
type: 'string',
|
||||
typeOptions: { rows: 4 },
|
||||
default: '',
|
||||
description:
|
||||
'Page content as plain text; each line becomes a paragraph. Blank lines and leading whitespace are removed.',
|
||||
description: `${contentNoun} as plain text; each line becomes a paragraph. Blank lines and leading whitespace are removed.`,
|
||||
displayOptions: { show: { ...show, bodyFormat: ['plainText'] } },
|
||||
...hint,
|
||||
},
|
||||
@@ -56,7 +59,7 @@ export function bodyProperties(operations: string[], bodyHint?: string): INodePr
|
||||
typeOptions: { rows: 4 },
|
||||
default: '',
|
||||
placeholder: '<h2>Heading</h2><p>Text</p>',
|
||||
description: 'Page content in Confluence storage format',
|
||||
description: `${contentNoun} in Confluence storage format`,
|
||||
displayOptions: { show: { ...show, bodyFormat: ['storage'] } },
|
||||
...hint,
|
||||
},
|
||||
@@ -66,7 +69,7 @@ export function bodyProperties(operations: string[], bodyHint?: string): INodePr
|
||||
type: 'json',
|
||||
default: '',
|
||||
placeholder: '{ "type": "doc", "version": 1, "content": [] }',
|
||||
description: 'Page content as an Atlassian Document Format document',
|
||||
description: `${contentNoun} as an Atlassian Document Format document`,
|
||||
displayOptions: { show: { ...show, bodyFormat: ['atlas_doc_format'] } },
|
||||
...hint,
|
||||
},
|
||||
@@ -134,6 +137,123 @@ export function buildBodyEnvelope(
|
||||
}
|
||||
}
|
||||
|
||||
// ADF node types that carry no meaning by themselves: only whitespace or children.
|
||||
// Unknown types (emoji, mention, media, cards, …) count as content, so an exotic
|
||||
// but real comment is never rejected as empty.
|
||||
const STRUCTURAL_ADF_TYPES = new Set([
|
||||
'doc',
|
||||
'paragraph',
|
||||
'text',
|
||||
'hardBreak',
|
||||
'heading',
|
||||
'blockquote',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'listItem',
|
||||
'codeBlock',
|
||||
'panel',
|
||||
'table',
|
||||
'tableRow',
|
||||
'tableCell',
|
||||
'tableHeader',
|
||||
'expand',
|
||||
'nestedExpand',
|
||||
'taskList',
|
||||
'taskItem',
|
||||
'decisionList',
|
||||
'decisionItem',
|
||||
'layoutSection',
|
||||
'layoutColumn',
|
||||
]);
|
||||
|
||||
function adfNodeHasContent(node: unknown): boolean {
|
||||
if (node === null || typeof node !== 'object' || Array.isArray(node)) return false;
|
||||
const { type, text, content, attrs } = node as {
|
||||
type?: unknown;
|
||||
text?: unknown;
|
||||
content?: unknown;
|
||||
attrs?: unknown;
|
||||
};
|
||||
if (typeof text === 'string' && text.trim() !== '') return true;
|
||||
if (typeof type === 'string' && !STRUCTURAL_ADF_TYPES.has(type)) return true;
|
||||
// Among structural types, expand/nestedExpand render their attrs.title as visible text
|
||||
const title =
|
||||
attrs !== null && typeof attrs === 'object' ? (attrs as IDataObject).title : undefined;
|
||||
if (typeof title === 'string' && title.trim() !== '') return true;
|
||||
return Array.isArray(content) && content.some(adfNodeHasContent);
|
||||
}
|
||||
|
||||
// Storage-format tags that render nothing by themselves: only their text does.
|
||||
// Unknown tags (ac:* macros, ri:* references, img, hr, …) count as content, so an
|
||||
// exotic but real comment is never rejected as empty. Mirrors STRUCTURAL_ADF_TYPES.
|
||||
const STRUCTURAL_STORAGE_TAGS = new Set([
|
||||
'p',
|
||||
'br',
|
||||
'div',
|
||||
'span',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'blockquote',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'pre',
|
||||
'code',
|
||||
'table',
|
||||
'tbody',
|
||||
'thead',
|
||||
'tfoot',
|
||||
'colgroup',
|
||||
'col',
|
||||
'tr',
|
||||
'td',
|
||||
'th',
|
||||
'strong',
|
||||
'b',
|
||||
'em',
|
||||
'i',
|
||||
'u',
|
||||
's',
|
||||
'del',
|
||||
'ins',
|
||||
'sub',
|
||||
'sup',
|
||||
]);
|
||||
|
||||
function storageHasContent(markup: string): boolean {
|
||||
// CDATA text renders verbatim (even when it looks like markup), and the tag strip
|
||||
// below would swallow it together with its wrapper
|
||||
for (const [, cdata] of markup.matchAll(/<!\[CDATA\[([\s\S]*?)\]\]>/g)) {
|
||||
if (cdata.trim() !== '') return true;
|
||||
}
|
||||
const text = markup
|
||||
.replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, ' ')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/&(?:nbsp|#160|#xa0);/gi, ' ')
|
||||
.trim();
|
||||
if (text !== '') return true;
|
||||
for (const [, tagName] of markup.matchAll(/<\/?([a-zA-Z][\w:-]*)/g)) {
|
||||
if (!STRUCTURAL_STORAGE_TAGS.has(tagName.toLowerCase())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True when the body renders to something a reader can see. An empty ADF document
|
||||
* still serializes to non-blank JSON, and empty storage markup (`<p></p>`) is non-blank
|
||||
* text, so both checks look through the wrapping to the rendered result. */
|
||||
export function envelopeHasContent(envelope: ConfluenceBodyEnvelope): boolean {
|
||||
if (envelope.representation !== 'atlas_doc_format') return storageHasContent(envelope.value);
|
||||
try {
|
||||
return adfNodeHasContent(JSON.parse(envelope.value));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const fieldByFormat: Record<ConfluenceBodyFormat, string> = {
|
||||
plainText: 'bodyPlainText',
|
||||
storage: 'bodyStorage',
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { IExecuteFunctions, INodeProperties } from 'n8n-workflow';
|
||||
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { confluenceApiRequest } from '../../transport';
|
||||
import type { ConfluenceOperation } from '../router';
|
||||
|
||||
const showOnDeleteComment = { resource: ['page'], operation: ['deleteComment'] };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Deleting a comment is permanent — it does not go to the trash',
|
||||
name: 'deleteCommentNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: { show: showOnDeleteComment },
|
||||
},
|
||||
{
|
||||
displayName: 'Comment ID',
|
||||
name: 'commentId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
placeholder: 'e.g. 123456',
|
||||
description: 'The ID of the footer comment to delete',
|
||||
displayOptions: { show: showOnDeleteComment },
|
||||
},
|
||||
];
|
||||
|
||||
export const execute: ConfluenceOperation = async function (
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
) {
|
||||
const commentId = String(this.getNodeParameter('commentId', itemIndex, '') ?? '').trim();
|
||||
if (commentId === '') {
|
||||
throw new NodeOperationError(this.getNode(), "The 'Comment ID' parameter is empty", {
|
||||
itemIndex,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await confluenceApiRequest.call(
|
||||
this,
|
||||
'DELETE',
|
||||
`/wiki/api/v2/footer-comments/${encodeURIComponent(commentId)}`,
|
||||
);
|
||||
} catch (error) {
|
||||
// Atlassian masks permission failures on this endpoint as 404
|
||||
if (error instanceof NodeApiError && error.httpCode === '404') {
|
||||
throw new NodeOperationError(this.getNode(), 'Confluence could not delete the comment', {
|
||||
itemIndex,
|
||||
description:
|
||||
'The comment may not exist or may already be deleted, or the connected user may lack view or delete permission on its page (Confluence reports permission failures as "not found").',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// DELETE replies 204 with no body
|
||||
return { deleted: true, commentId };
|
||||
};
|
||||
@@ -1,15 +1,28 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as addComment from './addComment.operation';
|
||||
import * as append from './append.operation';
|
||||
import * as create from './create.operation';
|
||||
import * as del from './delete.operation';
|
||||
import * as deleteComment from './deleteComment.operation';
|
||||
import * as get from './get.operation';
|
||||
import * as getComments from './getComments.operation';
|
||||
import * as getLabels from './getLabels.operation';
|
||||
import * as getManyByLabel from './getManyByLabel.operation';
|
||||
import * as update from './update.operation';
|
||||
|
||||
export { append, create, del as delete, get, getComments, getLabels, getManyByLabel, update };
|
||||
export {
|
||||
addComment,
|
||||
append,
|
||||
create,
|
||||
del as delete,
|
||||
deleteComment,
|
||||
get,
|
||||
getComments,
|
||||
getLabels,
|
||||
getManyByLabel,
|
||||
update,
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
@@ -23,6 +36,12 @@ export const description: INodeProperties[] = [
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Add Comment',
|
||||
value: 'addComment',
|
||||
description: 'Add a footer comment to a page, or reply to an existing comment',
|
||||
action: 'Add a comment to a page',
|
||||
},
|
||||
{
|
||||
name: 'Append',
|
||||
value: 'append',
|
||||
@@ -41,6 +60,12 @@ export const description: INodeProperties[] = [
|
||||
description: 'Move a page to trash, or permanently delete it',
|
||||
action: 'Delete a page',
|
||||
},
|
||||
{
|
||||
name: 'Delete Comment',
|
||||
value: 'deleteComment',
|
||||
description: 'Permanently delete a footer comment by ID',
|
||||
action: 'Delete a comment',
|
||||
},
|
||||
{
|
||||
name: 'Get',
|
||||
value: 'get',
|
||||
@@ -74,9 +99,11 @@ export const description: INodeProperties[] = [
|
||||
],
|
||||
default: 'create',
|
||||
},
|
||||
...addComment.description,
|
||||
...append.description,
|
||||
...create.description,
|
||||
...del.description,
|
||||
...deleteComment.description,
|
||||
...get.description,
|
||||
...getComments.description,
|
||||
...getLabels.description,
|
||||
|
||||
@@ -43,6 +43,9 @@ export async function router(this: IExecuteFunctions): Promise<INodeExecutionDat
|
||||
case 'attachment:upload':
|
||||
responseData = await attachment.upload.execute.call(this, i);
|
||||
break;
|
||||
case 'page:addComment':
|
||||
responseData = await page.addComment.execute.call(this, i);
|
||||
break;
|
||||
case 'page:append':
|
||||
responseData = await page.append.execute.call(this, i);
|
||||
break;
|
||||
@@ -52,6 +55,9 @@ export async function router(this: IExecuteFunctions): Promise<INodeExecutionDat
|
||||
case 'page:delete':
|
||||
responseData = await page.delete.execute.call(this, i);
|
||||
break;
|
||||
case 'page:deleteComment':
|
||||
responseData = await page.deleteComment.execute.call(this, i);
|
||||
break;
|
||||
case 'page:get':
|
||||
responseData = await page.get.execute.call(this, i);
|
||||
break;
|
||||
|
||||
@@ -35,9 +35,11 @@ describe('Confluence Node', () => {
|
||||
// Delete sorts first alphabetically; the default must stay non-destructive
|
||||
expect(operationProperty('attachment')?.default).toBe('getMany');
|
||||
expect(operationOptions('page')).toEqual([
|
||||
expect.objectContaining({ value: 'addComment' }),
|
||||
expect.objectContaining({ value: 'append' }),
|
||||
expect.objectContaining({ value: 'create' }),
|
||||
expect.objectContaining({ value: 'delete' }),
|
||||
expect.objectContaining({ value: 'deleteComment' }),
|
||||
expect.objectContaining({ value: 'get' }),
|
||||
expect.objectContaining({ value: 'getComments' }),
|
||||
expect.objectContaining({ value: 'getLabels' }),
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import { description, execute } from '../../../actions/page/addComment.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> = {
|
||||
page: { mode: 'id', value: '123' },
|
||||
bodyFormat: 'plainText',
|
||||
bodyPlainText: 'Nice page',
|
||||
parentCommentId: '',
|
||||
};
|
||||
|
||||
function notFound(): NodeApiError {
|
||||
return new NodeApiError(testNode, { message: 'Not found' }, { httpCode: '404' });
|
||||
}
|
||||
|
||||
describe('page:addComment', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiRequest.mockResolvedValue({ id: '555', pageId: '123' });
|
||||
});
|
||||
|
||||
it('hides the space and page pickers when a parent comment ID is set', () => {
|
||||
// Without the hide, the required page picker blocks reply-only executions
|
||||
const hide = { parentCommentId: [{ _cnd: { regex: '\\S' } }] };
|
||||
const byName = (name: string) => description.find((property) => property.name === name);
|
||||
|
||||
expect(byName('space')?.displayOptions?.hide).toEqual(hide);
|
||||
expect(byName('page')?.displayOptions?.hide).toEqual(hide);
|
||||
expect(byName('parentCommentId')?.displayOptions?.hide).toBeUndefined();
|
||||
});
|
||||
|
||||
it('posts a top-level comment on the resolved page', async () => {
|
||||
const result = await execute.call(mockExecuteCtx(baseParams), 0);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith('POST', '/wiki/api/v2/footer-comments', {
|
||||
pageId: '123',
|
||||
body: { representation: 'storage', value: '<p>Nice page</p>' },
|
||||
});
|
||||
expect(result).toEqual({ id: '555', pageId: '123' });
|
||||
});
|
||||
|
||||
it('sends parentCommentId instead of pageId when replying, without touching the page', async () => {
|
||||
// An empty page reference would make page resolution throw, proving it is skipped
|
||||
const ctx = mockExecuteCtx({
|
||||
...baseParams,
|
||||
page: { mode: 'id', value: '' },
|
||||
parentCommentId: '999',
|
||||
});
|
||||
|
||||
await execute.call(ctx, 0);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(apiRequest).toHaveBeenCalledWith('POST', '/wiki/api/v2/footer-comments', {
|
||||
parentCommentId: '999',
|
||||
body: { representation: 'storage', value: '<p>Nice page</p>' },
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a whitespace-only parent comment ID as absent', async () => {
|
||||
await execute.call(mockExecuteCtx({ ...baseParams, parentCommentId: ' ' }), 0);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith('POST', '/wiki/api/v2/footer-comments', {
|
||||
pageId: '123',
|
||||
body: { representation: 'storage', value: '<p>Nice page</p>' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes a storage-format body through verbatim', async () => {
|
||||
await execute.call(
|
||||
mockExecuteCtx({
|
||||
...baseParams,
|
||||
bodyFormat: 'storage',
|
||||
bodyStorage: '<p>Already <b>markup</b></p>',
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith('POST', '/wiki/api/v2/footer-comments', {
|
||||
pageId: '123',
|
||||
body: { representation: 'storage', value: '<p>Already <b>markup</b></p>' },
|
||||
});
|
||||
});
|
||||
|
||||
it('posts an ADF comment whose only content is a non-text node', async () => {
|
||||
const bodyAdf = JSON.stringify({
|
||||
type: 'doc',
|
||||
version: 1,
|
||||
content: [{ type: 'paragraph', content: [{ type: 'emoji', attrs: { shortName: ':+1:' } }] }],
|
||||
});
|
||||
|
||||
await execute.call(
|
||||
mockExecuteCtx({ ...baseParams, bodyFormat: 'atlas_doc_format', bodyAdf }),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith('POST', '/wiki/api/v2/footer-comments', {
|
||||
pageId: '123',
|
||||
body: { representation: 'atlas_doc_format', value: bodyAdf },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an empty plain-text body', { bodyPlainText: '' }],
|
||||
['a whitespace-only plain-text body', { bodyPlainText: ' \n ' }],
|
||||
['an empty storage body', { bodyFormat: 'storage', bodyStorage: '' }],
|
||||
[
|
||||
// Serializes to non-blank JSON, so a string check alone would let it through
|
||||
'an empty ADF document',
|
||||
{ bodyFormat: 'atlas_doc_format', bodyAdf: '{"type":"doc","version":1,"content":[]}' },
|
||||
],
|
||||
[
|
||||
'an ADF document with only empty paragraphs',
|
||||
{
|
||||
bodyFormat: 'atlas_doc_format',
|
||||
bodyAdf: '{"type":"doc","version":1,"content":[{"type":"paragraph","content":[]}]}',
|
||||
},
|
||||
],
|
||||
])('rejects %s before calling the API', async (_name, overrides) => {
|
||||
const promise = execute.call(mockExecuteCtx({ ...baseParams, ...overrides }), 0);
|
||||
|
||||
await expect(promise).rejects.toThrow(NodeOperationError);
|
||||
await expect(promise).rejects.toThrow('The comment body is empty');
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an invalid ADF body before calling the API', async () => {
|
||||
const promise = execute.call(
|
||||
mockExecuteCtx({ ...baseParams, bodyFormat: 'atlas_doc_format', bodyAdf: 'not-json' }),
|
||||
0,
|
||||
);
|
||||
|
||||
await expect(promise).rejects.toThrow(NodeOperationError);
|
||||
await expect(promise).rejects.toThrow('ADF JSON body is not valid JSON');
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps a 404 on a page comment to view/comment permission guidance', async () => {
|
||||
apiRequest.mockRejectedValue(notFound());
|
||||
|
||||
const promise = execute.call(mockExecuteCtx(baseParams), 0);
|
||||
|
||||
await expect(promise).rejects.toThrow(NodeOperationError);
|
||||
await expect(promise).rejects.toThrow('Confluence could not add the comment');
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
description: expect.stringContaining('The page may not exist'),
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a 404 on a reply to parent-comment guidance', async () => {
|
||||
apiRequest.mockRejectedValue(notFound());
|
||||
|
||||
const promise = execute.call(mockExecuteCtx({ ...baseParams, parentCommentId: '999' }), 0);
|
||||
|
||||
await expect(promise).rejects.toThrow('Confluence could not add the comment');
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
description: expect.stringContaining('The parent comment may not exist'),
|
||||
});
|
||||
});
|
||||
|
||||
it('rethrows other API errors untouched', async () => {
|
||||
const serverError = new NodeApiError(testNode, { message: 'Boom' }, { httpCode: '500' });
|
||||
apiRequest.mockRejectedValue(serverError);
|
||||
|
||||
await expect(execute.call(mockExecuteCtx(baseParams), 0)).rejects.toBe(serverError);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { buildBodyEnvelope, readBodyEnvelope } from '../../../actions/page/bodyEnvelope';
|
||||
import {
|
||||
buildBodyEnvelope,
|
||||
envelopeHasContent,
|
||||
readBodyEnvelope,
|
||||
} from '../../../actions/page/bodyEnvelope';
|
||||
import { mockExecuteCtx } from '../../shared';
|
||||
|
||||
describe('buildBodyEnvelope', () => {
|
||||
@@ -72,6 +76,131 @@ describe('buildBodyEnvelope', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('envelopeHasContent', () => {
|
||||
const adf = (content: unknown[]) =>
|
||||
buildBodyEnvelope('atlas_doc_format', { type: 'doc', version: 1, content });
|
||||
|
||||
it.each([
|
||||
['storage markup', buildBodyEnvelope('storage', '<p>x</p>'), true],
|
||||
['a whitespace-only storage body', buildBodyEnvelope('storage', ' \n '), false],
|
||||
[
|
||||
'a storage body of only empty paragraphs',
|
||||
buildBodyEnvelope('storage', '<p></p><p> </p>'),
|
||||
false,
|
||||
],
|
||||
[
|
||||
'a storage body of only nested structural markup',
|
||||
buildBodyEnvelope(
|
||||
'storage',
|
||||
'<div><br /><ul><li></li></ul><table><tr><td></td></tr></table></div>',
|
||||
),
|
||||
false,
|
||||
],
|
||||
[
|
||||
'a storage body of only non-breaking spaces',
|
||||
buildBodyEnvelope('storage', '<p> </p>'),
|
||||
false,
|
||||
],
|
||||
[
|
||||
'a storage body of only numeric non-breaking-space entities',
|
||||
buildBodyEnvelope('storage', '<p> </p><p> </p>'),
|
||||
false,
|
||||
],
|
||||
[
|
||||
'CDATA text as the only storage content',
|
||||
buildBodyEnvelope('storage', '<p><![CDATA[hello]]></p>'),
|
||||
true,
|
||||
],
|
||||
[
|
||||
'a storage body with only a blank CDATA section',
|
||||
buildBodyEnvelope('storage', '<p><![CDATA[ ]]></p>'),
|
||||
false,
|
||||
],
|
||||
['a storage body with a visible entity', buildBodyEnvelope('storage', '<p>&</p>'), true],
|
||||
[
|
||||
'text nested in structural storage markup',
|
||||
buildBodyEnvelope('storage', '<div><p>hi</p></div>'),
|
||||
true,
|
||||
],
|
||||
[
|
||||
'an emoticon as the only storage content',
|
||||
buildBodyEnvelope('storage', '<p><ac:emoticon ac:name="smile" /></p>'),
|
||||
true,
|
||||
],
|
||||
[
|
||||
'a macro as the only storage content',
|
||||
buildBodyEnvelope('storage', '<ac:structured-macro ac:name="toc" />'),
|
||||
true,
|
||||
],
|
||||
[
|
||||
'an attached image as the only storage content',
|
||||
buildBodyEnvelope(
|
||||
'storage',
|
||||
'<p><ac:image><ri:attachment ri:filename="x.png" /></ac:image></p>',
|
||||
),
|
||||
true,
|
||||
],
|
||||
['a horizontal rule as the only storage content', buildBodyEnvelope('storage', '<hr />'), true],
|
||||
['a plain-text body', buildBodyEnvelope('plainText', 'Hello'), true],
|
||||
['an empty plain-text body', buildBodyEnvelope('plainText', ' \n '), false],
|
||||
['an empty ADF document', adf([]), false],
|
||||
[
|
||||
'an ADF document with only empty paragraphs',
|
||||
adf([{ type: 'paragraph', content: [] }]),
|
||||
false,
|
||||
],
|
||||
[
|
||||
'an ADF document with only whitespace text',
|
||||
adf([{ type: 'paragraph', content: [{ type: 'text', text: ' ' }] }]),
|
||||
false,
|
||||
],
|
||||
[
|
||||
'an ADF document with text',
|
||||
adf([{ type: 'paragraph', content: [{ type: 'text', text: 'Hi' }] }]),
|
||||
true,
|
||||
],
|
||||
[
|
||||
'text nested in list structure',
|
||||
adf([
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'item' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
true,
|
||||
],
|
||||
[
|
||||
'a non-text node as the only content',
|
||||
adf([{ type: 'paragraph', content: [{ type: 'emoji', attrs: { shortName: ':+1:' } }] }]),
|
||||
true,
|
||||
],
|
||||
[
|
||||
'an expand whose only text is its title',
|
||||
adf([
|
||||
{
|
||||
type: 'expand',
|
||||
attrs: { title: 'Read me' },
|
||||
content: [{ type: 'paragraph', content: [] }],
|
||||
},
|
||||
]),
|
||||
true,
|
||||
],
|
||||
[
|
||||
'an expand with a blank title and no body text',
|
||||
adf([{ type: 'expand', attrs: { title: ' ' }, content: [{ type: 'paragraph' }] }]),
|
||||
false,
|
||||
],
|
||||
['malformed entries in the content array', adf([null, 'text', 42]), false],
|
||||
])('%s → %s', (_name, envelope, expected) => {
|
||||
expect(envelopeHasContent(envelope)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readBodyEnvelope', () => {
|
||||
it('reads the field matching the selected format', () => {
|
||||
const ctx = mockExecuteCtx({
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import { execute } from '../../../actions/page/deleteComment.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;
|
||||
|
||||
describe('page:deleteComment', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiRequest.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('deletes the comment and returns a deletion report', async () => {
|
||||
const result = await execute.call(mockExecuteCtx({ commentId: '555' }), 0);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith('DELETE', '/wiki/api/v2/footer-comments/555');
|
||||
expect(result).toEqual({ deleted: true, commentId: '555' });
|
||||
});
|
||||
|
||||
it('trims and URL-encodes the comment ID', async () => {
|
||||
const result = await execute.call(mockExecuteCtx({ commentId: ' 555/6 ' }), 0);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith('DELETE', '/wiki/api/v2/footer-comments/555%2F6');
|
||||
expect(result).toEqual({ deleted: true, commentId: '555/6' });
|
||||
});
|
||||
|
||||
it('rejects an empty comment ID before calling the API', async () => {
|
||||
const promise = execute.call(mockExecuteCtx({ commentId: ' ' }), 0);
|
||||
|
||||
await expect(promise).rejects.toThrow(NodeOperationError);
|
||||
await expect(promise).rejects.toThrow("The 'Comment ID' parameter is empty");
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps a 404 to not-found/permission guidance', async () => {
|
||||
apiRequest.mockRejectedValue(
|
||||
new NodeApiError(testNode, { message: 'Not found' }, { httpCode: '404' }),
|
||||
);
|
||||
|
||||
const promise = execute.call(mockExecuteCtx({ commentId: '555' }), 0);
|
||||
|
||||
await expect(promise).rejects.toThrow(NodeOperationError);
|
||||
await expect(promise).rejects.toThrow('Confluence could not delete the comment');
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
description: expect.stringContaining('The comment may not exist'),
|
||||
});
|
||||
});
|
||||
|
||||
it('rethrows other API errors untouched', async () => {
|
||||
const serverError = new NodeApiError(testNode, { message: 'Boom' }, { httpCode: '500' });
|
||||
apiRequest.mockRejectedValue(serverError);
|
||||
|
||||
await expect(execute.call(mockExecuteCtx({ commentId: '555' }), 0)).rejects.toBe(serverError);
|
||||
});
|
||||
});
|
||||
@@ -127,6 +127,42 @@ describe('Confluence router', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('dispatches page:addComment and returns the created comment', async () => {
|
||||
apiRequest.mockResolvedValue({ id: '555', pageId: '1' });
|
||||
|
||||
const result = await router.call(
|
||||
mockExecuteCtx({
|
||||
resource: 'page',
|
||||
operation: 'addComment',
|
||||
page: { mode: 'id', value: '1' },
|
||||
bodyFormat: 'plainText',
|
||||
bodyPlainText: 'Nice page',
|
||||
parentCommentId: '',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith('POST', '/wiki/api/v2/footer-comments', {
|
||||
pageId: '1',
|
||||
body: { representation: 'storage', value: '<p>Nice page</p>' },
|
||||
});
|
||||
expect(result).toEqual([[{ json: { id: '555', pageId: '1' }, pairedItem: { item: 0 } }]]);
|
||||
});
|
||||
|
||||
it('dispatches page:deleteComment and returns the deletion report', async () => {
|
||||
const result = await router.call(
|
||||
mockExecuteCtx({
|
||||
resource: 'page',
|
||||
operation: 'deleteComment',
|
||||
commentId: '555',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith('DELETE', '/wiki/api/v2/footer-comments/555');
|
||||
expect(result).toEqual([
|
||||
[{ json: { deleted: true, commentId: '555' }, pairedItem: { item: 0 } }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('dispatches page:delete and returns the deletion report', async () => {
|
||||
const result = await router.call(
|
||||
mockExecuteCtx({
|
||||
|
||||
Reference in New Issue
Block a user