mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 03:24:59 +02:00
feat(Microsoft Excel SharePoint Node): Add site and library dropdowns (no-changelog) (#34309)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
9f0a6fa68b
commit
d4684fbfd7
|
|
@ -7,6 +7,7 @@ import type {
|
|||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import * as readRows from './actions/worksheet/readRows.operation';
|
||||
import { listSearch } from './methods';
|
||||
|
||||
// Shell for the Excel-on-SharePoint build. Registered but hidden: workflows
|
||||
// using it always work; the launch ticket removes the `hidden` flag.
|
||||
|
|
@ -107,6 +108,10 @@ export class MicrosoftExcelSharePoint implements INodeType {
|
|||
],
|
||||
};
|
||||
|
||||
methods = {
|
||||
listSearch,
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const items = this.getInputData();
|
||||
const resource = this.getNodeParameter('resource', 0);
|
||||
|
|
|
|||
|
|
@ -184,6 +184,11 @@ export async function execute(
|
|||
// https://learn.microsoft.com/en-us/graph/api/worksheet-range
|
||||
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 {
|
||||
// Validate everything local before the workbook resolution, which may
|
||||
|
|
@ -195,7 +200,7 @@ export async function execute(
|
|||
qs.$select = settings.fields;
|
||||
}
|
||||
|
||||
const workbookRoot = await resolveWorkbookRoot.call(this, i);
|
||||
const workbookRoot = await resolveWorkbookRoot.call(this, i, workbookRootCache, siteIdCache);
|
||||
const sheetPath = `${workbookRoot}/workbook/worksheets/${encodeURIComponent(settings.worksheetId)}`;
|
||||
// Typed here instead of cast at the call site below. Parens are required:
|
||||
// a generic instantiation can't be followed directly by a property access.
|
||||
|
|
|
|||
|
|
@ -38,10 +38,36 @@ export const siteRLC: INodeProperties = {
|
|||
displayName: 'Site',
|
||||
name: 'site',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'id', value: '' },
|
||||
default: { mode: 'list', value: '' },
|
||||
description:
|
||||
'The SharePoint site the workbook lives in. Only needed when the workbook is chosen by ID.',
|
||||
// Field-shape-compatible with the site-selection component SharePoint 2.0
|
||||
// is building (ENT-182), so the two can converge later.
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'searchSites',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. https://contoso.sharepoint.com/sites/mysite',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: 'https://.+',
|
||||
errorMessage: 'The URL must start with https://',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
|
|
@ -55,10 +81,22 @@ export const libraryRLC: INodeProperties = {
|
|||
displayName: 'Document Library',
|
||||
name: 'library',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'id', value: '' },
|
||||
default: { mode: 'list', value: '' },
|
||||
description:
|
||||
'The document library the workbook lives in. Only needed when the workbook is chosen by ID.',
|
||||
typeOptions: {
|
||||
// So the editor re-fetches the library list whenever the chosen site changes.
|
||||
loadOptionsDependsOn: ['site.value'],
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'searchLibraries',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
import type { IExecuteFunctions, INode, INodeParameterResourceLocator } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import type { AuthContext } from './interfaces';
|
||||
import { microsoftApiRequest } from '../transport';
|
||||
|
||||
// One lookup per distinct pasted address per execution — without this, a
|
||||
// multi-item run repeats the same resolution call and risks throttling
|
||||
const workbookRootCache = new WeakMap<IExecuteFunctions, Map<string, string>>();
|
||||
|
||||
/**
|
||||
* Guards a user-supplied value destined for a URL path segment: rejects empty
|
||||
* values and dots-only values ('..' survives encodeURIComponent and would
|
||||
|
|
@ -31,10 +28,16 @@ export function validatePathSegment(node: INode, label: string, value: string):
|
|||
* Resolves the workbook fields to the Graph root every request hangs off:
|
||||
* `/v1.0/sites/{site}/drives/{drive}/items/{item}`. A pasted address costs one
|
||||
* lookup (Graph's sharing-URL exchange); IDs are used as given.
|
||||
*
|
||||
* `workbookRootCache`/`siteIdCache` are the caller's per-execution caches (one
|
||||
* lookup per distinct pasted address, not one per item) — the caller hoists
|
||||
* them once above its item loop and passes the same instances every call.
|
||||
*/
|
||||
export async function resolveWorkbookRoot(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
workbookRootCache: Map<string, string> = new Map(),
|
||||
siteIdCache: Map<string, string> = new Map(),
|
||||
): Promise<string> {
|
||||
const workbook = this.getNodeParameter('workbook', itemIndex) as INodeParameterResourceLocator;
|
||||
const workbookValue = String(workbook.value ?? '').trim();
|
||||
|
|
@ -46,12 +49,7 @@ export async function resolveWorkbookRoot(
|
|||
});
|
||||
}
|
||||
|
||||
let cache = workbookRootCache.get(this);
|
||||
if (!cache) {
|
||||
cache = new Map();
|
||||
workbookRootCache.set(this, cache);
|
||||
}
|
||||
const cached = cache.get(workbookValue);
|
||||
const cached = workbookRootCache.get(workbookValue);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
|
@ -78,16 +76,12 @@ export async function resolveWorkbookRoot(
|
|||
}
|
||||
|
||||
const root = `/v1.0/sites/${encodeURIComponent(parent.siteId)}/drives/${encodeURIComponent(parent.driveId)}/items/${encodeURIComponent(itemId)}`;
|
||||
cache.set(workbookValue, root);
|
||||
workbookRootCache.set(workbookValue, root);
|
||||
return root;
|
||||
}
|
||||
|
||||
const node = this.getNode();
|
||||
const siteId = validatePathSegment(
|
||||
node,
|
||||
'Site',
|
||||
String((this.getNodeParameter('site', itemIndex) as INodeParameterResourceLocator).value ?? ''),
|
||||
);
|
||||
const siteId = await resolveSiteId.call(this, itemIndex, siteIdCache);
|
||||
const driveId = validatePathSegment(
|
||||
node,
|
||||
'Library',
|
||||
|
|
@ -99,3 +93,67 @@ export async function resolveWorkbookRoot(
|
|||
|
||||
return `/v1.0/sites/${encodeURIComponent(siteId)}/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(itemId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the `site` resource locator to a Graph site ID — the one place
|
||||
* this happens, reused by workbook-root resolution and the library dropdown.
|
||||
* IDs (from "By ID" or a "From List" pick) are used as given; a pasted
|
||||
* address costs one lookup via Graph's `{hostname}:{site-path}` addressing.
|
||||
*
|
||||
* `cache` is the caller's per-execution cache; a load-options call (the
|
||||
* library dropdown) has no loop to amortize, so it just omits one.
|
||||
*/
|
||||
export async function resolveSiteId(
|
||||
this: AuthContext,
|
||||
itemIndex: number,
|
||||
cache: Map<string, string> = new Map(),
|
||||
): Promise<string> {
|
||||
const site = this.getNodeParameter('site', itemIndex) as INodeParameterResourceLocator;
|
||||
const value = String(site.value ?? '').trim();
|
||||
|
||||
if (site.mode !== 'url') {
|
||||
return validatePathSegment(this.getNode(), 'Site', value);
|
||||
}
|
||||
|
||||
if (value === '') {
|
||||
throw new NodeOperationError(this.getNode(), "The 'Site' parameter is empty", {
|
||||
description: "Paste the site's address, or switch to choosing it by ID or from the list.",
|
||||
});
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new NodeOperationError(this.getNode(), 'The site address is not valid', {
|
||||
description: 'Paste the full site address, e.g. https://contoso.sharepoint.com/sites/mysite.',
|
||||
});
|
||||
}
|
||||
const path = parsed.pathname.replace(/\/+$/, '');
|
||||
const endpoint =
|
||||
path === '' ? `/v1.0/sites/${parsed.hostname}` : `/v1.0/sites/${parsed.hostname}:${path}`;
|
||||
|
||||
const cached = cache.get(endpoint);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await microsoftApiRequest.call(this, 'GET', endpoint, {}, { $select: 'id' });
|
||||
} catch (error) {
|
||||
// Attribute a failed lookup to the Site field — the transport's generic
|
||||
// 404 mapping would otherwise blame the operation's resource (the workbook).
|
||||
if (error instanceof NodeApiError && error.httpCode === '404') {
|
||||
throw new NodeOperationError(this.getNode(), 'Site not found', {
|
||||
description:
|
||||
"Check the value in the 'Site' parameter — the address must point to an existing SharePoint site.",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const siteId = validatePathSegment(this.getNode(), 'Site', String(response.id ?? ''));
|
||||
cache.set(endpoint, siteId);
|
||||
return siteId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import { searchLibraries, searchSites } from './listSearch';
|
||||
|
||||
export const listSearch = {
|
||||
searchSites,
|
||||
searchLibraries,
|
||||
};
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import type {
|
||||
IDataObject,
|
||||
ILoadOptionsFunctions,
|
||||
INodeListSearchItems,
|
||||
INodeListSearchResult,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { SERVICE_PRINCIPAL_AUTH } from '../helpers/constants';
|
||||
import { resolveSiteId } from '../helpers/utils';
|
||||
import { getExcelSharePointCredentialType, microsoftApiRequest } from '../transport';
|
||||
|
||||
type GraphCollectionReply<T> = {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'@odata.nextLink'?: string;
|
||||
value?: T[];
|
||||
};
|
||||
|
||||
type Site = { id?: string; displayName?: string; webUrl?: string };
|
||||
type Drive = { id?: string; name?: string; webUrl?: string };
|
||||
|
||||
/**
|
||||
* Fetches one page of a Graph collection. An explicit `paginationToken` (a
|
||||
* complete next-page link) is requested exactly as returned, never rebuilt;
|
||||
* otherwise the initial request is built from `resource`/`qs`.
|
||||
*/
|
||||
async function fetchPage<T>(
|
||||
this: ILoadOptionsFunctions,
|
||||
resource: string,
|
||||
qs: IDataObject,
|
||||
paginationToken?: string,
|
||||
): Promise<GraphCollectionReply<T>> {
|
||||
return paginationToken
|
||||
? await (microsoftApiRequest<GraphCollectionReply<T>>).call(
|
||||
this,
|
||||
'GET',
|
||||
'',
|
||||
{},
|
||||
{},
|
||||
paginationToken,
|
||||
)
|
||||
: await (microsoftApiRequest<GraphCollectionReply<T>>).call(this, 'GET', resource, {}, qs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a page of Graph entries to dropdown items, dropping any without an ID.
|
||||
* Kept in the API's order: the editor concatenates pages, so a per-page sort
|
||||
* would reset at every page boundary and read as misordered.
|
||||
*/
|
||||
function toListItems<T extends { id?: string }>(
|
||||
entries: T[] | undefined,
|
||||
toItem: (entry: T) => INodeListSearchItems,
|
||||
): INodeListSearchItems[] {
|
||||
return (entries ?? []).filter((entry) => entry.id).map(toItem);
|
||||
}
|
||||
|
||||
function siteToItem(site: Site): INodeListSearchItems {
|
||||
return {
|
||||
name: site.displayName ?? String(site.id),
|
||||
value: String(site.id),
|
||||
url: site.webUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function driveToItem(drive: Drive): INodeListSearchItems {
|
||||
return {
|
||||
name: drive.name ?? String(drive.id),
|
||||
value: String(drive.id),
|
||||
url: drive.webUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* An app with only per-site permissions can't list what it can't see — point
|
||||
* at the "By URL"/"By ID" modes instead of surfacing the raw refusal.
|
||||
* Delegated refusals keep the transport's message, which already names the
|
||||
* missing permission, so only the Service Principal case is rewritten.
|
||||
*/
|
||||
function toSearchRefusal(this: ILoadOptionsFunctions, error: unknown): Error {
|
||||
if (
|
||||
error instanceof NodeApiError &&
|
||||
error.httpCode === '403' &&
|
||||
getExcelSharePointCredentialType.call(this) === SERVICE_PRINCIPAL_AUTH
|
||||
) {
|
||||
return new NodeOperationError(this.getNode(), 'This app sign-in cannot search sites', {
|
||||
description:
|
||||
"An app with only per-site permissions can't list sites it hasn't been granted. Choose the site by pasting its address instead — that still works.",
|
||||
});
|
||||
}
|
||||
return error as Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches sites by name. Graph quirks: the parameter is literally `search`
|
||||
* (not `$search`), and a site's name lives in `displayName` (no `title`).
|
||||
*/
|
||||
export async function searchSites(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
paginationToken?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
let response: GraphCollectionReply<Site>;
|
||||
try {
|
||||
response = await (fetchPage<Site>).call(
|
||||
this,
|
||||
'/v1.0/sites',
|
||||
{ search: filter ?? '*', $select: 'id,displayName,webUrl' },
|
||||
paginationToken,
|
||||
);
|
||||
} catch (error) {
|
||||
throw toSearchRefusal.call(this, error);
|
||||
}
|
||||
|
||||
return {
|
||||
results: toListItems(response.value, siteToItem),
|
||||
paginationToken: response['@odata.nextLink'],
|
||||
};
|
||||
}
|
||||
|
||||
/** Lists the chosen site's document libraries — new ground, no precedent in this node. */
|
||||
export async function searchLibraries(
|
||||
this: ILoadOptionsFunctions,
|
||||
_filter?: string,
|
||||
paginationToken?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
// Load-options calls have no item index; 0 is a no-op fallback position here
|
||||
// (mirrors getExcelSharePointCredentialType's use of the same 2-arg form).
|
||||
const siteId = await resolveSiteId.call(this, 0);
|
||||
|
||||
const response = await (fetchPage<Drive>).call(
|
||||
this,
|
||||
`/v1.0/sites/${encodeURIComponent(siteId)}/drives`,
|
||||
{ $select: 'id,name,webUrl' },
|
||||
paginationToken,
|
||||
);
|
||||
|
||||
return {
|
||||
results: toListItems(response.value, driveToItem),
|
||||
paginationToken: response['@odata.nextLink'],
|
||||
};
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import type { IExecuteFunctions, ILoadOptionsFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
import type { Mock } from 'vitest';
|
||||
import type { DeepMockProxy } from 'vitest-mock-extended';
|
||||
import { mock, mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import { resolveWorkbookRoot, validatePathSegment } from '../../helpers/utils';
|
||||
import { resolveSiteId, resolveWorkbookRoot, validatePathSegment } from '../../helpers/utils';
|
||||
import * as transport from '../../transport';
|
||||
import type * as _importType0 from '../../transport';
|
||||
|
||||
|
|
@ -118,6 +119,27 @@ describe('Microsoft Excel (SharePoint) — helpers/utils', () => {
|
|||
"The 'Workbook' value is not valid",
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves a pasted Site address before building the root path', async () => {
|
||||
setParams(ctx, {
|
||||
workbook: { mode: 'id', value: 'ITEM123' },
|
||||
site: { mode: 'url', value: 'https://contoso.sharepoint.com/sites/mysite' },
|
||||
library: { mode: 'id', value: 'b!drive1' },
|
||||
});
|
||||
apiRequest.mockResolvedValue({ id: 'contoso.sharepoint.com,g1,g2' });
|
||||
|
||||
const root = await resolveWorkbookRoot.call(ctx, 0);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/v1.0/sites/contoso.sharepoint.com:/sites/mysite',
|
||||
{},
|
||||
{ $select: 'id' },
|
||||
);
|
||||
expect(root).toBe(
|
||||
'/v1.0/sites/contoso.sharepoint.com%2Cg1%2Cg2/drives/b!drive1/items/ITEM123',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('by URL', () => {
|
||||
|
|
@ -152,7 +174,7 @@ describe('Microsoft Excel (SharePoint) — helpers/utils', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('caches the resolution per execution, across items', async () => {
|
||||
it('caches the resolution per execution, across items, when the caller shares its cache', async () => {
|
||||
setParams(ctx, {
|
||||
workbook: { mode: 'url', value: 'https://contoso.sharepoint.com/book.xlsx' },
|
||||
});
|
||||
|
|
@ -161,13 +183,16 @@ describe('Microsoft Excel (SharePoint) — helpers/utils', () => {
|
|||
parentReference: { siteId: 'contoso.sharepoint.com,g1,g2', driveId: 'b!drive1' },
|
||||
});
|
||||
|
||||
await resolveWorkbookRoot.call(ctx, 0);
|
||||
await resolveWorkbookRoot.call(ctx, 1);
|
||||
// The caller (readRows' execute()) hoists this once per run and passes
|
||||
// the same instance for every item — that's what makes it a per-run cache.
|
||||
const workbookRootCache = new Map<string, string>();
|
||||
await resolveWorkbookRoot.call(ctx, 0, workbookRootCache);
|
||||
await resolveWorkbookRoot.call(ctx, 1, workbookRootCache);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not share the cache across different executions', async () => {
|
||||
it('resolves again when no cache is passed, or a fresh one is', async () => {
|
||||
const workbook = { mode: 'url', value: 'https://contoso.sharepoint.com/book.xlsx' };
|
||||
setParams(ctx, { workbook });
|
||||
apiRequest.mockResolvedValue({
|
||||
|
|
@ -175,12 +200,8 @@ describe('Microsoft Excel (SharePoint) — helpers/utils', () => {
|
|||
parentReference: { siteId: 'contoso.sharepoint.com,g1,g2', driveId: 'b!drive1' },
|
||||
});
|
||||
|
||||
const otherCtx = mockDeep<IExecuteFunctions>();
|
||||
otherCtx.getNode.mockReturnValue(mock<INode>());
|
||||
setParams(otherCtx, { workbook });
|
||||
|
||||
await resolveWorkbookRoot.call(ctx, 0);
|
||||
await resolveWorkbookRoot.call(otherCtx, 0);
|
||||
await resolveWorkbookRoot.call(ctx, 0, new Map());
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
|
@ -211,4 +232,154 @@ describe('Microsoft Excel (SharePoint) — helpers/utils', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSiteId', () => {
|
||||
let ctx: DeepMockProxy<IExecuteFunctions>;
|
||||
const apiRequest = transport.microsoftApiRequest as Mock;
|
||||
|
||||
const setParams = (
|
||||
ctxToSet: DeepMockProxy<IExecuteFunctions> | DeepMockProxy<ILoadOptionsFunctions>,
|
||||
params: Record<string, unknown>,
|
||||
) => {
|
||||
ctxToSet.getNodeParameter.mockImplementation(
|
||||
(name: string, _itemIndex?: number, fallback?: unknown) =>
|
||||
(name in params ? params[name] : fallback) as never,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.getNode.mockReturnValue(mock<INode>());
|
||||
});
|
||||
|
||||
it('returns an ID as given, from the "By ID" mode', async () => {
|
||||
setParams(ctx, { site: { mode: 'id', value: 'contoso.sharepoint.com,g1,g2' } });
|
||||
|
||||
await expect(resolveSiteId.call(ctx, 0)).resolves.toBe('contoso.sharepoint.com,g1,g2');
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns an ID as given, from a "From List" pick', async () => {
|
||||
setParams(ctx, { site: { mode: 'list', value: 'contoso.sharepoint.com,g1,g2' } });
|
||||
|
||||
await expect(resolveSiteId.call(ctx, 0)).resolves.toBe('contoso.sharepoint.com,g1,g2');
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an empty Site ID', async () => {
|
||||
setParams(ctx, { site: { mode: 'id', value: '' } });
|
||||
|
||||
await expect(resolveSiteId.call(ctx, 0)).rejects.toThrow("The 'Site' parameter is empty");
|
||||
});
|
||||
|
||||
it('rejects a dots-only Site ID', async () => {
|
||||
setParams(ctx, { site: { mode: 'id', value: '..' } });
|
||||
|
||||
await expect(resolveSiteId.call(ctx, 0)).rejects.toThrow("The 'Site' value is not valid");
|
||||
});
|
||||
|
||||
it('rejects an empty pasted address', async () => {
|
||||
setParams(ctx, { site: { mode: 'url', value: '' } });
|
||||
|
||||
await expect(resolveSiteId.call(ctx, 0)).rejects.toThrow("The 'Site' parameter is empty");
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an address that is not a valid URL', async () => {
|
||||
setParams(ctx, { site: { mode: 'url', value: 'not a url' } });
|
||||
|
||||
await expect(resolveSiteId.call(ctx, 0)).rejects.toThrow('The site address is not valid');
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves a pasted address via Graph's hostname:path site addressing", async () => {
|
||||
setParams(ctx, {
|
||||
site: { mode: 'url', value: 'https://contoso.sharepoint.com/sites/mysite' },
|
||||
});
|
||||
apiRequest.mockResolvedValue({ id: 'contoso.sharepoint.com,g1,g2' });
|
||||
|
||||
const siteId = await resolveSiteId.call(ctx, 0);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/v1.0/sites/contoso.sharepoint.com:/sites/mysite',
|
||||
{},
|
||||
{ $select: 'id' },
|
||||
);
|
||||
expect(siteId).toBe('contoso.sharepoint.com,g1,g2');
|
||||
});
|
||||
|
||||
it('resolves the root site when the address has no path', async () => {
|
||||
setParams(ctx, { site: { mode: 'url', value: 'https://contoso.sharepoint.com' } });
|
||||
apiRequest.mockResolvedValue({ id: 'contoso.sharepoint.com,root' });
|
||||
|
||||
await resolveSiteId.call(ctx, 0);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/v1.0/sites/contoso.sharepoint.com',
|
||||
{},
|
||||
{ $select: 'id' },
|
||||
);
|
||||
});
|
||||
|
||||
it('caches the resolution per execution, across items, when the caller shares its cache', async () => {
|
||||
setParams(ctx, {
|
||||
site: { mode: 'url', value: 'https://contoso.sharepoint.com/sites/mysite' },
|
||||
});
|
||||
apiRequest.mockResolvedValue({ id: 'contoso.sharepoint.com,g1,g2' });
|
||||
|
||||
// The caller hoists this once per run and passes the same instance for
|
||||
// every item — that's what makes it a per-run cache, not the function itself.
|
||||
const siteIdCache = new Map<string, string>();
|
||||
await resolveSiteId.call(ctx, 0, siteIdCache);
|
||||
await resolveSiteId.call(ctx, 1, siteIdCache);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resolves again when no cache is passed, or a fresh one is', async () => {
|
||||
const site = { mode: 'url', value: 'https://contoso.sharepoint.com/sites/mysite' };
|
||||
setParams(ctx, { site });
|
||||
apiRequest.mockResolvedValue({ id: 'contoso.sharepoint.com,g1,g2' });
|
||||
|
||||
await resolveSiteId.call(ctx, 0);
|
||||
await resolveSiteId.call(ctx, 0, new Map());
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reports a not-found address as a Site problem, not the generic message', async () => {
|
||||
setParams(ctx, {
|
||||
site: { mode: 'url', value: 'https://contoso.sharepoint.com/sites/ghost' },
|
||||
});
|
||||
apiRequest.mockRejectedValue(
|
||||
new NodeApiError(mock<INode>(), { message: 'x' }, { httpCode: '404' }),
|
||||
);
|
||||
|
||||
await expect(resolveSiteId.call(ctx, 0)).rejects.toThrow('Site not found');
|
||||
});
|
||||
|
||||
it('lets other errors bubble up unchanged', async () => {
|
||||
setParams(ctx, {
|
||||
site: { mode: 'url', value: 'https://contoso.sharepoint.com/sites/mysite' },
|
||||
});
|
||||
apiRequest.mockRejectedValue(
|
||||
new NodeApiError(mock<INode>(), { message: 'x' }, { httpCode: '500' }),
|
||||
);
|
||||
|
||||
await expect(resolveSiteId.call(ctx, 0)).rejects.toThrow(NodeApiError);
|
||||
});
|
||||
|
||||
it('works from a load-options context, for the library dropdown', async () => {
|
||||
const loadOptionsCtx = mockDeep<ILoadOptionsFunctions>();
|
||||
loadOptionsCtx.getNode.mockReturnValue(mock<INode>());
|
||||
setParams(loadOptionsCtx, { site: { mode: 'id', value: 'contoso.sharepoint.com,g1,g2' } });
|
||||
|
||||
await expect(resolveSiteId.call(loadOptionsCtx, 0)).resolves.toBe(
|
||||
'contoso.sharepoint.com,g1,g2',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,240 @@
|
|||
import type { ILoadOptionsFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
import type { Mock } from 'vitest';
|
||||
import type { DeepMockProxy } from 'vitest-mock-extended';
|
||||
import { mock, mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import { libraryRLC, siteRLC } from '../../descriptions/common.descriptions';
|
||||
import { SERVICE_PRINCIPAL_AUTH } from '../../helpers/constants';
|
||||
import { searchLibraries, searchSites } from '../../methods/listSearch';
|
||||
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(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Microsoft Excel (SharePoint) — listSearch', () => {
|
||||
let ctx: DeepMockProxy<ILoadOptionsFunctions>;
|
||||
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,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ctx = mockDeep<ILoadOptionsFunctions>();
|
||||
ctx.getNode.mockReturnValue(mock<INode>({ typeVersion: 1 }));
|
||||
setParams({ authentication: 'microsoftOAuth2Api' });
|
||||
});
|
||||
|
||||
describe('searchSites', () => {
|
||||
it('searches sites by name using the literal `search` parameter', async () => {
|
||||
apiRequest.mockResolvedValue({
|
||||
value: [
|
||||
{ id: 'site-b', displayName: 'Marketing B', webUrl: 'https://c.sharepoint.com/sites/b' },
|
||||
{ id: 'site-a', displayName: 'Marketing A', webUrl: 'https://c.sharepoint.com/sites/a' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await searchSites.call(ctx, 'marketing');
|
||||
|
||||
// Graph quirk: `search`, not `$search`
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/v1.0/sites',
|
||||
{},
|
||||
{ search: 'marketing', $select: 'id,displayName,webUrl' },
|
||||
);
|
||||
// Kept in the API's order — no client-side sort
|
||||
expect(result.results).toEqual([
|
||||
{ name: 'Marketing B', value: 'site-b', url: 'https://c.sharepoint.com/sites/b' },
|
||||
{ name: 'Marketing A', value: 'site-a', url: 'https://c.sharepoint.com/sites/a' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists all sites when no search text is given', async () => {
|
||||
apiRequest.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchSites.call(ctx);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/v1.0/sites',
|
||||
{},
|
||||
{ search: '*', $select: 'id,displayName,webUrl' },
|
||||
);
|
||||
});
|
||||
|
||||
it('labels a site without a display name by its ID, and drops entries without one', async () => {
|
||||
apiRequest.mockResolvedValue({ value: [{ id: 'bare-site' }, { displayName: 'No ID' }] });
|
||||
|
||||
const result = await searchSites.call(ctx);
|
||||
|
||||
expect(result.results).toEqual([{ name: 'bare-site', value: 'bare-site', url: undefined }]);
|
||||
});
|
||||
|
||||
it('hands back the next-page link and requests it exactly as returned', async () => {
|
||||
const nextLink = 'https://graph.microsoft.com/v1.0/sites?search=%2A&$skiptoken=abc';
|
||||
apiRequest.mockResolvedValueOnce({
|
||||
value: [{ id: 's1', displayName: 'One' }],
|
||||
'@odata.nextLink': nextLink,
|
||||
});
|
||||
|
||||
const firstPage = await searchSites.call(ctx);
|
||||
expect(firstPage.paginationToken).toBe(nextLink);
|
||||
|
||||
apiRequest.mockResolvedValueOnce({ value: [{ id: 's2', displayName: 'Two' }] });
|
||||
const secondPage = await searchSites.call(ctx, undefined, nextLink);
|
||||
|
||||
// The link is a complete address — passed through verbatim, never rebuilt
|
||||
expect(apiRequest).toHaveBeenLastCalledWith('GET', '', {}, {}, nextLink);
|
||||
expect(secondPage.results).toEqual([{ name: 'Two', value: 's2', url: undefined }]);
|
||||
expect(secondPage.paginationToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it('points app-only sign-ins without search rights at the other modes', async () => {
|
||||
setParams({ authentication: SERVICE_PRINCIPAL_AUTH });
|
||||
apiRequest.mockRejectedValue(
|
||||
new NodeApiError(mock<INode>(), { message: 'refused' }, { httpCode: '403' }),
|
||||
);
|
||||
|
||||
await expect(searchSites.call(ctx)).rejects.toThrow('This app sign-in cannot search sites');
|
||||
});
|
||||
|
||||
it('keeps the permission-naming message for delegated refusals', async () => {
|
||||
setParams({ authentication: 'microsoftOAuth2Api' });
|
||||
// Mirrors the transport's delegated 403 shape: microsoftApiRequest has
|
||||
// already mapped the raw Graph error into a NodeApiError with the
|
||||
// permission-naming message baked into `error.message`
|
||||
apiRequest.mockRejectedValue(
|
||||
new NodeApiError(
|
||||
mock<INode>(),
|
||||
{ message: 'refused' },
|
||||
{
|
||||
httpCode: '403',
|
||||
message: 'the credential may be missing the Sites.Read.All permission',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await expect(searchSites.call(ctx)).rejects.toThrow(/Sites\.Read\.All/);
|
||||
});
|
||||
|
||||
it('lets a non-403 failure bubble up unchanged', async () => {
|
||||
setParams({ authentication: SERVICE_PRINCIPAL_AUTH });
|
||||
apiRequest.mockRejectedValue(
|
||||
new NodeApiError(mock<INode>(), {}, { httpCode: '500', message: 'boom' }),
|
||||
);
|
||||
|
||||
await expect(searchSites.call(ctx)).rejects.toThrow('boom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchLibraries', () => {
|
||||
it("lists the chosen site's document libraries", async () => {
|
||||
setParams({ site: { mode: 'id', value: 'contoso.sharepoint.com,g1,g2' } });
|
||||
apiRequest.mockResolvedValue({
|
||||
value: [
|
||||
{ id: 'b!drive1', name: 'Documents', webUrl: 'https://c.sharepoint.com/Documents' },
|
||||
{ id: 'b!drive2', name: 'Reports' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await searchLibraries.call(ctx);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/v1.0/sites/contoso.sharepoint.com%2Cg1%2Cg2/drives',
|
||||
{},
|
||||
{ $select: 'id,name,webUrl' },
|
||||
);
|
||||
expect(result.results).toEqual([
|
||||
{ name: 'Documents', value: 'b!drive1', url: 'https://c.sharepoint.com/Documents' },
|
||||
{ name: 'Reports', value: 'b!drive2', url: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves a pasted Site address before listing libraries', async () => {
|
||||
setParams({ site: { mode: 'url', value: 'https://contoso.sharepoint.com/sites/mysite' } });
|
||||
apiRequest.mockImplementation(async (_method: string, resource: string) =>
|
||||
resource.startsWith('/v1.0/sites/contoso.sharepoint.com:')
|
||||
? { id: 'contoso.sharepoint.com,g1,g2' }
|
||||
: { value: [{ id: 'b!drive1', name: 'Documents' }] },
|
||||
);
|
||||
|
||||
const result = await searchLibraries.call(ctx);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/v1.0/sites/contoso.sharepoint.com:/sites/mysite',
|
||||
{},
|
||||
{ $select: 'id' },
|
||||
);
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/v1.0/sites/contoso.sharepoint.com%2Cg1%2Cg2/drives',
|
||||
{},
|
||||
{ $select: 'id,name,webUrl' },
|
||||
);
|
||||
expect(result.results).toEqual([{ name: 'Documents', value: 'b!drive1', url: undefined }]);
|
||||
});
|
||||
|
||||
it('rejects when no Site has been chosen yet', async () => {
|
||||
setParams({ site: { mode: 'id', value: '' } });
|
||||
|
||||
await expect(searchLibraries.call(ctx)).rejects.toThrow("The 'Site' parameter is empty");
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hands back the next-page link and requests it exactly as returned', async () => {
|
||||
setParams({ site: { mode: 'id', value: 'contoso.sharepoint.com,g1,g2' } });
|
||||
const nextLink = 'https://graph.microsoft.com/v1.0/sites/s/drives?$skiptoken=abc';
|
||||
apiRequest.mockResolvedValueOnce({
|
||||
value: [{ id: 'b!drive1', name: 'Documents' }],
|
||||
'@odata.nextLink': nextLink,
|
||||
});
|
||||
|
||||
const firstPage = await searchLibraries.call(ctx);
|
||||
expect(firstPage.paginationToken).toBe(nextLink);
|
||||
|
||||
apiRequest.mockResolvedValueOnce({ value: [{ id: 'b!drive2', name: 'Reports' }] });
|
||||
const secondPage = await searchLibraries.call(ctx, undefined, nextLink);
|
||||
|
||||
expect(apiRequest).toHaveBeenLastCalledWith('GET', '', {}, {}, nextLink);
|
||||
expect(secondPage.paginationToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('field shape', () => {
|
||||
it('offers search first, with URL and ID modes alongside, for Site', () => {
|
||||
expect(siteRLC.modes?.map((mode) => mode.name)).toEqual(['list', 'url', 'id']);
|
||||
expect(siteRLC.modes?.[0].typeOptions?.searchListMethod).toBe('searchSites');
|
||||
expect(siteRLC.default).toEqual({ mode: 'list', value: '' });
|
||||
});
|
||||
|
||||
it('offers search first, with ID mode alongside, for Document Library', () => {
|
||||
expect(libraryRLC.modes?.map((mode) => mode.name)).toEqual(['list', 'id']);
|
||||
expect(libraryRLC.modes?.[0].typeOptions?.searchListMethod).toBe('searchLibraries');
|
||||
expect(libraryRLC.typeOptions?.loadOptionsDependsOn).toEqual(['site.value']);
|
||||
expect(libraryRLC.default).toEqual({ mode: 'list', value: '' });
|
||||
});
|
||||
|
||||
it('is wired into the node as list-search methods', () => {
|
||||
const node = new MicrosoftExcelSharePoint();
|
||||
|
||||
expect(node.methods?.listSearch?.searchSites).toBe(searchSites);
|
||||
expect(node.methods?.listSearch?.searchLibraries).toBe(searchLibraries);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user