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:
Thanasis G 2026-07-16 16:11:18 +03:00 committed by GitHub
parent 9f0a6fa68b
commit d4684fbfd7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 695 additions and 31 deletions

View File

@ -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);

View File

@ -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.

View File

@ -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',

View File

@ -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;
}

View File

@ -0,0 +1,6 @@
import { searchLibraries, searchSites } from './listSearch';
export const listSearch = {
searchSites,
searchLibraries,
};

View File

@ -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'],
};
}

View File

@ -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',
);
});
});
});

View File

@ -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);
});
});
});