diff --git a/docs/docs/cmd/spo/tenant/tenant-commandset-get.mdx b/docs/docs/cmd/spo/tenant/tenant-commandset-get.mdx index 1b6c6f12b5b..b3c6052fe58 100644 --- a/docs/docs/cmd/spo/tenant/tenant-commandset-get.mdx +++ b/docs/docs/cmd/spo/tenant/tenant-commandset-get.mdx @@ -191,7 +191,7 @@ m365 spo tenant commandset get --clientSideComponentId 7096cded-b83d-4eab-96f0-d ```md - # spo tenant applicationcustomizer get --id "2" --tenantWideExtensionComponentProperties "true" + # spo tenant commandset get --id "2" --tenantWideExtensionComponentProperties "true" Date: 17/05/2024 diff --git a/src/m365/spo/commands/hubsite/hubsite-get.spec.ts b/src/m365/spo/commands/hubsite/hubsite-get.spec.ts index 99295f92aad..eab02894b7c 100644 --- a/src/m365/spo/commands/hubsite/hubsite-get.spec.ts +++ b/src/m365/spo/commands/hubsite/hubsite-get.spec.ts @@ -11,14 +11,16 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import spoListItemListCommand from '../listitem/listitem-list.js'; import command from './hubsite-get.js'; import { settingsNames } from '../../../../settingsNames.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; +import { spo } from '../../../../utils/spo.js'; describe(commands.HUBSITE_GET, () => { const validId = '9ff01368-1183-4cbb-82f2-92e7e9a3f4ce'; const validTitle = 'Hub Site'; const validUrl = 'https://contoso.sharepoint.com'; + const spoAdminUrl = 'https://contoso-admin.sharepoint.com'; const hubsiteResponse = { "ID": validId, @@ -38,7 +40,8 @@ describe(commands.HUBSITE_GET, () => { sinon.stub(pid, 'getProcessName').returns(''); sinon.stub(session, 'getId').returns(''); auth.connection.active = true; - auth.connection.spoUrl = 'https://contoso.sharepoint.com'; + auth.connection.spoUrl = validUrl; + sinon.stub(spo, 'getSpoAdminUrl').resolves(spoAdminUrl); commandInfo = cli.getCommandInfo(command); sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName: string, defaultValue: any) => { if (settingName === 'prompt') { @@ -68,7 +71,7 @@ describe(commands.HUBSITE_GET, () => { afterEach(() => { sinonUtil.restore([ request.get, - cli.executeCommandWithOutput, + spoListItem.getListItems, cli.getSettingWithDefaultValue, cli.handleMultipleResultsFound ]); @@ -260,13 +263,13 @@ describe(commands.HUBSITE_GET, () => { }; } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(opts); }); - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command): Promise => { - if (command === spoListItemListCommand) { - return { - stdout: JSON.stringify([ + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === spoAdminUrl) { + if (options.listTitle === 'DO_NOT_DELETE_SPLIST_TENANTADMIN_AGGREGATED_SITECOLLECTIONS') { + return [ { Title: "Lucky Charms", SiteId: "c08c7be1-4b97-4caa-b88f-ec91100d7774", @@ -287,11 +290,11 @@ describe(commands.HUBSITE_GET, () => { SiteId: "ee8b42c3-3e6f-4822-87c1-c21ad666046b", SiteUrl: "https://contoso.sharepoint.com/sites/leadership-connection" } - ] - ) - }; + ] as any[]; + } } - throw 'Invalid request'; + + throw 'Invalid request: ' + JSON.stringify(options); }); await command.action(logger, { options: { id: 'ee8b42c3-3e6f-4822-87c1-c21ad666046b', includeAssociatedSites: true, output: 'json' } }); diff --git a/src/m365/spo/commands/hubsite/hubsite-get.ts b/src/m365/spo/commands/hubsite/hubsite-get.ts index 7f9b8aa63f8..01d8f622706 100644 --- a/src/m365/spo/commands/hubsite/hubsite-get.ts +++ b/src/m365/spo/commands/hubsite/hubsite-get.ts @@ -1,14 +1,13 @@ -import { cli, CommandOutput } from '../../../../cli/cli.js'; +import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; -import Command from '../../../../Command.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; import { spo } from '../../../../utils/spo.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; -import spoListItemListCommand, { Options as SpoListItemListCommandOptions } from '../listitem/listitem-list.js'; import { AssociatedSite } from './AssociatedSite.js'; import { HubSite } from './HubSite.js'; @@ -92,8 +91,7 @@ class SpoHubSiteGetCommand extends SpoCommand { if (args.options.includeAssociatedSites === true && args.options.output && !cli.shouldTrimOutput(args.options.output)) { const spoAdminUrl = await spo.getSpoAdminUrl(logger, this.debug); - const associatedSitesCommandOutput = await this.getAssociatedSites(spoAdminUrl, hubSite.SiteId, logger, args); - const associatedSites: AssociatedSite[] = JSON.parse((associatedSitesCommandOutput as CommandOutput).stdout) as AssociatedSite[]; + const associatedSites = await this.getAssociatedSites(spoAdminUrl, hubSite.SiteId, logger); hubSite.AssociatedSites = associatedSites.filter(s => s.SiteId !== hubSite.SiteId); } @@ -104,18 +102,16 @@ class SpoHubSiteGetCommand extends SpoCommand { } } - private async getAssociatedSites(spoAdminUrl: string, hubSiteId: string, logger: Logger, args: CommandArgs): Promise { - const options: SpoListItemListCommandOptions = { - output: 'json', - debug: args.options.debug, - verbose: args.options.verbose, - listTitle: 'DO_NOT_DELETE_SPLIST_TENANTADMIN_AGGREGATED_SITECOLLECTIONS', + private async getAssociatedSites(spoAdminUrl: string, hubSiteId: string, logger: Logger): Promise { + const options: ListItemListOptions = { webUrl: spoAdminUrl, + listTitle: 'DO_NOT_DELETE_SPLIST_TENANTADMIN_AGGREGATED_SITECOLLECTIONS', filter: `HubSiteId eq '${hubSiteId}'`, - fields: 'Title,SiteUrl,SiteId' + fields: ['Title', 'SiteUrl', 'SiteId'] }; - return cli.executeCommandWithOutput(spoListItemListCommand as Command, { options: { ...options, _: [] } }); + const listItems = await spoListItem.getListItems(options, logger, this.verbose); + return listItems as any as AssociatedSite[]; } private async getHubSiteById(spoUrl: string, options: Options): Promise { diff --git a/src/m365/spo/commands/listitem/listitem-add.spec.ts b/src/m365/spo/commands/listitem/listitem-add.spec.ts index f4891a328d7..90e07c37230 100644 --- a/src/m365/spo/commands/listitem/listitem-add.spec.ts +++ b/src/m365/spo/commands/listitem/listitem-add.spec.ts @@ -1,110 +1,31 @@ import assert from 'assert'; -import os from 'os'; import sinon from 'sinon'; import auth from '../../../../Auth.js'; -import { CommandError } from '../../../../Command.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; -import request from '../../../../request.js'; import { telemetry } from '../../../../telemetry.js'; -import { formatting } from '../../../../utils/formatting.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; -import { spo } from '../../../../utils/spo.js'; -import { urlUtil } from '../../../../utils/urlUtil.js'; import commands from '../../commands.js'; import command from './listitem-add.js'; import { settingsNames } from '../../../../settingsNames.js'; +import { spoListItem } from '../../../../utils/spoListItem.js'; +import { CommandError } from '../../../../Command.js'; describe(commands.LISTITEM_ADD, () => { let log: any[]; let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; - let ensureFolderStub: sinon.SinonStub; - const listUrl = 'sites/project-x/documents'; const webUrl = 'https://contoso.sharepoint.com/sites/project-x'; - const listServerRelativeUrl: string = urlUtil.getServerRelativePath(webUrl, listUrl); - const expectedTitle = `List Item 1`; - - const expectedId = 147; - let actualId = 0; - - const expectedContentType = 'Item'; - let actualContentType = ''; - - const postFakes = async (opts: any) => { - if (opts.url.indexOf('/_api/web/lists') > -1) { - if ((opts.url as string).indexOf('AddValidateUpdateItemUsingPath') > -1) { - const bodyString = JSON.stringify(opts.data); - const ctMatch = bodyString.match(/\"?FieldName\"?:\s*\"?ContentType\"?,\s*\"?FieldValue\"?:\s*\"?(\w*)\"?/i); - actualContentType = ctMatch ? ctMatch[1] : ""; - if (bodyString.indexOf("fail adding me") > -1) { return Promise.resolve({ value: [{ ErrorMessage: 'failed updating', 'FieldName': 'Title', 'HasException': true }] }); } - return { value: [{ FieldName: "Id", FieldValue: expectedId, HasException: false }] }; - } - } - if (opts.url === `https://contoso.sharepoint.com/sites/project-x/_api/web/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')/AddValidateUpdateItemUsingPath()`) { - const bodyString = JSON.stringify(opts.data); - const ctMatch = bodyString.match(/\"?FieldName\"?:\s*\"?ContentType\"?,\s*\"?FieldValue\"?:\s*\"?(\w*)\"?/i); - actualContentType = ctMatch ? ctMatch[1] : ""; - if (bodyString.indexOf("fail adding me") > -1) { return Promise.resolve({ value: [] }); } - return { value: [{ FieldName: "Id", FieldValue: expectedId }] }; - } - throw 'Invalid request'; - }; - - const getFakes = async (opts: any) => { - if (opts.url.indexOf('/_api/web/lists') > -1) { - if ((opts.url as string).indexOf('contenttypes') > -1) { - return { value: [{ Id: { StringValue: expectedContentType }, Name: "Item" }] }; - } - if ((opts.url as string).indexOf('rootFolder') > -1) { - return { ServerRelativeUrl: '/sites/project-xxx/Lists/Demo%20List' }; - } - if ((opts.url as string).indexOf('/items(') > -1) { - actualId = parseInt(opts.url.match(/\/items\((\d+)\)/i)[1]); - return { - "Attachments": false, - "AuthorId": 3, - "ContentTypeId": "0x0100B21BD271A810EE488B570BE49963EA34", - "Created": "2018-03-15T10:43:10Z", - "EditorId": 3, - "GUID": "ea093c7b-8ae6-4400-8b75-e2d01154dffc", - "Id": actualId, - "ID": actualId, - "Modified": "2018-03-15T10:43:10Z", - "Title": expectedTitle - }; - } - } - if (opts.url === `https://contoso.sharepoint.com/sites/project-x/_api/web/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')/contenttypes?$select=Name,Id`) { - return { value: [{ Id: { StringValue: expectedContentType }, Name: "Item" }] }; - } - if (opts.url === `https://contoso.sharepoint.com/sites/project-x/_api/web/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')/items(147)`) { - actualId = parseInt(opts.url.match(/\/items\((\d+)\)/i)[1]); - return { - "Attachments": false, - "AuthorId": 3, - "ContentTypeId": "0x0100B21BD271A810EE488B570BE49963EA34", - "Created": "2018-03-15T10:43:10Z", - "EditorId": 3, - "GUID": "ea093c7b-8ae6-4400-8b75-e2d01154dffc", - "Id": actualId, - "ID": actualId, - "Modified": "2018-03-15T10:43:10Z", - "Title": expectedTitle - }; - } - throw 'Invalid request'; - }; before(() => { sinon.stub(auth, 'restoreAuth').callsFake(() => Promise.resolve()); sinon.stub(telemetry, 'trackEvent').resolves(); sinon.stub(pid, 'getProcessName').callsFake(() => ''); sinon.stub(session, 'getId').callsFake(() => ''); - ensureFolderStub = sinon.stub(spo, 'ensureFolder').resolves(); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); }); @@ -122,12 +43,12 @@ describe(commands.LISTITEM_ADD, () => { log.push(msg); } }; + loggerLogSpy = sinon.spy(logger, 'log'); }); afterEach(() => { sinonUtil.restore([ - request.post, - request.get, + spoListItem.addListItem, cli.getSettingWithDefaultValue ]); }); @@ -207,156 +128,47 @@ describe(commands.LISTITEM_ADD, () => { assert(actual); }); - it('fails to create a list item when \'fail me\' values are used', async () => { - actualId = 0; - - sinon.stub(request, 'get').callsFake(getFakes); - sinon.stub(request, 'post').callsFake(postFakes); - - const options: any = { - listTitle: 'Demo List', - webUrl: webUrl, - Title: "fail adding me" - }; - - await assert.rejects(command.action(logger, { options: options } as any), new CommandError(`Creating the item failed with the following errors: ${os.EOL}- Title - failed updating`)); - assert.strictEqual(actualId, 0); - }); - - it('returns listItemInstance object when list item is added with correct values', async () => { - sinon.stub(request, 'get').callsFake(getFakes); - sinon.stub(request, 'post').callsFake(postFakes); - - command.allowUnknownOptions(); - - const options: any = { - debug: true, - listTitle: 'Demo List', - webUrl: webUrl, - Title: expectedTitle - }; - - await command.action(logger, { options: options } as any); - assert.strictEqual(actualId, expectedId); - }); - - it('creates list item in the list specified using ID', async () => { - sinon.stub(request, 'get').callsFake(getFakes); - sinon.stub(request, 'post').callsFake(postFakes); - - const options: any = { - listId: 'cf8c72a1-0207-40ee-aebd-fca67d20bc8a', - webUrl: webUrl, - Title: expectedTitle - }; - - await command.action(logger, { options: options } as any); - assert.strictEqual(actualId, expectedId); - }); - - it('creates list item in the list specified using URL', async () => { - sinon.stub(request, 'get').callsFake(getFakes); - sinon.stub(request, 'post').callsFake(postFakes); - - const options: any = { - verbose: true, - listUrl: listUrl, - webUrl: webUrl, - Title: expectedTitle - }; - - await command.action(logger, { options: options } as any); - assert.strictEqual(actualId, expectedId); - }); - - - it('attempts to create the listitem with the contenttype of \'Item\' when content type option 0x01 is specified', async () => { - sinon.stub(request, 'get').callsFake(getFakes); - sinon.stub(request, 'post').callsFake(postFakes); - - const options: any = { - debug: true, - listTitle: 'Demo List', - webUrl: webUrl, - contentType: expectedContentType, - Title: expectedTitle - }; - - await command.action(logger, { options: options } as any); - assert(expectedContentType === actualContentType); - }); - - it('fails to create the listitem when the specified contentType doesn\'t exist in the target list', async () => { - sinon.stub(request, 'get').callsFake(getFakes); - sinon.stub(request, 'post').callsFake(postFakes); - - const options: any = { - listTitle: 'Demo List', - webUrl: webUrl, - contentType: "Unexpected content type", - Title: expectedTitle - }; - - await assert.rejects(command.action(logger, { options: options } as any), new CommandError("Specified content type 'Unexpected content type' doesn't exist on the target list")); - }); - - it('should call ensure folder when folder arg specified', async () => { - sinon.stub(request, 'get').callsFake(getFakes); - sinon.stub(request, 'post').callsFake(postFakes); - - await command.action(logger, { - options: { - listTitle: 'Demo List', - webUrl: webUrl, - Title: expectedTitle, - contentType: expectedContentType, - folder: "InsideFolder2" - } - }); - assert.strictEqual(ensureFolderStub.lastCall.args[0], 'https://contoso.sharepoint.com/sites/project-x'); - assert.strictEqual(ensureFolderStub.lastCall.args[1], '/sites/project-xxx/Lists/Demo%20List/InsideFolder2'); - }); - - it('should call ensure folder when folder arg specified (debug)', async () => { - sinon.stub(request, 'get').callsFake(getFakes); - sinon.stub(request, 'post').callsFake(postFakes); + it('ignores global options when creating request data', async () => { + const addListItemStub = sinon.stub(spoListItem, 'addListItem').resolves(); await command.action(logger, { options: { debug: true, + verbose: true, + output: "text", listTitle: 'Demo List', webUrl: webUrl, - Title: expectedTitle, - contentType: expectedContentType, - folder: "InsideFolder2/Folder3" + Title: 'Some Title', + contentType: 'Some Content Type', + folder: "InsideFolder2/Folder3/" } }); - assert.strictEqual(ensureFolderStub.lastCall.args[0], 'https://contoso.sharepoint.com/sites/project-x'); - assert.strictEqual(ensureFolderStub.lastCall.args[1], '/sites/project-xxx/Lists/Demo%20List/InsideFolder2/Folder3'); - }); - - it('should not have end \'/\' in the folder path when FolderPath.DecodedUrl ', async () => { - sinon.stub(request, 'get').callsFake(getFakes); - const postStubs = sinon.stub(request, 'post').callsFake(postFakes); - await command.action(logger, { - options: { - debug: true, - listTitle: 'Demo List', - webUrl: webUrl, - Title: expectedTitle, - contentType: expectedContentType, - folder: "InsideFolder2/Folder3/" - } + assert.deepEqual(addListItemStub.firstCall.args[0], { + webUrl: webUrl, + listId: undefined, + listUrl: undefined, + listTitle: 'Demo List', + folder: 'InsideFolder2/Folder3/', + contentType: 'Some Content Type', + fieldValues: { Title: 'Some Title' } }); - const addValidateUpdateItemUsingPathRequest = postStubs.getCall(postStubs.callCount - 1).args[0]; - const info = addValidateUpdateItemUsingPathRequest.data.listItemCreateInfo; - assert.strictEqual(info.FolderPath.DecodedUrl, '/sites/project-xxx/Lists/Demo%20List/InsideFolder2/Folder3'); }); - it('ignores global options when creating request data', async () => { - sinon.stub(request, 'get').callsFake(getFakes); - const postStubs = sinon.stub(request, 'post').callsFake(postFakes); + it('successfully creates a list item', async () => { + const createdListItem: any = { + "Attachments": false, + "AuthorId": 3, + "ContentTypeId": "0x0100B21BD271A810EE488B570BE49963EA34", + "Created": "2018-03-15T10:43:10Z", + "EditorId": 3, + "GUID": "ea093c7b-8ae6-4400-8b75-e2d01154dffc", + "Id": 1, + "Modified": "2018-03-15T10:43:10Z", + "Title": "Some Title" + }; + + sinon.stub(spoListItem, 'addListItem').resolves(createdListItem); await command.action(logger, { options: { @@ -365,14 +177,25 @@ describe(commands.LISTITEM_ADD, () => { output: "text", listTitle: 'Demo List', webUrl: webUrl, - Title: expectedTitle, - contentType: expectedContentType, + Title: 'Some Title', + contentType: 'Some Content Type', folder: "InsideFolder2/Folder3/" } }); - assert.deepEqual(postStubs.firstCall.args[0].data, { - formValues: [{ FieldName: 'Title', FieldValue: 'List Item 1' }, { FieldName: 'ContentType', FieldValue: 'Item' }], - listItemCreateInfo: { FolderPath: { DecodedUrl: '/sites/project-xxx/Lists/Demo%20List/InsideFolder2/Folder3' } } - }); + + assert(loggerLogSpy.calledWith(createdListItem)); + }); + + it('correctly handles random API error', async () => { + sinon.stub(spoListItem, 'addListItem').callsFake(() => Promise.reject('An error has occurred')); + + const options: any = { + listId: '935c13a0-cc53-4103-8b48-c1d0828eaa7f', + webUrl: 'https://contoso.sharepoint.com/sites/project-x', + camlQuery: "Demo List Item 1" + }; + + await assert.rejects(command.action(logger, { options: options } as any), + new CommandError('An error has occurred')); }); }); diff --git a/src/m365/spo/commands/listitem/listitem-add.ts b/src/m365/spo/commands/listitem/listitem-add.ts index 6decc9dc657..fd3b4fe7f32 100644 --- a/src/m365/spo/commands/listitem/listitem-add.ts +++ b/src/m365/spo/commands/listitem/listitem-add.ts @@ -1,23 +1,15 @@ -import os from 'os'; import GlobalOptions from '../../../../GlobalOptions.js'; import { Logger } from '../../../../cli/Logger.js'; -import request from '../../../../request.js'; -import { basic } from '../../../../utils/basic.js'; -import { formatting } from '../../../../utils/formatting.js'; -import { ODataResponse } from '../../../../utils/odata.js'; -import { spo } from '../../../../utils/spo.js'; -import { urlUtil } from '../../../../utils/urlUtil.js'; +import { ListItemAddOptions, spoListItem } from '../../../../utils/spoListItem.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; -import { ListItemFieldValueResult } from './ListItemFieldValueResult.js'; -import { ListItemInstance } from './ListItemInstance.js'; interface CommandArgs { options: Options; } -export interface Options extends GlobalOptions { +interface Options extends GlobalOptions { webUrl: string; listId?: string; listTitle?: string; @@ -26,13 +18,6 @@ export interface Options extends GlobalOptions { folder?: string; } -interface ContentType { - Id: { - StringValue: string; - }; - Name: string; -} - class SpoListItemAddCommand extends SpoCommand { public allowUnknownOptions(): boolean | undefined { return true; @@ -126,148 +111,17 @@ class SpoListItemAddCommand extends SpoCommand { public async commandAction(logger: Logger, args: CommandArgs): Promise { try { - - let requestUrl = `${args.options.webUrl}/_api/web`; - - if (args.options.listId) { - requestUrl += `/lists(guid'${formatting.encodeQueryParameter(args.options.listId)}')`; - } - else if (args.options.listTitle) { - requestUrl += `/lists/getByTitle('${formatting.encodeQueryParameter(args.options.listTitle)}')`; - } - else if (args.options.listUrl) { - const listServerRelativeUrl: string = urlUtil.getServerRelativePath(args.options.webUrl, args.options.listUrl); - requestUrl += `/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')`; - } - - let contentTypeName: string = ''; - let targetFolderServerRelativeUrl: string = ''; - - if (this.verbose) { - await logger.logToStderr(`Getting content types for list ${args.options.listId || args.options.listTitle || args.options.listUrl}...`); - } - - let requestOptions: any = { - url: `${requestUrl}/contenttypes?$select=Name,Id`, - headers: { - 'accept': 'application/json;odata=nometadata' - }, - responseType: 'json' - }; - - const ctypes = await request.get>(requestOptions); - if (args.options.contentType) { - const foundContentType = await basic.asyncFilter(ctypes.value, async (ct: ContentType) => { - const contentTypeMatch: boolean = ct.Id.StringValue === args.options.contentType || ct.Name === args.options.contentType; - - if (this.debug) { - await logger.logToStderr(`Checking content type value [${ct.Name}]: ${contentTypeMatch}`); - } - - return contentTypeMatch; - }); - - if (this.debug) { - await logger.logToStderr('content type filter output...'); - await logger.logToStderr(foundContentType); - } - - if (foundContentType.length > 0) { - contentTypeName = foundContentType[0].Name; - } - - // After checking for content types, throw an error if the name is blank - if (!contentTypeName || contentTypeName === '') { - throw `Specified content type '${args.options.contentType}' doesn't exist on the target list`; - } - - if (this.debug) { - await logger.logToStderr(`using content type name: ${contentTypeName}`); - } - } - - if (args.options.folder) { - if (this.debug) { - await logger.logToStderr('setting up folder lookup response ...'); - } - - requestOptions = { - url: `${requestUrl}/rootFolder`, - headers: { - 'accept': 'application/json;odata=nometadata' - }, - responseType: 'json' - }; - - const rootFolderResponse = await request.get(requestOptions); - targetFolderServerRelativeUrl = urlUtil.getServerRelativePath(rootFolderResponse["ServerRelativeUrl"], args.options.folder as string); - await spo.ensureFolder(args.options.webUrl, targetFolderServerRelativeUrl, logger, this.debug); - } - - if (this.verbose) { - await logger.logToStderr(`Creating item in list ${args.options.listId || args.options.listTitle || args.options.listUrl} in site ${args.options.webUrl}...`); - } - - const requestBody: any = { - formValues: this.mapRequestBody(args.options) + const options: ListItemAddOptions = { + webUrl: args.options.webUrl, + listId: args.options.listId, + listUrl: args.options.listUrl, + listTitle: args.options.listTitle, + contentType: args.options.contentType, + folder: args.options.folder, + fieldValues: this.mapUnknownProperties(args.options) }; - if (args.options.folder) { - requestBody.listItemCreateInfo = { - FolderPath: { - DecodedUrl: targetFolderServerRelativeUrl - } - }; - } - - if (args.options.contentType && contentTypeName !== '') { - if (this.debug) { - await logger.logToStderr(`Specifying content type name [${contentTypeName}] in request body`); - } - - requestBody.formValues.push({ - FieldName: 'ContentType', - FieldValue: contentTypeName - }); - } - - requestOptions = { - url: `${requestUrl}/AddValidateUpdateItemUsingPath()`, - headers: { - 'accept': 'application/json;odata=nometadata' - }, - data: requestBody, - responseType: 'json' - }; - - const response = await request.post(requestOptions); - - // Response is from /AddValidateUpdateItemUsingPath POST call, perform get on added item to get all field values - const fieldValues: ListItemFieldValueResult[] = response.value; - if (fieldValues.some(f => f.HasException)) { - throw `Creating the item failed with the following errors: ${os.EOL}${fieldValues.filter(f => f.HasException).map(f => { return `- ${f.FieldName} - ${f.ErrorMessage}`; }).join(os.EOL)}`; - } - - const idField = fieldValues.filter((thisField) => { - return (thisField.FieldName === "Id"); - }); - - if (this.debug) { - await logger.logToStderr(`Field values returned:`); - await logger.logToStderr(fieldValues); - await logger.logToStderr(`Id returned by AddValidateUpdateItemUsingPath: ${idField[0].FieldValue}`); - } - - requestOptions = { - url: `${requestUrl}/items(${idField[0].FieldValue})`, - headers: { - 'accept': 'application/json;odata=nometadata' - }, - responseType: 'json' - }; - - const item = await request.get(requestOptions); - delete item.ID; + const item = await spoListItem.addListItem(options, logger, this.verbose, this.debug); await logger.log(item); } catch (err: any) { @@ -275,8 +129,8 @@ class SpoListItemAddCommand extends SpoCommand { } } - private mapRequestBody(options: Options): any { - const requestBody: any = []; + private mapUnknownProperties(options: Options): any { + const fieldValues: { [key: string]: any } = {}; const excludeOptions: string[] = [ 'listTitle', 'listId', @@ -292,10 +146,11 @@ class SpoListItemAddCommand extends SpoCommand { Object.keys(options).forEach(key => { if (excludeOptions.indexOf(key) === -1) { - requestBody.push({ FieldName: key, FieldValue: `${(options)[key]}` }); + fieldValues[key] = `${(options)[key]}`; } }); - return requestBody; + + return fieldValues; } } diff --git a/src/m365/spo/commands/listitem/listitem-list.spec.ts b/src/m365/spo/commands/listitem/listitem-list.spec.ts index 9497b20b69c..e8be8c94955 100644 --- a/src/m365/spo/commands/listitem/listitem-list.spec.ts +++ b/src/m365/spo/commands/listitem/listitem-list.spec.ts @@ -3,88 +3,47 @@ import sinon from 'sinon'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; -import { Logger } from '../../../../cli/Logger.js'; -import { CommandError } from '../../../../Command.js'; -import request from '../../../../request.js'; import { telemetry } from '../../../../telemetry.js'; -import { formatting } from '../../../../utils/formatting.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import { spo } from '../../../../utils/spo.js'; -import { urlUtil } from '../../../../utils/urlUtil.js'; import commands from '../../commands.js'; import command from './listitem-list.js'; +import { spoListItem } from '../../../../utils/spoListItem.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { CommandError } from '../../../../Command.js'; describe(commands.LISTITEM_LIST, () => { - const webUrl = 'https://contoso.sharepoint.com/sites/project-x'; - const listUrl = 'sites/project-x/documents'; - const listServerRelativeUrl: string = urlUtil.getServerRelativePath(webUrl, listUrl); - const listItemResponse = { - value: - [{ - "Attachments": false, - "AuthorId": 3, - "ContentTypeId": "0x0100B21BD271A810EE488B570BE49963EA34", - "Created": "2018-08-15T13:43:12Z", - "EditorId": 3, - "GUID": "2b6bd9e0-3c43-4420-891e-20053e3c4664", - "Id": 1, - "ID": 1, - "Modified": "2018-08-15T13:43:12Z", - "Title": "Example item 1" - }, - { - "Attachments": false, - "AuthorId": 3, - "ContentTypeId": "0x0100B21BD271A810EE488B570BE49963EA34", - "Created": "2018-08-15T13:44:10Z", - "EditorId": 3, - "GUID": "47c5fc61-afb7-4081-aa32-f4386b8a86ea", - "Id": 2, - "ID": 2, - "Modified": "2018-08-15T13:44:10Z", - "Title": "Example item 2" - }] - }; - let log: any[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; - const expectedArrayLength = 2; - let returnArrayLength = 0; - - const postFakes = async (opts: any) => { - if (opts.url.indexOf('/_api/web/lists') > -1) { - if ((opts.url as string).indexOf('/GetItems') > -1) { - returnArrayLength = 2; - return opts.data.query.ListItemCollectionPosition === undefined ? listItemResponse : { value: [] }; - } - } - returnArrayLength = 0; - throw 'Invalid request'; - }; - - const getFakes = async (opts: any) => { - if (opts.url.indexOf('/_api/web/lists') > -1) { - if ((opts.url as string).indexOf('/items') > -1 && (opts.url as string).indexOf('$top=6') > -1) { - returnArrayLength = 0; - return { value: [] }; - } - if ((opts.url as string).indexOf('/items') > -1) { - returnArrayLength = 2; - return listItemResponse; - } - } - if (opts.url === `https://contoso.sharepoint.com/sites/project-x/_api/web/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')/items?$top=5000&$select=Title%2CID`) { - returnArrayLength = 2; - return listItemResponse; - } - returnArrayLength = 0; - throw 'Invalid request'; - }; + const listItemResponse = [{ + "Attachments": false, + "AuthorId": 3, + "ContentTypeId": "0x0100B21BD271A810EE488B570BE49963EA34", + "Created": "2018-08-15T13:43:12Z", + "EditorId": 3, + "GUID": "2b6bd9e0-3c43-4420-891e-20053e3c4664", + "Id": 1, + "ID": 1, + "Modified": "2018-08-15T13:43:12Z", + "Title": "Example item 1" + }, + { + "Attachments": false, + "AuthorId": 3, + "ContentTypeId": "0x0100B21BD271A810EE488B570BE49963EA34", + "Created": "2018-08-15T13:44:10Z", + "EditorId": 3, + "GUID": "47c5fc61-afb7-4081-aa32-f4386b8a86ea", + "Id": 2, + "ID": 2, + "Modified": "2018-08-15T13:44:10Z", + "Title": "Example item 2" + }]; before(() => { sinon.stub(auth, 'restoreAuth').callsFake(() => Promise.resolve()); @@ -120,8 +79,7 @@ describe(commands.LISTITEM_LIST, () => { afterEach(() => { sinonUtil.restore([ - request.post, - request.get + spoListItem.getListItems ]); }); @@ -204,244 +162,58 @@ describe(commands.LISTITEM_LIST, () => { assert.notStrictEqual(actual, true); }); - it('returns array of listItemInstance objects when a list of items is requested, and debug mode enabled', async () => { - sinon.stub(request, 'get').callsFake(getFakes); - sinon.stub(request, 'post').callsFake(postFakes); - - const options: any = { - debug: true, - listTitle: 'Demo List', - webUrl: 'https://contoso.sharepoint.com/sites/project-x' - }; - - await command.action(logger, { options: options } as any); - assert.strictEqual(returnArrayLength, expectedArrayLength); - }); - - it('returns array of listItemInstance objects when a list of items is requested with an output type of json, and a list of fields and a filter specified', async () => { - const listTitle = `Test'list`; - const filter = `Title eq 'Demo list item'`; - const fields = 'Title,ID'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://contoso.sharepoint.com/sites/project-x/_api/web/lists/getByTitle('${formatting.encodeQueryParameter(listTitle)}')/items?$top=2&$filter=${encodeURIComponent(filter)}&$select=${formatting.encodeQueryParameter(fields)}`) { - returnArrayLength = 2; - return listItemResponse; - } - throw 'Invalid request'; - }); - - const options: any = { - debug: true, - listTitle: listTitle, - webUrl: 'https://contoso.sharepoint.com/sites/project-x', - output: "json", - pageSize: 2, - filter: filter, - fields: "Title,ID" - }; - - await command.action(logger, { options: options } as any); - assert.strictEqual(returnArrayLength, expectedArrayLength); - returnArrayLength = 0; - }); - - it('returns array of listItemInstance objects when a list of items is requested with an output type of json, a page number specified, a list of fields and a filter specified', async () => { - sinon.stub(request, 'get').callsFake(getFakes); - sinon.stub(request, 'post').callsFake(postFakes); - - const options: any = { - debug: true, - listTitle: 'Demo List', - webUrl: 'https://contoso.sharepoint.com/sites/project-x', - output: "json", - pageSize: 2, - pageNumber: 2, - filter: "Title eq 'Demo list item", - fields: "Title,ID" - }; - - await command.action(logger, { options: options } as any); - assert.strictEqual(returnArrayLength, expectedArrayLength); - }); - - it('returns empty array of listItemInstance objects when a list of items is requested with an output type of json, a page number specified, a list of fields and a filter specified', async () => { - sinon.stub(request, 'get').callsFake(getFakes); - sinon.stub(request, 'post').callsFake(postFakes); - - const options: any = { - debug: true, - listTitle: 'Demo List', - webUrl: 'https://contoso.sharepoint.com/sites/project-x', - output: "json", - pageSize: 3, - pageNumber: 2, - filter: "Title eq 'Demo list item", - fields: "Title,ID" - }; - - await command.action(logger, { options: options } as any); - assert.strictEqual(returnArrayLength, 0); - }); - - it('returns array of listItemInstance objects when a list of items is requested with an output type of json, and a pageNumber is specified', async () => { - sinon.stub(request, 'get').callsFake(getFakes); - sinon.stub(request, 'post').callsFake(postFakes); - - const options: any = { - listTitle: 'Demo List', - webUrl: 'https://contoso.sharepoint.com/sites/project-x', - output: "json", - pageSize: 2, - pageNumber: 2, - fields: "Title,ID" - }; - - await command.action(logger, { options: options } as any); - assert.strictEqual(returnArrayLength, expectedArrayLength); - }); - - it('returns array of listItemInstance objects when a list of items is requested with no output type specified, and a list of fields specified', async () => { - sinon.stub(request, 'get').callsFake(getFakes); - sinon.stub(request, 'post').callsFake(postFakes); - - const options: any = { - listTitle: 'Demo List', - webUrl: 'https://contoso.sharepoint.com/sites/project-x', - fields: "Title,ID" - }; - - await command.action(logger, { options: options } as any); - assert.strictEqual(returnArrayLength, expectedArrayLength); - }); - - it('returns array of listItemInstance objects when a list of items by list url is requested with no output type specified, and a list of fields specified', async () => { - sinon.stub(request, 'get').callsFake(getFakes); - sinon.stub(request, 'post').callsFake(postFakes); - - const options: any = { - verbose: true, - listUrl: listUrl, - webUrl: webUrl, - fields: "Title,ID" - }; - - await command.action(logger, { options: options } as any); - assert.strictEqual(returnArrayLength, expectedArrayLength); - }); - - it('returns array of listItemInstance objects when a list of items is requested with no output type specified, a list of fields with lookup field specified', async () => { - sinon.stub(request, 'get').callsFake(opts => { - if ((opts.url as string).indexOf('$expand=') > -1) { - return Promise.resolve({ - value: - [{ - "ID": 1, - "Modified": "2018-08-15T13:43:12Z", - "Title": "Example item 1", - "Company": { "Title": "Contoso" } - }, - { - "ID": 2, - "Modified": "2018-08-15T13:44:10Z", - "Title": "Example item 2", - "Company": { "Title": "Fabrikam" } - }] - }); - } - - return Promise.reject('Invalid request'); - }); - - const options: any = { - listTitle: 'Demo List', - webUrl: 'https://contoso.sharepoint.com/sites/project-x', - fields: "Title,Modified,Company/Title" - }; - - await command.action(logger, { options: options } as any); - assert.deepStrictEqual(JSON.stringify(loggerLogSpy.lastCall.args[0]), JSON.stringify([ - { - "Modified": "2018-08-15T13:43:12Z", - "Title": "Example item 1", - "Company": { "Title": "Contoso" } - }, - { - "Modified": "2018-08-15T13:44:10Z", - "Title": "Example item 2", - "Company": { "Title": "Fabrikam" } - } - ])); - }); - - it('returns array of listItemInstance objects when a list of items is requested with an output type of text, and no fields specified', async () => { - sinon.stub(request, 'get').callsFake(getFakes); - sinon.stub(request, 'post').callsFake(postFakes); - - const options: any = { - listTitle: 'Demo List', - webUrl: 'https://contoso.sharepoint.com/sites/project-x', - output: "text" - }; - - await command.action(logger, { options: options } as any); - assert.strictEqual(returnArrayLength, expectedArrayLength); - }); - it('returns array of listItemInstance objects when a list of items is requested with an output type of json, and no fields specified', async () => { - sinon.stub(request, 'get').callsFake(getFakes); - sinon.stub(request, 'post').callsFake(postFakes); + sinon.stub(spoListItem, 'getListItems').resolves(listItemResponse as any[]); const options: any = { listTitle: 'Demo List', webUrl: 'https://contoso.sharepoint.com/sites/project-x', - output: "json" + output: 'json', + verbose: true }; await command.action(logger, { options: options } as any); - assert.strictEqual(returnArrayLength, expectedArrayLength); + assert.deepStrictEqual(JSON.stringify(loggerLogSpy.lastCall.args[0]), JSON.stringify(listItemResponse)); }); - it('returns array of listItemInstance objects when a list of items is requested with a camlQuery specified, and output set to json, and debug mode is enabled', async () => { - sinon.stub(request, 'get').callsFake(getFakes); - sinon.stub(request, 'post').callsFake(postFakes); + it('returns array of listItemInstance objects when a list of items is requested with an output type of json, and fields specified', async () => { + sinon.stub(spoListItem, 'getListItems').resolves(listItemResponse as any[]); const options: any = { - debug: true, listTitle: 'Demo List', webUrl: 'https://contoso.sharepoint.com/sites/project-x', - camlQuery: "Demo List Item 1", - output: "json" + output: 'json', + fields: 'Title,Id', + verbose: true }; await command.action(logger, { options: options } as any); - assert.strictEqual(returnArrayLength, expectedArrayLength); + assert.deepStrictEqual(JSON.stringify(loggerLogSpy.lastCall.args[0]), JSON.stringify(listItemResponse)); }); - it('returns array of listItemInstance objects when a list of items is requested with a camlQuery specified', async () => { - sinon.stub(request, 'get').callsFake(getFakes); - sinon.stub(request, 'post').callsFake(postFakes); + it('correctly handles random API error', async () => { + sinon.stub(spoListItem, 'getListItems').callsFake(() => Promise.reject('An error has occurred')); const options: any = { - listTitle: 'Demo List', + listId: '935c13a0-cc53-4103-8b48-c1d0828eaa7f', webUrl: 'https://contoso.sharepoint.com/sites/project-x', camlQuery: "Demo List Item 1" }; - await command.action(logger, { options: options } as any); - assert.strictEqual(returnArrayLength, expectedArrayLength); + await assert.rejects(command.action(logger, { options: options } as any), + new CommandError('An error has occurred')); }); - it('correctly handles random API error', async () => { - sinon.stub(request, 'get').callsFake(getFakes); - sinon.stub(request, 'post').callsFake(() => Promise.reject('An error has occurred')); + it('returns array of listItemInstance objects when a list of items is requested with an output type of text, and no fields specified', async () => { + sinon.stub(spoListItem, 'getListItems').resolves(listItemResponse as any[]); const options: any = { - listId: '935c13a0-cc53-4103-8b48-c1d0828eaa7f', + listTitle: 'Demo List', webUrl: 'https://contoso.sharepoint.com/sites/project-x', - camlQuery: "Demo List Item 1" + output: 'text' }; - await assert.rejects(command.action(logger, { options: options } as any), - new CommandError('An error has occurred')); + const listItems = await spoListItem.getListItems(options, logger, true); + assert.strictEqual(listItems.length, 2); }); }); diff --git a/src/m365/spo/commands/listitem/listitem-list.ts b/src/m365/spo/commands/listitem/listitem-list.ts index 662fe4c3ea3..04fdfd0291c 100644 --- a/src/m365/spo/commands/listitem/listitem-list.ts +++ b/src/m365/spo/commands/listitem/listitem-list.ts @@ -1,29 +1,23 @@ import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; import GlobalOptions from '../../../../GlobalOptions.js'; -import request, { CliRequestOptions } from '../../../../request.js'; -import { formatting } from '../../../../utils/formatting.js'; -import { odata } from '../../../../utils/odata.js'; -import { spo } from '../../../../utils/spo.js'; -import { urlUtil } from '../../../../utils/urlUtil.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; -import { ListItemInstance } from './ListItemInstance.js'; -import { ListItemInstanceCollection } from './ListItemInstanceCollection.js'; interface CommandArgs { options: Options; } -export interface Options extends GlobalOptions { +interface Options extends GlobalOptions { listId?: string; listTitle?: string; listUrl?: string; fields?: string; filter?: string; - pageNumber?: string; - pageSize?: string; + pageNumber?: number; + pageSize?: number; camlQuery?: string; webUrl: string; } @@ -153,156 +147,28 @@ class SpoListItemListCommand extends SpoCommand { } public async commandAction(logger: Logger, args: CommandArgs): Promise { - let listApiUrl = `${args.options.webUrl}/_api/web`; - - if (args.options.listId) { - listApiUrl += `/lists(guid'${formatting.encodeQueryParameter(args.options.listId)}')`; - } - else if (args.options.listTitle) { - listApiUrl += `/lists/getByTitle('${formatting.encodeQueryParameter(args.options.listTitle)}')`; - } - else if (args.options.listUrl) { - const listServerRelativeUrl: string = urlUtil.getServerRelativePath(args.options.webUrl, args.options.listUrl); - listApiUrl += `/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')`; - } - try { - const listItems = args.options.camlQuery - ? await this.getItemsUsingCAMLQuery(logger, args.options, listApiUrl) - : await this.getItems(logger, args.options, listApiUrl); + const options: ListItemListOptions = { + webUrl: args.options.webUrl, + listId: args.options.listId, + listUrl: args.options.listUrl, + listTitle: args.options.listTitle, + fields: args.options.fields ? args.options.fields.split(",") + : (!args.options.output || cli.shouldTrimOutput(args.options.output)) ? ["Title", "Id"] : [], + filter: args.options.filter, + pageNumber: args.options.pageNumber, + pageSize: args.options.pageSize, + camlQuery: args.options.camlQuery + }; + + const listItems = await spoListItem.getListItems(options, logger, this.verbose); - listItems.forEach(v => delete v['ID']); await logger.log(listItems); } catch (err: any) { this.handleRejectedODataJsonPromise(err); } } - - private async getItems(logger: Logger, options: Options, listApiUrl: string): Promise { - if (this.verbose) { - await logger.logToStderr(`Getting list items`); - } - - const queryParams = []; - const fieldsArray: string[] = options.fields ? options.fields.split(",") - : (!options.output || cli.shouldTrimOutput(options.output)) ? ["Title", "Id"] : []; - const expandFieldsArray: string[] = this.getExpandFieldsArray(fieldsArray); - const skipTokenId = await this.getLastItemIdForPage(logger, options, listApiUrl); - - queryParams.push(`$top=${options.pageSize || 5000}`); - - if (options.filter) { - queryParams.push(`$filter=${encodeURIComponent(options.filter)}`); - } - - if (expandFieldsArray.length > 0) { - queryParams.push(`$expand=${expandFieldsArray.join(",")}`); - } - - if (fieldsArray.length > 0) { - queryParams.push(`$select=${formatting.encodeQueryParameter(fieldsArray.join(","))}`); - } - - if (skipTokenId !== undefined) { - queryParams.push(`$skiptoken=Paged=TRUE%26p_ID=${skipTokenId}`); - } - - // If skiptoken is not found, then we are past the last page - if (options.pageNumber && Number(options.pageNumber) > 0 && skipTokenId === undefined) { - return []; - } - - if (!options.pageSize) { - return await odata.getAllItems(`${listApiUrl}/items?${queryParams.join('&')}`); - } - else { - const requestOptions: CliRequestOptions = { - url: `${listApiUrl}/items?${queryParams.join('&')}`, - headers: { - 'accept': 'application/json;odata=nometadata' - }, - responseType: 'json' - }; - - const listItemCollection = await request.get(requestOptions); - return listItemCollection.value; - } - } - - private async getItemsUsingCAMLQuery(logger: Logger, options: Options, listApiUrl: string): Promise { - const formDigestValue = (await spo.getRequestDigest(options.webUrl)).FormDigestValue; - - if (this.verbose) { - await logger.logToStderr(`Getting list items using CAML query`); - } - - const items: ListItemInstance[] = []; - let skipTokenId: number | undefined = undefined; - - do { - const requestBody: any = { - "query": { - "ViewXml": options.camlQuery, - "AllowIncrementalResults": true - } - }; - - if (skipTokenId !== undefined) { - requestBody.query.ListItemCollectionPosition = { - "PagingInfo": `Paged=TRUE&p_ID=${skipTokenId}` - }; - } - - const requestOptions: CliRequestOptions = { - url: `${listApiUrl}/GetItems`, - headers: { - 'accept': 'application/json;odata=nometadata', - 'X-RequestDigest': formDigestValue - }, - responseType: 'json', - data: requestBody - }; - - const listItemInstances = await request.post(requestOptions); - skipTokenId = listItemInstances.value.length > 0 ? listItemInstances.value[listItemInstances.value.length - 1].Id : undefined; - items.push(...listItemInstances.value); - } - while (skipTokenId !== undefined); - - return items; - } - - private getExpandFieldsArray(fieldsArray: string[]): string[] { - const fieldsWithSlash: string[] = fieldsArray.filter(item => item.includes('/')); - const fieldsToExpand: string[] = fieldsWithSlash.map(e => e.split('/')[0]); - const expandFieldsArray: string[] = fieldsToExpand.filter((item, pos) => fieldsToExpand.indexOf(item) === pos); - return expandFieldsArray; - } - - private async getLastItemIdForPage(logger: Logger, options: Options, listApiUrl: string): Promise { - if (!options.pageNumber || Number(options.pageNumber) === 0) { - return undefined; - } - - if (this.verbose) { - await logger.logToStderr(`Getting skipToken Id for page ${options.pageNumber}`); - } - - const rowLimit: string = `$top=${Number(options.pageSize) * Number(options.pageNumber)}`; - const filter: string = options.filter ? `$filter=${encodeURIComponent(options.filter)}` : ``; - - const requestOptions: CliRequestOptions = { - url: `${listApiUrl}/items?$select=Id&${rowLimit}&${filter}`, - headers: { - 'accept': 'application/json;odata=nometadata' - }, - responseType: 'json' - }; - - const response = await request.get<{ value: [{ Id: number }] }>(requestOptions); - return response.value[response.value.length - 1]?.Id; - } } export default new SpoListItemListCommand(); \ No newline at end of file diff --git a/src/m365/spo/commands/site/site-remove.spec.ts b/src/m365/spo/commands/site/site-remove.spec.ts index 973fa2e445f..7c59a11a762 100644 --- a/src/m365/spo/commands/site/site-remove.spec.ts +++ b/src/m365/spo/commands/site/site-remove.spec.ts @@ -16,6 +16,7 @@ import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { formatting } from '../../../../utils/formatting.js'; import request from '../../../../request.js'; import { CommandError } from '../../../../Command.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; describe(commands.SITE_REMOVE, () => { let log: string[]; @@ -23,9 +24,9 @@ describe(commands.SITE_REMOVE, () => { let commandInfo: CommandInfo; let promptIssued: boolean = false; + const adminSitesListTitle = 'DO_NOT_DELETE_SPLIST_TENANTADMIN_AGGREGATED_SITECOLLECTIONS'; const siteUrl = 'https://contoso.sharepoint.com/sites/project-x'; - const adminUrl = 'https://contoso-admin.sharepoint.com'; - const odataUrl = `${adminUrl}/_api/web/lists/GetByTitle('DO_NOT_DELETE_SPLIST_TENANTADMIN_AGGREGATED_SITECOLLECTIONS')/items?$filter=SiteUrl eq '${formatting.encodeQueryParameter(siteUrl)}'&$select=GroupId,TimeDeleted,SiteId`; + const spoAdminUrl = 'https://contoso-admin.sharepoint.com'; const siteDetailsNonGroup = { GroupId: '00000000-0000-0000-0000-000000000000', @@ -43,7 +44,7 @@ describe(commands.SITE_REMOVE, () => { sinon.stub(telemetry, 'trackEvent').resolves(); sinon.stub(pid, 'getProcessName').returns(''); sinon.stub(session, 'getId').returns(''); - sinon.stub(spo, 'getSpoAdminUrl').resolves(adminUrl); + sinon.stub(spo, 'getSpoAdminUrl').resolves(spoAdminUrl); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); (command as any).pollingInterval = 0; @@ -83,7 +84,7 @@ describe(commands.SITE_REMOVE, () => { sinonUtil.restore([ cli.promptForConfirmation, cli.getSettingWithDefaultValue, - odata.getAllItems, + spoListItem.getListItems, request.delete, request.post, request.get @@ -104,18 +105,23 @@ describe(commands.SITE_REMOVE, () => { }); it('deletes a classic site and also immediately deletes it from the recycle bin', async () => { - sinon.stub(odata, 'getAllItems').callsFake(async (url) => { - if (url === odataUrl) { - return [siteDetailsNonGroup]; + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === spoAdminUrl) { + if (options.listTitle === adminSitesListTitle && + options.filter === `SiteUrl eq '${formatting.encodeQueryParameter(siteUrl)}'` + ) { + return [siteDetailsNonGroup] as any; + } } - throw 'Invalid request'; + + throw 'Invalid request: ' + JSON.stringify(options); }); const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `${adminUrl}/_api/Microsoft.Online.SharePoint.TenantAdministration.Tenant/RemoveSite`) { + if (opts.url === `${spoAdminUrl}/_api/Microsoft.Online.SharePoint.TenantAdministration.Tenant/RemoveSite`) { return; } - if (opts.url === `${adminUrl}/_api/Microsoft.Online.SharePoint.TenantAdministration.Tenant/RemoveDeletedSite`) { + if (opts.url === `${spoAdminUrl}/_api/Microsoft.Online.SharePoint.TenantAdministration.Tenant/RemoveDeletedSite`) { return; } throw 'Invalid request'; @@ -128,11 +134,16 @@ describe(commands.SITE_REMOVE, () => { }); it('deletes a group site, deletes the m365 group from entra id', async () => { - sinon.stub(odata, 'getAllItems').callsFake(async (url) => { - if (url === odataUrl) { - return [siteDetailsGroup]; + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === spoAdminUrl) { + if (options.listTitle === adminSitesListTitle && + options.filter === `SiteUrl eq '${formatting.encodeQueryParameter(siteUrl)}'` + ) { + return [siteDetailsGroup] as any; + } } - throw 'Invalid request'; + + throw 'Invalid request: ' + JSON.stringify(options); }); sinon.stub(request, 'get').callsFake(async (opts) => { @@ -147,7 +158,7 @@ describe(commands.SITE_REMOVE, () => { }); const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `${adminUrl}/_api/GroupSiteManager/Delete?siteUrl='${formatting.encodeQueryParameter(siteUrl)}'`) { + if (opts.url === `${spoAdminUrl}/_api/GroupSiteManager/Delete?siteUrl='${formatting.encodeQueryParameter(siteUrl)}'`) { return; } throw 'Invalid request'; @@ -158,11 +169,16 @@ describe(commands.SITE_REMOVE, () => { }); it('deletes a group site, deletes the m365 group from entra id and immediately deletes it from the recycle bin', async () => { - sinon.stub(odata, 'getAllItems').callsFake(async (url) => { - if (url === odataUrl) { - return [siteDetailsGroup]; + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === spoAdminUrl) { + if (options.listTitle === adminSitesListTitle && + options.filter === `SiteUrl eq '${formatting.encodeQueryParameter(siteUrl)}'` + ) { + return [siteDetailsGroup] as any; + } } - throw 'Invalid request'; + + throw 'Invalid request: ' + JSON.stringify(options); }); const getRequestStub = sinon.stub(request, 'get'); @@ -186,10 +202,10 @@ describe(commands.SITE_REMOVE, () => { }); const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `${adminUrl}/_api/GroupSiteManager/Delete?siteUrl='${formatting.encodeQueryParameter(siteUrl)}'`) { + if (opts.url === `${spoAdminUrl}/_api/GroupSiteManager/Delete?siteUrl='${formatting.encodeQueryParameter(siteUrl)}'`) { return; } - if (opts.url === `${adminUrl}/_api/Microsoft.Online.SharePoint.TenantAdministration.Tenant/RemoveDeletedSite`) { + if (opts.url === `${spoAdminUrl}/_api/Microsoft.Online.SharePoint.TenantAdministration.Tenant/RemoveDeletedSite`) { return; } throw 'Invalid request'; @@ -208,11 +224,16 @@ describe(commands.SITE_REMOVE, () => { }); it('deletes a group site, deletes the m365 group from entra id and immediately deletes the site from the recycle bin, but skips deletion of the m365 group when it does not exist in Entra', async () => { - sinon.stub(odata, 'getAllItems').callsFake(async (url) => { - if (url === odataUrl) { - return [siteDetailsGroup]; + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === spoAdminUrl) { + if (options.listTitle === adminSitesListTitle && + options.filter === `SiteUrl eq '${formatting.encodeQueryParameter(siteUrl)}'` + ) { + return [siteDetailsGroup] as any; + } } - throw 'Invalid request'; + + throw 'Invalid request: ' + JSON.stringify(options); }); sinon.stub(request, 'get').callsFake(async (opts) => { @@ -227,10 +248,10 @@ describe(commands.SITE_REMOVE, () => { }); const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `${adminUrl}/_api/GroupSiteManager/Delete?siteUrl='${formatting.encodeQueryParameter(siteUrl)}'`) { + if (opts.url === `${spoAdminUrl}/_api/GroupSiteManager/Delete?siteUrl='${formatting.encodeQueryParameter(siteUrl)}'`) { return; } - if (opts.url === `${adminUrl}/_api/Microsoft.Online.SharePoint.TenantAdministration.Tenant/RemoveDeletedSite`) { + if (opts.url === `${spoAdminUrl}/_api/Microsoft.Online.SharePoint.TenantAdministration.Tenant/RemoveDeletedSite`) { return; } throw 'Invalid request'; @@ -244,15 +265,20 @@ describe(commands.SITE_REMOVE, () => { }); it('deletes a group site from recycle bin, removes the m365 group from entra id recycle bin', async () => { - sinon.stub(odata, 'getAllItems').callsFake(async (url) => { - if (url === odataUrl) { - return [{ ...siteDetailsGroup, TimeDeleted: new Date().toISOString() }]; + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === spoAdminUrl) { + if (options.listTitle === adminSitesListTitle && + options.filter === `SiteUrl eq '${formatting.encodeQueryParameter(siteUrl)}'` + ) { + return [{ ...siteDetailsGroup, TimeDeleted: new Date().toISOString() }] as any; + } } - throw 'Invalid request'; + + throw 'Invalid request: ' + JSON.stringify(options); }); sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `${adminUrl}/_api/Microsoft.Online.SharePoint.TenantAdministration.Tenant/RemoveDeletedSite`) { + if (opts.url === `${spoAdminUrl}/_api/Microsoft.Online.SharePoint.TenantAdministration.Tenant/RemoveDeletedSite`) { return; } throw 'Invalid request'; @@ -280,15 +306,20 @@ describe(commands.SITE_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); - sinon.stub(odata, 'getAllItems').callsFake(async (url) => { - if (url === odataUrl) { - return [{ ...siteDetailsGroup, TimeDeleted: new Date().toISOString() }]; + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === spoAdminUrl) { + if (options.listTitle === adminSitesListTitle && + options.filter === `SiteUrl eq '${formatting.encodeQueryParameter(siteUrl)}'` + ) { + return [{ ...siteDetailsGroup, TimeDeleted: new Date().toISOString() }] as any; + } } - throw 'Invalid request'; + + throw 'Invalid request: ' + JSON.stringify(options); }); const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `${adminUrl}/_api/Microsoft.Online.SharePoint.TenantAdministration.Tenant/RemoveDeletedSite`) { + if (opts.url === `${spoAdminUrl}/_api/Microsoft.Online.SharePoint.TenantAdministration.Tenant/RemoveDeletedSite`) { return; } throw 'Invalid request'; @@ -321,8 +352,17 @@ describe(commands.SITE_REMOVE, () => { it('throws error if the endpoint fails when retrieving the deleted group', async () => { const errorMessage = 'Error occurred on processing the request.'; - sinon.stub(odata, 'getAllItems').resolves([{ ...siteDetailsGroup, TimeDeleted: new Date().toISOString() }]); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === spoAdminUrl) { + if (options.listTitle === adminSitesListTitle && + options.filter === `SiteUrl eq '${formatting.encodeQueryParameter(siteUrl)}'` + ) { + return [{ ...siteDetailsGroup, TimeDeleted: new Date().toISOString() }] as any; + } + } + throw 'Invalid request: ' + JSON.stringify(options); + }); sinon.stub(request, 'get').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/directory/deletedItems/Microsoft.Graph.Group/${siteDetailsGroup.GroupId}?$select=id`) { throw { @@ -341,21 +381,36 @@ describe(commands.SITE_REMOVE, () => { }); it('throws error if site has already been deleted when trying to remove it', async () => { - sinon.stub(odata, 'getAllItems').resolves([{ ...siteDetailsNonGroup, TimeDeleted: new Date().toISOString() }]); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === spoAdminUrl) { + if (options.listTitle === adminSitesListTitle && + options.filter === `SiteUrl eq '${formatting.encodeQueryParameter(siteUrl)}'` + ) { + return [{ ...siteDetailsNonGroup, TimeDeleted: new Date().toISOString() }] as any; + } + } + + throw 'Invalid request: ' + JSON.stringify(options); + }); await assert.rejects(command.action(logger, { options: { url: siteUrl, verbose: true, force: true } }), new CommandError('Site is already in the recycle bin. Use --fromRecycleBin to permanently delete it.')); }); it('throws an error when attempting to delete a site that is not present in the recycle bin', async () => { - sinon.stub(odata, 'getAllItems').callsFake(async (url) => { - if (url === odataUrl) { - return [siteDetailsNonGroup]; + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === spoAdminUrl) { + if (options.listTitle === adminSitesListTitle && + options.filter === `SiteUrl eq '${formatting.encodeQueryParameter(siteUrl)}'` + ) { + return [siteDetailsNonGroup] as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); + await assert.rejects(command.action(logger, { options: { url: siteUrl, fromRecycleBin: true, verbose: true, force: true } }), new CommandError('Site is currently not in the recycle bin. Remove --fromRecycleBin if you want to remove it as active site.')); }); diff --git a/src/m365/spo/commands/site/site-remove.ts b/src/m365/spo/commands/site/site-remove.ts index 39bcf717d5b..b6961376d59 100644 --- a/src/m365/spo/commands/site/site-remove.ts +++ b/src/m365/spo/commands/site/site-remove.ts @@ -6,10 +6,10 @@ import { formatting } from '../../../../utils/formatting.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; -import { odata } from '../../../../utils/odata.js'; import { spo } from '../../../../utils/spo.js'; import { setTimeout } from 'timers/promises'; import { urlUtil } from '../../../../utils/urlUtil.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; interface CommandArgs { options: Options; @@ -264,12 +264,20 @@ class SpoSiteRemoveCommand extends SpoCommand { await logger.logToStderr(`Retrieving site info.`); } - const sites = await odata.getAllItems(`${this.spoAdminUrl}/_api/web/lists/GetByTitle('DO_NOT_DELETE_SPLIST_TENANTADMIN_AGGREGATED_SITECOLLECTIONS')/items?$filter=SiteUrl eq '${formatting.encodeQueryParameter(url)}'&$select=GroupId,TimeDeleted,SiteId`); + const options: ListItemListOptions = { + webUrl: this.spoAdminUrl!, + listTitle: 'DO_NOT_DELETE_SPLIST_TENANTADMIN_AGGREGATED_SITECOLLECTIONS', + filter: `SiteUrl eq '${formatting.encodeQueryParameter(url)}'`, + fields: ['GroupId', 'TimeDeleted', 'SiteId'] + }; + + const listItems = await spoListItem.getListItems(options, logger, this.verbose); - if (sites.length === 0) { + if (listItems.length === 0) { throw `Site not found in the tenant.`; } - return sites[0]; + + return listItems[0] as any; } private async deleteGroupifiedSite(logger: Logger, siteUrl: string): Promise { diff --git a/src/m365/spo/commands/tenant/tenant-applicationcustomizer-add.spec.ts b/src/m365/spo/commands/tenant/tenant-applicationcustomizer-add.spec.ts index a6147546388..ca3ec88da44 100644 --- a/src/m365/spo/commands/tenant/tenant-applicationcustomizer-add.spec.ts +++ b/src/m365/spo/commands/tenant/tenant-applicationcustomizer-add.spec.ts @@ -9,12 +9,10 @@ import { CommandError } from '../../../../Command.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; -import { urlUtil } from '../../../../utils/urlUtil.js'; import commands from '../../commands.js'; -import spoListItemAddCommand from '../listitem/listitem-add.js'; -import spoListItemListCommand from '../listitem/listitem-list.js'; -import spoTenantAppCatalogUrlGetCommand from '../tenant/tenant-appcatalogurl-get.js'; import command from './tenant-applicationcustomizer-add.js'; +import { spo } from '../../../../utils/spo.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; describe(commands.TENANT_APPLICATIONCUSTOMIZER_ADD, () => { const clientSideComponentId = '9748c81b-d72e-4048-886a-e98649543743'; @@ -29,6 +27,19 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_ADD, () => { const solutionResponse = [solution]; const application = { "FileSystemObjectType": 0, "Id": 31, "ServerRedirectedEmbedUri": null, "ServerRedirectedEmbedUrl": "", "SkipFeatureDeployment": true, "ContainsTenantWideExtension": true, "Modified": "2022-11-03T11:26:03", "CheckoutUserId": null, "EditorId": 9 }; const applicationResponse = [application]; + const listItemResponse = { + Attachments: false, + AuthorId: 3, + ContentTypeId: '0x0100B21BD271A810EE488B570BE49963EA34', + Created: new Date('2018-03-15T10:43:10Z'), + EditorId: 3, + GUID: 'ea093c7b-8ae6-4400-8b75-e2d01154dffc', + Id: 0, + ID: 0, + Modified: new Date('2018-03-15T10:43:10Z'), + Title: 'listTitle', + RoleAssignments: [] + }; let log: string[]; let logger: Logger; @@ -39,6 +50,12 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_ADD, () => { sinon.stub(telemetry, 'trackEvent').resolves(); sinon.stub(pid, 'getProcessName').returns(''); sinon.stub(session, 'getId').returns(''); + sinon.stub(spo, 'getRequestDigest').callsFake(() => Promise.resolve({ + FormDigestValue: 'abc', + FormDigestTimeoutSeconds: 1800, + FormDigestExpiresAt: new Date(), + WebFullUrl: 'https://contoso.sharepoint.com' + })); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); }); @@ -60,8 +77,9 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_ADD, () => { afterEach(() => { sinonUtil.restore([ - cli.executeCommand, - cli.executeCommandWithOutput + spo.getTenantAppCatalogUrl, + spoListItem.addListItem, + spoListItem.getListItems ]); }); @@ -80,27 +98,24 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_ADD, () => { it('adds a tenant-wide application customizer', async () => { let executeCommandCalled = false; - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { - return { 'stdout': JSON.stringify(solutionResponse) }; + + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return solutionResponse as any[]; } - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/AppCatalog`) { - return { 'stdout': JSON.stringify(applicationResponse) }; + else if (options.listUrl === '/sites/apps/AppCatalog') { + return applicationResponse as any[]; } } - if (command === spoTenantAppCatalogUrlGetCommand) { - return { 'stdout': appCatalogUrl }; - } + throw 'Invalid request'; }); - sinon.stub(cli, 'executeCommand').callsFake(async (command): Promise => { - if (command === spoListItemAddCommand) { - executeCommandCalled = true; - return; - } - throw 'Invalid request'; + sinon.stub(spoListItem, 'addListItem').callsFake(async () => { + executeCommandCalled = true; + return listItemResponse; }); await command.action(logger, { options: { clientSideComponentId: clientSideComponentId, title: customizerTitle, verbose: true } }); @@ -109,27 +124,24 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_ADD, () => { it('adds a tenant-wide application customizer to a specific webtemplate including clientSideComponentProperties', async () => { let executeCommandCalled = false; - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { - return { 'stdout': JSON.stringify(solutionResponse) }; + + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return solutionResponse as any[]; } - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/AppCatalog`) { - return { 'stdout': JSON.stringify(applicationResponse) }; + else if (options.listUrl === '/sites/apps/AppCatalog') { + return applicationResponse as any[]; } } - if (command === spoTenantAppCatalogUrlGetCommand) { - return { 'stdout': appCatalogUrl }; - } + throw 'Invalid request'; }); - sinon.stub(cli, 'executeCommand').callsFake(async (command): Promise => { - if (command === spoListItemAddCommand) { - executeCommandCalled = true; - return; - } - throw 'Invalid request'; + sinon.stub(spoListItem, 'addListItem').callsFake(async () => { + executeCommandCalled = true; + return listItemResponse; }); await command.action(logger, { options: { clientSideComponentId: clientSideComponentId, title: customizerTitle, webTemplate: webTemplate, clientSideComponentProperties: clientSideComponentProperties, verbose: true } }); @@ -137,27 +149,21 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_ADD, () => { }); it('throws an error when no app catalog is found', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command): Promise => { - if (command === spoTenantAppCatalogUrlGetCommand) { - return { 'stdout': null }; - } - throw 'Invalid request'; - }); + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(null); await assert.rejects(command.action(logger, { options: { title: customizerTitle, clientSideComponentId: clientSideComponentId, verbose: true } }), new CommandError('Cannot add tenant-wide application customizer as app catalog cannot be found')); }); it('throws an error when specific client side component is not found in manifest list', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { - return { 'stdout': JSON.stringify([]) }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return []; } } - if (command === spoTenantAppCatalogUrlGetCommand) { - return { 'stdout': appCatalogUrl }; - } + throw 'Invalid request'; }); @@ -166,18 +172,20 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_ADD, () => { }); it('throws an error when the manifest of a specific client side component is not of type application customizer', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { const faultyClientComponentManifest = "{\"id\":\"6b2a54c5-3317-49eb-8621-1bbb76263629\",\"alias\":\"HelloWorldApplicationCustomizer\",\"componentType\":\"Extension\",\"extensionType\":\"FormCustomizer\",\"version\":\"0.0.1\",\"manifestVersion\":2,\"loaderConfig\":{\"internalModuleBaseUrls\":[\"HTTPS://SPCLIENTSIDEASSETLIBRARY/\"],\"entryModuleId\":\"hello-world-application-customizer\",\"scriptResources\":{\"hello-world-application-customizer\":{\"type\":\"path\",\"path\":\"hello-world-application-customizer_b47769f9eca3d3b6c4d5.js\"},\"HelloWorldApplicationCustomizerStrings\":{\"type\":\"path\",\"path\":\"HelloWorldApplicationCustomizerStrings_en-us_72ca11838ac9bae2790a8692c260e1ac.js\"},\"@microsoft/sp-application-base\":{\"type\":\"component\",\"id\":\"4df9bb86-ab0a-4aab-ab5f-48bf167048fb\",\"version\":\"1.15.2\"},\"@microsoft/sp-core-library\":{\"type\":\"component\",\"id\":\"7263c7d0-1d6a-45ec-8d85-d4d1d234171b\",\"version\":\"1.15.2\"}}},\"mpnId\":\"Undefined-1.15.2\",\"clientComponentDeveloper\":\"\"}"; const solutionDuplicate = { ...solution }; solutionDuplicate.ClientComponentManifest = faultyClientComponentManifest; - return { 'stdout': JSON.stringify([solutionDuplicate]) }; + return [solutionDuplicate]; + } + else if (options.listUrl === '/sites/apps/AppCatalog') { + return applicationResponse as any[]; } } - if (command === spoTenantAppCatalogUrlGetCommand) { - return { 'stdout': appCatalogUrl }; - } + throw 'Invalid request'; }); @@ -186,18 +194,17 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_ADD, () => { }); it('throws an error when solution is not found in app catalog', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { - return { 'stdout': JSON.stringify(solutionResponse) }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return solutionResponse as any[]; } - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/AppCatalog`) { - return { 'stdout': JSON.stringify([]) }; + else if (options.listUrl === '/sites/apps/AppCatalog') { + return []; } } - if (command === spoTenantAppCatalogUrlGetCommand) { - return { 'stdout': appCatalogUrl }; - } + throw 'Invalid request'; }); @@ -206,20 +213,19 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_ADD, () => { }); it('throws an error when solution does not contain extension that can be deployed tenant-wide', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { - return { 'stdout': JSON.stringify(solutionResponse) }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return solutionResponse as any[]; } - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/AppCatalog`) { + else if (options.listUrl === '/sites/apps/AppCatalog') { const faultyApplication = { ...application }; faultyApplication.ContainsTenantWideExtension = false; - return { 'stdout': JSON.stringify([faultyApplication]) }; + return [faultyApplication]; } } - if (command === spoTenantAppCatalogUrlGetCommand) { - return { 'stdout': appCatalogUrl }; - } + throw 'Invalid request'; }); @@ -228,20 +234,19 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_ADD, () => { }); it('throws an error when solution is not deployed globally', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { - return { 'stdout': JSON.stringify(solutionResponse) }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return solutionResponse as any[]; } - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/AppCatalog`) { + else if (options.listUrl === '/sites/apps/AppCatalog') { const faultyApplication = { ...application }; faultyApplication.SkipFeatureDeployment = false; - return { 'stdout': JSON.stringify([faultyApplication]) }; + return [faultyApplication]; } } - if (command === spoTenantAppCatalogUrlGetCommand) { - return { 'stdout': appCatalogUrl }; - } + throw 'Invalid request'; }); diff --git a/src/m365/spo/commands/tenant/tenant-applicationcustomizer-add.ts b/src/m365/spo/commands/tenant/tenant-applicationcustomizer-add.ts index 1e901a884ec..ce9d6d323e1 100644 --- a/src/m365/spo/commands/tenant/tenant-applicationcustomizer-add.ts +++ b/src/m365/spo/commands/tenant/tenant-applicationcustomizer-add.ts @@ -1,14 +1,11 @@ -import { cli, CommandOutput } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; -import Command from '../../../../Command.js'; import GlobalOptions from '../../../../GlobalOptions.js'; +import { spo } from '../../../../utils/spo.js'; +import { ListItemAddOptions, ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; -import spoListItemAddCommand, { Options as spoListItemAddCommandOptions } from '../listitem/listitem-add.js'; -import spoListItemListCommand, { Options as spoListItemListCommandOptions } from '../listitem/listitem-list.js'; -import spoTenantAppCatalogUrlGetCommand from '../tenant/tenant-appcatalogurl-get.js'; import { Solution } from './Solution.js'; interface CommandArgs { @@ -104,12 +101,8 @@ class SpoTenantApplicationCustomizerAddCommand extends SpoCommand { } private async getAppCatalogUrl(logger: Logger): Promise { - const spoTenantAppCatalogUrlGetCommandOutput: CommandOutput = await cli.executeCommandWithOutput(spoTenantAppCatalogUrlGetCommand as Command, { options: { output: 'text', _: [] } }); - if (this.verbose) { - await logger.logToStderr(spoTenantAppCatalogUrlGetCommandOutput.stderr); - } + const appCatalogUrl: string | null = await spo.getTenantAppCatalogUrl(logger, this.verbose); - const appCatalogUrl: string | undefined = spoTenantAppCatalogUrlGetCommandOutput.stdout; if (!appCatalogUrl) { throw 'Cannot add tenant-wide application customizer as app catalog cannot be found'; } @@ -125,27 +118,19 @@ class SpoTenantApplicationCustomizerAddCommand extends SpoCommand { await logger.logToStderr('Retrieving component manifest item from the ComponentManifests list on the app catalog site so that we get the solution id'); } - const camlQuery = `${clientSideComponentId}`; - const commandOptions: spoListItemListCommandOptions = { + const options: ListItemListOptions = { webUrl: appCatalogUrl, listUrl: `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`, - camlQuery: camlQuery, - verbose: this.verbose, - debug: this.debug, - output: 'json' + camlQuery: `${clientSideComponentId}` }; - const output = await cli.executeCommandWithOutput(spoListItemListCommand as Command, { options: { ...commandOptions, _: [] } }); - if (this.verbose) { - await logger.logToStderr(output.stderr); - } + const output = await spoListItem.getListItems(options, logger, this.verbose); - const outputParsed = JSON.parse(output.stdout); - if (outputParsed.length === 0) { + if (output.length === 0) { throw 'No component found with the specified clientSideComponentId found in the component manifest list. Make sure that the application is added to the application catalog'; } - return outputParsed[0]; + return output[0]; } private async getSolutionFromAppCatalog(appCatalogUrl: string, solutionId: string, logger: Logger): Promise { @@ -153,27 +138,19 @@ class SpoTenantApplicationCustomizerAddCommand extends SpoCommand { await logger.logToStderr(`Retrieving solution with id ${solutionId} from the application catalog`); } - const camlQuery = `${solutionId}`; - const commandOptions: spoListItemListCommandOptions = { + const options: ListItemListOptions = { webUrl: appCatalogUrl, listUrl: `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/AppCatalog`, - camlQuery: camlQuery, - verbose: this.verbose, - debug: this.debug, - output: 'json' + camlQuery: `${solutionId}` }; - const output = await cli.executeCommandWithOutput(spoListItemListCommand as Command, { options: { ...commandOptions, _: [] } }); - if (this.verbose) { - await logger.logToStderr(output.stderr); - } + const output = await spoListItem.getListItems(options, logger, this.verbose) as any[]; - const outputParsed = JSON.parse(output.stdout); - if (outputParsed.length === 0) { + if (output.length === 0) { throw `No component found with the solution id ${solutionId}. Make sure that the solution is available in the app catalog`; } - return outputParsed[0]; + return output[0]; } private async addTenantWideExtension(appCatalogUrl: string, options: Options, logger: Logger): Promise { @@ -181,23 +158,22 @@ class SpoTenantApplicationCustomizerAddCommand extends SpoCommand { await logger.logToStderr('Pre-checks finished. Adding tenant wide extension to the TenantWideExtensions list'); } - const commandOptions: spoListItemAddCommandOptions = { + const listItemAddOptions: ListItemAddOptions = { webUrl: appCatalogUrl, listUrl: `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/TenantWideExtensions`, - Title: options.title, - TenantWideExtensionComponentId: options.clientSideComponentId, - TenantWideExtensionLocation: 'ClientSideExtension.ApplicationCustomizer', - TenantWideExtensionSequence: 0, - TenantWideExtensionListTemplate: 0, - TenantWideExtensionComponentProperties: options.clientSideComponentProperties || '', - TenantWideExtensionWebTemplate: options.webTemplate || '', - TenantWideExtensionDisabled: false, - verbose: this.verbose, - debug: this.debug, - output: options.output + fieldValues: { + Title: options.title, + TenantWideExtensionComponentId: options.clientSideComponentId, + TenantWideExtensionLocation: 'ClientSideExtension.ApplicationCustomizer', + TenantWideExtensionSequence: 0, + TenantWideExtensionListTemplate: 0, + TenantWideExtensionComponentProperties: options.clientSideComponentProperties || '', + TenantWideExtensionWebTemplate: options.webTemplate || '', + TenantWideExtensionDisabled: false + } }; - await cli.executeCommand(spoListItemAddCommand as Command, { options: { ...commandOptions, _: [] } }); + await spoListItem.addListItem(listItemAddOptions, logger, this.verbose, this.debug); } } diff --git a/src/m365/spo/commands/tenant/tenant-applicationcustomizer-get.spec.ts b/src/m365/spo/commands/tenant/tenant-applicationcustomizer-get.spec.ts index 2819d4f47fe..ad1378c441a 100644 --- a/src/m365/spo/commands/tenant/tenant-applicationcustomizer-get.spec.ts +++ b/src/m365/spo/commands/tenant/tenant-applicationcustomizer-get.spec.ts @@ -5,7 +5,6 @@ import { CommandError } from '../../../../Command.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; -import request from '../../../../request.js'; import { telemetry } from '../../../../telemetry.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; @@ -13,6 +12,8 @@ import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './tenant-applicationcustomizer-get.js'; import { settingsNames } from '../../../../settingsNames.js'; +import { spo } from '../../../../utils/spo.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; describe(commands.TENANT_APPLICATIONCUSTOMIZER_GET, () => { const title = 'Some customizer'; @@ -20,33 +21,30 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_GET, () => { const clientSideComponentId = '7096cded-b83d-4eab-96f0-df477ed7c0bc'; const spoUrl = 'https://contoso.sharepoint.com'; const appCatalogUrl = 'https://contoso.sharepoint.com/sites/apps'; - const applicationCustomizerResponse = { - value: - [{ - "FileSystemObjectType": 0, - "ID": 4, - "ServerRedirectedEmbedUri": null, - "ServerRedirectedEmbedUrl": "", - "ContentTypeId": "0x00693E2C487575B448BD420C12CEAE7EFE", - "Title": title, - "Modified": "2023-01-11T15:47:38Z", - "Created": "2023-01-11T15:47:38Z", - "AuthorId": 9, - "EditorId": 9, - "OData__UIVersionString": "1.0", - "Attachments": false, - "GUID": id, - "ComplianceAssetId": null, - "TenantWideExtensionComponentId": clientSideComponentId, - "TenantWideExtensionComponentProperties": "{\"testMessage\":\"Test message\"}", - "TenantWideExtensionWebTemplate": null, - "TenantWideExtensionListTemplate": 0, - "TenantWideExtensionLocation": "ClientSideExtension.ApplicationCustomizer", - "TenantWideExtensionSequence": 0, - "TenantWideExtensionHostProperties": null, - "TenantWideExtensionDisabled": false - }] - }; + const applicationCustomizerResponse = [{ + "FileSystemObjectType": 0, + "Id": 4, + "ServerRedirectedEmbedUri": null, + "ServerRedirectedEmbedUrl": "", + "ContentTypeId": "0x00693E2C487575B448BD420C12CEAE7EFE", + "Title": title, + "Modified": "2023-01-11T15:47:38Z", + "Created": "2023-01-11T15:47:38Z", + "AuthorId": 9, + "EditorId": 9, + "OData__UIVersionString": "1.0", + "Attachments": false, + "GUID": id, + "ComplianceAssetId": null, + "TenantWideExtensionComponentId": clientSideComponentId, + "TenantWideExtensionComponentProperties": "{\"testMessage\":\"Test message\"}", + "TenantWideExtensionWebTemplate": null, + "TenantWideExtensionListTemplate": 0, + "TenantWideExtensionLocation": "ClientSideExtension.ApplicationCustomizer", + "TenantWideExtensionSequence": 0, + "TenantWideExtensionHostProperties": null, + "TenantWideExtensionDisabled": false + }]; let log: any[]; let logger: Logger; @@ -81,7 +79,8 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_GET, () => { afterEach(() => { sinonUtil.restore([ - request.get, + spo.getTenantAppCatalogUrl, + spoListItem.getListItems, cli.getSettingWithDefaultValue, cli.handleMultipleResultsFound ]); @@ -217,14 +216,7 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_GET, () => { it('throws error when tenant app catalog doesn\'t exist', async () => { const errorMessage = 'No app catalog URL found'; - - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: null }; - } - - throw 'Invalid request'; - }); + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(null); await assert.rejects(command.action(logger, { options: { @@ -236,12 +228,8 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_GET, () => { it('throws error when retrieving a tenant app catalog fails with an exception', async () => { const errorMessage = 'Couldn\'t retrieve tenant app catalog URL'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - throw errorMessage; - } - - throw 'Invalid request'; + sinon.stub(spo, 'getTenantAppCatalogUrl').callsFake(() => { + throw errorMessage; }); await assert.rejects(command.action(logger, { @@ -252,16 +240,17 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_GET, () => { }); it('retrieves an application customizer by title', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'`) { - return applicationCustomizerResponse; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'` + ) { + return applicationCustomizerResponse as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await command.action(logger, { @@ -269,7 +258,7 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_GET, () => { title: title } }); - assert(loggerLogSpy.calledOnceWithExactly(applicationCustomizerResponse.value[0])); + assert(loggerLogSpy.calledOnceWithExactly(applicationCustomizerResponse[0])); }); it('handles error when multiple application customizers with the specified title found', async () => { @@ -281,22 +270,20 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_GET, () => { return defaultValue; }); - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'`) { - return { - value: - [ - { Title: title, GUID: '14125658-a9bc-4ddf-9c75-1b5767c9a337', TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bc' }, - { Title: title, GUID: '14125658-a9bc-4ddf-9c75-1b5767c9a338', TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bd' } - ] - }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'` + ) { + return [ + { Title: title, GUID: '14125658-a9bc-4ddf-9c75-1b5767c9a337', TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bc' }, + { Title: title, GUID: '14125658-a9bc-4ddf-9c75-1b5767c9a338', TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bd' } + ] as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { @@ -307,45 +294,44 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_GET, () => { }); it('handles selecting single result when multiple application customizers with the specified name found and cli is set to prompt', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'`) { - return { - value: - [ - { Title: title, GUID: '14125658-a9bc-4ddf-9c75-1b5767c9a337', TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bc' }, - { Title: title, GUID: '14125658-a9bc-4ddf-9c75-1b5767c9a338', TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bd' } - ] - }; - } - - throw 'Invalid request'; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'` + ) { + return [ + applicationCustomizerResponse[0], + applicationCustomizerResponse[0] + ] as any; + } + } + + throw 'Invalid request: ' + JSON.stringify(options); }); - sinon.stub(cli, 'handleMultipleResultsFound').resolves(applicationCustomizerResponse.value[0]); + sinon.stub(cli, 'handleMultipleResultsFound').resolves(applicationCustomizerResponse[0]); await command.action(logger, { options: { title: title } }); - assert(loggerLogSpy.calledOnceWithExactly(applicationCustomizerResponse.value[0])); + assert(loggerLogSpy.calledOnceWithExactly(applicationCustomizerResponse[0])); }); it('retrieves an application customizer by id', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Id eq 4` + ) { + return applicationCustomizerResponse as any; + } } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Id eq '4'`) { - return applicationCustomizerResponse; - } - - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await command.action(logger, { @@ -353,20 +339,21 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_GET, () => { id: id } }); - assert(loggerLogSpy.calledOnceWithExactly(applicationCustomizerResponse.value[0])); + assert(loggerLogSpy.calledOnceWithExactly(applicationCustomizerResponse[0])); }); it('retrieves an application customizer by clientSideComponentId', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'`) { - return applicationCustomizerResponse; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'` + ) { + return applicationCustomizerResponse as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await command.action(logger, { @@ -374,20 +361,21 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_GET, () => { clientSideComponentId: clientSideComponentId } }); - assert(loggerLogSpy.calledOnceWithExactly(applicationCustomizerResponse.value[0])); + assert(loggerLogSpy.calledOnceWithExactly(applicationCustomizerResponse[0])); }); it('retrieves an application customizer component properties', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Id eq 4` + ) { + return applicationCustomizerResponse as any; + } } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Id eq '4'`) { - return applicationCustomizerResponse; - } - - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await command.action(logger, { @@ -396,7 +384,7 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_GET, () => { tenantWideExtensionComponentProperties: true } }); - assert(loggerLogSpy.calledOnceWithExactly(JSON.parse(applicationCustomizerResponse.value[0].TenantWideExtensionComponentProperties))); + assert(loggerLogSpy.calledOnceWithExactly(JSON.parse(applicationCustomizerResponse[0].TenantWideExtensionComponentProperties))); }); it('handles error when multiple application customizers with the clientSideComponentId found', async () => { @@ -408,22 +396,20 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_GET, () => { return defaultValue; }); - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'`) { - return { - value: - [ - { Title: title, GUID: '14125658-a9bc-4ddf-9c75-1b5767c9a337', TenantWideExtensionComponentId: clientSideComponentId }, - { Title: 'Another customizer', GUID: '14125658-a9bc-4ddf-9c75-1b5767c9a338', TenantWideExtensionComponentId: clientSideComponentId } - ] - }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'` + ) { + return [ + { Title: title, GUID: '14125658-a9bc-4ddf-9c75-1b5767c9a337', TenantWideExtensionComponentId: clientSideComponentId }, + { Title: 'Another customizer', GUID: '14125658-a9bc-4ddf-9c75-1b5767c9a338', TenantWideExtensionComponentId: clientSideComponentId } + ] as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { @@ -435,16 +421,17 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_GET, () => { it('handles error when specified application customizer not found', async () => { const errorMessage = 'The specified application customizer was not found'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'`) { - return { value: [] }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'` + ) { + return []; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { @@ -456,16 +443,17 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_GET, () => { it('handles error when listItemInstances are falsy', async () => { const errorMessage = 'The specified application customizer was not found'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'` + ) { + return undefined as any; + } } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'`) { - return { value: undefined }; - } - - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { @@ -478,16 +466,17 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_GET, () => { it('handles error when retrieving application customizer', async () => { const errorMessage = 'An error has occurred'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'`) { - throw errorMessage; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'` + ) { + throw errorMessage; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { diff --git a/src/m365/spo/commands/tenant/tenant-applicationcustomizer-get.ts b/src/m365/spo/commands/tenant/tenant-applicationcustomizer-get.ts index 6b0c74a862b..7d7a6b9f3f3 100644 --- a/src/m365/spo/commands/tenant/tenant-applicationcustomizer-get.ts +++ b/src/m365/spo/commands/tenant/tenant-applicationcustomizer-get.ts @@ -1,14 +1,13 @@ import { Logger } from '../../../../cli/Logger.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import { formatting } from '../../../../utils/formatting.js'; -import { odata } from '../../../../utils/odata.js'; import { spo } from '../../../../utils/spo.js'; -import { urlUtil } from '../../../../utils/urlUtil.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; import { cli } from '../../../../cli/cli.js'; import { ListItemInstance } from '../listitem/ListItemInstance.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; interface CommandArgs { options: Options; @@ -106,29 +105,32 @@ class SpoTenantApplicationCustomizerGetCommand extends SpoCommand { filter = `Title eq '${args.options.title}'`; } else if (args.options.id) { - filter = `Id eq '${args.options.id}'`; + filter = `Id eq ${args.options.id}`; } else { filter = `TenantWideExtensionComponentId eq '${args.options.clientSideComponentId}'`; } - const listServerRelativeUrl: string = urlUtil.getServerRelativePath(appCatalogUrl, '/lists/TenantWideExtensions'); - const listItemInstances = await odata.getAllItems(`${appCatalogUrl}/_api/web/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and ${filter}`); + const options: ListItemListOptions = { + webUrl: appCatalogUrl, + listUrl: '/Lists/TenantWideExtensions', + filter: `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and ${filter}` + }; - if (listItemInstances) { - if (listItemInstances.length === 0) { + const listItems = await spoListItem.getListItems(options, logger, this.verbose); + + if (listItems) { + if (listItems.length === 0) { throw 'The specified application customizer was not found'; } - listItemInstances.forEach(v => delete (v as any)['ID']); - let listItemInstance: ListItemInstance; - if (listItemInstances.length > 1) { - const resultAsKeyValuePair = formatting.convertArrayToHashTable('Id', listItemInstances); + if (listItems.length > 1) { + const resultAsKeyValuePair = formatting.convertArrayToHashTable('Id', listItems); listItemInstance = await cli.handleMultipleResultsFound(`Multiple application customizers with ${args.options.title || args.options.clientSideComponentId} were found.`, resultAsKeyValuePair); } else { - listItemInstance = listItemInstances[0]; + listItemInstance = listItems[0]; } if (!args.options.tenantWideExtensionComponentProperties) { diff --git a/src/m365/spo/commands/tenant/tenant-applicationcustomizer-list.spec.ts b/src/m365/spo/commands/tenant/tenant-applicationcustomizer-list.spec.ts index 5308a02b133..58b9b877112 100644 --- a/src/m365/spo/commands/tenant/tenant-applicationcustomizer-list.spec.ts +++ b/src/m365/spo/commands/tenant/tenant-applicationcustomizer-list.spec.ts @@ -3,13 +3,14 @@ import sinon from 'sinon'; import auth from '../../../../Auth.js'; import { CommandError } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; -import request from '../../../../request.js'; import { telemetry } from '../../../../telemetry.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './tenant-applicationcustomizer-list.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; +import { spo } from '../../../../utils/spo.js'; describe(commands.TENANT_APPLICATIONCUSTOMIZER_LIST, () => { const spoUrl = 'https://contoso.sharepoint.com'; @@ -40,12 +41,9 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_LIST, () => { "TenantWideExtensionDisabled": false }; - const applicationCustomizerResponse = { - value: - [ - { ...applicationCustomizer, "ID": 8 } - ] - }; + const applicationCustomizerList = [ + { ...applicationCustomizer } + ]; let log: any[]; let logger: Logger; @@ -78,7 +76,8 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_LIST, () => { afterEach(() => { sinonUtil.restore([ - request.get + spo.getTenantAppCatalogUrl, + spoListItem.getListItems ]); }); @@ -103,28 +102,23 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_LIST, () => { it('throws error when tenant app catalog doesn\'t exist', async () => { const errorMessage = 'No app catalog URL found'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: null }; - } - - throw 'Invalid request'; - }); + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(null); await assert.rejects(command.action(logger, { options: {} }), new CommandError(errorMessage)); }); it('retrieves application customizers that are installed tenant wide', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer'` + ) { + return applicationCustomizerList as any; + } } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer'`) { - return applicationCustomizerResponse; - } - - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await command.action(logger, { options: {} }); @@ -134,16 +128,17 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_LIST, () => { it('handles error when retrieving tenant wide installed application customizers', async () => { const errorMessage = 'An error has occurred'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer'`) { - throw errorMessage; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer'` + ) { + throw errorMessage; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { options: {} }), new CommandError(errorMessage)); diff --git a/src/m365/spo/commands/tenant/tenant-applicationcustomizer-list.ts b/src/m365/spo/commands/tenant/tenant-applicationcustomizer-list.ts index 13472860b3a..4f3bc15439d 100644 --- a/src/m365/spo/commands/tenant/tenant-applicationcustomizer-list.ts +++ b/src/m365/spo/commands/tenant/tenant-applicationcustomizer-list.ts @@ -1,12 +1,9 @@ import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; -import { formatting } from '../../../../utils/formatting.js'; -import { odata } from '../../../../utils/odata.js'; import { spo } from '../../../../utils/spo.js'; -import { urlUtil } from '../../../../utils/urlUtil.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; -import { ListItemInstance } from '../listitem/ListItemInstance.js'; class SpoTenantApplicationCustomizerListCommand extends SpoCommand { public get name(): string { @@ -28,11 +25,14 @@ class SpoTenantApplicationCustomizerListCommand extends SpoCommand { throw new CommandError('No app catalog URL found'); } - const listServerRelativeUrl: string = urlUtil.getServerRelativePath(appCatalogUrl, '/lists/TenantWideExtensions'); - try { - const listItems = await odata.getAllItems(`${appCatalogUrl}/_api/web/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer'`); - listItems.forEach(i => delete i.ID); + const options: ListItemListOptions = { + webUrl: appCatalogUrl, + listUrl: '/Lists/TenantWideExtensions', + filter: `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer'` + }; + + const listItems = await spoListItem.getListItems(options, logger, this.verbose); await logger.log(listItems); } diff --git a/src/m365/spo/commands/tenant/tenant-applicationcustomizer-remove.spec.ts b/src/m365/spo/commands/tenant/tenant-applicationcustomizer-remove.spec.ts index 8fc84585814..d3632ed729e 100644 --- a/src/m365/spo/commands/tenant/tenant-applicationcustomizer-remove.spec.ts +++ b/src/m365/spo/commands/tenant/tenant-applicationcustomizer-remove.spec.ts @@ -13,6 +13,8 @@ import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './tenant-applicationcustomizer-remove.js'; import { settingsNames } from '../../../../settingsNames.js'; +import { spo } from '../../../../utils/spo.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; describe(commands.TENANT_APPLICATIONCUSTOMIZER_REMOVE, () => { const title = 'Some customizer'; @@ -20,34 +22,31 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_REMOVE, () => { const clientSideComponentId = '7096cded-b83d-4eab-96f0-df477ed7c0bc'; const spoUrl = 'https://contoso.sharepoint.com'; const appCatalogUrl = 'https://contoso.sharepoint.com/sites/apps'; - const applicationCustomizerResponse = { - value: - [{ - "FileSystemObjectType": 0, - "ID": 4, - "Id": 4, - "ServerRedirectedEmbedUri": null, - "ServerRedirectedEmbedUrl": "", - "ContentTypeId": "0x00693E2C487575B448BD420C12CEAE7EFE", - "Title": title, - "Modified": "2023-01-11T15:47:38Z", - "Created": "2023-01-11T15:47:38Z", - "AuthorId": 9, - "EditorId": 9, - "OData__UIVersionString": "1.0", - "Attachments": false, - "GUID": '14125658-a9bc-4ddf-9c75-1b5767c9a337', - "ComplianceAssetId": null, - "TenantWideExtensionComponentId": clientSideComponentId, - "TenantWideExtensionComponentProperties": "{\"testMessage\":\"Test message\"}", - "TenantWideExtensionWebTemplate": null, - "TenantWideExtensionListTemplate": 0, - "TenantWideExtensionLocation": "ClientSideExtension.ApplicationCustomizer", - "TenantWideExtensionSequence": 0, - "TenantWideExtensionHostProperties": null, - "TenantWideExtensionDisabled": false - }] - }; + const applicationCustomizerResponse = [{ + "FileSystemObjectType": 0, + "ID": 4, + "Id": 4, + "ServerRedirectedEmbedUri": null, + "ServerRedirectedEmbedUrl": "", + "ContentTypeId": "0x00693E2C487575B448BD420C12CEAE7EFE", + "Title": title, + "Modified": "2023-01-11T15:47:38Z", + "Created": "2023-01-11T15:47:38Z", + "AuthorId": 9, + "EditorId": 9, + "OData__UIVersionString": "1.0", + "Attachments": false, + "GUID": '14125658-a9bc-4ddf-9c75-1b5767c9a337', + "ComplianceAssetId": null, + "TenantWideExtensionComponentId": clientSideComponentId, + "TenantWideExtensionComponentProperties": "{\"testMessage\":\"Test message\"}", + "TenantWideExtensionWebTemplate": null, + "TenantWideExtensionListTemplate": 0, + "TenantWideExtensionLocation": "ClientSideExtension.ApplicationCustomizer", + "TenantWideExtensionSequence": 0, + "TenantWideExtensionHostProperties": null, + "TenantWideExtensionDisabled": false + }]; let log: any[]; let logger: Logger; @@ -94,8 +93,9 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_REMOVE, () => { afterEach(() => { sinonUtil.restore([ - request.get, request.post, + spo.getTenantAppCatalogUrl, + spoListItem.getListItems, cli.getSettingWithDefaultValue, cli.promptForConfirmation, cli.handleMultipleResultsFound @@ -255,13 +255,7 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_REMOVE, () => { it('throws error when tenant app catalog doesn\'t exist', async () => { const errorMessage = 'No app catalog URL found'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: null }; - } - - throw 'Invalid request'; - }); + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(null); await assert.rejects(command.action(logger, { options: { @@ -274,12 +268,8 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_REMOVE, () => { it('throws error when retrieving a tenant app catalog fails with an exception', async () => { const errorMessage = 'Couldn\'t retrieve tenant app catalog URL'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - throw errorMessage; - } - - throw 'Invalid request'; + sinon.stub(spo, 'getTenantAppCatalogUrl').callsFake(() => { + throw errorMessage; }); await assert.rejects(command.action(logger, { @@ -291,16 +281,17 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_REMOVE, () => { }); it('removes an application customizer by title (debug)', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'&$select=Id`) { - return applicationCustomizerResponse; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'` + ) { + return applicationCustomizerResponse as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); const postSpy = sinon.stub(request, 'post').callsFake(async (opts) => { @@ -324,16 +315,17 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_REMOVE, () => { }); it('removes an application customizer by title with confirm', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'&$select=Id`) { - return applicationCustomizerResponse; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'` + ) { + return applicationCustomizerResponse as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); const postSpy = sinon.stub(request, 'post').callsFake(async (opts) => { @@ -357,16 +349,17 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_REMOVE, () => { }); it('removes an application customizer by id (debug)', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Id eq ${id}` + ) { + return applicationCustomizerResponse as any; + } } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Id eq '${id}'&$select=Id`) { - return applicationCustomizerResponse; - } - - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); const postSpy = sinon.stub(request, 'post').callsFake(async (opts) => { @@ -390,16 +383,17 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_REMOVE, () => { }); it('removes an application customizer by clientSideComponentId (debug)', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'&$select=Id`) { - return applicationCustomizerResponse; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'` + ) { + return applicationCustomizerResponse as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); const postSpy = sinon.stub(request, 'post').callsFake(async (opts) => { @@ -431,22 +425,20 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_REMOVE, () => { return defaultValue; }); - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'` + ) { + return [ + { Title: title, Id: id, TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bc' }, + { Title: title, Id: 5, TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bd' } + ] as any; + } } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'&$select=Id`) { - return { - value: - [ - { Title: title, Id: id, TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bc' }, - { Title: title, Id: 5, TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bd' } - ] - }; - } - - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { @@ -458,25 +450,23 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_REMOVE, () => { }); it('handles selecting single result when multiple application customizers with the specified name found and cli is set to prompt', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'` + ) { + return [ + { Title: title, Id: id, TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bc' }, + { Title: title, Id: 5, TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bd' } + ] as any; + } } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'&$select=Id`) { - return { - value: - [ - { Title: title, Id: id, TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bc' }, - { Title: title, Id: 5, TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bd' } - ] - }; - } - - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); - sinon.stub(cli, 'handleMultipleResultsFound').resolves(applicationCustomizerResponse.value[0]); + sinon.stub(cli, 'handleMultipleResultsFound').resolves(applicationCustomizerResponse[0]); const postSpy = sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items(4)`) { @@ -507,22 +497,20 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_REMOVE, () => { return defaultValue; }); - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'` + ) { + return [ + { Title: title, Id: id, TenantWideExtensionComponentId: clientSideComponentId }, + { Title: 'Another customizer', Id: 5, TenantWideExtensionComponentId: clientSideComponentId } + ] as any; + } } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'&$select=Id`) { - return { - value: - [ - { Title: title, Id: id, TenantWideExtensionComponentId: clientSideComponentId }, - { Title: 'Another customizer', Id: 5, TenantWideExtensionComponentId: clientSideComponentId } - ] - }; - } - - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { @@ -535,16 +523,17 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_REMOVE, () => { it('handles error when specified application customizer not found', async () => { const errorMessage = 'The specified application customizer was not found'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'&$select=Id`) { - return { value: [] }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'` + ) { + return []; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { @@ -558,16 +547,17 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_REMOVE, () => { it('handles error when retrieving application customizer', async () => { const errorMessage = 'An error has occurred'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'&$select=Id`) { - throw errorMessage; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'` + ) { + throw errorMessage; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { diff --git a/src/m365/spo/commands/tenant/tenant-applicationcustomizer-remove.ts b/src/m365/spo/commands/tenant/tenant-applicationcustomizer-remove.ts index 1e0cd593caa..a68dcef792b 100644 --- a/src/m365/spo/commands/tenant/tenant-applicationcustomizer-remove.ts +++ b/src/m365/spo/commands/tenant/tenant-applicationcustomizer-remove.ts @@ -3,8 +3,8 @@ import { Logger } from '../../../../cli/Logger.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; -import { odata } from '../../../../utils/odata.js'; import { spo } from '../../../../utils/spo.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; @@ -109,34 +109,41 @@ class SpoTenantApplicationCustomizerRemoveCommand extends SpoCommand { } } - public async getTenantApplicationCustomizerId(logger: Logger, args: CommandArgs, requestUrl: string): Promise { + public async getTenantApplicationCustomizerId(logger: Logger, args: CommandArgs, appCatalogUrl: string): Promise { if (this.verbose) { await logger.logToStderr(`Getting the tenant application customizer ${args.options.id || args.options.title || args.options.clientSideComponentId}`); } - const filter: string[] = [`TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer'`]; + let filter: string; if (args.options.title) { - filter.push(`Title eq '${args.options.title}'`); + filter = `Title eq '${args.options.title}'`; } else if (args.options.id) { - filter.push(`Id eq '${args.options.id}'`); + filter = `Id eq ${args.options.id}`; } - else if (args.options.clientSideComponentId) { - filter.push(`TenantWideExtensionComponentId eq '${args.options.clientSideComponentId}'`); + else { + filter = `TenantWideExtensionComponentId eq '${args.options.clientSideComponentId}'`; } - const listItemInstances: ListItemInstance[] = await odata.getAllItems(`${requestUrl}/items?$filter=${filter.join(' and ')}&$select=Id`); + const options: ListItemListOptions = { + webUrl: appCatalogUrl, + listUrl: '/Lists/TenantWideExtensions', + filter: `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and ${filter}`, + fields: ['Id'] + }; + + const listItems = await spoListItem.getListItems(options, logger, this.verbose); - if (listItemInstances.length === 0) { + if (listItems.length === 0) { throw 'The specified application customizer was not found'; } - if (listItemInstances.length > 1) { - const resultAsKeyValuePair = formatting.convertArrayToHashTable('Id', listItemInstances); - listItemInstances[0] = await cli.handleMultipleResultsFound(`Multiple application customizers with ${args.options.title || args.options.clientSideComponentId} were found.`, resultAsKeyValuePair); + if (listItems.length > 1) { + const resultAsKeyValuePair = formatting.convertArrayToHashTable('Id', listItems); + listItems[0] = await cli.handleMultipleResultsFound(`Multiple application customizers with ${args.options.title || args.options.clientSideComponentId} were found.`, resultAsKeyValuePair); } - return listItemInstances[0].Id; + return listItems[0].Id; } private async removeTenantApplicationCustomizer(logger: Logger, args: CommandArgs): Promise { @@ -146,16 +153,15 @@ class SpoTenantApplicationCustomizerRemoveCommand extends SpoCommand { throw 'No app catalog URL found'; } - const listServerRelativeUrl: string = urlUtil.getServerRelativePath(appCatalogUrl, '/lists/TenantWideExtensions'); - const requestUrl = `${appCatalogUrl}/_api/web/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')`; - const id = await this.getTenantApplicationCustomizerId(logger, args, requestUrl); + const id = await this.getTenantApplicationCustomizerId(logger, args, appCatalogUrl); if (this.verbose) { await logger.logToStderr(`Removing tenant application customizer ${args.options.id || args.options.title || args.options.clientSideComponentId}`); } + const listServerRelativeUrl: string = urlUtil.getServerRelativePath(appCatalogUrl, '/lists/TenantWideExtensions'); const requestOptions: CliRequestOptions = { - url: `${requestUrl}/items(${id})`, + url: `${appCatalogUrl}/_api/web/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')/items(${id})`, method: 'POST', headers: { 'X-HTTP-Method': 'DELETE', diff --git a/src/m365/spo/commands/tenant/tenant-applicationcustomizer-set.spec.ts b/src/m365/spo/commands/tenant/tenant-applicationcustomizer-set.spec.ts index e710d66017f..c144401edd7 100644 --- a/src/m365/spo/commands/tenant/tenant-applicationcustomizer-set.spec.ts +++ b/src/m365/spo/commands/tenant/tenant-applicationcustomizer-set.spec.ts @@ -10,11 +10,11 @@ import { telemetry } from '../../../../telemetry.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; -import { urlUtil } from '../../../../utils/urlUtil.js'; import commands from '../../commands.js'; -import spoListItemListCommand from '../listitem/listitem-list.js'; import command from './tenant-applicationcustomizer-set.js'; import { settingsNames } from '../../../../settingsNames.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; +import { spo } from '../../../../utils/spo.js'; describe(commands.TENANT_APPLICATIONCUSTOMIZER_SET, () => { const title = 'Some customizer'; @@ -32,55 +32,53 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_SET, () => { const solutionResponse = [solution]; const application = { "FileSystemObjectType": 0, "Id": 31, "ServerRedirectedEmbedUri": null, "ServerRedirectedEmbedUrl": "", "SkipFeatureDeployment": true, "ContainsTenantWideExtension": true, "Modified": "2022-11-03T11:26:03", "CheckoutUserId": null, "EditorId": 9 }; const applicationResponse = [application]; - const applicationCustomizerResponse = { - value: - [{ - "FileSystemObjectType": 0, - "Id": id, - "ServerRedirectedEmbedUri": null, - "ServerRedirectedEmbedUrl": "", - "ContentTypeId": "0x00693E2C487575B448BD420C12CEAE7EFE", - "Title": title, - "Modified": "2023-01-11T15:47:38Z", - "Created": "2023-01-11T15:47:38Z", - "AuthorId": 9, - "EditorId": 9, - "OData__UIVersionString": "1.0", - "Attachments": false, - "GUID": '14125658-a9bc-4ddf-9c75-1b5767c9a337', - "ComplianceAssetId": null, - "TenantWideExtensionComponentId": clientSideComponentId, - "TenantWideExtensionComponentProperties": "{\"testMessage\":\"Test message\"}", - "TenantWideExtensionWebTemplate": null, - "TenantWideExtensionListTemplate": 0, - "TenantWideExtensionLocation": "ClientSideExtension.ApplicationCustomizer", - "TenantWideExtensionSequence": 0, - "TenantWideExtensionHostProperties": null, - "TenantWideExtensionDisabled": false - }] - }; - const multipleResponses = { - value: - [ - { Title: title, Id: 3, TenantWideExtensionComponentId: clientSideComponentId }, - { Title: title, Id: 4, TenantWideExtensionComponentId: clientSideComponentId } - ] - }; + const applicationCustomizerResponse = [{ + "FileSystemObjectType": 0, + "Id": id, + "ServerRedirectedEmbedUri": null, + "ServerRedirectedEmbedUrl": "", + "ContentTypeId": "0x00693E2C487575B448BD420C12CEAE7EFE", + "Title": title, + "Modified": "2023-01-11T15:47:38Z", + "Created": "2023-01-11T15:47:38Z", + "AuthorId": 9, + "EditorId": 9, + "OData__UIVersionString": "1.0", + "Attachments": false, + "GUID": '14125658-a9bc-4ddf-9c75-1b5767c9a337', + "ComplianceAssetId": null, + "TenantWideExtensionComponentId": clientSideComponentId, + "TenantWideExtensionComponentProperties": "{\"testMessage\":\"Test message\"}", + "TenantWideExtensionWebTemplate": null, + "TenantWideExtensionListTemplate": 0, + "TenantWideExtensionLocation": "ClientSideExtension.ApplicationCustomizer", + "TenantWideExtensionSequence": 0, + "TenantWideExtensionHostProperties": null, + "TenantWideExtensionDisabled": false + }]; + const multipleResponses = [ + { Title: title, Id: 3, TenantWideExtensionComponentId: clientSideComponentId }, + { Title: title, Id: 4, TenantWideExtensionComponentId: clientSideComponentId } + ]; let log: any[]; let logger: Logger; let commandInfo: CommandInfo; const defaultGetCallStub = (filter: string): sinon.SinonStub => { - return sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and ${filter}`) { - return applicationCustomizerResponse; + return sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return solutionResponse as any[]; + } + else if (options.listUrl === '/sites/apps/AppCatalog') { + return applicationResponse as any[]; + } + else if (options.listUrl === `/sites/apps/lists/TenantWideExtensions` && options.filter === `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and ${filter}`) { + return applicationCustomizerResponse as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); }; @@ -97,7 +95,7 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_SET, () => { }; } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(opts); }); }; @@ -114,7 +112,7 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_SET, () => { }; } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(opts); }); }; @@ -145,12 +143,12 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_SET, () => { afterEach(() => { sinonUtil.restore([ - request.get, + spo.getTenantAppCatalogUrl, + spoListItem.addListItem, + spoListItem.getListItems, request.post, cli.getSettingWithDefaultValue, - cli.handleMultipleResultsFound, - cli.executeCommand, - cli.executeCommandWithOutput + cli.handleMultipleResultsFound ]); }); @@ -220,13 +218,8 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_SET, () => { it('handles error when tenant app catalog doesn\'t exist', async () => { const errorMessage = 'No app catalog URL found'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: null }; - } - throw 'Invalid request'; - }); + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(null); await assert.rejects(command.action(logger, { options: { @@ -238,18 +231,21 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_SET, () => { it('handles error when no application customizer with the specified title found', async () => { const errorMessage = 'The specified application customizer was not found'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'`) { - return { value: [] }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return []; + } + + if (options.listUrl === `/sites/apps/lists/TenantWideExtensions`) { + return []; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); - await assert.rejects(command.action(logger, { options: { title: title, newTitle: newTitle @@ -266,16 +262,19 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_SET, () => { return defaultValue; }); - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return []; + } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'`) { - return multipleResponses; + if (options.listUrl === `/sites/apps/lists/TenantWideExtensions`) { + return multipleResponses as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { @@ -294,16 +293,19 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_SET, () => { return defaultValue; }); - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return []; + } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'`) { - return multipleResponses; + if (options.listUrl === `/sites/apps/lists/TenantWideExtensions`) { + return multipleResponses as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { @@ -314,19 +316,22 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_SET, () => { }); it('handles selecting single result when multiple application customizers with the specified name found and cli is set to prompt', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return []; + } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'`) { - return multipleResponses; + if (options.listUrl === `/sites/apps/lists/TenantWideExtensions`) { + return multipleResponses as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); - sinon.stub(cli, 'handleMultipleResultsFound').resolves(applicationCustomizerResponse.value[0]); + sinon.stub(cli, 'handleMultipleResultsFound').resolves(applicationCustomizerResponse[0]); const executeCallsStub: sinon.SinonStub = defaultPostCallsStub(); await command.action(logger, { @@ -339,16 +344,20 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_SET, () => { it('handles error when listItemInstances are falsy', async () => { const errorMessage = 'The specified application customizer was not found'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and Title eq 'Some customizer'`) { - return { value: undefined }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return []; + } + + if (options.listUrl === `/sites/apps/lists/TenantWideExtensions`) { + return []; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { @@ -360,16 +369,14 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_SET, () => { it('handles error when executing command', async () => { const errorMessage = 'An error has occurred'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'`) { + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { throw errorMessage; } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { @@ -380,6 +387,7 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_SET, () => { }); it('updates title of an application customizer by title', async () => { + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); defaultGetCallStub("Title eq 'Some customizer'"); const executeCallsStub: sinon.SinonStub = defaultPostCallsStub(); await command.action(logger, { @@ -392,20 +400,9 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_SET, () => { }); it('updates client side component id of an application customizer by title', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { - return { 'stdout': JSON.stringify(solutionResponse) }; - } - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/AppCatalog`) { - return { 'stdout': JSON.stringify(applicationResponse) }; - } - } - - throw 'Invalid request'; - }); - + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); defaultGetCallStub("Title eq 'Some customizer'"); + const executeCallsStub: sinon.SinonStub = postCallsStubClientSideComponentId(); await command.action(logger, { options: { @@ -416,7 +413,9 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_SET, () => { }); it('updates properties of an application customizer by id', async () => { + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); defaultGetCallStub("Id eq '3'"); + const executeCallsStub: sinon.SinonStub = defaultPostCallsStub(); await command.action(logger, { options: { @@ -427,7 +426,9 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_SET, () => { }); it('updates an application customizer by clientSideComponentId', async () => { + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); defaultGetCallStub("TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'"); + const executeCallsStub: sinon.SinonStub = defaultPostCallsStub(); await command.action(logger, { options: { @@ -438,17 +439,17 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_SET, () => { }); it('throws an error when specific client side component is not found in manifest list', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { - return { 'stdout': JSON.stringify([]) }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return []; } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); - defaultGetCallStub("Id eq '3'"); postCallsStubClientSideComponentId(); await assert.rejects(command.action(logger, { options: { id: id, newClientSideComponentId: newClientSideComponentId, verbose: true } }), @@ -456,20 +457,23 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_SET, () => { }); it('throws an error when client side component to update is not of type application customizer', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { const faultyClientComponentManifest = "{\"id\":\"6b2a54c5-3317-49eb-8621-1bbb76263629\",\"alias\":\"HelloWorldApplicationCustomizer\",\"componentType\":\"Extension\",\"extensionType\":\"FormCustomizer\",\"version\":\"0.0.1\",\"manifestVersion\":2,\"loaderConfig\":{\"internalModuleBaseUrls\":[\"HTTPS://SPCLIENTSIDEASSETLIBRARY/\"],\"entryModuleId\":\"hello-world-application-customizer\",\"scriptResources\":{\"hello-world-application-customizer\":{\"type\":\"path\",\"path\":\"hello-world-application-customizer_b47769f9eca3d3b6c4d5.js\"},\"HelloWorldApplicationCustomizerStrings\":{\"type\":\"path\",\"path\":\"HelloWorldApplicationCustomizerStrings_en-us_72ca11838ac9bae2790a8692c260e1ac.js\"},\"@microsoft/sp-application-base\":{\"type\":\"component\",\"id\":\"4df9bb86-ab0a-4aab-ab5f-48bf167048fb\",\"version\":\"1.15.2\"},\"@microsoft/sp-core-library\":{\"type\":\"component\",\"id\":\"7263c7d0-1d6a-45ec-8d85-d4d1d234171b\",\"version\":\"1.15.2\"}}},\"mpnId\":\"Undefined-1.15.2\",\"clientComponentDeveloper\":\"\"}"; const solutionDuplicate = { ...solution }; solutionDuplicate.ClientComponentManifest = faultyClientComponentManifest; - return { 'stdout': JSON.stringify([solutionDuplicate]) }; + return [solutionDuplicate]; + } + else if (options.listUrl === '/sites/apps/AppCatalog') { + return applicationResponse as any[]; } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); - defaultGetCallStub("Id eq '3'"); postCallsStubClientSideComponentId(); await assert.rejects(command.action(logger, { options: { id: id, newClientSideComponentId: newClientSideComponentId, verbose: true } }), @@ -477,20 +481,20 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_SET, () => { }); it('throws an error when solution is not found in app catalog', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { - return { 'stdout': JSON.stringify(solutionResponse) }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return solutionResponse as any[]; } - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/AppCatalog`) { - return { 'stdout': JSON.stringify([]) }; + else if (options.listUrl === '/sites/apps/AppCatalog') { + return []; } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); - defaultGetCallStub("Id eq '3'"); postCallsStubClientSideComponentId(); await assert.rejects(command.action(logger, { options: { id: id, newClientSideComponentId: newClientSideComponentId, verbose: true } }), @@ -498,22 +502,22 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_SET, () => { }); it('throws an error when solution does not contain extension that can be deployed tenant-wide', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { - return { 'stdout': JSON.stringify(solutionResponse) }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return solutionResponse as any[]; } - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/AppCatalog`) { + else if (options.listUrl === '/sites/apps/AppCatalog') { const faultyApplication = { ...application }; faultyApplication.ContainsTenantWideExtension = false; - return { 'stdout': JSON.stringify([faultyApplication]) }; + return [faultyApplication]; } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); - defaultGetCallStub("Id eq '3'"); postCallsStubClientSideComponentId(); await assert.rejects(command.action(logger, { options: { id: id, newClientSideComponentId: newClientSideComponentId, verbose: true } }), @@ -521,22 +525,22 @@ describe(commands.TENANT_APPLICATIONCUSTOMIZER_SET, () => { }); it('throws an error when solution is not deployed globally', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { - return { 'stdout': JSON.stringify(solutionResponse) }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return solutionResponse as any[]; } - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/AppCatalog`) { + else if (options.listUrl === '/sites/apps/AppCatalog') { const faultyApplication = { ...application }; faultyApplication.SkipFeatureDeployment = false; - return { 'stdout': JSON.stringify([faultyApplication]) }; + return [faultyApplication]; } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); - defaultGetCallStub("Id eq '3'"); postCallsStubClientSideComponentId(); await assert.rejects(command.action(logger, { options: { id: id, newClientSideComponentId: newClientSideComponentId, verbose: true } }), diff --git a/src/m365/spo/commands/tenant/tenant-applicationcustomizer-set.ts b/src/m365/spo/commands/tenant/tenant-applicationcustomizer-set.ts index 71906710fae..95561f9db76 100644 --- a/src/m365/spo/commands/tenant/tenant-applicationcustomizer-set.ts +++ b/src/m365/spo/commands/tenant/tenant-applicationcustomizer-set.ts @@ -1,18 +1,16 @@ -import Command from '../../../../Command.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; -import { odata } from '../../../../utils/odata.js'; import { spo } from '../../../../utils/spo.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; import { ListItemInstance } from '../listitem/ListItemInstance.js'; -import spoListItemListCommand, { Options as spoListItemListCommandOptions } from '../listitem/listitem-list.js'; import { Solution } from './Solution.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; interface CommandArgs { options: Options; @@ -156,25 +154,31 @@ class SpoTenantApplicationCustomizerSetCommand extends SpoCommand { private async getListItemId(appCatalogUrl: string, options: Options, listServerRelativeUrl: string, logger: Logger): Promise { const { title, id, clientSideComponentId } = options; - const filter = title ? `Title eq '${title}'` : id ? `Id eq '${id}'` : `TenantWideExtensionComponentId eq '${clientSideComponentId}'`; if (this.verbose) { await logger.logToStderr(`Getting tenant-wide application customizer: "${title || id || clientSideComponentId}"...`); } + const defaultFilter = `TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer'`; - const listItemInstances = await odata.getAllItems(`${appCatalogUrl}/_api/web/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')/items?$filter=TenantWideExtensionLocation eq 'ClientSideExtension.ApplicationCustomizer' and ${filter}`); + const listOptions: ListItemListOptions = { + webUrl: appCatalogUrl, + listUrl: listServerRelativeUrl, + filter: title ? `${defaultFilter} and Title eq '${title}'` : id ? `${defaultFilter} and Id eq '${id}'` : `${defaultFilter} and TenantWideExtensionComponentId eq '${clientSideComponentId}'` + }; + + const listItems = await spoListItem.getListItems(listOptions, logger, this.verbose) as any[]; - if (!listItemInstances || listItemInstances.length === 0) { + if (!listItems || listItems.length === 0) { throw 'The specified application customizer was not found'; } - if (listItemInstances.length > 1) { - const resultAsKeyValuePair = formatting.convertArrayToHashTable('Id', listItemInstances); + if (listItems.length > 1) { + const resultAsKeyValuePair = formatting.convertArrayToHashTable('Id', listItems); const result = await cli.handleMultipleResultsFound(`Multiple application customizers with ${title ? `title '${title}'` : `ClientSideComponentId '${clientSideComponentId}'`} found.`, resultAsKeyValuePair); return result.Id; } - return listItemInstances[0].Id; + return listItems[0].Id; } private async getComponentManifest(appCatalogUrl: string, clientSideComponentId: string, logger: Logger): Promise { @@ -182,29 +186,19 @@ class SpoTenantApplicationCustomizerSetCommand extends SpoCommand { await logger.logToStderr('Retrieving component manifest item from the ComponentManifests list on the app catalog site so that we get the solution id'); } - const camlQuery = `${clientSideComponentId}`; - const commandOptions: spoListItemListCommandOptions = { + const options: ListItemListOptions = { webUrl: appCatalogUrl, listUrl: `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`, - camlQuery: camlQuery, - verbose: this.verbose, - debug: this.debug, - output: 'json' + camlQuery: `${clientSideComponentId}` }; - const output = await cli.executeCommandWithOutput(spoListItemListCommand as Command, { options: { ...commandOptions, _: [] } }); - - if (this.verbose) { - await logger.logToStderr(output.stderr); - } + const output = await spoListItem.getListItems(options, logger, this.verbose); - const outputParsed = JSON.parse(output.stdout); - - if (outputParsed.length === 0) { + if (output.length === 0) { throw 'No component found with the specified clientSideComponentId found in the component manifest list. Make sure that the application is added to the application catalog'; } - return outputParsed[0]; + return output[0]; } private async getSolutionFromAppCatalog(appCatalogUrl: string, solutionId: string, logger: Logger): Promise { @@ -212,29 +206,19 @@ class SpoTenantApplicationCustomizerSetCommand extends SpoCommand { await logger.logToStderr(`Retrieving solution with id ${solutionId} from the application catalog`); } - const camlQuery = `${solutionId}`; - const commandOptions: spoListItemListCommandOptions = { + const options: ListItemListOptions = { webUrl: appCatalogUrl, listUrl: `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/AppCatalog`, - camlQuery: camlQuery, - verbose: this.verbose, - debug: this.debug, - output: 'json' + camlQuery: `${solutionId}` }; - const output = await cli.executeCommandWithOutput(spoListItemListCommand as Command, { options: { ...commandOptions, _: [] } }); - - if (this.verbose) { - await logger.logToStderr(output.stderr); - } - - const outputParsed = JSON.parse(output.stdout); + const output = await spoListItem.getListItems(options, logger, this.verbose) as any[]; - if (outputParsed.length === 0) { + if (output.length === 0) { throw `No component found with the solution id ${solutionId}. Make sure that the solution is available in the app catalog`; } - return outputParsed[0]; + return output[0]; } private async updateTenantWideExtension(appCatalogUrl: string, options: Options, listServerRelativeUrl: string, itemId: number, logger: Logger): Promise { diff --git a/src/m365/spo/commands/tenant/tenant-commandset-add.spec.ts b/src/m365/spo/commands/tenant/tenant-commandset-add.spec.ts index 9c269d6be52..76363818eaa 100644 --- a/src/m365/spo/commands/tenant/tenant-commandset-add.spec.ts +++ b/src/m365/spo/commands/tenant/tenant-commandset-add.spec.ts @@ -9,12 +9,10 @@ import { telemetry } from '../../../../telemetry.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; -import { urlUtil } from '../../../../utils/urlUtil.js'; import commands from '../../commands.js'; -import spoListItemAddCommand from '../listitem/listitem-add.js'; -import spoListItemListCommand from '../listitem/listitem-list.js'; -import spoTenantAppCatalogUrlGetCommand from './tenant-appcatalogurl-get.js'; import command from './tenant-commandset-add.js'; +import { spo } from '../../../../utils/spo.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; describe(commands.TENANT_COMMANDSET_ADD, () => { const clientSideComponentId = '9748c81b-d72e-4048-886a-e98649543743'; @@ -29,6 +27,19 @@ describe(commands.TENANT_COMMANDSET_ADD, () => { const solutionResponse = [solution]; const application = { "FileSystemObjectType": 0, "Id": 31, "ServerRedirectedEmbedUri": null, "ServerRedirectedEmbedUrl": "", "SkipFeatureDeployment": true, "ContainsTenantWideExtension": true, "Modified": "2022-11-03T11:26:03", "CheckoutUserId": null, "EditorId": 9 }; const applicationResponse = [application]; + const listItemResponse = { + Attachments: false, + AuthorId: 3, + ContentTypeId: '0x0100B21BD271A810EE488B570BE49963EA34', + Created: new Date('2018-03-15T10:43:10Z'), + EditorId: 3, + GUID: 'ea093c7b-8ae6-4400-8b75-e2d01154dffc', + Id: 0, + ID: 0, + Modified: new Date('2018-03-15T10:43:10Z'), + Title: 'listTitle', + RoleAssignments: [] + }; let log: string[]; let logger: Logger; @@ -39,6 +50,12 @@ describe(commands.TENANT_COMMANDSET_ADD, () => { sinon.stub(telemetry, 'trackEvent').resolves(); sinon.stub(pid, 'getProcessName').returns(''); sinon.stub(session, 'getId').returns(''); + sinon.stub(spo, 'getRequestDigest').callsFake(() => Promise.resolve({ + FormDigestValue: 'abc', + FormDigestTimeoutSeconds: 1800, + FormDigestExpiresAt: new Date(), + WebFullUrl: 'https://contoso.sharepoint.com' + })); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); }); @@ -60,8 +77,9 @@ describe(commands.TENANT_COMMANDSET_ADD, () => { afterEach(() => { sinonUtil.restore([ - cli.executeCommand, - cli.executeCommandWithOutput + spo.getTenantAppCatalogUrl, + spoListItem.addListItem, + spoListItem.getListItems ]); }); @@ -80,28 +98,24 @@ describe(commands.TENANT_COMMANDSET_ADD, () => { it('adds a tenant-wide ListView Command Set for lists', async () => { let executeCommandCalled = false; - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { - return { 'stdout': JSON.stringify(solutionResponse) }; + + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return solutionResponse as any[]; } - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/AppCatalog`) { - return { 'stdout': JSON.stringify(applicationResponse) }; + else if (options.listUrl === '/sites/apps/AppCatalog') { + return applicationResponse; } } - if (command === spoTenantAppCatalogUrlGetCommand) { - return { 'stdout': appCatalogUrl }; - } - throw 'Invalid request'; - }); - sinon.stub(cli, 'executeCommand').callsFake(async (command): Promise => { - if (command === spoListItemAddCommand) { - executeCommandCalled = true; - return; - } throw 'Invalid request'; }); + sinon.stub(spoListItem, 'addListItem').callsFake(async () => { + executeCommandCalled = true; + return listItemResponse; + }); await command.action(logger, { options: { clientSideComponentId: clientSideComponentId, listType: 'List', title: customizerTitle, verbose: true } }); assert.strictEqual(executeCommandCalled, true); @@ -109,28 +123,24 @@ describe(commands.TENANT_COMMANDSET_ADD, () => { it('adds a tenant-wide ListView Command Set for libraries', async () => { let executeCommandCalled = false; - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { - return { 'stdout': JSON.stringify(solutionResponse) }; + + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return solutionResponse as any[]; } - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/AppCatalog`) { - return { 'stdout': JSON.stringify(applicationResponse) }; + else if (options.listUrl === '/sites/apps/AppCatalog') { + return applicationResponse; } } - if (command === spoTenantAppCatalogUrlGetCommand) { - return { 'stdout': appCatalogUrl }; - } - throw 'Invalid request'; - }); - sinon.stub(cli, 'executeCommand').callsFake(async (command): Promise => { - if (command === spoListItemAddCommand) { - executeCommandCalled = true; - return; - } throw 'Invalid request'; }); + sinon.stub(spoListItem, 'addListItem').callsFake(async () => { + executeCommandCalled = true; + return listItemResponse; + }); await command.action(logger, { options: { clientSideComponentId: clientSideComponentId, listType: 'Library', location: 'ContextMenu', title: customizerTitle, verbose: true } }); assert.strictEqual(executeCommandCalled, true); @@ -138,28 +148,24 @@ describe(commands.TENANT_COMMANDSET_ADD, () => { it('adds a tenant-wide ListView Command Set for the SitePages library', async () => { let executeCommandCalled = false; - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { - return { 'stdout': JSON.stringify(solutionResponse) }; + + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return solutionResponse as any[]; } - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/AppCatalog`) { - return { 'stdout': JSON.stringify(applicationResponse) }; + else if (options.listUrl === '/sites/apps/AppCatalog') { + return applicationResponse; } } - if (command === spoTenantAppCatalogUrlGetCommand) { - return { 'stdout': appCatalogUrl }; - } - throw 'Invalid request'; - }); - sinon.stub(cli, 'executeCommand').callsFake(async (command): Promise => { - if (command === spoListItemAddCommand) { - executeCommandCalled = true; - return; - } throw 'Invalid request'; }); + sinon.stub(spoListItem, 'addListItem').callsFake(async () => { + executeCommandCalled = true; + return listItemResponse; + }); await command.action(logger, { options: { clientSideComponentId: clientSideComponentId, listType: 'SitePages', location: 'CommandBar', title: customizerTitle, verbose: true } }); assert.strictEqual(executeCommandCalled, true); @@ -167,55 +173,45 @@ describe(commands.TENANT_COMMANDSET_ADD, () => { it('adds a tenant-wide ListView Command Set to a specific webtemplate and location including clientSideComponentProperties', async () => { let executeCommandCalled = false; - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { - return { 'stdout': JSON.stringify(solutionResponse) }; + + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return solutionResponse as any[]; } - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/AppCatalog`) { - return { 'stdout': JSON.stringify(applicationResponse) }; + else if (options.listUrl === '/sites/apps/AppCatalog') { + return applicationResponse; } } - if (command === spoTenantAppCatalogUrlGetCommand) { - return { 'stdout': appCatalogUrl }; - } - throw 'Invalid request'; - }); - sinon.stub(cli, 'executeCommand').callsFake(async (command): Promise => { - if (command === spoListItemAddCommand) { - executeCommandCalled = true; - return; - } throw 'Invalid request'; }); + sinon.stub(spoListItem, 'addListItem').callsFake(async () => { + executeCommandCalled = true; + return listItemResponse; + }); await command.action(logger, { options: { clientSideComponentId: clientSideComponentId, listType: 'Library', title: customizerTitle, webTemplate: webTemplate, location: 'Both', clientSideComponentProperties: clientSideComponentProperties, verbose: true } }); assert.strictEqual(executeCommandCalled, true); }); it('throws an error when no app catalog is found', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command): Promise => { - if (command === spoTenantAppCatalogUrlGetCommand) { - return { 'stdout': null }; - } - throw 'Invalid request'; - }); + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(null); await assert.rejects(command.action(logger, { options: { title: customizerTitle, clientSideComponentId: clientSideComponentId, verbose: true } }), new CommandError('Cannot add tenant-wide ListView Command Set as app catalog cannot be found')); }); it('throws an error when specific client side component is not found in manifest list', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { - return { 'stdout': JSON.stringify([]) }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return []; } } - if (command === spoTenantAppCatalogUrlGetCommand) { - return { 'stdout': appCatalogUrl }; - } + throw 'Invalid request'; }); @@ -224,18 +220,20 @@ describe(commands.TENANT_COMMANDSET_ADD, () => { }); it('throws an error when the manifest of a specific client side component is not of type ListView Command Set', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { const faultyClientComponentManifest = "{\"id\":\"6b2a54c5-3317-49eb-8621-1bbb76263629\",\"alias\":\"HelloWorldCommandSet\",\"componentType\":\"Extension\",\"extensionType\":\"FormCustomizer\",\"version\":\"0.0.1\",\"manifestVersion\":2,\"loaderConfig\":{\"internalModuleBaseUrls\":[\"HTTPS://SPCLIENTSIDEASSETLIBRARY/\"],\"entryModuleId\":\"hello-world-command-set\",\"scriptResources\":{\"hello-world-command-set\":{\"type\":\"path\",\"path\":\"hello-world-command-set_b47769f9eca3d3b6c4d5.js\"},\"HelloWorldCommandSetStrings\":{\"type\":\"path\",\"path\":\"HelloWorldCommandSetStrings_en-us_72ca11838ac9bae2790a8692c260e1ac.js\"},\"@microsoft/sp-application-base\":{\"type\":\"component\",\"id\":\"4df9bb86-ab0a-4aab-ab5f-48bf167048fb\",\"version\":\"1.15.2\"},\"@microsoft/sp-core-library\":{\"type\":\"component\",\"id\":\"7263c7d0-1d6a-45ec-8d85-d4d1d234171b\",\"version\":\"1.15.2\"}}},\"mpnId\":\"Undefined-1.15.2\",\"clientComponentDeveloper\":\"\"}"; const solutionDuplicate = { ...solution }; solutionDuplicate.ClientComponentManifest = faultyClientComponentManifest; - return { 'stdout': JSON.stringify([solutionDuplicate]) }; + return [solutionDuplicate]; + } + else if (options.listUrl === '/sites/apps/AppCatalog') { + return applicationResponse as any[]; } } - if (command === spoTenantAppCatalogUrlGetCommand) { - return { 'stdout': appCatalogUrl }; - } + throw 'Invalid request'; }); @@ -244,18 +242,17 @@ describe(commands.TENANT_COMMANDSET_ADD, () => { }); it('throws an error when solution is not found in app catalog', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { - return { 'stdout': JSON.stringify(solutionResponse) }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return solutionResponse as any[]; } - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/AppCatalog`) { - return { 'stdout': JSON.stringify([]) }; + else if (options.listUrl === '/sites/apps/AppCatalog') { + return []; } } - if (command === spoTenantAppCatalogUrlGetCommand) { - return { 'stdout': appCatalogUrl }; - } + throw 'Invalid request'; }); @@ -264,20 +261,19 @@ describe(commands.TENANT_COMMANDSET_ADD, () => { }); it('throws an error when solution does not contain extension that can be deployed tenant-wide', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { - return { 'stdout': JSON.stringify(solutionResponse) }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return solutionResponse as any[]; } - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/AppCatalog`) { + else if (options.listUrl === '/sites/apps/AppCatalog') { const faultyApplication = { ...application }; faultyApplication.ContainsTenantWideExtension = false; - return { 'stdout': JSON.stringify([faultyApplication]) }; + return [faultyApplication]; } } - if (command === spoTenantAppCatalogUrlGetCommand) { - return { 'stdout': appCatalogUrl }; - } + throw 'Invalid request'; }); @@ -286,20 +282,19 @@ describe(commands.TENANT_COMMANDSET_ADD, () => { }); it('throws an error when solution is not deployed globally', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command, args): Promise => { - if (command === spoListItemListCommand) { - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`) { - return { 'stdout': JSON.stringify(solutionResponse) }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === '/sites/apps/Lists/ComponentManifests') { + return solutionResponse as any[]; } - if (args.options.listUrl === `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/AppCatalog`) { + else if (options.listUrl === '/sites/apps/AppCatalog') { const faultyApplication = { ...application }; faultyApplication.SkipFeatureDeployment = false; - return { 'stdout': JSON.stringify([faultyApplication]) }; + return [faultyApplication]; } } - if (command === spoTenantAppCatalogUrlGetCommand) { - return { 'stdout': appCatalogUrl }; - } + throw 'Invalid request'; }); diff --git a/src/m365/spo/commands/tenant/tenant-commandset-add.ts b/src/m365/spo/commands/tenant/tenant-commandset-add.ts index f8934f6e242..97c0bc9172a 100644 --- a/src/m365/spo/commands/tenant/tenant-commandset-add.ts +++ b/src/m365/spo/commands/tenant/tenant-commandset-add.ts @@ -1,15 +1,12 @@ -import Command from '../../../../Command.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import { Logger } from '../../../../cli/Logger.js'; -import { CommandOutput, cli } from '../../../../cli/cli.js'; +import { spo } from '../../../../utils/spo.js'; +import { ListItemAddOptions, ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; -import spoListItemAddCommand, { Options as spoListItemAddCommandOptions } from '../listitem/listitem-add.js'; -import spoListItemListCommand, { Options as spoListItemListCommandOptions } from '../listitem/listitem-list.js'; import { Solution } from './Solution.js'; -import spoTenantAppCatalogUrlGetCommand from './tenant-appcatalogurl-get.js'; interface CommandArgs { options: Options; @@ -127,12 +124,8 @@ class SpoTenantCommandSetAddCommand extends SpoCommand { } private async getAppCatalogUrl(logger: Logger): Promise { - const spoTenantAppCatalogUrlGetCommandOutput: CommandOutput = await cli.executeCommandWithOutput(spoTenantAppCatalogUrlGetCommand as Command, { options: { output: 'text', _: [] } }); - if (this.verbose) { - await logger.logToStderr(spoTenantAppCatalogUrlGetCommandOutput.stderr); - } + const appCatalogUrl: string | null = await spo.getTenantAppCatalogUrl(logger, this.verbose); - const appCatalogUrl: string | undefined = spoTenantAppCatalogUrlGetCommandOutput.stdout; if (!appCatalogUrl) { throw 'Cannot add tenant-wide ListView Command Set as app catalog cannot be found'; } @@ -148,27 +141,19 @@ class SpoTenantCommandSetAddCommand extends SpoCommand { await logger.logToStderr('Retrieving component manifest item from the ComponentManifests list on the app catalog site so that we get the solution id'); } - const camlQuery = `${clientSideComponentId}`; - const commandOptions: spoListItemListCommandOptions = { + const options: ListItemListOptions = { webUrl: appCatalogUrl, listUrl: `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/ComponentManifests`, - camlQuery: camlQuery, - verbose: this.verbose, - debug: this.debug, - output: 'json' + camlQuery: `${clientSideComponentId}` }; - const output = await cli.executeCommandWithOutput(spoListItemListCommand as Command, { options: { ...commandOptions, _: [] } }); - if (this.verbose) { - await logger.logToStderr(output.stderr); - } + const output = await spoListItem.getListItems(options, logger, this.verbose); - const outputParsed = JSON.parse(output.stdout); - if (outputParsed.length === 0) { + if (output.length === 0) { throw 'No component found with the specified clientSideComponentId found in the component manifest list. Make sure that the application is added to the application catalog'; } - return outputParsed[0]; + return output[0]; } private async getSolutionFromAppCatalog(appCatalogUrl: string, solutionId: string, logger: Logger): Promise { @@ -176,27 +161,19 @@ class SpoTenantCommandSetAddCommand extends SpoCommand { await logger.logToStderr(`Retrieving solution with id ${solutionId} from the application catalog`); } - const camlQuery = `${solutionId}`; - const commandOptions: spoListItemListCommandOptions = { + const options: ListItemListOptions = { webUrl: appCatalogUrl, listUrl: `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/AppCatalog`, - camlQuery: camlQuery, - verbose: this.verbose, - debug: this.debug, - output: 'json' + camlQuery: `${solutionId}` }; - const output = await cli.executeCommandWithOutput(spoListItemListCommand as Command, { options: { ...commandOptions, _: [] } }); - if (this.verbose) { - await logger.logToStderr(output.stderr); - } + const output = await spoListItem.getListItems(options, logger, this.verbose); - const outputParsed = JSON.parse(output.stdout); - if (outputParsed.length === 0) { + if (output.length === 0) { throw `No component found with the solution id ${solutionId}. Make sure that the solution is available in the app catalog`; } - return outputParsed[0]; + return output[0] as any; } private async addTenantWideExtension(appCatalogUrl: string, options: Options, logger: Logger): Promise { @@ -204,23 +181,21 @@ class SpoTenantCommandSetAddCommand extends SpoCommand { await logger.logToStderr('Pre-checks finished. Adding tenant wide extension to the TenantWideExtensions list'); } - const commandOptions: spoListItemAddCommandOptions = { + const listItemAddOptions: ListItemAddOptions = { webUrl: appCatalogUrl, listUrl: `${urlUtil.getServerRelativeSiteUrl(appCatalogUrl)}/Lists/TenantWideExtensions`, - Title: options.title, - TenantWideExtensionComponentId: options.clientSideComponentId, - TenantWideExtensionLocation: this.getLocation(options.location), - TenantWideExtensionSequence: 0, - TenantWideExtensionListTemplate: this.getListTemplate(options.listType), - TenantWideExtensionComponentProperties: options.clientSideComponentProperties || '', - TenantWideExtensionWebTemplate: options.webTemplate || '', - TenantWideExtensionDisabled: false, - verbose: this.verbose, - debug: this.debug, - output: options.output + fieldValues: { + Title: options.title, + TenantWideExtensionComponentId: options.clientSideComponentId, + TenantWideExtensionLocation: this.getLocation(options.location), + TenantWideExtensionSequence: 0, + TenantWideExtensionListTemplate: this.getListTemplate(options.listType), + TenantWideExtensionComponentProperties: options.clientSideComponentProperties || '', + TenantWideExtensionWebTemplate: options.webTemplate || '' + } }; - await cli.executeCommand(spoListItemAddCommand as Command, { options: { ...commandOptions, _: [] } }); + await spoListItem.addListItem(listItemAddOptions, logger, this.verbose, this.debug); } private getLocation(location: string | undefined): string { diff --git a/src/m365/spo/commands/tenant/tenant-commandset-get.spec.ts b/src/m365/spo/commands/tenant/tenant-commandset-get.spec.ts index 298ef8249dd..080e064593d 100644 --- a/src/m365/spo/commands/tenant/tenant-commandset-get.spec.ts +++ b/src/m365/spo/commands/tenant/tenant-commandset-get.spec.ts @@ -13,6 +13,8 @@ import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './tenant-commandset-get.js'; import { settingsNames } from '../../../../settingsNames.js'; +import { spo } from '../../../../utils/spo.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; describe(commands.TENANT_COMMANDSET_GET, () => { const title = 'Some ListView Command Set'; @@ -20,33 +22,30 @@ describe(commands.TENANT_COMMANDSET_GET, () => { const clientSideComponentId = '7096cded-b83d-4eab-96f0-df477ed7c0bc'; const spoUrl = 'https://contoso.sharepoint.com'; const appCatalogUrl = 'https://contoso.sharepoint.com/sites/apps'; - const commandSetResponse = { - value: - [{ - "FileSystemObjectType": 0, - "ID": id, - "ServerRedirectedEmbedUri": null, - "ServerRedirectedEmbedUrl": "", - "ContentTypeId": "0x00693E2C487575B448BD420C12CEAE7EFE", - "Title": title, - "Modified": "2023-01-11T15:47:38Z", - "Created": "2023-01-11T15:47:38Z", - "AuthorId": 9, - "EditorId": 9, - "OData__UIVersionString": "1.0", - "Attachments": false, - "GUID": "6e6f2429-cdec-4b90-89da-139d2665919e", - "ComplianceAssetId": null, - "TenantWideExtensionComponentId": clientSideComponentId, - "TenantWideExtensionComponentProperties": "{\"testMessage\":\"Test message\"}", - "TenantWideExtensionWebTemplate": null, - "TenantWideExtensionListTemplate": 101, - "TenantWideExtensionLocation": "ClientSideExtension.ListViewCommandSet.ContextMenu", - "TenantWideExtensionSequence": 0, - "TenantWideExtensionHostProperties": null, - "TenantWideExtensionDisabled": false - }] - }; + const commandSetResponse = [{ + "FileSystemObjectType": 0, + "Id": id, + "ServerRedirectedEmbedUri": null, + "ServerRedirectedEmbedUrl": "", + "ContentTypeId": "0x00693E2C487575B448BD420C12CEAE7EFE", + "Title": title, + "Modified": "2023-01-11T15:47:38Z", + "Created": "2023-01-11T15:47:38Z", + "AuthorId": 9, + "EditorId": 9, + "OData__UIVersionString": "1.0", + "Attachments": false, + "GUID": "6e6f2429-cdec-4b90-89da-139d2665919e", + "ComplianceAssetId": null, + "TenantWideExtensionComponentId": clientSideComponentId, + "TenantWideExtensionComponentProperties": "{\"testMessage\":\"Test message\"}", + "TenantWideExtensionWebTemplate": null, + "TenantWideExtensionListTemplate": 101, + "TenantWideExtensionLocation": "ClientSideExtension.ListViewCommandSet.ContextMenu", + "TenantWideExtensionSequence": 0, + "TenantWideExtensionHostProperties": null, + "TenantWideExtensionDisabled": false + }]; let log: any[]; let logger: Logger; @@ -88,7 +87,8 @@ describe(commands.TENANT_COMMANDSET_GET, () => { afterEach(() => { sinonUtil.restore([ - request.get, + spo.getTenantAppCatalogUrl, + spoListItem.getListItems, cli.handleMultipleResultsFound ]); }); @@ -184,13 +184,7 @@ describe(commands.TENANT_COMMANDSET_GET, () => { it('throws error when tenant app catalog doesn\'t exist', async () => { const errorMessage = 'No app catalog URL found'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: null }; - } - - throw 'Invalid request'; - }); + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(null); await assert.rejects(command.action(logger, { options: { @@ -218,16 +212,17 @@ describe(commands.TENANT_COMMANDSET_GET, () => { }); it('retrieves a command set by title', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Title eq 'Some ListView Command Set'` + ) { + return commandSetResponse as any; + } } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Title eq 'Some ListView Command Set'`) { - return commandSetResponse; - } - - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await command.action(logger, { @@ -235,75 +230,73 @@ describe(commands.TENANT_COMMANDSET_GET, () => { title: title } }); - assert(loggerLogSpy.calledOnceWithExactly(commandSetResponse.value[0])); + assert(loggerLogSpy.calledOnceWithExactly(commandSetResponse[0])); }); - it('handles error when multiple command sets with the specified title found', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Title eq 'Some ListView Command Set'`) { - return { - value: - [ - { Title: title, Id: 4, TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bc' }, - { Title: title, Id: 3, TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bd' } - ] - }; + it('handles error when multiple ListView Command Sets with the specified title found', async () => { + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Title eq 'Some ListView Command Set'` + ) { + return [ + { Title: title, GUID: '14125658-a9bc-4ddf-9c75-1b5767c9a337', TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bc' }, + { Title: title, GUID: '14125658-a9bc-4ddf-9c75-1b5767c9a338', TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bd' } + ] as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { options: { title: title } - }), new CommandError("Multiple ListView Command Sets with Some ListView Command Set were found. Found: 3, 4.")); + }), new CommandError("Multiple ListView Command Sets with Some ListView Command Set were found. Found: undefined.")); }); - it('handles selecting single result when multiple command sets with the specified name found and cli is set to prompt', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Title eq 'Some ListView Command Set'`) { - return { - value: - [ - { Title: title, Id: 4, TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bc' }, - { Title: title, Id: 3, TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bd' } - ] - }; - } - - throw 'Invalid request'; + it('handles selecting single result when multiple ListView Command Sets with the specified name found and cli is set to prompt', async () => { + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Title eq 'Some ListView Command Set'` + ) { + return [ + commandSetResponse[0], + commandSetResponse[0] + ] as any; + } + } + + throw 'Invalid request: ' + JSON.stringify(options); }); - sinon.stub(cli, 'handleMultipleResultsFound').resolves(commandSetResponse.value[0]); + sinon.stub(cli, 'handleMultipleResultsFound').resolves(commandSetResponse[0]); await command.action(logger, { options: { title: title } }); - assert(loggerLogSpy.calledOnceWithExactly(commandSetResponse.value[0])); + assert(loggerLogSpy.calledOnceWithExactly(commandSetResponse[0])); }); - it('retrieves a command set by id', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Id eq 4`) { - return commandSetResponse; + it('retrieves a ListView Command Set by id', async () => { + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Id eq 4` + ) { + return commandSetResponse as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await command.action(logger, { @@ -311,20 +304,21 @@ describe(commands.TENANT_COMMANDSET_GET, () => { id: id } }); - assert(loggerLogSpy.calledOnceWithExactly(commandSetResponse.value[0])); + assert(loggerLogSpy.calledOnceWithExactly(commandSetResponse[0])); }); - it('retrieves a command set by clientSideComponentId', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'`) { - return commandSetResponse; + it('retrieves a ListView Command Set by clientSideComponentId', async () => { + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'` + ) { + return commandSetResponse as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await command.action(logger, { @@ -332,20 +326,21 @@ describe(commands.TENANT_COMMANDSET_GET, () => { clientSideComponentId: clientSideComponentId } }); - assert(loggerLogSpy.calledOnceWithExactly(commandSetResponse.value[0])); + assert(loggerLogSpy.calledOnceWithExactly(commandSetResponse[0])); }); - it('retrieves command set properties', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Id eq 4`) { - return commandSetResponse; + it('retrieves a ListView Command Set component properties', async () => { + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Id eq 4` + ) { + return commandSetResponse as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await command.action(logger, { @@ -354,47 +349,46 @@ describe(commands.TENANT_COMMANDSET_GET, () => { tenantWideExtensionComponentProperties: true } }); - assert(loggerLogSpy.calledOnceWithExactly(JSON.parse(commandSetResponse.value[0].TenantWideExtensionComponentProperties))); + assert(loggerLogSpy.calledOnceWithExactly(JSON.parse(commandSetResponse[0].TenantWideExtensionComponentProperties))); }); - it('handles error when multiple command sets with the clientSideComponentId found', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'`) { - return { - value: - [ - { Title: title, Id: 4, TenantWideExtensionComponentId: clientSideComponentId }, - { Title: 'Another customizer', Id: 3, TenantWideExtensionComponentId: clientSideComponentId } - ] - }; - } - - throw 'Invalid request'; + it('handles error when multiple ListView Command Sets with the clientSideComponentId found', async () => { + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'` + ) { + return [ + { Title: title, GUID: '14125658-a9bc-4ddf-9c75-1b5767c9a337', TenantWideExtensionComponentId: clientSideComponentId }, + { Title: 'Another customizer', GUID: '14125658-a9bc-4ddf-9c75-1b5767c9a338', TenantWideExtensionComponentId: clientSideComponentId } + ] as any; + } + } + + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { options: { clientSideComponentId: clientSideComponentId } - }), new CommandError("Multiple ListView Command Sets with 7096cded-b83d-4eab-96f0-df477ed7c0bc were found. Found: 3, 4.")); + }), new CommandError("Multiple ListView Command Sets with 7096cded-b83d-4eab-96f0-df477ed7c0bc were found. Found: undefined.")); }); - it('handles error when specified command set not found', async () => { + it('handles error when specified ListView Command Set not found', async () => { const errorMessage = 'The specified ListView Command Set was not found'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Title eq 'Some ListView Command Set'`) { - return { value: [] }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Title eq 'Some ListView Command Set'` + ) { + return []; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { @@ -406,16 +400,17 @@ describe(commands.TENANT_COMMANDSET_GET, () => { it('handles error when listItemInstances are falsy', async () => { const errorMessage = 'The specified ListView Command Set was not found'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Title eq 'Some ListView Command Set'`) { - return; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Title eq 'Some ListView Command Set'` + ) { + return undefined as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { @@ -425,19 +420,20 @@ describe(commands.TENANT_COMMANDSET_GET, () => { }), new CommandError(errorMessage)); }); - it('handles error when retrieving command set', async () => { + it('handles error when retrieving ListView Command Set', async () => { const errorMessage = 'An error has occurred'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'` + ) { + throw errorMessage; + } } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'`) { - throw errorMessage; - } - - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { diff --git a/src/m365/spo/commands/tenant/tenant-commandset-get.ts b/src/m365/spo/commands/tenant/tenant-commandset-get.ts index 29193fc7814..c70d9f19733 100644 --- a/src/m365/spo/commands/tenant/tenant-commandset-get.ts +++ b/src/m365/spo/commands/tenant/tenant-commandset-get.ts @@ -2,15 +2,13 @@ import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import GlobalOptions from '../../../../GlobalOptions.js'; -import request, { CliRequestOptions } from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; import { spo } from '../../../../utils/spo.js'; -import { urlUtil } from '../../../../utils/urlUtil.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; import { ListItemInstance } from '../listitem/ListItemInstance'; -import { ListItemInstanceCollection } from '../listitem/ListItemInstanceCollection.js'; interface CommandArgs { options: Options; @@ -101,47 +99,43 @@ class SpoTenantCommandSetGetCommand extends SpoCommand { throw new CommandError('No app catalog URL found'); } - let filter: string = `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet')`; + let filter: string; if (args.options.title) { - filter += ` and Title eq '${args.options.title}'`; + filter = `Title eq '${args.options.title}'`; } else if (args.options.id) { - filter += ` and Id eq ${args.options.id}`; + filter = `Id eq ${args.options.id}`; } - else if (args.options.clientSideComponentId) { - filter += ` and TenantWideExtensionComponentId eq '${args.options.clientSideComponentId}'`; + else { + filter = `TenantWideExtensionComponentId eq '${args.options.clientSideComponentId}'`; } - const listServerRelativeUrl: string = urlUtil.getServerRelativePath(appCatalogUrl, '/lists/TenantWideExtensions'); - const reqOptions: CliRequestOptions = { - url: `${appCatalogUrl}/_api/web/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')/items?$filter=${filter}`, - headers: { - accept: 'application/json;odata=nometadata' - }, - responseType: 'json' - }; - try { - const listItemInstances = await request.get(reqOptions); + const options: ListItemListOptions = { + webUrl: appCatalogUrl, + listUrl: '/Lists/TenantWideExtensions', + filter: `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and ${filter}` + }; + + const listItems = await spoListItem.getListItems(options, logger, this.verbose); - if (listItemInstances?.value.length > 0) { - listItemInstances.value.forEach(v => delete v['ID']); + if (listItems?.length > 0) { - let listItemInstance: ListItemInstance; - if (listItemInstances.value.length > 1) { - const resultAsKeyValuePair = formatting.convertArrayToHashTable('Id', listItemInstances.value); - listItemInstance = await cli.handleMultipleResultsFound(`Multiple ListView Command Sets with ${args.options.title || args.options.clientSideComponentId} were found.`, resultAsKeyValuePair); + let listItem: ListItemInstance; + if (listItems.length > 1) { + const resultAsKeyValuePair = formatting.convertArrayToHashTable('Id', listItems); + listItem = await cli.handleMultipleResultsFound(`Multiple ListView Command Sets with ${args.options.title || args.options.clientSideComponentId} were found.`, resultAsKeyValuePair); } else { - listItemInstance = listItemInstances.value[0]; + listItem = listItems[0]; } if (!args.options.tenantWideExtensionComponentProperties) { - await logger.log(listItemInstance); + await logger.log(listItem); } else { - const properties = formatting.tryParseJson((listItemInstance as any).TenantWideExtensionComponentProperties); + const properties = formatting.tryParseJson((listItem as any).TenantWideExtensionComponentProperties); await logger.log(properties); } } diff --git a/src/m365/spo/commands/tenant/tenant-commandset-list.spec.ts b/src/m365/spo/commands/tenant/tenant-commandset-list.spec.ts index aaccf56fad5..88c0dbb84fe 100644 --- a/src/m365/spo/commands/tenant/tenant-commandset-list.spec.ts +++ b/src/m365/spo/commands/tenant/tenant-commandset-list.spec.ts @@ -3,13 +3,14 @@ import sinon from 'sinon'; import auth from '../../../../Auth.js'; import { CommandError } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; -import request from '../../../../request.js'; import { telemetry } from '../../../../telemetry.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './tenant-commandset-list.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; +import { spo } from '../../../../utils/spo.js'; describe(commands.TENANT_COMMANDSET_LIST, () => { const spoUrl = 'https://contoso.sharepoint.com'; @@ -40,12 +41,9 @@ describe(commands.TENANT_COMMANDSET_LIST, () => { "TenantWideExtensionDisabled": false }; - const commandSetResponse = { - value: - [ - { ...commandSet, "ID": 9 } - ] - }; + const commandSetList = [ + { ...commandSet } + ]; let log: any[]; let logger: Logger; @@ -78,7 +76,8 @@ describe(commands.TENANT_COMMANDSET_LIST, () => { afterEach(() => { sinonUtil.restore([ - request.get + spo.getTenantAppCatalogUrl, + spoListItem.getListItems ]); }); @@ -103,28 +102,23 @@ describe(commands.TENANT_COMMANDSET_LIST, () => { it('throws error when tenant app catalog doesn\'t exist', async () => { const errorMessage = 'No app catalog URL found'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: null }; - } - - throw 'Invalid request'; - }); + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(null); await assert.rejects(command.action(logger, { options: {} }), new CommandError(errorMessage)); }); it('retrieves listview command sets that are installed tenant wide', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `startswith(TenantWideExtensionLocation, 'ClientSideExtension.ListViewCommandSet')` + ) { + return commandSetList as any; + } } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=startswith(TenantWideExtensionLocation, 'ClientSideExtension.ListViewCommandSet')`) { - return commandSetResponse; - } - - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await command.action(logger, { options: { debug: true } }); @@ -134,16 +128,17 @@ describe(commands.TENANT_COMMANDSET_LIST, () => { it('handles error when retrieving tenant wide installed listview command sets', async () => { const errorMessage = 'An error has occurred'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=startswith(TenantWideExtensionLocation, 'ClientSideExtension.ListViewCommandSet')`) { - throw errorMessage; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `startswith(TenantWideExtensionLocation, 'ClientSideExtension.ListViewCommandSet')` + ) { + throw errorMessage; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { options: {} }), new CommandError(errorMessage)); diff --git a/src/m365/spo/commands/tenant/tenant-commandset-list.ts b/src/m365/spo/commands/tenant/tenant-commandset-list.ts index bf787ee9493..4eb201a7d41 100644 --- a/src/m365/spo/commands/tenant/tenant-commandset-list.ts +++ b/src/m365/spo/commands/tenant/tenant-commandset-list.ts @@ -1,12 +1,9 @@ import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; -import { formatting } from '../../../../utils/formatting.js'; -import { odata } from '../../../../utils/odata.js'; import { spo } from '../../../../utils/spo.js'; -import { urlUtil } from '../../../../utils/urlUtil.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; -import { ListItemInstance } from '../listitem/ListItemInstance.js'; class SpoTenantCommandSetListCommand extends SpoCommand { public get name(): string { @@ -32,11 +29,14 @@ class SpoTenantCommandSetListCommand extends SpoCommand { await logger.logToStderr('Retrieving a list of ListView Command Sets that are installed tenant-wide'); } - const listServerRelativeUrl: string = urlUtil.getServerRelativePath(appCatalogUrl, '/lists/TenantWideExtensions'); - try { - const listItems = await odata.getAllItems(`${appCatalogUrl}/_api/web/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')/items?$filter=startswith(TenantWideExtensionLocation, 'ClientSideExtension.ListViewCommandSet')`); - listItems.forEach(i => delete i.ID); + const options: ListItemListOptions = { + webUrl: appCatalogUrl, + listUrl: '/Lists/TenantWideExtensions', + filter: `startswith(TenantWideExtensionLocation, 'ClientSideExtension.ListViewCommandSet')` + }; + + const listItems = await spoListItem.getListItems(options, logger, this.verbose); await logger.log(listItems); } diff --git a/src/m365/spo/commands/tenant/tenant-commandset-remove.spec.ts b/src/m365/spo/commands/tenant/tenant-commandset-remove.spec.ts index 3248f295dc7..92064f54fae 100644 --- a/src/m365/spo/commands/tenant/tenant-commandset-remove.spec.ts +++ b/src/m365/spo/commands/tenant/tenant-commandset-remove.spec.ts @@ -13,6 +13,8 @@ import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './tenant-commandset-remove.js'; import { settingsNames } from '../../../../settingsNames.js'; +import { spo } from '../../../../utils/spo.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; describe(commands.TENANT_COMMANDSET_REMOVE, () => { const title = 'Some commandset'; @@ -20,33 +22,30 @@ describe(commands.TENANT_COMMANDSET_REMOVE, () => { const clientSideComponentId = '7096cded-b83d-4eab-96f0-df477ed7c0bc'; const spoUrl = 'https://contoso.sharepoint.com'; const appCatalogUrl = 'https://contoso.sharepoint.com/sites/apps'; - const commandSetResponse = { - value: - [{ - "FileSystemObjectType": 0, - "Id": id, - "ServerRedirectedEmbedUri": null, - "ServerRedirectedEmbedUrl": "", - "ContentTypeId": "0x00693E2C487575B448BD420C12CEAE7EFE", - "Title": title, - "Modified": "2023-01-11T15:47:38Z", - "Created": "2023-01-11T15:47:38Z", - "AuthorId": 9, - "EditorId": 9, - "OData__UIVersionString": "1.0", - "Attachments": false, - "GUID": "6e6f2429-cdec-4b90-89da-139d2665919e", - "ComplianceAssetId": null, - "TenantWideExtensionComponentId": clientSideComponentId, - "TenantWideExtensionComponentProperties": "{\"testMessage\":\"Test message\"}", - "TenantWideExtensionWebTemplate": null, - "TenantWideExtensionListTemplate": 101, - "TenantWideExtensionLocation": "ClientSideExtension.ListViewCommandSet.ContextMenu", - "TenantWideExtensionSequence": 0, - "TenantWideExtensionHostProperties": null, - "TenantWideExtensionDisabled": false - }] - }; + const commandSetResponse = [{ + "FileSystemObjectType": 0, + "Id": id, + "ServerRedirectedEmbedUri": null, + "ServerRedirectedEmbedUrl": "", + "ContentTypeId": "0x00693E2C487575B448BD420C12CEAE7EFE", + "Title": title, + "Modified": "2023-01-11T15:47:38Z", + "Created": "2023-01-11T15:47:38Z", + "AuthorId": 9, + "EditorId": 9, + "OData__UIVersionString": "1.0", + "Attachments": false, + "GUID": "6e6f2429-cdec-4b90-89da-139d2665919e", + "ComplianceAssetId": null, + "TenantWideExtensionComponentId": clientSideComponentId, + "TenantWideExtensionComponentProperties": "{\"testMessage\":\"Test message\"}", + "TenantWideExtensionWebTemplate": null, + "TenantWideExtensionListTemplate": 101, + "TenantWideExtensionLocation": "ClientSideExtension.ListViewCommandSet.ContextMenu", + "TenantWideExtensionSequence": 0, + "TenantWideExtensionHostProperties": null, + "TenantWideExtensionDisabled": false + }]; let log: any[]; let logger: Logger; @@ -94,7 +93,8 @@ describe(commands.TENANT_COMMANDSET_REMOVE, () => { afterEach(() => { sinonUtil.restore([ - request.get, + spo.getTenantAppCatalogUrl, + spoListItem.getListItems, request.post, cli.getSettingWithDefaultValue, cli.promptForConfirmation, @@ -255,13 +255,7 @@ describe(commands.TENANT_COMMANDSET_REMOVE, () => { it('throws error when tenant app catalog doesn\'t exist', async () => { const errorMessage = 'No app catalog URL found'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: null }; - } - - throw 'Invalid request'; - }); + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(null); await assert.rejects(command.action(logger, { options: { @@ -291,16 +285,17 @@ describe(commands.TENANT_COMMANDSET_REMOVE, () => { }); it('removes a command set by title (debug)', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Title eq 'Some commandset'`) { - return commandSetResponse; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Title eq 'Some commandset'` + ) { + return commandSetResponse as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); const postSpy = sinon.stub(request, 'post').callsFake(async (opts) => { @@ -324,16 +319,17 @@ describe(commands.TENANT_COMMANDSET_REMOVE, () => { }); it('removes a command set by title with confirm', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Title eq 'Some commandset'` + ) { + return commandSetResponse as any; + } } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Title eq 'Some commandset'`) { - return commandSetResponse; - } - - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); const postSpy = sinon.stub(request, 'post').callsFake(async (opts) => { @@ -357,16 +353,17 @@ describe(commands.TENANT_COMMANDSET_REMOVE, () => { }); it('removes a command set by id (debug)', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Id eq 4`) { - return commandSetResponse; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Id eq 4` + ) { + return commandSetResponse as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); const postSpy = sinon.stub(request, 'post').callsFake(async (opts) => { @@ -390,16 +387,17 @@ describe(commands.TENANT_COMMANDSET_REMOVE, () => { }); it('removes a command set by clientSideComponentId (debug)', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'` + ) { + return commandSetResponse as any; + } } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'`) { - return commandSetResponse; - } - - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); const postSpy = sinon.stub(request, 'post').callsFake(async (opts) => { @@ -431,22 +429,20 @@ describe(commands.TENANT_COMMANDSET_REMOVE, () => { return defaultValue; }); - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Title eq 'Some commandset'`) { - return { - value: - [ - { Title: title, Id: id, TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bc' }, - { Title: title, Id: 5, TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bd' } - ] - }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Title eq 'Some commandset'` + ) { + return [ + { Title: title, Id: id, TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bc' }, + { Title: title, Id: 5, TenantWideExtensionComponentId: '7096cded-b83d-4eab-96f0-df477ed7c0bd' } + ] as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { @@ -466,22 +462,20 @@ describe(commands.TENANT_COMMANDSET_REMOVE, () => { return defaultValue; }); - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'` + ) { + return [ + { Title: title, Id: id, TenantWideExtensionComponentId: clientSideComponentId }, + { Title: 'Another commandset', Id: 5, TenantWideExtensionComponentId: clientSideComponentId } + ] as any; + } } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'`) { - return { - value: - [ - { Title: title, Id: id, TenantWideExtensionComponentId: clientSideComponentId }, - { Title: 'Another commandset', Id: 5, TenantWideExtensionComponentId: clientSideComponentId } - ] - }; - } - - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { @@ -493,25 +487,23 @@ describe(commands.TENANT_COMMANDSET_REMOVE, () => { }); it('handles selecting single result when multiple command sets with the specified name found and cli is set to prompt', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Title eq 'Some commandset'`) { - return { - value: - [ - { Title: title, Id: id, TenantWideExtensionComponentId: clientSideComponentId }, - { Title: 'Another commandset', Id: 5, TenantWideExtensionComponentId: clientSideComponentId } - ] - }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Title eq 'Some commandset'` + ) { + return [ + { Title: title, Id: id, TenantWideExtensionComponentId: clientSideComponentId }, + { Title: 'Another commandset', Id: 5, TenantWideExtensionComponentId: clientSideComponentId } + ] as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); - sinon.stub(cli, 'handleMultipleResultsFound').resolves(commandSetResponse.value[0]); + sinon.stub(cli, 'handleMultipleResultsFound').resolves(commandSetResponse[0]); const postSpy = sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items(4)`) { @@ -535,16 +527,17 @@ describe(commands.TENANT_COMMANDSET_REMOVE, () => { it('handles error when specified command set not found', async () => { const errorMessage = 'The specified command set was not found'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Title eq 'Some commandset'` + ) { + return []; + } } - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and Title eq 'Some commandset'`) { - return { value: [] }; - } - - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { @@ -558,16 +551,17 @@ describe(commands.TENANT_COMMANDSET_REMOVE, () => { it('handles error when retrieving command set', async () => { const errorMessage = 'An error has occurred'; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${spoUrl}/_api/SP_TenantSettings_Current`) { - return { CorporateCatalogUrl: appCatalogUrl }; - } - - if (opts.url === `https://contoso.sharepoint.com/sites/apps/_api/web/GetList('%2Fsites%2Fapps%2Flists%2FTenantWideExtensions')/items?$filter=startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'`) { - throw errorMessage; + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(appCatalogUrl); + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === appCatalogUrl) { + if (options.listUrl === `/Lists/TenantWideExtensions` && + options.filter === `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and TenantWideExtensionComponentId eq '7096cded-b83d-4eab-96f0-df477ed7c0bc'` + ) { + throw errorMessage; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await assert.rejects(command.action(logger, { diff --git a/src/m365/spo/commands/tenant/tenant-commandset-remove.ts b/src/m365/spo/commands/tenant/tenant-commandset-remove.ts index 552a2a2184b..c8818ce3916 100644 --- a/src/m365/spo/commands/tenant/tenant-commandset-remove.ts +++ b/src/m365/spo/commands/tenant/tenant-commandset-remove.ts @@ -3,8 +3,8 @@ import { Logger } from '../../../../cli/Logger.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; -import { odata } from '../../../../utils/odata.js'; import { spo } from '../../../../utils/spo.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; @@ -117,16 +117,15 @@ class SpoTenantCommandSetRemoveCommand extends SpoCommand { throw 'No app catalog URL found'; } - const listServerRelativeUrl: string = urlUtil.getServerRelativePath(appCatalogUrl, '/lists/TenantWideExtensions'); - const requestUrl = `${appCatalogUrl}/_api/web/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')`; - const id = await this.getTenantCommandSetId(logger, args, requestUrl); + const id = await this.getTenantCommandSetId(logger, args, appCatalogUrl); if (this.verbose) { await logger.logToStderr(`Removing tenant command set ${args.options.id || args.options.title || args.options.clientSideComponentId}`); } + const listServerRelativeUrl: string = urlUtil.getServerRelativePath(appCatalogUrl, '/lists/TenantWideExtensions'); const requestOptions: CliRequestOptions = { - url: `${requestUrl}/items(${id})`, + url: `${appCatalogUrl}/_api/web/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')/items(${id})`, method: 'POST', headers: { 'X-HTTP-Method': 'DELETE', @@ -139,12 +138,12 @@ class SpoTenantCommandSetRemoveCommand extends SpoCommand { await request.post(requestOptions); } - public async getTenantCommandSetId(logger: Logger, args: CommandArgs, requestUrl: string): Promise { + public async getTenantCommandSetId(logger: Logger, args: CommandArgs, appCatalogUrl: string): Promise { if (this.verbose) { await logger.logToStderr(`Getting the tenant command set ${args.options.id || args.options.title || args.options.clientSideComponentId}`); } - let filter: string = ''; + let filter: string; if (args.options.title) { filter = `Title eq '${args.options.title}'`; } @@ -155,19 +154,27 @@ class SpoTenantCommandSetRemoveCommand extends SpoCommand { filter = `TenantWideExtensionComponentId eq '${args.options.clientSideComponentId}'`; } - const listItemInstances: ListItemInstance[] = await odata.getAllItems(`${requestUrl}/items?$filter=startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and ${filter}`); + const options: ListItemListOptions = { + webUrl: appCatalogUrl, + listUrl: '/Lists/TenantWideExtensions', + filter: `startswith(TenantWideExtensionLocation,'ClientSideExtension.ListViewCommandSet') and ${filter}`, + fields: ['Id'] + }; + + const listItems = await spoListItem.getListItems(options, logger, this.verbose); + - if (listItemInstances.length === 0) { + if (listItems.length === 0) { throw 'The specified command set was not found'; } - if (listItemInstances.length > 1) { - const resultAsKeyValuePair = formatting.convertArrayToHashTable('Id', listItemInstances); + if (listItems.length > 1) { + const resultAsKeyValuePair = formatting.convertArrayToHashTable('Id', listItems); const result = await cli.handleMultipleResultsFound(`Multiple command sets with ${args.options.title || args.options.clientSideComponentId} were found.`, resultAsKeyValuePair); return result.Id; } - return listItemInstances[0].Id; + return listItems[0].Id; } } diff --git a/src/m365/spo/commands/tenant/tenant-recyclebinitem-restore.spec.ts b/src/m365/spo/commands/tenant/tenant-recyclebinitem-restore.spec.ts index 0582df9b465..3ff91127f4e 100644 --- a/src/m365/spo/commands/tenant/tenant-recyclebinitem-restore.spec.ts +++ b/src/m365/spo/commands/tenant/tenant-recyclebinitem-restore.spec.ts @@ -12,8 +12,8 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './tenant-recyclebinitem-restore.js'; -import { odata } from '../../../../utils/odata.js'; import { formatting } from '../../../../utils/formatting.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; describe(commands.TENANT_RECYCLEBINITEM_RESTORE, () => { let log: any[]; @@ -21,8 +21,9 @@ describe(commands.TENANT_RECYCLEBINITEM_RESTORE, () => { let commandInfo: CommandInfo; const siteUrl = 'https://contoso.sharepoint.com/sites/hr'; + const spoAdminUrl = 'https://contoso-admin.sharepoint.com'; const siteRestoreUrl = 'https://contoso-admin.sharepoint.com/_api/SPO.Tenant/RestoreDeletedSite'; - const odataUrl = `https://contoso-admin.sharepoint.com/_api/web/lists/GetByTitle('DO_NOT_DELETE_SPLIST_TENANTADMIN_AGGREGATED_SITECOLLECTIONS')/items?$filter=SiteUrl eq '${formatting.encodeQueryParameter(siteUrl)}'&$select=GroupId`; + const adminSitesListTitle = 'DO_NOT_DELETE_SPLIST_TENANTADMIN_AGGREGATED_SITECOLLECTIONS'; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -52,7 +53,7 @@ describe(commands.TENANT_RECYCLEBINITEM_RESTORE, () => { afterEach(() => { sinonUtil.restore([ request.post, - odata.getAllItems + spoListItem.getListItems ]); }); @@ -95,12 +96,16 @@ describe(commands.TENANT_RECYCLEBINITEM_RESTORE, () => { throw 'Invalid request'; }); - sinon.stub(odata, 'getAllItems').callsFake(async (url: string) => { - if (url === odataUrl) { - return [{ GroupId: groupId }]; + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === spoAdminUrl) { + if (options.listTitle === adminSitesListTitle && + options.filter === `SiteUrl eq '${formatting.encodeQueryParameter(siteUrl)}'` + ) { + return [{ GroupId: groupId }] as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await command.action(logger, { options: { siteUrl: siteUrl, verbose: true } }); @@ -116,12 +121,16 @@ describe(commands.TENANT_RECYCLEBINITEM_RESTORE, () => { throw 'Invalid request'; }); - sinon.stub(odata, 'getAllItems').callsFake(async (url: string) => { - if (url === odataUrl) { - return [{ GroupId: '00000000-0000-0000-0000-000000000000' }]; + sinon.stub(spoListItem, 'getListItems').callsFake(async (options: ListItemListOptions) => { + if (options.webUrl === spoAdminUrl) { + if (options.listTitle === adminSitesListTitle && + options.filter === `SiteUrl eq '${formatting.encodeQueryParameter(siteUrl)}'` + ) { + return [{ GroupId: '00000000-0000-0000-0000-000000000000' }] as any; + } } - throw 'Invalid request'; + throw 'Invalid request: ' + JSON.stringify(options); }); await command.action(logger, { options: { siteUrl: siteUrl, verbose: true } }); diff --git a/src/m365/spo/commands/tenant/tenant-recyclebinitem-restore.ts b/src/m365/spo/commands/tenant/tenant-recyclebinitem-restore.ts index 589f91bb101..c4f069dd70e 100644 --- a/src/m365/spo/commands/tenant/tenant-recyclebinitem-restore.ts +++ b/src/m365/spo/commands/tenant/tenant-recyclebinitem-restore.ts @@ -2,8 +2,8 @@ import { Logger } from '../../../../cli/Logger.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; -import { odata } from '../../../../utils/odata.js'; import { spo } from '../../../../utils/spo.js'; +import { ListItemListOptions, spoListItem } from '../../../../utils/spoListItem.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; @@ -72,7 +72,7 @@ class SpoTenantRecycleBinItemRestoreCommand extends SpoCommand { await request.post(requestOptions); - const groupId = await this.getSiteGroupId(adminUrl, siteUrl); + const groupId = await this.getSiteGroupId(adminUrl, siteUrl, logger); if (groupId && groupId !== '00000000-0000-0000-0000-000000000000') { if (this.verbose) { @@ -96,9 +96,17 @@ class SpoTenantRecycleBinItemRestoreCommand extends SpoCommand { } } - private async getSiteGroupId(adminUrl: string, url: string): Promise { - const sites = await odata.getAllItems<{ GroupId?: string }>(`${adminUrl}/_api/web/lists/GetByTitle('DO_NOT_DELETE_SPLIST_TENANTADMIN_AGGREGATED_SITECOLLECTIONS')/items?$filter=SiteUrl eq '${formatting.encodeQueryParameter(url)}'&$select=GroupId`); - return sites[0].GroupId; + private async getSiteGroupId(adminUrl: string, url: string, logger: Logger): Promise { + const options: ListItemListOptions = { + webUrl: adminUrl, + listTitle: 'DO_NOT_DELETE_SPLIST_TENANTADMIN_AGGREGATED_SITECOLLECTIONS', + filter: `SiteUrl eq '${formatting.encodeQueryParameter(url)}'`, + fields: ['GroupId'] + }; + + const listItems = await spoListItem.getListItems(options, logger, this.verbose); + + return (listItems[0] as any).GroupId; } } diff --git a/src/utils/spoListItem.spec.ts b/src/utils/spoListItem.spec.ts new file mode 100644 index 00000000000..4a3fa152e8c --- /dev/null +++ b/src/utils/spoListItem.spec.ts @@ -0,0 +1,542 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import os from 'os'; +import auth from '../Auth.js'; +import { Logger } from '../cli/Logger.js'; +import request from '../request.js'; +import { sinonUtil } from '../utils/sinonUtil.js'; +import { formatting } from './formatting.js'; +import { ListItemAddOptions, ListItemListOptions, spoListItem } from './spoListItem.js'; +import { urlUtil } from './urlUtil.js'; +import { spo } from './spo.js'; + +describe('utils/spoListItem', () => { + const webUrl = 'https://contoso.sharepoint.com/sites/project-x'; + const listUrl = 'sites/project-x/documents'; + const listServerRelativeUrl: string = urlUtil.getServerRelativePath(webUrl, listUrl); + const listItemResponse = { + value: + [{ + "Attachments": false, + "AuthorId": 3, + "ContentTypeId": "0x0100B21BD271A810EE488B570BE49963EA34", + "Created": "2018-08-15T13:43:12Z", + "EditorId": 3, + "GUID": "2b6bd9e0-3c43-4420-891e-20053e3c4664", + "Id": 1, + "ID": 1, + "Modified": "2018-08-15T13:43:12Z", + "Title": "Example item 1" + }, + { + "Attachments": false, + "AuthorId": 3, + "ContentTypeId": "0x0100B21BD271A810EE488B570BE49963EA34", + "Created": "2018-08-15T13:44:10Z", + "EditorId": 3, + "GUID": "47c5fc61-afb7-4081-aa32-f4386b8a86ea", + "Id": 2, + "ID": 2, + "Modified": "2018-08-15T13:44:10Z", + "Title": "Example item 2" + }] + }; + + let logger: Logger; + let log: string[]; + let ensureFolderStub: sinon.SinonStub; + + const expectedArrayLength = 2; + + const listOperationPostFakes = async (opts: any) => { + if (opts.url.indexOf('/_api/web/lists') > -1) { + if ((opts.url as string).indexOf('/GetItems') > -1) { + return opts.data.query.ListItemCollectionPosition === undefined ? listItemResponse : { value: [] }; + } + } + throw 'Invalid request: ' + JSON.stringify(opts);; + }; + + const listOperationGetFakes = async (opts: any) => { + if (opts.url.indexOf('/_api/web/lists') > -1) { + if ((opts.url as string).indexOf('/items') > -1 && (opts.url as string).indexOf('$top=6') > -1) { + return { value: [] }; + } + if ((opts.url as string).indexOf('/items') > -1) { + return listItemResponse; + } + } + if (opts.url === `https://contoso.sharepoint.com/sites/project-x/_api/web/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')/items?$top=5000&$select=Title%2CID`) { + return listItemResponse; + } + throw 'Invalid request: ' + JSON.stringify(opts); + }; + + + const expectedId = 147; + let actualId = 0; + + const expectedContentType = 'Item'; + let actualContentType = ''; + const expectedTitle = `List Item 1`; + + const addOperationPostFakes = async (opts: any) => { + if (opts.url.indexOf('/_api/web/lists') > -1) { + if ((opts.url as string).indexOf('AddValidateUpdateItemUsingPath') > -1) { + const bodyString = JSON.stringify(opts.data); + const ctMatch = bodyString.match(/\"?FieldName\"?:\s*\"?ContentType\"?,\s*\"?FieldValue\"?:\s*\"?(\w*)\"?/i); + actualContentType = ctMatch ? ctMatch[1] : ""; + if (bodyString.indexOf("fail adding me") > -1) { return Promise.resolve({ value: [{ ErrorMessage: 'failed updating', 'FieldName': 'Title', 'HasException': true }] }); } + return { value: [{ FieldName: "Id", FieldValue: expectedId, HasException: false }] }; + } + } + if (opts.url === `https://contoso.sharepoint.com/sites/project-x/_api/web/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')/AddValidateUpdateItemUsingPath()`) { + const bodyString = JSON.stringify(opts.data); + const ctMatch = bodyString.match(/\"?FieldName\"?:\s*\"?ContentType\"?,\s*\"?FieldValue\"?:\s*\"?(\w*)\"?/i); + actualContentType = ctMatch ? ctMatch[1] : ""; + if (bodyString.indexOf("fail adding me") > -1) { return Promise.resolve({ value: [] }); } + return { value: [{ FieldName: "Id", FieldValue: expectedId }] }; + } + throw 'Invalid request'; + }; + + const addOperationGetFakes = async (opts: any) => { + if (opts.url.indexOf('/_api/web/lists') > -1) { + if ((opts.url as string).indexOf('contenttypes') > -1) { + return { value: [{ Id: { StringValue: expectedContentType }, Name: "Item" }] }; + } + if ((opts.url as string).indexOf('rootFolder') > -1) { + return { ServerRelativeUrl: '/sites/project-xxx/Lists/Demo%20List' }; + } + if ((opts.url as string).indexOf('/items(') > -1) { + actualId = parseInt(opts.url.match(/\/items\((\d+)\)/i)[1]); + return { + "Attachments": false, + "AuthorId": 3, + "ContentTypeId": "0x0100B21BD271A810EE488B570BE49963EA34", + "Created": "2018-03-15T10:43:10Z", + "EditorId": 3, + "GUID": "ea093c7b-8ae6-4400-8b75-e2d01154dffc", + "Id": actualId, + "ID": actualId, + "Modified": "2018-03-15T10:43:10Z", + "Title": expectedTitle + }; + } + } + if (opts.url === `https://contoso.sharepoint.com/sites/project-x/_api/web/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')/contenttypes?$select=Name,Id`) { + return { value: [{ Id: { StringValue: expectedContentType }, Name: "Item" }] }; + } + if (opts.url === `https://contoso.sharepoint.com/sites/project-x/_api/web/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')/items(147)`) { + actualId = parseInt(opts.url.match(/\/items\((\d+)\)/i)[1]); + return { + "Attachments": false, + "AuthorId": 3, + "ContentTypeId": "0x0100B21BD271A810EE488B570BE49963EA34", + "Created": "2018-03-15T10:43:10Z", + "EditorId": 3, + "GUID": "ea093c7b-8ae6-4400-8b75-e2d01154dffc", + "Id": actualId, + "ID": actualId, + "Modified": "2018-03-15T10:43:10Z", + "Title": expectedTitle + }; + } + throw 'Invalid request'; + }; + + before(() => { + auth.connection.active = true; + sinon.stub(spo, 'getRequestDigest').callsFake(() => Promise.resolve({ + FormDigestValue: 'abc', + FormDigestTimeoutSeconds: 1800, + FormDigestExpiresAt: new Date(), + WebFullUrl: 'https://contoso.sharepoint.com' + })); + ensureFolderStub = sinon.stub(spo, 'ensureFolder').resolves(); + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + }); + + afterEach(() => { + sinonUtil.restore([ + request.get, + request.post + ]); + auth.connection.spoUrl = undefined; + auth.connection.spoTenantId = undefined; + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('returns array of listItemInstance objects when a list of items is requested, and debug mode enabled', async () => { + sinon.stub(request, 'get').callsFake(listOperationGetFakes); + sinon.stub(request, 'post').callsFake(listOperationPostFakes); + + const options: ListItemListOptions = { + listTitle: 'Demo List', + webUrl: 'https://contoso.sharepoint.com/sites/project-x' + }; + + const listItems = await spoListItem.getListItems(options, logger, true); + assert.strictEqual(listItems.length, expectedArrayLength); + }); + + it('returns array of listItemInstance objects when a list of items is requested, and a list of fields and a filter specified', async () => { + const listTitle = `Test'list`; + const filter = `Title eq 'Demo list item'`; + const fields = 'Title,ID'; + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/project-x/_api/web/lists/getByTitle('${formatting.encodeQueryParameter(listTitle)}')/items?$top=2&$filter=${encodeURIComponent(filter)}&$select=${formatting.encodeQueryParameter(fields)}`) { + return listItemResponse; + } + throw 'Invalid request'; + }); + + const options: ListItemListOptions = { + listTitle: listTitle, + webUrl: 'https://contoso.sharepoint.com/sites/project-x', + pageSize: 2, + filter: filter, + fields: ['Title', 'ID'] + }; + + const listItems = await spoListItem.getListItems(options, logger, true); + assert.strictEqual(listItems.length, expectedArrayLength); + }); + + it('returns array of listItemInstance objects when a list of items is requested with an output type of json, a page number specified, a list of fields and a filter specified', async () => { + sinon.stub(request, 'get').callsFake(listOperationGetFakes); + sinon.stub(request, 'post').callsFake(listOperationPostFakes); + + const options: ListItemListOptions = { + listTitle: 'Demo List', + webUrl: 'https://contoso.sharepoint.com/sites/project-x', + pageSize: 2, + pageNumber: 2, + filter: "Title eq 'Demo list item", + fields: ['Title', 'ID'] + }; + + const listItems = await spoListItem.getListItems(options, logger, true); + assert.strictEqual(listItems.length, expectedArrayLength); + }); + + it('returns empty array of listItemInstance objects when a list of items is requested with an output type of json, a page number specified, a list of fields and a filter specified', async () => { + sinon.stub(request, 'get').callsFake(listOperationGetFakes); + sinon.stub(request, 'post').callsFake(listOperationPostFakes); + + const options: ListItemListOptions = { + listTitle: 'Demo List', + webUrl: 'https://contoso.sharepoint.com/sites/project-x', + pageSize: 3, + pageNumber: 2, + filter: "Title eq 'Demo list item", + fields: ['Title', 'ID'] + }; + + const listItems = await spoListItem.getListItems(options, logger, true); + assert.strictEqual(listItems.length, 0); + }); + + it('returns array of listItemInstance objects when a list of items is requested with an output type of json, and a pageNumber is specified', async () => { + sinon.stub(request, 'get').callsFake(listOperationGetFakes); + sinon.stub(request, 'post').callsFake(listOperationPostFakes); + + const options: ListItemListOptions = { + listTitle: 'Demo List', + webUrl: 'https://contoso.sharepoint.com/sites/project-x', + pageSize: 2, + pageNumber: 2, + fields: ['Title', 'ID'] + }; + + const listItems = await spoListItem.getListItems(options, logger, true); + assert.strictEqual(listItems.length, expectedArrayLength); + }); + + it('returns array of listItemInstance objects when a list of items is requested with no output type specified, and a list of fields specified', async () => { + sinon.stub(request, 'get').callsFake(listOperationGetFakes); + sinon.stub(request, 'post').callsFake(listOperationPostFakes); + + const options: ListItemListOptions = { + listTitle: 'Demo List', + webUrl: 'https://contoso.sharepoint.com/sites/project-x', + fields: ['Title', 'ID'] + }; + + const listItems = await spoListItem.getListItems(options, logger, true); + assert.strictEqual(listItems.length, expectedArrayLength); + }); + + it('returns array of listItemInstance objects when a list of items by list url is requested with no output type specified, and a list of fields specified', async () => { + sinon.stub(request, 'get').callsFake(listOperationGetFakes); + sinon.stub(request, 'post').callsFake(listOperationPostFakes); + + const options: ListItemListOptions = { + listUrl: listUrl, + webUrl: webUrl, + fields: ['Title', 'ID'] + }; + + const listItems = await spoListItem.getListItems(options, logger, true); + assert.strictEqual(listItems.length, expectedArrayLength); + }); + + it('returns array of listItemInstance objects when a list of items is requested with no output type specified, a list of fields with lookup field specified', async () => { + sinon.stub(request, 'get').callsFake(opts => { + if ((opts.url as string).indexOf('$expand=') > -1) { + return Promise.resolve({ + value: + [{ + "ID": 1, + "Modified": "2018-08-15T13:43:12Z", + "Title": "Example item 1", + "Company": { "Title": "Contoso" } + }, + { + "ID": 2, + "Modified": "2018-08-15T13:44:10Z", + "Title": "Example item 2", + "Company": { "Title": "Fabrikam" } + }] + }); + } + + return Promise.reject('Invalid request'); + }); + + const options: ListItemListOptions = { + listTitle: 'Demo List', + webUrl: 'https://contoso.sharepoint.com/sites/project-x', + fields: ['Title', 'Modified', 'Company/Title'] + }; + + const listItems = await spoListItem.getListItems(options, logger, true); + assert.deepStrictEqual(JSON.stringify(listItems), JSON.stringify([ + { + "Modified": "2018-08-15T13:43:12Z", + "Title": "Example item 1", + "Company": { "Title": "Contoso" } + }, + { + "Modified": "2018-08-15T13:44:10Z", + "Title": "Example item 2", + "Company": { "Title": "Fabrikam" } + } + ])); + }); + + it('returns array of listItemInstance objects when a list of items is requested with an output type of json, and no fields specified', async () => { + sinon.stub(request, 'get').callsFake(listOperationGetFakes); + sinon.stub(request, 'post').callsFake(listOperationPostFakes); + + const options: ListItemListOptions = { + listTitle: 'Demo List', + webUrl: 'https://contoso.sharepoint.com/sites/project-x' + }; + + const listItems = await spoListItem.getListItems(options, logger, true); + assert.strictEqual(listItems.length, expectedArrayLength); + }); + + it('returns array of listItemInstance objects when a list of items is requested with a camlQuery specified, and output set to json, and debug mode is enabled', async () => { + sinon.stub(request, 'get').callsFake(listOperationGetFakes); + sinon.stub(request, 'post').callsFake(listOperationPostFakes); + + const options: ListItemListOptions = { + listTitle: 'Demo List', + webUrl: 'https://contoso.sharepoint.com/sites/project-x', + camlQuery: "Demo List Item 1" + }; + + const listItems = await spoListItem.getListItems(options, logger, true); + assert.strictEqual(listItems.length, expectedArrayLength); + }); + + it('returns array of listItemInstance objects when a list of items is requested with a camlQuery specified', async () => { + sinon.stub(request, 'get').callsFake(listOperationGetFakes); + sinon.stub(request, 'post').callsFake(listOperationPostFakes); + + const options: ListItemListOptions = { + listTitle: 'Demo List', + webUrl: 'https://contoso.sharepoint.com/sites/project-x', + camlQuery: "Demo List Item 1" + }; + + const listItems = await spoListItem.getListItems(options, logger, true); + assert.strictEqual(listItems.length, expectedArrayLength); + }); + + it('correctly handles random API error', async () => { + sinon.stub(request, 'get').callsFake(listOperationGetFakes); + sinon.stub(request, 'post').callsFake(() => Promise.reject(new Error('An error has occurred'))); + + const options: ListItemListOptions = { + listId: '935c13a0-cc53-4103-8b48-c1d0828eaa7f', + webUrl: 'https://contoso.sharepoint.com/sites/project-x', + camlQuery: "Demo List Item 1" + }; + + await assert.rejects(spoListItem.getListItems(options, logger, true), new Error('An error has occurred')); + }); + + it('fails to create a list item when \'fail me\' values are used', async () => { + actualId = 0; + + sinon.stub(request, 'get').callsFake(addOperationGetFakes); + sinon.stub(request, 'post').callsFake(addOperationPostFakes); + + const options: ListItemAddOptions = { + listTitle: 'Demo List', + webUrl: webUrl, + fieldValues: { Title: "fail adding me" } + }; + + await assert.rejects(spoListItem.addListItem(options, logger, true, true), new Error(`Creating the item failed with the following errors: ${os.EOL}- Title - failed updating`)); + assert.strictEqual(actualId, 0); + }); + + it('returns listItemInstance object when list item is added with correct values', async () => { + sinon.stub(request, 'get').callsFake(addOperationGetFakes); + sinon.stub(request, 'post').callsFake(addOperationPostFakes); + + const options: ListItemAddOptions = { + listTitle: 'Demo List', + webUrl: webUrl, + fieldValues: { Title: expectedTitle } + }; + + await spoListItem.addListItem(options, logger, true, true); + assert.strictEqual(actualId, expectedId); + }); + + it('creates list item in the list specified using ID', async () => { + sinon.stub(request, 'get').callsFake(addOperationGetFakes); + sinon.stub(request, 'post').callsFake(addOperationPostFakes); + + const options: ListItemAddOptions = { + listId: 'cf8c72a1-0207-40ee-aebd-fca67d20bc8a', + webUrl: webUrl, + fieldValues: { Title: expectedTitle } + }; + + await spoListItem.addListItem(options, logger, true, true); + assert.strictEqual(actualId, expectedId); + }); + + it('creates list item in the list specified using URL', async () => { + sinon.stub(request, 'get').callsFake(addOperationGetFakes); + sinon.stub(request, 'post').callsFake(addOperationPostFakes); + + const options: ListItemAddOptions = { + listUrl: listUrl, + webUrl: webUrl, + fieldValues: { Title: expectedTitle } + }; + + await spoListItem.addListItem(options, logger, true, true); + assert.strictEqual(actualId, expectedId); + }); + + + it('attempts to create the listitem with the contenttype of \'Item\' when content type option 0x01 is specified', async () => { + sinon.stub(request, 'get').callsFake(addOperationGetFakes); + sinon.stub(request, 'post').callsFake(addOperationPostFakes); + + const options: ListItemAddOptions = { + listTitle: 'Demo List', + webUrl: webUrl, + contentType: expectedContentType, + fieldValues: { Title: expectedTitle } + }; + + await spoListItem.addListItem(options, logger, true, true); + assert(expectedContentType === actualContentType); + }); + + it('fails to create the listitem when the specified contentType doesn\'t exist in the target list', async () => { + sinon.stub(request, 'get').callsFake(addOperationGetFakes); + sinon.stub(request, 'post').callsFake(addOperationPostFakes); + + const options: ListItemAddOptions = { + listTitle: 'Demo List', + webUrl: webUrl, + contentType: "Unexpected content type", + fieldValues: { Title: expectedTitle } + }; + + await assert.rejects(spoListItem.addListItem(options, logger, true, true), new Error("Specified content type 'Unexpected content type' doesn't exist on the target list")); + }); + + it('should call ensure folder when folder arg specified', async () => { + sinon.stub(request, 'get').callsFake(addOperationGetFakes); + sinon.stub(request, 'post').callsFake(addOperationPostFakes); + + const options: ListItemAddOptions = { + listTitle: 'Demo List', + webUrl: webUrl, + fieldValues: { Title: expectedTitle }, + contentType: expectedContentType, + folder: "InsideFolder2" + }; + + await spoListItem.addListItem(options, logger, true, true); + + assert.strictEqual(ensureFolderStub.lastCall.args[0], 'https://contoso.sharepoint.com/sites/project-x'); + assert.strictEqual(ensureFolderStub.lastCall.args[1], '/sites/project-xxx/Lists/Demo%20List/InsideFolder2'); + }); + + it('should call ensure folder when folder arg specified (debug)', async () => { + sinon.stub(request, 'get').callsFake(addOperationGetFakes); + sinon.stub(request, 'post').callsFake(addOperationPostFakes); + + const options: ListItemAddOptions = { + listTitle: 'Demo List', + webUrl: webUrl, + fieldValues: { Title: expectedTitle }, + contentType: expectedContentType, + folder: "InsideFolder2/Folder3" + }; + + await spoListItem.addListItem(options, logger, true, true); + + assert.strictEqual(ensureFolderStub.lastCall.args[0], 'https://contoso.sharepoint.com/sites/project-x'); + assert.strictEqual(ensureFolderStub.lastCall.args[1], '/sites/project-xxx/Lists/Demo%20List/InsideFolder2/Folder3'); + }); + + it('should not have end \'/\' in the folder path when FolderPath.DecodedUrl ', async () => { + sinon.stub(request, 'get').callsFake(addOperationGetFakes); + const postStubs = sinon.stub(request, 'post').callsFake(addOperationPostFakes); + + const options: ListItemAddOptions = { + listTitle: 'Demo List', + webUrl: webUrl, + fieldValues: { Title: expectedTitle }, + contentType: expectedContentType, + folder: "InsideFolder2/Folder3/" + }; + + await spoListItem.addListItem(options, logger, true, true); + + const addValidateUpdateItemUsingPathRequest = postStubs.getCall(postStubs.callCount - 1).args[0]; + const info = addValidateUpdateItemUsingPathRequest.data.listItemCreateInfo; + assert.strictEqual(info.FolderPath.DecodedUrl, '/sites/project-xxx/Lists/Demo%20List/InsideFolder2/Folder3'); + }); +}); \ No newline at end of file diff --git a/src/utils/spoListItem.ts b/src/utils/spoListItem.ts new file mode 100644 index 00000000000..c0b5c5b909d --- /dev/null +++ b/src/utils/spoListItem.ts @@ -0,0 +1,365 @@ +import os from 'os'; +import { urlUtil } from "./urlUtil.js"; +import { Logger } from "../cli/Logger.js"; +import request, { CliRequestOptions } from "../request.js"; +import { formatting } from './formatting.js'; +import { odata, ODataResponse } from './odata.js'; +import { ListItemInstance } from '../m365/spo/commands/listitem/ListItemInstance.js'; +import { ListItemFieldValueResult } from '../m365/spo/commands/listitem/ListItemFieldValueResult.js'; +import { ListItemInstanceCollection } from '../m365/spo/commands/listitem/ListItemInstanceCollection.js'; +import { spo } from './spo.js'; +import { basic } from './basic.js'; + +interface ContentType { + Id: { + StringValue: string; + }; + Name: string; +} +interface ListSelectionOptions { + webUrl: string; + listId?: string; + listTitle?: string; + listUrl?: string; +} + +export interface ListItemListOptions extends ListSelectionOptions { + fields?: string[]; + filter?: string; + pageNumber?: number; + pageSize?: number; + camlQuery?: string; + webUrl: string; +} + +export interface ListItemAddOptions extends ListSelectionOptions { + contentType?: string; + folder?: string; + fieldValues: { [key: string]: any }; +} + +function getListApiUrl(options: ListSelectionOptions): string { + let listApiUrl = `${options.webUrl}/_api/web`; + + if (options.listId) { + listApiUrl += `/lists(guid'${formatting.encodeQueryParameter(options.listId)}')`; + } + else if (options.listTitle) { + listApiUrl += `/lists/getByTitle('${formatting.encodeQueryParameter(options.listTitle)}')`; + } + else if (options.listUrl) { + const listServerRelativeUrl: string = urlUtil.getServerRelativePath(options.webUrl, options.listUrl); + listApiUrl += `/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')`; + } + + return listApiUrl; +}; + +function getExpandFieldsArray(fieldsArray: string[]): string[] { + const fieldsWithSlash: string[] = fieldsArray.filter(item => item.includes('/')); + const fieldsToExpand: string[] = fieldsWithSlash.map(e => e.split('/')[0]); + const expandFieldsArray: string[] = fieldsToExpand.filter((item, pos) => fieldsToExpand.indexOf(item) === pos); + return expandFieldsArray; +} + +async function getLastItemIdForPage(options: ListItemListOptions, listApiUrl: string, logger: Logger, verbose: boolean): Promise { + if (!(options.pageNumber) || Number(options.pageNumber) === 0 || !(options.pageSize)) { + return undefined; + } + + if (verbose) { + await logger.logToStderr(`Getting skipToken Id for page ${options.pageNumber}`); + } + + const rowLimit: string = `$top=${Number(options.pageSize) * Number(options.pageNumber)}`; + const filter: string = options.filter ? `$filter=${encodeURIComponent(options.filter)}` : ``; + + const requestOptions: CliRequestOptions = { + url: `${listApiUrl}/items?$select=Id&${rowLimit}&${filter}`, + headers: { + 'accept': 'application/json;odata=nometadata' + }, + responseType: 'json' + }; + + const response = await request.get<{ value: [{ Id: number }] }>(requestOptions); + return response.value[response.value.length - 1]?.Id; +} + +async function getListItemsByCamlQuery(options: ListItemListOptions, listApiUrl: string, logger: Logger, verbose: boolean): Promise { + const formDigestValue = (await spo.getRequestDigest(options.webUrl)).FormDigestValue; + + if (verbose) { + await logger.logToStderr(`Getting list items using CAML query`); + } + + const items: ListItemInstance[] = []; + let skipTokenId: number | undefined = undefined; + + do { + const requestBody: any = { + "query": { + "ViewXml": options.camlQuery, + "AllowIncrementalResults": true + } + }; + + if (skipTokenId !== undefined) { + requestBody.query.ListItemCollectionPosition = { + "PagingInfo": `Paged=TRUE&p_ID=${skipTokenId}` + }; + } + + const requestOptions: CliRequestOptions = { + url: `${listApiUrl}/GetItems`, + headers: { + 'accept': 'application/json;odata=nometadata', + 'X-RequestDigest': formDigestValue + }, + responseType: 'json', + data: requestBody + }; + + const listItemInstances = await request.post(requestOptions); + skipTokenId = listItemInstances.value.length > 0 ? listItemInstances.value[listItemInstances.value.length - 1].Id : undefined; + items.push(...listItemInstances.value); + } + while (skipTokenId !== undefined); + + return items; +} + +async function getListItems(options: ListItemListOptions, listApiUrl: string, logger: Logger, verbose: boolean): Promise { + if (verbose) { + await logger.logToStderr(`Getting list items`); + } + + const fieldsArray: string[] = options.fields ? options.fields : []; + const expandFieldsArray: string[] = getExpandFieldsArray(fieldsArray); + const queryParams = [options.pageSize ? `$top=${options.pageSize}` : '$top=5000']; + const skipTokenId = await getLastItemIdForPage(options, listApiUrl, logger, verbose); + + if (options.filter) { + queryParams.push(`$filter=${encodeURIComponent(options.filter)}`); + } + + if (expandFieldsArray.length > 0) { + queryParams.push(`$expand=${expandFieldsArray.join(",")}`); + } + + if (fieldsArray.length > 0) { + queryParams.push(`$select=${formatting.encodeQueryParameter(fieldsArray.join(','))}`); + } + + if (skipTokenId !== undefined) { + queryParams.push(`$skiptoken=Paged=TRUE%26p_ID=${skipTokenId}`); + } + + // If skiptoken is not found, then we are past the last page + if (options.pageNumber && Number(options.pageNumber) > 0 && skipTokenId === undefined) { + return []; + } + + if (!options.pageSize) { + return await odata.getAllItems(`${listApiUrl}/items?${queryParams.join('&')}`); + } + + const requestOptions: CliRequestOptions = { + url: `${listApiUrl}/items?${queryParams.join('&')}`, + headers: { + 'accept': 'application/json;odata=nometadata' + }, + responseType: 'json' + }; + + const listItemCollection = await request.get(requestOptions); + return listItemCollection.value; +} + +async function getContentTypeName(options: ListItemAddOptions, listApiUrl: string, logger: Logger, verbose: boolean, debug: boolean): Promise { + if (!options.contentType) { + return undefined; + } + + let contentTypeName: string = ''; + + if (verbose) { + await logger.logToStderr(`Getting content types for list...`); + } + + const requestOptions: CliRequestOptions = { + url: `${listApiUrl}/contenttypes?$select=Name,Id`, + headers: { + 'accept': 'application/json;odata=nometadata' + }, + responseType: 'json' + }; + + const contentTypes = await request.get>(requestOptions); + const foundContentType = await basic.asyncFilter(contentTypes.value, async (ct: ContentType) => { + const contentTypeMatch: boolean = ct.Id.StringValue === options.contentType || ct.Name === options.contentType; + + if (debug) { + await logger.logToStderr(`Checking content type value [${ct.Name}]: ${contentTypeMatch}`); + } + + return contentTypeMatch; + }); + + if (debug) { + await logger.logToStderr('Content type filter output...'); + await logger.logToStderr(foundContentType); + } + + if (foundContentType.length > 0) { + contentTypeName = foundContentType[0].Name; + } + + // After checking for content types, throw an error if the name is blank + if (!contentTypeName || contentTypeName === '') { + throw new Error(`Specified content type '${options.contentType}' doesn't exist on the target list`); + } + + if (debug) { + await logger.logToStderr(`Using content type name: ${contentTypeName}`); + } + + return contentTypeName; +} + +async function ensureTargetFolder(options: ListItemAddOptions, listApiUrl: string, logger: Logger, verbose: boolean, debug: boolean): Promise { + if (!options.folder) { + return undefined; + } + + if (verbose) { + await logger.logToStderr('Setting up folder lookup response ...'); + } + + const requestOptions: CliRequestOptions = { + url: `${listApiUrl}/rootFolder`, + headers: { + 'accept': 'application/json;odata=nometadata' + }, + responseType: 'json' + }; + + const rootFolderResponse = await request.get(requestOptions); + const targetFolderServerRelativeUrl = urlUtil.getServerRelativePath(rootFolderResponse["ServerRelativeUrl"], options.folder as string); + await spo.ensureFolder(options.webUrl, targetFolderServerRelativeUrl, logger, debug === true); + + return targetFolderServerRelativeUrl; +} + +function mapListItemCreationRequestBody(options: ListItemAddOptions): any { + const requestBody: any = []; + + Object.keys(options.fieldValues).forEach(key => { + requestBody.push({ FieldName: key, FieldValue: `${options.fieldValues[key]}` }); + }); + + return requestBody; +} + +export const spoListItem = { + /** + * Get the listitems of a SharePoint list. + * Returns an array of ListItemInstance or an array with the field properties if supplied + * @param options The options to get the list items + * @param logger The logger object + * @param verbose If the function is executed in verbose mode + */ + async getListItems(options: ListItemListOptions, logger: Logger, verbose: boolean): Promise { + const listApiUrl = getListApiUrl(options); + + const listItems = options.camlQuery ? + await getListItemsByCamlQuery(options, listApiUrl, logger, verbose) : + await getListItems(options, listApiUrl, logger, verbose); + + listItems.forEach(v => delete v['ID']); + + return listItems; + }, + + /** + * Adds a list item to a list + * Returns a ListItemInstance object + * @param options The options relting to then new listitem + * @param logger the Logger object + * @param verbose If the function is executed in verbose mode + * @param debug If the function is executed in debug mode + */ + async addListItem(options: ListItemAddOptions, logger: Logger, verbose: boolean, debug: boolean): Promise { + const listApiUrl = getListApiUrl(options); + + const contentTypeName: string | undefined = await getContentTypeName(options, listApiUrl, logger, verbose, debug); + const targetFolderServerRelativeUrl: string | undefined = await ensureTargetFolder(options, listApiUrl, logger, verbose, debug); + + const requestBody: any = { + formValues: mapListItemCreationRequestBody(options) + }; + + if (options.folder) { + requestBody.listItemCreateInfo = { + FolderPath: { + DecodedUrl: targetFolderServerRelativeUrl + } + }; + } + + if (options.contentType && contentTypeName !== undefined) { + if (debug) { + await logger.logToStderr(`Specifying content type name [${contentTypeName}] in request body`); + } + + requestBody.formValues.push({ + FieldName: 'ContentType', + FieldValue: contentTypeName + }); + } + + if (verbose) { + await logger.logToStderr(`Adding a list item in list '${options.listId || options.listTitle || options.listUrl}'...`); + } + + const postRequestOptions: CliRequestOptions = { + url: `${listApiUrl}/AddValidateUpdateItemUsingPath()`, + headers: { + 'accept': 'application/json;odata=nometadata' + }, + data: requestBody, + responseType: 'json' + }; + + const response = await request.post(postRequestOptions); + + // Response is from /AddValidateUpdateItemUsingPath POST call, perform get on added item to get all field values + const fieldValues: ListItemFieldValueResult[] = response.value; + if (fieldValues.some(f => f.HasException)) { + throw new Error(`Creating the item failed with the following errors: ${os.EOL}${fieldValues.filter(f => f.HasException).map(f => { return `- ${f.FieldName} - ${f.ErrorMessage}`; }).join(os.EOL)}`); + } + + const idField = fieldValues.filter((thisField) => { + return (thisField.FieldName === "Id"); + }); + + if (debug) { + await logger.logToStderr(`Field values returned:`); + await logger.logToStderr(fieldValues); + await logger.logToStderr(`Id returned by AddValidateUpdateItemUsingPath: ${idField[0].FieldValue}`); + } + + const getRequestOptions: CliRequestOptions = { + url: `${listApiUrl}/items(${idField[0].FieldValue})`, + headers: { + 'accept': 'application/json;odata=nometadata' + }, + responseType: 'json' + }; + + const item = await request.get(getRequestOptions); + delete item.ID; + + return item; + } +}; \ No newline at end of file