diff --git a/src/m365/spp/commands/contentcenter/contentcenter-list.spec.ts b/src/m365/spp/commands/contentcenter/contentcenter-list.spec.ts index 9f2a27a1573..6c88c445f3e 100644 --- a/src/m365/spp/commands/contentcenter/contentcenter-list.spec.ts +++ b/src/m365/spp/commands/contentcenter/contentcenter-list.spec.ts @@ -1,6 +1,8 @@ import assert from 'assert'; import sinon from 'sinon'; import auth from '../../../../Auth.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import config from '../../../../config.js'; @@ -11,12 +13,14 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import { spo } from '../../../../utils/spo.js'; import commands from '../../commands.js'; -import command from './contentcenter-list.js'; +import command, { options } from './contentcenter-list.js'; describe(commands.CONTENTCENTER_LIST, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -27,6 +31,8 @@ describe(commands.CONTENTCENTER_LIST, () => { sinon.stub(spo, 'ensureFormDigest').resolves({ FormDigestValue: 'abc', FormDigestTimeoutSeconds: 1800, FormDigestExpiresAt: new Date(), WebFullUrl: 'https://contoso.sharepoint.com' }); auth.connection.active = true; auth.connection.spoUrl = 'https://contoso.sharepoint.com'; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -69,6 +75,16 @@ describe(commands.CONTENTCENTER_LIST, () => { assert.deepStrictEqual(command.defaultProperties(), ['Title', 'Url']); }); + it('passes validation with no options', () => { + const actual = commandOptionsSchema.safeParse({}); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ unknownOption: 'value' }); + assert.strictEqual(actual.success, false); + }); + it('retrieves list of content centers', async () => { sinon.stub(request, 'post').callsFake(async (opts) => { if ((opts.url === `https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery`)) { @@ -99,7 +115,7 @@ describe(commands.CONTENTCENTER_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(loggerLogSpy.calledOnceWithExactly([ { "_ObjectType_": "Microsoft.Online.SharePoint.TenantAdministration.SiteProperties", "_ObjectIdentity_": "487c379e-80f8-4000-80be-1d37a4995717|908bed80-a04a-4433-b4a0-883d9847d110:67753f63-bc14-4012-869e-f808a43fe023\nSiteProperties\nhttps%3a%2f%2fcontoso.sharepoint.com%2fsites%2fctest_101", "AllowDownloadingNonWebViewableFiles": false, "AllowEditing": false, "AllowSelfServiceUpgrade": true, "AverageResourceUsage": 0, "CommentsOnSitePagesDisabled": false, "CompatibilityLevel": 15, "ConditionalAccessPolicy": 0, "CurrentResourceUsage": 0, "DenyAddAndCustomizePages": 2, "DisableAppViews": 0, "DisableCompanyWideSharingLinks": 0, "DisableFlows": 0, "HasHolds": false, "LastContentModifiedDate": "/Date(2017,11,17,4,12,28,997)/", "Lcid": 1033, "LockIssue": null, "LockState": "Unlock", "NewUrl": "", "Owner": "", "OwnerEmail": null, "PWAEnabled": 0, "RestrictedToRegion": 3, "SandboxedCodeActivationCapability": 0, "SharingAllowedDomainList": null, "SharingBlockedDomainList": null, "SharingCapability": 1, "SharingDomainRestrictionMode": 0, "ShowPeoplePickerSuggestionsForGuestUsers": false, "SiteDefinedSharingCapability": 0, "Status": "Active", "StorageMaximumLevel": 26214400, "StorageQuotaType": null, "StorageUsage": 1, "StorageWarningLevel": 25574400, "Template": "CONTENTCTR#0", "TimeZoneId": 13, "Title": "Content Center 101", "Url": "https:\u002f\u002fcontoso.sharepoint.com\u002fsites\u002fctest_101", "UserCodeMaximumLevel": 300, "UserCodeWarningLevel": 200, "WebsCount": 0 @@ -160,7 +176,7 @@ describe(commands.CONTENTCENTER_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(loggerLogSpy.calledOnceWith([ { "_ObjectType_": "Microsoft.Online.SharePoint.TenantAdministration.SiteProperties", "_ObjectIdentity_": "487c379e-80f8-4000-80be-1d37a4995717|908bed80-a04a-4433-b4a0-883d9847d110:67753f63-bc14-4012-869e-f808a43fe023\nSiteProperties\nhttps%3a%2f%2fcontoso.sharepoint.com%2fsites%2fctest_101", "AllowDownloadingNonWebViewableFiles": false, "AllowEditing": false, "AllowSelfServiceUpgrade": true, "AverageResourceUsage": 0, "CommentsOnSitePagesDisabled": false, "CompatibilityLevel": 15, "ConditionalAccessPolicy": 0, "CurrentResourceUsage": 0, "DenyAddAndCustomizePages": 2, "DisableAppViews": 0, "DisableCompanyWideSharingLinks": 0, "DisableFlows": 0, "HasHolds": false, "LastContentModifiedDate": "/Date(2017,11,17,4,12,28,997)/", "Lcid": 1033, "LockIssue": null, "LockState": "Unlock", "NewUrl": "", "Owner": "", "OwnerEmail": null, "PWAEnabled": 0, "RestrictedToRegion": 3, "SandboxedCodeActivationCapability": 0, "SharingAllowedDomainList": null, "SharingBlockedDomainList": null, "SharingCapability": 1, "SharingDomainRestrictionMode": 0, "ShowPeoplePickerSuggestionsForGuestUsers": false, "SiteDefinedSharingCapability": 0, "Status": "Active", "StorageMaximumLevel": 26214400, "StorageQuotaType": null, "StorageUsage": 1, "StorageWarningLevel": 25574400, "Template": "CONTENTCTR#0", "TimeZoneId": 13, "Title": "Content Center 101", "Url": "https:\u002f\u002fcontoso.sharepoint.com\u002fsites\u002fctest_101", "UserCodeMaximumLevel": 300, "UserCodeWarningLevel": 200, "WebsCount": 0 @@ -193,12 +209,12 @@ describe(commands.CONTENTCENTER_LIST, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { debug: true } } as any), new CommandError("Syntax error in the filter expression 'Url like 'test''.")); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ debug: true }) }), new CommandError("Syntax error in the filter expression 'Url like 'test''.")); }); it('correctly handles random API error', async () => { sinon.stub(request, 'post').rejects(new Error('An error has occurred')); - await assert.rejects(command.action(logger, { options: { debug: true } } as any), new CommandError('An error has occurred')); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ debug: true }) }), new CommandError('An error has occurred')); }); }); diff --git a/src/m365/spp/commands/contentcenter/contentcenter-list.ts b/src/m365/spp/commands/contentcenter/contentcenter-list.ts index 71d48a49049..55e478123d1 100644 --- a/src/m365/spp/commands/contentcenter/contentcenter-list.ts +++ b/src/m365/spp/commands/contentcenter/contentcenter-list.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; import config from '../../../../config.js'; import request, { CliRequestOptions } from '../../../../request.js'; @@ -7,6 +9,8 @@ import { SiteProperties } from '../../../spo/commands/site/SiteProperties.js'; import { SPOSitePropertiesEnumerable } from '../../../spo/commands/site/SPOSitePropertiesEnumerable.js'; import commands from '../../commands.js'; +export const options = globalOptionsZod.strict(); + class SppContentCenterListCommand extends SpoCommand { public get name(): string { return commands.CONTENTCENTER_LIST; @@ -20,6 +24,10 @@ class SppContentCenterListCommand extends SpoCommand { return ['Title', 'Url']; } + public get schema(): z.ZodType | undefined { + return options; + } + public async commandAction(logger: Logger): Promise { try { if (this.verbose) { diff --git a/src/m365/spp/commands/model/model-get.spec.ts b/src/m365/spp/commands/model/model-get.spec.ts index e6627996c3e..b91f9e10982 100644 --- a/src/m365/spp/commands/model/model-get.spec.ts +++ b/src/m365/spp/commands/model/model-get.spec.ts @@ -11,7 +11,7 @@ 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 './model-get.js'; +import command, { options } from './model-get.js'; import { spp } from '../../../../utils/spp.js'; describe(commands.MODEL_GET, () => { @@ -19,6 +19,7 @@ describe(commands.MODEL_GET, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; const model = { "AIBuilderHybridModelType": null, "AzureCognitivePrebuiltModelName": null, @@ -175,6 +176,7 @@ describe(commands.MODEL_GET, () => { sinon.stub(spp, 'assertSiteIsContentCenter').resolves(); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -214,34 +216,43 @@ describe(commands.MODEL_GET, () => { assert.notStrictEqual(command.description, null); }); - it('passes validation when required parameters are valid with id', async () => { - const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when required parameters are valid with id', () => { + const actual = commandOptionsSchema.safeParse({ siteUrl: 'https://contoso.sharepoint.com/sites/sales', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' }); + assert.strictEqual(actual.success, true); }); - it('passes validation when required parameters are valid with title', async () => { - const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', title: 'ModelName' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when required parameters are valid with title', () => { + const actual = commandOptionsSchema.safeParse({ siteUrl: 'https://contoso.sharepoint.com/sites/sales', title: 'ModelName' }); + assert.strictEqual(actual.success, true); }); - it('passes validation when required parameters are valid with id and withPublications', async () => { - const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', withPublications: true } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when required parameters are valid with id and withPublications', () => { + const actual = commandOptionsSchema.safeParse({ siteUrl: 'https://contoso.sharepoint.com/sites/sales', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', withPublications: true }); + assert.strictEqual(actual.success, true); }); - it('passes validation when required parameters are valid with title and withPublications', async () => { - const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', title: 'ModelName', withPublications: true } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when required parameters are valid with title and withPublications', () => { + const actual = commandOptionsSchema.safeParse({ siteUrl: 'https://contoso.sharepoint.com/sites/sales', title: 'ModelName', withPublications: true }); + assert.strictEqual(actual.success, true); }); - it('fails validation when siteUrl is not valid', async () => { - const actual = await command.validate({ options: { siteUrl: 'invalidUrl', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation when siteUrl is not valid', () => { + const actual = commandOptionsSchema.safeParse({ siteUrl: 'invalidUrl', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' }); + assert.strictEqual(actual.success, false); }); - it('fails validation when id is not valid', async () => { - const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', id: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation when id is not valid', () => { + const actual = commandOptionsSchema.safeParse({ siteUrl: 'https://contoso.sharepoint.com/sites/sales', id: 'foo' }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + siteUrl: 'https://contoso.sharepoint.com/sites/sales', + id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); }); it('correctly handles a model is not found error by id', async () => { @@ -257,42 +268,42 @@ describe(commands.MODEL_GET, () => { } }); - await assert.rejects(command.action(logger, { options: { verbose: true, siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' }) }), new CommandError('File Not Found.')); }); it('retrieves model by id', async () => { sinon.stub(spp, 'getModelById').resolves(model); - await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' }) }); assert.deepStrictEqual(loggerLogSpy.lastCall.args[0], modelResult); }); it('retrieves model by title', async () => { sinon.stub(spp, 'getModelByTitle').resolves(model); - await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', title: 'ModelName' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ siteUrl: 'https://contoso.sharepoint.com/sites/portal', title: 'ModelName' }) }); assert.deepStrictEqual(loggerLogSpy.lastCall.args[0], modelResult); }); it('retrieves model without additional information by title', async () => { sinon.stub(spp, 'getModelByTitle').resolves(modelWithoutAdditionalData as any); - await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', title: 'ModelName' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ siteUrl: 'https://contoso.sharepoint.com/sites/portal', title: 'ModelName' }) }); assert.deepStrictEqual(loggerLogSpy.lastCall.args[0], modelResultWithoutAdditionalData); }); it('retrieves model by title with classifier suffix', async () => { sinon.stub(spp, 'getModelByTitle').resolves(model); - await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', title: 'ModelName.classifier' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ siteUrl: 'https://contoso.sharepoint.com/sites/portal', title: 'ModelName.classifier' }) }); assert.deepStrictEqual(loggerLogSpy.lastCall.args[0], modelResult); }); it('gets correct model when the site URL has a trailing slash', async () => { sinon.stub(spp, 'getModelById').resolves(model); - await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal/', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ siteUrl: 'https://contoso.sharepoint.com/sites/portal/', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' }) }); assert.deepStrictEqual(loggerLogSpy.lastCall.args[0], modelResult); }); @@ -306,7 +317,7 @@ describe(commands.MODEL_GET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '164720c8-35ee-4157-ba26-db6726264f9d', withPublications: true, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '164720c8-35ee-4157-ba26-db6726264f9d', withPublications: true, verbose: true }) }); assert.deepStrictEqual(loggerLogSpy.lastCall.args[0], { ...modelResult, Publications: publications }); }); @@ -320,7 +331,7 @@ describe(commands.MODEL_GET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', title: 'ModelName', withPublications: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ siteUrl: 'https://contoso.sharepoint.com/sites/portal', title: 'ModelName', withPublications: true }) }); assert.deepStrictEqual(loggerLogSpy.lastCall.args[0], { ...modelResult, Publications: publications }); }); }); \ No newline at end of file diff --git a/src/m365/spp/commands/model/model-get.ts b/src/m365/spp/commands/model/model-get.ts index ec71f42ab01..8d7f8f1369b 100644 --- a/src/m365/spp/commands/model/model-get.ts +++ b/src/m365/spp/commands/model/model-get.ts @@ -1,5 +1,6 @@ +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; import { odata } from '../../../../utils/odata.js'; import { spp } from '../../../../utils/spp.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; @@ -7,17 +8,29 @@ import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + siteUrl: z.string() + .refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `'${e.input}' is not a valid SharePoint Online site URL.` + }) + .alias('u'), + id: z.string() + .refine(val => validation.isValidGuid(val), { + message: `The value specified for option 'id' is not a valid GUID.` + }) + .optional() + .alias('i'), + title: z.string().optional().alias('t'), + withPublications: z.boolean().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - siteUrl: string; - id?: string; - title?: string; - withPublications?: boolean; -} - class SppModelGetCommand extends SpoCommand { public get name(): string { return commands.MODEL_GET; @@ -27,62 +40,19 @@ class SppModelGetCommand extends SpoCommand { return 'Retrieves information about a document understanding model'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); + public get schema(): z.ZodType | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - id: typeof args.options.id !== 'undefined', - title: typeof args.options.title !== 'undefined', - withPublications: !!args.options.withPublications - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --siteUrl ' - }, - { - option: '-i, --id [id]' - }, - { - option: '-t, --title [title]' - }, - { - option: '--withPublications' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.id && !validation.isValidGuid(args.options.id)) { - return `${args.options.id} is not a valid GUID for option 'id'.`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => [opts.id, opts.title].filter(x => x !== undefined).length === 1, { + message: `Specify either 'id' or 'title', but not both.`, + params: { + customCode: 'optionSet', + options: ['id', 'title'] } - - return validation.isValidSharePointUrl(args.options.siteUrl); - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['id', 'title'] }); - } - - #initTypes(): void { - this.types.string.push('siteUrl', 'id', 'title'); - this.types.boolean.push('withPublications'); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/spp/commands/model/model-list.spec.ts b/src/m365/spp/commands/model/model-list.spec.ts index 00c7d6b8bb5..89209b0ad7d 100644 --- a/src/m365/spp/commands/model/model-list.spec.ts +++ b/src/m365/spp/commands/model/model-list.spec.ts @@ -11,13 +11,14 @@ 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 './model-list.js'; +import command, { options } from './model-list.js'; describe(commands.MODEL_LIST, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; const models = [ { "AIBuilderHybridModelType": null, @@ -55,6 +56,7 @@ describe(commands.MODEL_LIST, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -96,14 +98,22 @@ describe(commands.MODEL_LIST, () => { assert.deepStrictEqual(command.defaultProperties(), ['AIBuilderHybridModelType', 'ContentTypeName', 'LastTrained', 'UniqueId']); }); - it('passes validation when required parameters are valid', async () => { - const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when required parameters are valid', () => { + const actual = commandOptionsSchema.safeParse({ siteUrl: 'https://contoso.sharepoint.com/sites/sales' }); + assert.strictEqual(actual.success, true); }); - it('fails validation when siteUrl is not valid', async () => { - const actual = await command.validate({ options: { siteUrl: 'invalidUrl' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation when siteUrl is not valid', () => { + const actual = commandOptionsSchema.safeParse({ siteUrl: 'invalidUrl' }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + siteUrl: 'https://contoso.sharepoint.com/sites/sales', + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); }); it('correctly handles site is not Content Site', async () => { @@ -117,7 +127,7 @@ describe(commands.MODEL_LIST, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { verbose: true, siteUrl: 'https://contoso.sharepoint.com/sites/portal' } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, siteUrl: 'https://contoso.sharepoint.com/sites/portal' }) }), new CommandError('https://contoso.sharepoint.com/sites/portal is not a content site.')); }); @@ -140,7 +150,7 @@ describe(commands.MODEL_LIST, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { verbose: true, siteUrl: 'https://contoso.sharepoint.com/sites/portal' } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, siteUrl: 'https://contoso.sharepoint.com/sites/portal' }) }), new CommandError('Attempted to perform an unauthorized operation.')); }); @@ -160,7 +170,7 @@ describe(commands.MODEL_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ siteUrl: 'https://contoso.sharepoint.com/sites/portal' }) }); assert(loggerLogSpy.calledOnceWithExactly(models)); }); @@ -179,7 +189,7 @@ describe(commands.MODEL_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal/' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ siteUrl: 'https://contoso.sharepoint.com/sites/portal/' }) }); assert(loggerLogSpy.calledOnceWithExactly(models)); }); }); diff --git a/src/m365/spp/commands/model/model-list.ts b/src/m365/spp/commands/model/model-list.ts index 55b0b14c710..2f9426d1cf6 100644 --- a/src/m365/spp/commands/model/model-list.ts +++ b/src/m365/spp/commands/model/model-list.ts @@ -1,5 +1,6 @@ +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; import { odata } from '../../../../utils/odata.js'; import { spp } from '../../../../utils/spp.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; @@ -7,14 +8,21 @@ import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + siteUrl: z.string() + .refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `'${e.input}' is not a valid SharePoint Online site URL.` + }) + .alias('u') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - siteUrl: string; -} - class SppModelListCommand extends SpoCommand { public get name(): string { return commands.MODEL_LIST; @@ -28,32 +36,8 @@ class SppModelListCommand extends SpoCommand { return ['AIBuilderHybridModelType', 'ContentTypeName', 'LastTrained', 'UniqueId']; } - constructor() { - super(); - - this.#initOptions(); - this.#initTypes(); - this.#initValidators(); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --siteUrl ' - } - ); - } - - #initTypes(): void { - this.types.string.push('siteUrl'); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - return validation.isValidSharePointUrl(args.options.siteUrl); - } - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/spp/commands/model/model-remove.spec.ts b/src/m365/spp/commands/model/model-remove.spec.ts index 423834b20d4..f5f15a8150f 100644 --- a/src/m365/spp/commands/model/model-remove.spec.ts +++ b/src/m365/spp/commands/model/model-remove.spec.ts @@ -11,13 +11,14 @@ 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 './model-remove.js'; +import command, { options } from './model-remove.js'; import { spp } from '../../../../utils/spp.js'; describe(commands.MODEL_REMOVE, () => { let log: string[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -27,6 +28,7 @@ describe(commands.MODEL_REMOVE, () => { sinon.stub(spp, 'assertSiteIsContentCenter').resolves(); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -64,29 +66,38 @@ describe(commands.MODEL_REMOVE, () => { assert.notStrictEqual(command.description, null); }); - it('passes validation when required parameters are valid with id', async () => { - const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when required parameters are valid with id', () => { + const actual = commandOptionsSchema.safeParse({ siteUrl: 'https://contoso.sharepoint.com/sites/sales', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' }); + assert.strictEqual(actual.success, true); }); - it('passes validation when required parameters are valid with title', async () => { - const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', title: 'ModelName' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when required parameters are valid with title', () => { + const actual = commandOptionsSchema.safeParse({ siteUrl: 'https://contoso.sharepoint.com/sites/sales', title: 'ModelName' }); + assert.strictEqual(actual.success, true); }); - it('passes validation when required parameters are valid with id and force', async () => { - const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', force: true } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when required parameters are valid with id and force', () => { + const actual = commandOptionsSchema.safeParse({ siteUrl: 'https://contoso.sharepoint.com/sites/sales', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', force: true }); + assert.strictEqual(actual.success, true); }); - it('fails validation when siteUrl is not valid', async () => { - const actual = await command.validate({ options: { siteUrl: 'invalidUrl', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation when siteUrl is not valid', () => { + const actual = commandOptionsSchema.safeParse({ siteUrl: 'invalidUrl', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' }); + assert.strictEqual(actual.success, false); }); - it('fails validation when id is not valid', async () => { - const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', id: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation when id is not valid', () => { + const actual = commandOptionsSchema.safeParse({ siteUrl: 'https://contoso.sharepoint.com/sites/sales', id: 'foo' }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + siteUrl: 'https://contoso.sharepoint.com/sites/sales', + id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); }); it('correctly handles an error when the model id is not found', async () => { @@ -106,7 +117,7 @@ describe(commands.MODEL_REMOVE, () => { } }); - await assert.rejects(command.action(logger, { options: { verbose: true, siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', force: true } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', force: true }) }), new CommandError('File Not Found.')); }); @@ -121,21 +132,21 @@ describe(commands.MODEL_REMOVE, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', title: 'modelTitle.classifier', force: true } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ siteUrl: 'https://contoso.sharepoint.com/sites/portal', title: 'modelTitle.classifier', force: true }) }), new CommandError('Model not found.')); }); it('is the confirmation prompt called with id information', async () => { const confirmationStub = sinon.stub(cli, 'promptForConfirmation').resolves(false); - await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' }) }); assert(confirmationStub.args[0][0].message.startsWith(`Are you sure you want to remove model '9b1b1e42-794b-4c71-93ac-5ed92488b67f'?`)); }); it('is the confirmation prompt called with title information', async () => { const confirmationStub = sinon.stub(cli, 'promptForConfirmation').resolves(false); - await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', title: 'modelTitle' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ siteUrl: 'https://contoso.sharepoint.com/sites/portal', title: 'modelTitle' }) }); assert(confirmationStub.args[0][0].message.startsWith(`Are you sure you want to remove model 'modelTitle'?`)); }); @@ -148,7 +159,7 @@ describe(commands.MODEL_REMOVE, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', force: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', force: true }) }); assert(stubDelete.calledOnce); }); @@ -157,7 +168,7 @@ describe(commands.MODEL_REMOVE, () => { const stubDelete = sinon.stub(request, 'delete').resolves(); - await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' }) }); assert(stubDelete.notCalled); }); @@ -170,7 +181,7 @@ describe(commands.MODEL_REMOVE, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal/', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', force: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ siteUrl: 'https://contoso.sharepoint.com/sites/portal/', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', force: true }) }); assert(stubDelete.calledOnce); }); @@ -183,7 +194,7 @@ describe(commands.MODEL_REMOVE, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', title: 'ModelName', force: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ siteUrl: 'https://contoso.sharepoint.com/sites/portal', title: 'ModelName', force: true }) }); assert(stubDelete.calledOnce); }); @@ -196,7 +207,7 @@ describe(commands.MODEL_REMOVE, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', title: 'ModelName.classifier', force: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ siteUrl: 'https://contoso.sharepoint.com/sites/portal', title: 'ModelName.classifier', force: true }) }); assert(stubDelete.calledOnce); }); }); \ No newline at end of file diff --git a/src/m365/spp/commands/model/model-remove.ts b/src/m365/spp/commands/model/model-remove.ts index d773db214f6..b68668e956d 100644 --- a/src/m365/spp/commands/model/model-remove.ts +++ b/src/m365/spp/commands/model/model-remove.ts @@ -1,6 +1,7 @@ +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; 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 { spp } from '../../../../utils/spp.js'; @@ -9,17 +10,29 @@ import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + siteUrl: z.string() + .refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `'${e.input}' is not a valid SharePoint Online site URL.` + }) + .alias('u'), + id: z.string() + .refine(val => validation.isValidGuid(val), { + message: `The value specified for option 'id' is not a valid GUID.` + }) + .optional() + .alias('i'), + title: z.string().optional().alias('t'), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - siteUrl: string; - id?: string; - title?: string; - force?: boolean; -} - class SppModelRemoveCommand extends SpoCommand { public get name(): string { return commands.MODEL_REMOVE; @@ -29,62 +42,19 @@ class SppModelRemoveCommand extends SpoCommand { return 'Deletes a document understanding model'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); + public get schema(): z.ZodType | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - id: typeof args.options.id !== 'undefined', - title: typeof args.options.title !== 'undefined', - force: !!args.options.force - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --siteUrl ' - }, - { - option: '-i, --id [id]' - }, - { - option: '-t, --title [title]' - }, - { - option: '-f, --force' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.id && !validation.isValidGuid(args.options.id)) { - return `${args.options.id} is not a valid GUID for option 'id'.`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => [opts.id, opts.title].filter(x => x !== undefined).length === 1, { + message: `Specify either 'id' or 'title', but not both.`, + params: { + customCode: 'optionSet', + options: ['id', 'title'] } - - return validation.isValidSharePointUrl(args.options.siteUrl); - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['id', 'title'] }); - } - - #initTypes(): void { - this.types.string.push('siteUrl', 'id', 'title'); - this.types.boolean.push('force'); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise {