diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/drive/index.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/drive/index.test.ts new file mode 100644 index 00000000000..76990d46d31 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/drive/index.test.ts @@ -0,0 +1,106 @@ +import type { ILoadOptionsFunctions, INode } from 'n8n-workflow'; +import type { Mock } from 'vitest'; +import type { DeepMockProxy } from 'vitest-mock-extended'; +import { mock, mockDeep } from 'vitest-mock-extended'; + +import { versionDescription } from '../../../v2/actions/versionDescription'; +import { getDrives } from '../../../v2/drive'; +import { MicrosoftSharePointV2 } from '../../../v2/MicrosoftSharePointV2.node'; +import * as transport from '../../../v2/transport'; +import type * as _importType0 from '../../../v2/transport'; + +vi.mock('../../../v2/transport', async () => { + const originalModule = await vi.importActual('../../../v2/transport'); + return { + ...originalModule, + microsoftApiRequest: vi.fn(), + }; +}); + +const SITE_ID = + 'contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE'; +const ENCODED_SITE_ID = encodeURIComponent(SITE_ID); + +const MIXED = { + value: [ + { id: 'b!docs', name: 'Documents' }, + { id: 'b!hold', name: 'Preservation Hold Library', system: {} }, + { id: 'b!policies', name: 'Policies' }, + { name: 'No id at all' }, + ], +}; + +describe('Microsoft SharePoint v2 — drive search', () => { + let ctx: DeepMockProxy; + const apiRequest = transport.microsoftApiRequest as Mock; + + beforeEach(() => { + vi.clearAllMocks(); + ctx = mockDeep(); + ctx.getNode.mockReturnValue(mock({ typeVersion: 2 })); + ctx.getNodeParameter.mockImplementation( + (name: string, fallback?: unknown) => + (name === 'site' ? { mode: 'id', value: SITE_ID } : fallback) as never, + ); + }); + + it('selects the system facet, which Graph omits unless asked for', async () => { + apiRequest.mockResolvedValue({ value: [] }); + + await getDrives.call(ctx); + + expect(apiRequest).toHaveBeenCalledWith( + 'GET', + `/v1.0/sites/${ENCODED_SITE_ID}/drives`, + {}, + { $select: 'id,name,system' }, + ); + }); + + it.each([ + [ + 'drops system libraries', + undefined, + [ + { name: 'Documents', value: 'b!docs' }, + { name: 'Policies', value: 'b!policies' }, + ], + ], + ['filters by typed text', 'pol', [{ name: 'Policies', value: 'b!policies' }]], + ['matches case-insensitively', 'DOCU', [{ name: 'Documents', value: 'b!docs' }]], + ['returns nothing when no name matches', 'zzz', []], + ])('%s', async (_name, filter, expected) => { + apiRequest.mockResolvedValue(MIXED); + + expect((await getDrives.call(ctx, filter)).results).toEqual(expected); + }); + + it('requests a next page exactly as returned, without re-sending the query', async () => { + const nextLink = 'https://graph.microsoft.com/v1.0/sites/x/drives?$skiptoken=abc'; + apiRequest.mockResolvedValueOnce({ value: [], '@odata.nextLink': nextLink }); + const firstPage = await getDrives.call(ctx); + + apiRequest.mockResolvedValueOnce({ value: [{ id: 'b!next', name: 'Later' }] }); + const secondPage = await getDrives.call(ctx, undefined, firstPage.paginationToken); + + expect(firstPage.paginationToken).toBe(nextLink); + expect(apiRequest).toHaveBeenLastCalledWith('GET', '', {}, {}, nextLink); + expect(secondPage.results).toEqual([{ name: 'Later', value: 'b!next' }]); + }); + + it('rejects an empty Site value before calling Graph', async () => { + ctx.getNodeParameter.mockImplementation( + (name: string, fallback?: unknown) => + (name === 'site' ? { mode: 'list', value: '' } : fallback) as never, + ); + + await expect(getDrives.call(ctx)).rejects.toThrow("The 'Site' parameter is empty"); + expect(apiRequest).not.toHaveBeenCalled(); + }); + + it('is wired into the node as a list-search method', () => { + const node = new MicrosoftSharePointV2(versionDescription); + + expect(node.methods?.listSearch?.getDrives).toBe(getDrives); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/drive/index.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/drive/index.ts new file mode 100644 index 00000000000..d442e7eda09 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/drive/index.ts @@ -0,0 +1,47 @@ +import type { INodeListSearchResult } from 'n8n-workflow'; + +import { type GraphSearchReply } from '../helpers/utils'; +import { resolveSiteId } from '../site'; +import { microsoftApiRequest, type SharePointContext } from '../transport'; + +type SharePointDrive = { id?: string; name?: string; system?: object }; +type DriveSearchReply = GraphSearchReply; + +/** + * Searches a site's document libraries. Each library is one drive, and a + * drive id is what the file change feed is addressed by. + * + * Graph hides system-faceted drives unless `system` is selected, so it is + * selected here to drop them deliberately rather than by omission. + */ +export async function getDrives( + this: SharePointContext, + filter?: string, + paginationToken?: string, +): Promise { + const siteId = await resolveSiteId.call(this, 0); + + const response = ( + paginationToken + ? await microsoftApiRequest.call(this, 'GET', '', {}, {}, paginationToken) + : await microsoftApiRequest.call( + this, + 'GET', + `/v1.0/sites/${encodeURIComponent(siteId)}/drives`, + {}, + { $select: 'id,name,system' }, + ) + ) as DriveSearchReply; + + const filterLower = filter?.toLowerCase(); + const results = (response.value ?? []) + .filter( + (drive) => + drive.id && + drive.system === undefined && + (!filterLower || (drive.name ?? '').toLowerCase().includes(filterLower)), + ) + .map((drive) => ({ name: drive.name ?? String(drive.id), value: String(drive.id) })); + + return { results, paginationToken: response['@odata.nextLink'] }; +} diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/methods/index.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/methods/index.ts index 583fef73af1..1acd8e2b6a6 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/methods/index.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/methods/index.ts @@ -1,3 +1,4 @@ +import { getDrives } from '../drive'; import { getFiles } from '../file'; import { getFolders } from '../folder'; import { getItems } from '../item'; @@ -9,6 +10,7 @@ export const listSearch = { getFiles, getFolders, getSites, + getDrives, getLists, getItems, }; diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/site/index.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/site/index.ts index 06071ac76ef..61072d63b70 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/site/index.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/site/index.ts @@ -1,5 +1,4 @@ import type { - IExecuteFunctions, ILoadOptionsFunctions, INodeListSearchResult, INodeParameterResourceLocator, @@ -13,6 +12,7 @@ import { getSharePointCredentialType, microsoftApiRequest, SERVICE_PRINCIPAL_AUTH, + type SharePointContext, } from '../transport'; // The whole site-selection piece lives here — the field, the search behind @@ -155,7 +155,7 @@ export async function getSites( * hoist and pass none — each dropdown open is its own one-off lookup. */ export async function resolveSiteId( - this: IExecuteFunctions | ILoadOptionsFunctions, + this: SharePointContext, itemIndex: number, siteIdCache?: Map, ): Promise {