mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-11 22:50:06 +00:00
feat(Microsoft Excel SharePoint Node): Add Sheet Clear and Delete (no-changelog) (#34245)
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"issues": 559
|
||||
"issues": 168
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import * as clear from './actions/worksheet/clear.operation';
|
||||
import * as deleteWorksheet from './actions/worksheet/deleteWorksheet.operation';
|
||||
import * as readRows from './actions/worksheet/readRows.operation';
|
||||
import { listSearch } from './methods';
|
||||
|
||||
@@ -94,6 +96,18 @@ export class MicrosoftExcelSharePoint implements INodeType {
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Clear',
|
||||
value: 'clear',
|
||||
description: 'Clear sheet',
|
||||
action: 'Clear sheet',
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
value: 'deleteWorksheet',
|
||||
description: 'Delete sheet',
|
||||
action: 'Delete sheet',
|
||||
},
|
||||
{
|
||||
name: 'Get Rows',
|
||||
value: 'readRows',
|
||||
@@ -104,6 +118,8 @@ export class MicrosoftExcelSharePoint implements INodeType {
|
||||
default: 'readRows',
|
||||
},
|
||||
|
||||
...clear.description,
|
||||
...deleteWorksheet.description,
|
||||
...readRows.description,
|
||||
],
|
||||
};
|
||||
@@ -121,6 +137,14 @@ export class MicrosoftExcelSharePoint implements INodeType {
|
||||
return [await readRows.execute.call(this, items)];
|
||||
}
|
||||
|
||||
if (resource === 'worksheet' && operation === 'clear') {
|
||||
return [await clear.execute.call(this, items)];
|
||||
}
|
||||
|
||||
if (resource === 'worksheet' && operation === 'deleteWorksheet') {
|
||||
return [await deleteWorksheet.execute.call(this, items)];
|
||||
}
|
||||
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The operation "${String(operation)}" is not supported!`,
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import {
|
||||
libraryRLC,
|
||||
siteRLC,
|
||||
workbookRLC,
|
||||
worksheetRLC,
|
||||
} from '../../descriptions/common.descriptions';
|
||||
import { resolveWorkbookRoot, validatePathSegment } from '../../helpers/utils';
|
||||
import { microsoftApiRequest } from '../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
workbookRLC,
|
||||
siteRLC,
|
||||
libraryRLC,
|
||||
worksheetRLC,
|
||||
{
|
||||
displayName: 'Apply To',
|
||||
name: 'applyTo',
|
||||
type: 'options',
|
||||
// values in capital case as required by the API
|
||||
options: [
|
||||
{
|
||||
name: 'All',
|
||||
value: 'All',
|
||||
description: 'Clear data in cells and remove all formatting',
|
||||
},
|
||||
{
|
||||
name: 'Formats',
|
||||
value: 'Formats',
|
||||
description: 'Clear formatting (e.g. font size, color) of cells',
|
||||
},
|
||||
{
|
||||
name: 'Contents',
|
||||
value: 'Contents',
|
||||
description: 'Clear data contained in cells',
|
||||
},
|
||||
],
|
||||
default: 'All',
|
||||
},
|
||||
{
|
||||
displayName: 'Select a Range',
|
||||
name: 'useRange',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
displayName: 'Range',
|
||||
name: 'range',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. A1:B2',
|
||||
default: '',
|
||||
description: 'The sheet range that would be cleared, specified using A1-style notation',
|
||||
hint: 'Leave blank for entire worksheet',
|
||||
displayOptions: {
|
||||
show: {
|
||||
useRange: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['worksheet'],
|
||||
operation: ['clear'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
type ClearSettings = {
|
||||
/** Validated sheet resource-locator value; the segment of the request path after `/workbook/worksheets/`. */
|
||||
worksheetId: string;
|
||||
/** A1-style range to clear, e.g. `A1:B2`. Empty means clear the whole worksheet instead. */
|
||||
range: string;
|
||||
/** Graph's `applyTo` choice: 'All' | 'Formats' | 'Contents'. */
|
||||
applyTo: string;
|
||||
};
|
||||
|
||||
// Validated and read up front so the request-building code below only ever deals
|
||||
// with `settings.xxx`, never raw `getNodeParameter` calls or `options.foo as bar`.
|
||||
function getSettings(this: IExecuteFunctions, itemIndex: number): ClearSettings {
|
||||
const worksheetId = validatePathSegment(
|
||||
this.getNode(),
|
||||
'Sheet',
|
||||
this.getNodeParameter('worksheet', itemIndex, '', { extractValue: true }) as string,
|
||||
);
|
||||
|
||||
const useRange = this.getNodeParameter('useRange', itemIndex, false) as boolean;
|
||||
const range = useRange ? (this.getNodeParameter('range', itemIndex, '') as string) : '';
|
||||
|
||||
return {
|
||||
worksheetId,
|
||||
range,
|
||||
applyTo: this.getNodeParameter('applyTo', itemIndex) as string,
|
||||
};
|
||||
}
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
items: INodeExecutionData[],
|
||||
): Promise<INodeExecutionData[]> {
|
||||
// https://learn.microsoft.com/en-us/graph/api/range-clear
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
// Hoisted once for the whole run and passed into resolveWorkbookRoot below,
|
||||
// so a pasted Workbook/Site address is resolved once, not once per item.
|
||||
const workbookRootCache = new Map<string, string>();
|
||||
const siteIdCache = new Map<string, string>();
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
const settings = getSettings.call(this, i);
|
||||
|
||||
const workbookRoot = await resolveWorkbookRoot.call(this, i, workbookRootCache, siteIdCache);
|
||||
const sheetPath = `${workbookRoot}/workbook/worksheets/${encodeURIComponent(settings.worksheetId)}`;
|
||||
const clearPath = settings.range
|
||||
? `${sheetPath}/range(address='${encodeURIComponent(settings.range)}')/clear`
|
||||
: `${sheetPath}/range/clear`;
|
||||
|
||||
await microsoftApiRequest.call(this, 'POST', clearPath, { applyTo: settings.applyTo });
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray({ success: true }),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
returnData.push(...executionData);
|
||||
} catch (error) {
|
||||
if (!this.continueOnFail()) throw error;
|
||||
|
||||
const executionErrorData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray({ error: error.message }),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
returnData.push(...executionErrorData);
|
||||
}
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import {
|
||||
libraryRLC,
|
||||
siteRLC,
|
||||
workbookRLC,
|
||||
worksheetRLC,
|
||||
} from '../../descriptions/common.descriptions';
|
||||
import { resolveWorkbookRoot, validatePathSegment } from '../../helpers/utils';
|
||||
import { microsoftApiRequest } from '../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [workbookRLC, siteRLC, libraryRLC, worksheetRLC];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['worksheet'],
|
||||
operation: ['deleteWorksheet'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
items: INodeExecutionData[],
|
||||
): Promise<INodeExecutionData[]> {
|
||||
// https://learn.microsoft.com/en-us/graph/api/worksheet-delete
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
// Hoisted once for the whole run and passed into resolveWorkbookRoot below,
|
||||
// so a pasted Workbook/Site address is resolved once, not once per item.
|
||||
const workbookRootCache = new Map<string, string>();
|
||||
const siteIdCache = new Map<string, string>();
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
const worksheetId = validatePathSegment(
|
||||
this.getNode(),
|
||||
'Sheet',
|
||||
this.getNodeParameter('worksheet', i, '', { extractValue: true }) as string,
|
||||
);
|
||||
|
||||
const workbookRoot = await resolveWorkbookRoot.call(this, i, workbookRootCache, siteIdCache);
|
||||
|
||||
await microsoftApiRequest.call(
|
||||
this,
|
||||
'DELETE',
|
||||
`${workbookRoot}/workbook/worksheets/${encodeURIComponent(worksheetId)}`,
|
||||
);
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray({ success: true }),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
returnData.push(...executionData);
|
||||
} catch (error) {
|
||||
if (!this.continueOnFail()) throw error;
|
||||
|
||||
const executionErrorData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray({ error: error.message }),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
returnData.push(...executionErrorData);
|
||||
}
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -10,9 +10,22 @@ export const REQUIRED_PERMISSIONS: Record<string, { delegated: string; applicati
|
||||
delegated: 'Sites.Read.All',
|
||||
application: 'Sites.Read.All (or Sites.Selected granted for this site)',
|
||||
},
|
||||
'worksheet:clear': {
|
||||
delegated: 'Sites.ReadWrite.All',
|
||||
application: 'Sites.ReadWrite.All (or Sites.Selected granted for this site)',
|
||||
},
|
||||
'worksheet:deleteWorksheet': {
|
||||
delegated: 'Sites.ReadWrite.All',
|
||||
application: 'Sites.ReadWrite.All (or Sites.Selected granted for this site)',
|
||||
},
|
||||
};
|
||||
|
||||
// A 404 can come from any of the location segments, so the message must not
|
||||
// single one out
|
||||
export const NOT_FOUND_MESSAGE =
|
||||
'The requested resource was not found. Check the Site, Library, Workbook, and Sheet values.';
|
||||
|
||||
// Graph's real not-found code is 'ItemNotFound' (verified against a live tenant); 'NotFound'
|
||||
// is kept as a defensive fallback for other Graph surfaces that may use the generic OData code.
|
||||
// Match on code alone, not the message text, which Microsoft doesn't treat as a stable contract.
|
||||
export const NOT_FOUND_CODES = ['ItemNotFound', 'NotFound'];
|
||||
|
||||
@@ -8,6 +8,10 @@ export type GraphRequestError = {
|
||||
message?: string;
|
||||
code?: string;
|
||||
error?: { error?: GraphRequestError };
|
||||
// Once `httpRequestWithAuthentication` has already wrapped a failure in a
|
||||
// NodeApiError (the normal case for a real request), Graph's parsed body
|
||||
// lives here instead of under `error.error`.
|
||||
context?: { data?: { error?: GraphRequestError } };
|
||||
};
|
||||
|
||||
/** Normalizes Graph's inconsistent status-code fields (string, number, or absent) into a clean number. */
|
||||
@@ -26,10 +30,16 @@ export function toGraphBaseUrl(graphApiBaseUrl: unknown): string {
|
||||
return base.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/** Graph nests its real error one level under `error.error`; unwraps it while preserving the outer status code. */
|
||||
/**
|
||||
* Graph nests its real error one level under `error.error` on a raw HTTP client error, or
|
||||
* under `context.data.error` once n8n has already wrapped the failure in a NodeApiError
|
||||
* (the case for every request that goes through `httpRequestWithAuthentication`). Unwraps
|
||||
* whichever is present while preserving the outer status code.
|
||||
*/
|
||||
export function unwrapGraphError(error: GraphRequestError): GraphRequestError {
|
||||
if (!error.error?.error) return error;
|
||||
return { ...error.error.error, statusCode: error.statusCode };
|
||||
const nested = error.error?.error ?? error.context?.data?.error;
|
||||
if (!nested) return error;
|
||||
return { ...nested, statusCode: error.statusCode };
|
||||
}
|
||||
|
||||
/** Builds the `REQUIRED_PERMISSIONS` lookup key for a resource/operation pair. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { IDataObject, JsonObject } from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import { NOT_FOUND_MESSAGE, REQUIRED_PERMISSIONS } from './constants';
|
||||
import { NOT_FOUND_CODES, NOT_FOUND_MESSAGE, REQUIRED_PERMISSIONS } from './constants';
|
||||
import {
|
||||
toHttpCode,
|
||||
toPermissionKey,
|
||||
@@ -78,12 +78,15 @@ export function delegatedApiError(this: AuthContext, error: GraphRequestError):
|
||||
: 'Microsoft Graph refused this request. The credential may be missing a required permission, or the signed-in account may not have access to this resource';
|
||||
description =
|
||||
"Check the credential's scopes (with admin consent if your tenant requires it) and that the signed-in account can access the site, then try again.";
|
||||
} else if (error.error?.error) {
|
||||
} else {
|
||||
// Returns `error` itself, unchanged, when there's no nested Graph error to unwrap
|
||||
const unwrapped = unwrapGraphError(error);
|
||||
message =
|
||||
unwrapped.code === 'NotFound' && unwrapped.message === 'Resource not found'
|
||||
? NOT_FOUND_MESSAGE
|
||||
: unwrapped.message;
|
||||
if (unwrapped !== error) {
|
||||
message =
|
||||
unwrapped.code && NOT_FOUND_CODES.includes(unwrapped.code)
|
||||
? NOT_FOUND_MESSAGE
|
||||
: unwrapped.message;
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizedError: JsonObject = message ? { message } : {};
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [0, 0],
|
||||
"id": "8f4a1c22-6e1d-4a35-9c40-1af2f9f70402",
|
||||
"name": "When clicking ‘Test workflow’"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"authentication": "microsoftOAuth2Api",
|
||||
"resource": "worksheet",
|
||||
"operation": "clear",
|
||||
"workbook": {
|
||||
"__rl": true,
|
||||
"mode": "url",
|
||||
"value": "https://contoso.sharepoint.com/sites/site1/Shared Documents/book.xlsx"
|
||||
},
|
||||
"worksheet": {
|
||||
"__rl": true,
|
||||
"mode": "id",
|
||||
"value": "Sheet1"
|
||||
},
|
||||
"applyTo": "Contents",
|
||||
"useRange": true,
|
||||
"range": "A1:B2"
|
||||
},
|
||||
"type": "n8n-nodes-base.microsoftExcelSharePoint",
|
||||
"typeVersion": 1,
|
||||
"position": [200, 0],
|
||||
"id": "4c9be7de-51f2-4633-8b0a-2f4f3f1f9d13",
|
||||
"name": "Microsoft Excel (SharePoint)",
|
||||
"credentials": {
|
||||
"microsoftOAuth2Api": {
|
||||
"id": "c1",
|
||||
"name": "Microsoft account"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"When clicking ‘Test workflow’": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Microsoft Excel (SharePoint)",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"pinData": {
|
||||
"Microsoft Excel (SharePoint)": [
|
||||
{
|
||||
"json": {
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
|
||||
|
||||
const workbookUrl = 'https://contoso.sharepoint.com/sites/site1/Shared Documents/book.xlsx';
|
||||
const shareId = `u!${Buffer.from(workbookUrl)
|
||||
.toString('base64')
|
||||
.replace(/=+$/, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')}`;
|
||||
|
||||
const credentials = {
|
||||
microsoftOAuth2Api: {
|
||||
oauthTokenData: {
|
||||
access_token: 'test-access-token',
|
||||
},
|
||||
graphApiBaseUrl: 'https://graph.microsoft.com',
|
||||
},
|
||||
};
|
||||
|
||||
const shareLookupMock = {
|
||||
method: 'get' as const,
|
||||
path: `/v1.0/shares/${shareId}/driveItem?%24select=id%2CparentReference`,
|
||||
statusCode: 200,
|
||||
responseBody: {
|
||||
id: 'ITEM123',
|
||||
parentReference: {
|
||||
siteId: 'contoso.sharepoint.com,g1,g2',
|
||||
driveId: 'b!drive1',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Clear without a range matches the OneDrive node's whole-sheet path
|
||||
describe('Microsoft Excel (SharePoint) Node — Sheet: Clear (whole sheet)', () => {
|
||||
new NodeTestHarness().setupTests({
|
||||
credentials,
|
||||
workflowFiles: ['clear.workflow.json'],
|
||||
nock: {
|
||||
baseUrl: 'https://graph.microsoft.com',
|
||||
mocks: [
|
||||
shareLookupMock,
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1.0/sites/contoso.sharepoint.com%2Cg1%2Cg2/drives/b!drive1/items/ITEM123/workbook/worksheets/Sheet1/range/clear',
|
||||
statusCode: 200,
|
||||
requestBody: { applyTo: 'All' },
|
||||
responseBody: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Clear with a range, and a non-default Apply To choice reaching the API
|
||||
describe('Microsoft Excel (SharePoint) Node — Sheet: Clear (with range)', () => {
|
||||
new NodeTestHarness().setupTests({
|
||||
credentials,
|
||||
workflowFiles: ['clear-with-range.workflow.json'],
|
||||
nock: {
|
||||
baseUrl: 'https://graph.microsoft.com',
|
||||
mocks: [
|
||||
shareLookupMock,
|
||||
{
|
||||
method: 'post',
|
||||
path: `/v1.0/sites/contoso.sharepoint.com%2Cg1%2Cg2/drives/b!drive1/items/ITEM123/workbook/worksheets/Sheet1/range(address='${encodeURIComponent('A1:B2')}')/clear`,
|
||||
statusCode: 200,
|
||||
requestBody: { applyTo: 'Contents' },
|
||||
responseBody: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [0, 0],
|
||||
"id": "8f4a1c22-6e1d-4a35-9c40-1af2f9f70401",
|
||||
"name": "When clicking ‘Test workflow’"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"authentication": "microsoftOAuth2Api",
|
||||
"resource": "worksheet",
|
||||
"operation": "clear",
|
||||
"workbook": {
|
||||
"__rl": true,
|
||||
"mode": "url",
|
||||
"value": "https://contoso.sharepoint.com/sites/site1/Shared Documents/book.xlsx"
|
||||
},
|
||||
"worksheet": {
|
||||
"__rl": true,
|
||||
"mode": "id",
|
||||
"value": "Sheet1"
|
||||
},
|
||||
"applyTo": "All"
|
||||
},
|
||||
"type": "n8n-nodes-base.microsoftExcelSharePoint",
|
||||
"typeVersion": 1,
|
||||
"position": [200, 0],
|
||||
"id": "4c9be7de-51f2-4633-8b0a-2f4f3f1f9d12",
|
||||
"name": "Microsoft Excel (SharePoint)",
|
||||
"credentials": {
|
||||
"microsoftOAuth2Api": {
|
||||
"id": "c1",
|
||||
"name": "Microsoft account"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"When clicking ‘Test workflow’": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Microsoft Excel (SharePoint)",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"pinData": {
|
||||
"Microsoft Excel (SharePoint)": [
|
||||
{
|
||||
"json": {
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,19 @@ describe('Microsoft Excel (SharePoint) Converters', () => {
|
||||
|
||||
expect(unwrapGraphError(error)).toBe(error);
|
||||
});
|
||||
|
||||
it('unwraps a nested error under context.data.error, the shape a NodeApiError-wrapped failure has', () => {
|
||||
const error = {
|
||||
statusCode: 404,
|
||||
context: { data: { error: { code: 'ItemNotFound', message: "Doesn't exist" } } },
|
||||
};
|
||||
|
||||
expect(unwrapGraphError(error)).toEqual({
|
||||
code: 'ItemNotFound',
|
||||
message: "Doesn't exist",
|
||||
statusCode: 404,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toPermissionKey', () => {
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
|
||||
|
||||
const workbookUrl = 'https://contoso.sharepoint.com/sites/site1/Shared Documents/book.xlsx';
|
||||
const shareId = `u!${Buffer.from(workbookUrl)
|
||||
.toString('base64')
|
||||
.replace(/=+$/, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')}`;
|
||||
|
||||
const credentials = {
|
||||
microsoftOAuth2Api: {
|
||||
oauthTokenData: {
|
||||
access_token: 'test-access-token',
|
||||
},
|
||||
graphApiBaseUrl: 'https://graph.microsoft.com',
|
||||
},
|
||||
};
|
||||
|
||||
// Delete removes the sheet
|
||||
describe('Microsoft Excel (SharePoint) Node — Sheet: Delete', () => {
|
||||
new NodeTestHarness().setupTests({
|
||||
credentials,
|
||||
workflowFiles: ['deleteWorksheet.workflow.json'],
|
||||
nock: {
|
||||
baseUrl: 'https://graph.microsoft.com',
|
||||
mocks: [
|
||||
{
|
||||
method: 'get',
|
||||
path: `/v1.0/shares/${shareId}/driveItem?%24select=id%2CparentReference`,
|
||||
statusCode: 200,
|
||||
responseBody: {
|
||||
id: 'ITEM123',
|
||||
parentReference: {
|
||||
siteId: 'contoso.sharepoint.com,g1,g2',
|
||||
driveId: 'b!drive1',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'delete',
|
||||
path: '/v1.0/sites/contoso.sharepoint.com%2Cg1%2Cg2/drives/b!drive1/items/ITEM123/workbook/worksheets/Sheet1',
|
||||
statusCode: 200,
|
||||
responseBody: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
|
||||
import nock from 'nock';
|
||||
|
||||
// SP smoke test: site/library/workbook by ID, signed in as the Service Principal
|
||||
// (app-only) credential. preAuthentication is a no-op in the harness since the
|
||||
// fixture already carries an accessToken, so `authenticate` attaches Bearer directly.
|
||||
const credentials = {
|
||||
microsoftEntraServicePrincipalApi: {
|
||||
accessToken: 'test-access-token',
|
||||
authentication: 'clientSecret',
|
||||
tenantId: '11111111-1111-1111-1111-111111111111',
|
||||
clientId: '22222222-2222-2222-2222-222222222222',
|
||||
clientSecret: 'test-client-secret',
|
||||
graphApiBaseUrl: 'https://graph.microsoft.com',
|
||||
},
|
||||
};
|
||||
|
||||
describe('Microsoft Excel (SharePoint), Service Principal worksheet => deleteWorksheet smoke', () => {
|
||||
nock('https://graph.microsoft.com')
|
||||
.matchHeader('Authorization', 'Bearer test-access-token')
|
||||
.delete(
|
||||
'/v1.0/sites/contoso.sharepoint.com%2Cg1%2Cg2/drives/b!drive1/items/ITEM123/workbook/worksheets/Sheet1',
|
||||
)
|
||||
.reply(200, {});
|
||||
|
||||
new NodeTestHarness().setupTests({
|
||||
credentials,
|
||||
workflowFiles: ['deleteWorksheet.sp.workflow.json'],
|
||||
});
|
||||
});
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [0, 0],
|
||||
"id": "8f4a1c22-6e1d-4a35-9c40-1af2f9f70405",
|
||||
"name": "When clicking ‘Test workflow’"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"authentication": "microsoftEntraServicePrincipalApi",
|
||||
"resource": "worksheet",
|
||||
"operation": "deleteWorksheet",
|
||||
"workbook": {
|
||||
"__rl": true,
|
||||
"mode": "id",
|
||||
"value": "ITEM123"
|
||||
},
|
||||
"site": {
|
||||
"__rl": true,
|
||||
"mode": "id",
|
||||
"value": "contoso.sharepoint.com,g1,g2"
|
||||
},
|
||||
"library": {
|
||||
"__rl": true,
|
||||
"mode": "id",
|
||||
"value": "b!drive1"
|
||||
},
|
||||
"worksheet": {
|
||||
"__rl": true,
|
||||
"mode": "id",
|
||||
"value": "Sheet1"
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.microsoftExcelSharePoint",
|
||||
"typeVersion": 1,
|
||||
"position": [200, 0],
|
||||
"id": "4c9be7de-51f2-4633-8b0a-2f4f3f1f9d15",
|
||||
"name": "Microsoft Excel (SharePoint)",
|
||||
"credentials": {
|
||||
"microsoftEntraServicePrincipalApi": {
|
||||
"id": "sp1",
|
||||
"name": "Microsoft Entra Service Principal account"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"When clicking ‘Test workflow’": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Microsoft Excel (SharePoint)",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"pinData": {
|
||||
"Microsoft Excel (SharePoint)": [
|
||||
{
|
||||
"json": {
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [0, 0],
|
||||
"id": "8f4a1c22-6e1d-4a35-9c40-1af2f9f70404",
|
||||
"name": "When clicking ‘Test workflow’"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"authentication": "microsoftOAuth2Api",
|
||||
"resource": "worksheet",
|
||||
"operation": "deleteWorksheet",
|
||||
"workbook": {
|
||||
"__rl": true,
|
||||
"mode": "url",
|
||||
"value": "https://contoso.sharepoint.com/sites/site1/Shared Documents/book.xlsx"
|
||||
},
|
||||
"worksheet": {
|
||||
"__rl": true,
|
||||
"mode": "id",
|
||||
"value": "Sheet1"
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.microsoftExcelSharePoint",
|
||||
"typeVersion": 1,
|
||||
"position": [200, 0],
|
||||
"id": "4c9be7de-51f2-4633-8b0a-2f4f3f1f9d14",
|
||||
"name": "Microsoft Excel (SharePoint)",
|
||||
"credentials": {
|
||||
"microsoftOAuth2Api": {
|
||||
"id": "c1",
|
||||
"name": "Microsoft account"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"When clicking ‘Test workflow’": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Microsoft Excel (SharePoint)",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"pinData": {
|
||||
"Microsoft Excel (SharePoint)": [
|
||||
{
|
||||
"json": {
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -57,10 +57,49 @@ describe('Microsoft Excel (SharePoint) Error Handler', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces a nested NotFound error with the not-found message', () => {
|
||||
it('replaces a nested ItemNotFound error with the not-found message', () => {
|
||||
// This is the shape a live tenant actually returns for a nonexistent
|
||||
// site/library/workbook/sheet, confirmed against a real Microsoft Graph tenant.
|
||||
const error = delegatedApiError.call(ctx, {
|
||||
statusCode: 404,
|
||||
error: { error: { code: 'NotFound', message: 'Resource not found' } },
|
||||
error: {
|
||||
error: { code: 'ItemNotFound', message: "The requested resource doesn't exist." },
|
||||
},
|
||||
});
|
||||
|
||||
expect(error.message).toBe(
|
||||
'The requested resource was not found. Check the Site, Library, Workbook, and Sheet values.',
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces the not-found message when the error arrives already wrapped in a NodeApiError', () => {
|
||||
// httpRequestWithAuthentication always wraps failures in `new NodeApiError(node, rawError)`
|
||||
// before this function ever sees them. That constructor lifts the parsed JSON body onto
|
||||
// `context.data`, not `error.error`, and overwrites `.message` with a generic per-status-code
|
||||
// default — both confirmed against a real Microsoft Graph tenant hitting a missing sheet.
|
||||
const rawHttpClientError = {
|
||||
response: {
|
||||
status: 404,
|
||||
data: {
|
||||
error: { code: 'ItemNotFound', message: "The requested resource doesn't exist." },
|
||||
},
|
||||
},
|
||||
};
|
||||
const wrapped = new NodeApiError(ctx.getNode(), rawHttpClientError as never);
|
||||
expect(wrapped.httpCode).toBe('404');
|
||||
expect(wrapped.message).toBe('The resource you are requesting could not be found');
|
||||
|
||||
const error = delegatedApiError.call(ctx, wrapped);
|
||||
|
||||
expect(error.message).toBe(
|
||||
'The requested resource was not found. Check the Site, Library, Workbook, and Sheet values.',
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces a nested NotFound error with the not-found message regardless of its message text', () => {
|
||||
const error = delegatedApiError.call(ctx, {
|
||||
statusCode: 404,
|
||||
error: { error: { code: 'NotFound', message: 'some other wording' } },
|
||||
});
|
||||
|
||||
expect(error.message).toBe(
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import type { Mock } from 'vitest';
|
||||
import type { DeepMockProxy } from 'vitest-mock-extended';
|
||||
import { mock, mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import { MicrosoftExcelSharePoint } from '../../MicrosoftExcelSharePoint.node';
|
||||
import * as transport from '../../transport';
|
||||
import type * as _importType0 from '../../transport';
|
||||
|
||||
// Real transport module except the network helper
|
||||
vi.mock('../../transport', async () => {
|
||||
const originalModule = await vi.importActual<typeof _importType0>('../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
microsoftApiRequest: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const SITE_ID = 'contoso.sharepoint.com,g1,g2';
|
||||
const WORKBOOK_ROOT = `/v1.0/sites/${encodeURIComponent(SITE_ID)}/drives/b!drive1/items/ITEM123`;
|
||||
|
||||
describe('Microsoft Excel (SharePoint) — Sheet: Clear', () => {
|
||||
let node: MicrosoftExcelSharePoint;
|
||||
let ctx: DeepMockProxy<IExecuteFunctions>;
|
||||
const apiRequest = transport.microsoftApiRequest as Mock;
|
||||
|
||||
const setParams = (params: Record<string, unknown>) => {
|
||||
ctx.getNodeParameter.mockImplementation(
|
||||
(name: string, _itemIndex?: number, fallback?: unknown) =>
|
||||
(name in params ? params[name] : fallback) as never,
|
||||
);
|
||||
};
|
||||
|
||||
const byIdParams = {
|
||||
resource: 'worksheet',
|
||||
operation: 'clear',
|
||||
workbook: { mode: 'id', value: 'ITEM123' },
|
||||
site: { mode: 'id', value: SITE_ID },
|
||||
library: { mode: 'id', value: 'b!drive1' },
|
||||
worksheet: 'Sheet1',
|
||||
applyTo: 'All',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
node = new MicrosoftExcelSharePoint();
|
||||
ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.getInputData.mockReturnValue([{ json: {} }]);
|
||||
ctx.getNode.mockReturnValue(mock<INode>({ typeVersion: 1 }));
|
||||
ctx.continueOnFail.mockReturnValue(false);
|
||||
ctx.helpers.returnJsonArray.mockImplementation((data) =>
|
||||
(Array.isArray(data) ? data : [data]).map((json) => ({ json })),
|
||||
);
|
||||
ctx.helpers.constructExecutionMetaData.mockImplementation((inputData, options) =>
|
||||
inputData.map((data) => ({ ...data, pairedItem: options?.itemData })),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears the whole worksheet when no range is selected', async () => {
|
||||
setParams(byIdParams);
|
||||
apiRequest.mockResolvedValue({});
|
||||
|
||||
const result = await node.execute.call(ctx);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
`${WORKBOOK_ROOT}/workbook/worksheets/Sheet1/range/clear`,
|
||||
{ applyTo: 'All' },
|
||||
);
|
||||
expect(result[0].map((item) => item.json)).toEqual([{ success: true }]);
|
||||
});
|
||||
|
||||
it('clears a specific range when one is selected', async () => {
|
||||
setParams({ ...byIdParams, useRange: true, range: 'A1:B2' });
|
||||
apiRequest.mockResolvedValue({});
|
||||
|
||||
await node.execute.call(ctx);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
`${WORKBOOK_ROOT}/workbook/worksheets/Sheet1/range(address='${encodeURIComponent('A1:B2')}')/clear`,
|
||||
{ applyTo: 'All' },
|
||||
);
|
||||
});
|
||||
|
||||
it('does not use the Range field when Select a Range is off', async () => {
|
||||
setParams({ ...byIdParams, useRange: false, range: 'A1:B2' });
|
||||
apiRequest.mockResolvedValue({});
|
||||
|
||||
await node.execute.call(ctx);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
`${WORKBOOK_ROOT}/workbook/worksheets/Sheet1/range/clear`,
|
||||
{ applyTo: 'All' },
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['All', 'Formats', 'Contents'])(
|
||||
'sends the %s Apply To choice to the API',
|
||||
async (applyTo) => {
|
||||
setParams({ ...byIdParams, applyTo });
|
||||
apiRequest.mockResolvedValue({});
|
||||
|
||||
await node.execute.call(ctx);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
`${WORKBOOK_ROOT}/workbook/worksheets/Sheet1/range/clear`,
|
||||
{ applyTo },
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an empty Sheet', async () => {
|
||||
setParams({ ...byIdParams, worksheet: '' });
|
||||
|
||||
await expect(node.execute.call(ctx)).rejects.toThrow("The 'Sheet' parameter is empty");
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an empty Site when the workbook is chosen by ID', async () => {
|
||||
setParams({ ...byIdParams, site: { mode: 'id', value: '' } });
|
||||
|
||||
await expect(node.execute.call(ctx)).rejects.toThrow("The 'Site' parameter is empty");
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps later items running when continue-on-fail is on', async () => {
|
||||
ctx.getInputData.mockReturnValue([{ json: {} }, { json: {} }]);
|
||||
ctx.continueOnFail.mockReturnValue(true);
|
||||
setParams(byIdParams);
|
||||
apiRequest.mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce({});
|
||||
|
||||
const result = await node.execute.call(ctx);
|
||||
|
||||
expect(result[0].map((item) => item.json)).toEqual([{ error: 'boom' }, { success: true }]);
|
||||
});
|
||||
});
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import type { Mock } from 'vitest';
|
||||
import type { DeepMockProxy } from 'vitest-mock-extended';
|
||||
import { mock, mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import { MicrosoftExcelSharePoint } from '../../MicrosoftExcelSharePoint.node';
|
||||
import * as transport from '../../transport';
|
||||
import type * as _importType0 from '../../transport';
|
||||
|
||||
// Real transport module except the network helper
|
||||
vi.mock('../../transport', async () => {
|
||||
const originalModule = await vi.importActual<typeof _importType0>('../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
microsoftApiRequest: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const SITE_ID = 'contoso.sharepoint.com,g1,g2';
|
||||
const WORKBOOK_ROOT = `/v1.0/sites/${encodeURIComponent(SITE_ID)}/drives/b!drive1/items/ITEM123`;
|
||||
|
||||
describe('Microsoft Excel (SharePoint) — Sheet: Delete', () => {
|
||||
let node: MicrosoftExcelSharePoint;
|
||||
let ctx: DeepMockProxy<IExecuteFunctions>;
|
||||
const apiRequest = transport.microsoftApiRequest as Mock;
|
||||
|
||||
const setParams = (params: Record<string, unknown>) => {
|
||||
ctx.getNodeParameter.mockImplementation(
|
||||
(name: string, _itemIndex?: number, fallback?: unknown) =>
|
||||
(name in params ? params[name] : fallback) as never,
|
||||
);
|
||||
};
|
||||
|
||||
const byIdParams = {
|
||||
resource: 'worksheet',
|
||||
operation: 'deleteWorksheet',
|
||||
workbook: { mode: 'id', value: 'ITEM123' },
|
||||
site: { mode: 'id', value: SITE_ID },
|
||||
library: { mode: 'id', value: 'b!drive1' },
|
||||
worksheet: 'Sheet1',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
node = new MicrosoftExcelSharePoint();
|
||||
ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.getInputData.mockReturnValue([{ json: {} }]);
|
||||
ctx.getNode.mockReturnValue(mock<INode>({ typeVersion: 1 }));
|
||||
ctx.continueOnFail.mockReturnValue(false);
|
||||
ctx.helpers.returnJsonArray.mockImplementation((data) =>
|
||||
(Array.isArray(data) ? data : [data]).map((json) => ({ json })),
|
||||
);
|
||||
ctx.helpers.constructExecutionMetaData.mockImplementation((inputData, options) =>
|
||||
inputData.map((data) => ({ ...data, pairedItem: options?.itemData })),
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes the sheet', async () => {
|
||||
setParams(byIdParams);
|
||||
apiRequest.mockResolvedValue({});
|
||||
|
||||
const result = await node.execute.call(ctx);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'DELETE',
|
||||
`${WORKBOOK_ROOT}/workbook/worksheets/Sheet1`,
|
||||
);
|
||||
expect(result[0].map((item) => item.json)).toEqual([{ success: true }]);
|
||||
});
|
||||
|
||||
it('rejects an empty Sheet', async () => {
|
||||
setParams({ ...byIdParams, worksheet: '' });
|
||||
|
||||
await expect(node.execute.call(ctx)).rejects.toThrow("The 'Sheet' parameter is empty");
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a dots-only Workbook ID', async () => {
|
||||
setParams({ ...byIdParams, workbook: { mode: 'id', value: '..' } });
|
||||
|
||||
await expect(node.execute.call(ctx)).rejects.toThrow("The 'Workbook' value is not valid");
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps later items running when continue-on-fail is on', async () => {
|
||||
ctx.getInputData.mockReturnValue([{ json: {} }, { json: {} }]);
|
||||
ctx.continueOnFail.mockReturnValue(true);
|
||||
setParams(byIdParams);
|
||||
apiRequest.mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce({});
|
||||
|
||||
const result = await node.execute.call(ctx);
|
||||
|
||||
expect(result[0].map((item) => item.json)).toEqual([{ error: 'boom' }, { success: true }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user