feat(Confluence Node): Add comment write operations (no-changelog) (#37024)

This commit is contained in:
Stephen Wright
2026-08-27 08:02:41 +00:00
committed by GitHub
parent 0aa9aa8348
commit c6222d4b96
10 changed files with 708 additions and 8 deletions
@@ -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>&nbsp;</p>'),
false,
],
[
'a storage body of only numeric non-breaking-space entities',
buildBodyEnvelope('storage', '<p>&#160;</p><p>&#xa0;</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>&amp;</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({
@@ -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({