diff --git a/.gitignore b/.gitignore index 4146a43b816..add596b6b82 100644 --- a/.gitignore +++ b/.gitignore @@ -87,4 +87,5 @@ docs/.env.production.local docs/npm-debug.log* docs/yarn-debug.log* -docs/yarn-error.log* \ No newline at end of file +docs/yarn-error.log* +.impeccable/ diff --git a/src/m365/planner/commands/roster/roster-add.spec.ts b/src/m365/planner/commands/roster/roster-add.spec.ts index e465e1c623c..4dbe5740db8 100644 --- a/src/m365/planner/commands/roster/roster-add.spec.ts +++ b/src/m365/planner/commands/roster/roster-add.spec.ts @@ -1,6 +1,8 @@ import assert from 'assert'; 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'; @@ -10,17 +12,19 @@ import { session } from '../../../../utils/session.js'; import { powerPlatform } from '../../../../utils/powerPlatform.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './roster-add.js'; +import command, { options } from './roster-add.js'; describe(commands.ROSTER_ADD, () => { const rosterResponse = { - id: "8bc07d47-c06f-41e1-8f00-1c113c8f6067", + id: '8bc07d47-c06f-41e1-8f00-1c113c8f6067', assignedSensitivityLabel: null }; let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -32,6 +36,8 @@ describe(commands.ROSTER_ADD, () => { accessToken: 'abc', expiresOn: new Date() }; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -70,6 +76,18 @@ describe(commands.ROSTER_ADD, () => { assert.notStrictEqual(command.description, null); }); + 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('adds a new roster', async () => { sinon.stub(request, 'post').callsFake(async opts => { if (opts.url === 'https://graph.microsoft.com/beta/planner/rosters') { @@ -79,7 +97,7 @@ describe(commands.ROSTER_ADD, () => { throw `Invalid request ${opts.url}`; }); - await command.action(logger, { options: { verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true }) }); assert(loggerLogSpy.calledWith(rosterResponse)); }); @@ -87,7 +105,7 @@ describe(commands.ROSTER_ADD, () => { sinon.stub(request, 'post').rejects(new Error('An error has occurred')); await assert.rejects(command.action(logger, { - options: {} + options: commandOptionsSchema.parse({}) }), new CommandError('An error has occurred')); }); }); diff --git a/src/m365/planner/commands/roster/roster-add.ts b/src/m365/planner/commands/roster/roster-add.ts index f6a7bb933ad..990cdfb97af 100644 --- a/src/m365/planner/commands/roster/roster-add.ts +++ b/src/m365/planner/commands/roster/roster-add.ts @@ -1,8 +1,12 @@ +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; import request, { CliRequestOptions } from '../../../../request.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = globalOptionsZod.strict(); + class PlannerRosterAddCommand extends GraphCommand { public get name(): string { return commands.ROSTER_ADD; @@ -12,6 +16,10 @@ class PlannerRosterAddCommand extends GraphCommand { return 'Creates a new Microsoft Planner Roster'; } + public get schema(): z.ZodType | undefined { + return options; + } + public async commandAction(logger: Logger): Promise { if (this.verbose) { await logger.logToStderr('Creating a new Microsoft Planner Roster'); @@ -34,7 +42,6 @@ class PlannerRosterAddCommand extends GraphCommand { this.handleRejectedODataJsonPromise(err); } } - } -export default new PlannerRosterAddCommand(); \ No newline at end of file +export default new PlannerRosterAddCommand(); diff --git a/src/m365/planner/commands/roster/roster-get.spec.ts b/src/m365/planner/commands/roster/roster-get.spec.ts index 68123540649..1c8fa1d89de 100644 --- a/src/m365/planner/commands/roster/roster-get.spec.ts +++ b/src/m365/planner/commands/roster/roster-get.spec.ts @@ -1,6 +1,8 @@ import assert from 'assert'; 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'; @@ -9,18 +11,20 @@ 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 './roster-get.js'; +import command, { options } from './roster-get.js'; describe(commands.ROSTER_GET, () => { const id = '8bc07d47-c06f-41e1-8f00-1c113c8f6067'; const rosterGetResponse = { - "id": id, - "assignedSensitivityLabel": null + id: id, + assignedSensitivityLabel: null }; let log: any[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -28,6 +32,8 @@ describe(commands.ROSTER_GET, () => { sinon.stub(pid, 'getProcessName').returns(''); sinon.stub(session, 'getId').returns(''); auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -65,6 +71,21 @@ describe(commands.ROSTER_GET, () => { assert.notStrictEqual(command.description, null); }); + it('passes validation with valid id', () => { + const actual = commandOptionsSchema.safeParse({ + id + }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + id, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); + }); + it('retrieves Microsoft Planner Roster by specified id', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/beta/planner/rosters/${id}`) { @@ -74,7 +95,7 @@ describe(commands.ROSTER_GET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: id, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id, verbose: true }) }); assert(loggerLogSpy.calledWith(rosterGetResponse)); }); @@ -89,10 +110,10 @@ describe(commands.ROSTER_GET, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, - id: id - } + id + }) }), new CommandError(errorMessage)); }); }); diff --git a/src/m365/planner/commands/roster/roster-get.ts b/src/m365/planner/commands/roster/roster-get.ts index 3cbb65c563f..1524320e545 100644 --- a/src/m365/planner/commands/roster/roster-get.ts +++ b/src/m365/planner/commands/roster/roster-get.ts @@ -1,17 +1,21 @@ +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.string() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id: string; -} - class PlannerRosterGetCommand extends GraphCommand { public get name(): string { return commands.ROSTER_GET; @@ -21,23 +25,8 @@ class PlannerRosterGetCommand extends GraphCommand { return 'Retrieve information about a specific Microsoft Planner Roster'; } - constructor() { - super(); - - this.#initOptions(); - this.#initTypes(); - } - - #initOptions(): void { - this.options.unshift( - { - option: '--id ' - } - ); - } - - #initTypes(): void { - this.types.string.push('id'); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { @@ -63,4 +52,4 @@ class PlannerRosterGetCommand extends GraphCommand { } } -export default new PlannerRosterGetCommand(); \ No newline at end of file +export default new PlannerRosterGetCommand(); diff --git a/src/m365/planner/commands/roster/roster-member-add.spec.ts b/src/m365/planner/commands/roster/roster-member-add.spec.ts index 5c40f278900..e3aff95076f 100644 --- a/src/m365/planner/commands/roster/roster-member-add.spec.ts +++ b/src/m365/planner/commands/roster/roster-member-add.spec.ts @@ -12,19 +12,20 @@ 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 './roster-member-add.js'; +import command, { options } from './roster-member-add.js'; describe(commands.ROSTER_MEMBER_ADD, () => { let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; const rosterMemberResponse = { - "id": "b3a1be03-54a5-43d2-b4fb-6562fe9bec0b", - "userId": "2056d2f6-3257-4253-8cfc-b73393e414e5", - "tenantId": "5b7b813c-2339-48cd-8c51-bd4fcb269420", - "roles": [] + id: 'b3a1be03-54a5-43d2-b4fb-6562fe9bec0b', + userId: '2056d2f6-3257-4253-8cfc-b73393e414e5', + tenantId: '5b7b813c-2339-48cd-8c51-bd4fcb269420', + roles: [] }; - const validRosterId = "iryDKm9VLku2HIoC2G-TX5gABJw0"; - const validUserId = "2056d2f6-3257-4253-8cfc-b73393e414e5"; - const validUserName = "john.doe@contoso.com"; + const validRosterId = 'iryDKm9VLku2HIoC2G-TX5gABJw0'; + const validUserId = '2056d2f6-3257-4253-8cfc-b73393e414e5'; + const validUserName = 'john.doe@contoso.com'; let log: string[]; let logger: Logger; @@ -37,6 +38,7 @@ describe(commands.ROSTER_MEMBER_ADD, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -75,34 +77,55 @@ describe(commands.ROSTER_MEMBER_ADD, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if userId is not a valid guid.', async () => { - const actual = await command.validate({ - options: { - rosterId: validRosterId, - userId: 'Invalid GUID' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userId is not a valid guid.', () => { + const actual = commandOptionsSchema.safeParse({ + rosterId: validRosterId, + userId: 'Invalid GUID' + }); + assert.strictEqual(actual.success, false); }); - it('fails validation when userName is not a valid upn', async () => { - const actual = await command.validate({ - options: { - rosterId: validRosterId, - userName: 'Invalid upn' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation when userName is not a valid upn', () => { + const actual = commandOptionsSchema.safeParse({ + rosterId: validRosterId, + userName: 'Invalid upn' + }); + assert.strictEqual(actual.success, false); }); - it('passes validation if required options specified (id)', async () => { - const actual = await command.validate({ options: { rosterId: validRosterId, userId: validUserId } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation when neither userId nor userName is specified', () => { + const actual = commandOptionsSchema.safeParse({ + rosterId: validRosterId + }); + assert.strictEqual(actual.success, false); }); - it('passes validation if required options specified (name)', async () => { - const actual = await command.validate({ options: { rosterId: validRosterId, userName: validUserName } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation when both userId and userName are specified', () => { + const actual = commandOptionsSchema.safeParse({ + rosterId: validRosterId, + userId: validUserId, + userName: validUserName + }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation if required options specified (id)', () => { + const actual = commandOptionsSchema.safeParse({ rosterId: validRosterId, userId: validUserId }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation if required options specified (name)', () => { + const actual = commandOptionsSchema.safeParse({ rosterId: validRosterId, userName: validUserName }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + rosterId: validRosterId, + userId: validUserId, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); }); it('correctly adds a new roster member by userId', async () => { @@ -114,7 +137,7 @@ describe(commands.ROSTER_MEMBER_ADD, () => { throw `Invalid request ${opts.url}`; }); - await command.action(logger, { options: { rosterId: validRosterId, userId: validUserId } }); + await command.action(logger, { options: commandOptionsSchema.parse({ rosterId: validRosterId, userId: validUserId }) }); assert(loggerLogSpy.calledWith(rosterMemberResponse)); }); @@ -129,7 +152,7 @@ describe(commands.ROSTER_MEMBER_ADD, () => { throw `Invalid request ${opts.url}`; }); - await command.action(logger, { options: { verbose: true, rosterId: validRosterId, userName: validUserName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, rosterId: validRosterId, userName: validUserName }) }); assert(loggerLogSpy.calledWith(rosterMemberResponse)); }); @@ -142,7 +165,7 @@ describe(commands.ROSTER_MEMBER_ADD, () => { sinon.stub(request, 'post').rejects(error); await assert.rejects(command.action(logger, { - options: { rosterId: validRosterId, userId: validUserId } + options: commandOptionsSchema.parse({ rosterId: validRosterId, userId: validUserId }) }), new CommandError('The requested item is not found.')); }); -}); \ No newline at end of file +}); diff --git a/src/m365/planner/commands/roster/roster-member-add.ts b/src/m365/planner/commands/roster/roster-member-add.ts index 90455c94ee4..cbb7e232c11 100644 --- a/src/m365/planner/commands/roster/roster-member-add.ts +++ b/src/m365/planner/commands/roster/roster-member-add.ts @@ -1,21 +1,33 @@ +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { entraUser } from '../../../../utils/entraUser.js'; import { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + rosterId: z.string(), + userId: z.string() + .refine(val => validation.isValidGuid(val), { + message: 'The value is not a valid GUID.' + }) + .optional(), + userName: z.string() + .refine(val => validation.isValidUserPrincipalName(val), { + message: 'The value is not a valid user principal name (UPN).' + }) + .optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - rosterId: string; - userId?: string; - userName?: string; -} - class PlannerRosterMemberAddCommand extends GraphCommand { public get name(): string { return commands.ROSTER_MEMBER_ADD; @@ -25,63 +37,19 @@ class PlannerRosterMemberAddCommand extends GraphCommand { return 'Adds a user to a Microsoft Planner Roster'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - userId: typeof args.options.userId !== 'undefined', - userName: typeof args.options.userName !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '--rosterId ' - }, - { - option: '--userId [userId]' - }, - { - option: '--userName [userName]' - } - ); + public get schema(): z.ZodType | undefined { + return options; } - #initOptionSets(): void { - this.optionSets.push( - { options: ['userId', 'userName'] } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.userId && !validation.isValidGuid(args.options.userId)) { - return `${args.options.userId} is not a valid GUID`; - } - - if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) { - return `${args.options.userName} is not a valid user principal name (UPN)`; + public getRefinedSchema(schema: typeof options): z.ZodType | undefined { + return schema + .refine(opts => [opts.userId, opts.userName].filter(x => x !== undefined).length === 1, { + message: `Specify exactly one of the following options: 'userId' or 'userName'.`, + params: { + customCode: 'optionSet', + options: ['userId', 'userName'] } - - return true; - } - ); - } - - #initTypes(): void { - this.types.string.push('rosterId', 'userId', 'userName'); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { @@ -113,7 +81,7 @@ class PlannerRosterMemberAddCommand extends GraphCommand { private async getUserId(logger: Logger, args: CommandArgs): Promise { if (this.verbose) { - await logger.logToStderr("Getting the user ID"); + await logger.logToStderr('Getting the user ID'); } if (args.options.userId) { @@ -126,4 +94,4 @@ class PlannerRosterMemberAddCommand extends GraphCommand { } } -export default new PlannerRosterMemberAddCommand(); \ No newline at end of file +export default new PlannerRosterMemberAddCommand(); diff --git a/src/m365/planner/commands/roster/roster-member-get.spec.ts b/src/m365/planner/commands/roster/roster-member-get.spec.ts index c3d8d8bdf0d..31aa6e479e0 100644 --- a/src/m365/planner/commands/roster/roster-member-get.spec.ts +++ b/src/m365/planner/commands/roster/roster-member-get.spec.ts @@ -12,19 +12,20 @@ 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 './roster-member-get.js'; +import command, { options } from './roster-member-get.js'; describe(commands.ROSTER_MEMBER_GET, () => { let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; //#region Mocked Responses const validRosterId = 'iryDKm9VLku2HIoC2G-TX5gABJw0'; const validUserId = '2056d2f6-3257-4253-8cfc-b73393e414e5'; const validUserName = 'john.doe@contoso.com'; const rosterMemberResponse = { - "id": "c98ca8a9-1ae3-4709-ab65-5751f8d58694", - "userId": "d242e467-bd06-4fa0-93c6-aea8aca9d90d", - "tenantId": "8eca2a6b-80a4-4230-aca3-3781b92a179b", - "roles": [] + id: 'c98ca8a9-1ae3-4709-ab65-5751f8d58694', + userId: 'd242e467-bd06-4fa0-93c6-aea8aca9d90d', + tenantId: '8eca2a6b-80a4-4230-aca3-3781b92a179b', + roles: [] }; const userResponse = { value: [{ id: validUserId }] }; @@ -41,6 +42,7 @@ describe(commands.ROSTER_MEMBER_GET, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -78,34 +80,55 @@ describe(commands.ROSTER_MEMBER_GET, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if userId is not a valid guid', async () => { - const actual = await command.validate({ - options: { - rosterId: validRosterId, - userId: 'Invalid GUID' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userId is not a valid guid', () => { + const actual = commandOptionsSchema.safeParse({ + rosterId: validRosterId, + userId: 'Invalid GUID' + }); + assert.strictEqual(actual.success, false); }); - it('fails validation if userName is not a valid upn', async () => { - const actual = await command.validate({ - options: { - rosterId: validRosterId, - userName: 'John Doe' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userName is not a valid upn', () => { + const actual = commandOptionsSchema.safeParse({ + rosterId: validRosterId, + userName: 'John Doe' + }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation when neither userId nor userName is specified', () => { + const actual = commandOptionsSchema.safeParse({ + rosterId: validRosterId + }); + assert.strictEqual(actual.success, false); }); - it('passes validation if required options specified (userId)', async () => { - const actual = await command.validate({ options: { rosterId: validRosterId, userId: validUserId } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation when both userId and userName are specified', () => { + const actual = commandOptionsSchema.safeParse({ + rosterId: validRosterId, + userId: validUserId, + userName: validUserName + }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation if required options specified (userId)', () => { + const actual = commandOptionsSchema.safeParse({ rosterId: validRosterId, userId: validUserId }); + assert.strictEqual(actual.success, true); }); - it('passes validation if required options specified (userName)', async () => { - const actual = await command.validate({ options: { rosterId: validRosterId, userName: validUserName } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if required options specified (userName)', () => { + const actual = commandOptionsSchema.safeParse({ rosterId: validRosterId, userName: validUserName }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + rosterId: validRosterId, + userId: validUserId, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); }); it('gets the specified roster member by userName', async () => { @@ -122,11 +145,11 @@ describe(commands.ROSTER_MEMBER_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, rosterId: validRosterId, userName: validUserName - } + }) }); assert(loggerLogSpy.calledWith(rosterMemberResponse)); @@ -142,11 +165,11 @@ describe(commands.ROSTER_MEMBER_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, rosterId: validRosterId, userId: validUserId - } + }) }); assert(loggerLogSpy.calledWith(rosterMemberResponse)); @@ -161,10 +184,10 @@ describe(commands.ROSTER_MEMBER_GET, () => { sinon.stub(request, 'get').rejects(error); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ rosterId: validRosterId, userId: validUserId - } + }) }), new CommandError('The roster member cannot be found.')); }); }); diff --git a/src/m365/planner/commands/roster/roster-member-get.ts b/src/m365/planner/commands/roster/roster-member-get.ts index c89fa8a607f..acc1b69618f 100644 --- a/src/m365/planner/commands/roster/roster-member-get.ts +++ b/src/m365/planner/commands/roster/roster-member-get.ts @@ -1,21 +1,33 @@ +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; import { entraUser } from '../../../../utils/entraUser.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + rosterId: z.string(), + userId: z.string() + .refine(val => validation.isValidGuid(val), { + message: 'The value is not a valid GUID.' + }) + .optional(), + userName: z.string() + .refine(val => validation.isValidUserPrincipalName(val), { + message: 'The value is not a valid user principal name (UPN).' + }) + .optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - rosterId: string; - userId?: string; - userName?: string; -} - class PlannerRosterMemberGetCommand extends GraphCommand { public get name(): string { return commands.ROSTER_MEMBER_GET; @@ -25,63 +37,19 @@ class PlannerRosterMemberGetCommand extends GraphCommand { return 'Gets a member of the specified Microsoft Planner Roster'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - userId: typeof args.options.userId !== 'undefined', - userName: typeof args.options.userName !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '--rosterId ' - }, - { - option: '--userId [userId]' - }, - { - option: '--userName [userName]' - } - ); + public get schema(): z.ZodType | undefined { + return options; } - #initOptionSets(): void { - this.optionSets.push( - { options: ['userId', 'userName'] } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.userId && !validation.isValidGuid(args.options.userId)) { - return `${args.options.userId} is not a valid GUID`; - } - - if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) { - return `${args.options.userName} is not a valid userName`; + public getRefinedSchema(schema: typeof options): z.ZodType | undefined { + return schema + .refine(opts => [opts.userId, opts.userName].filter(x => x !== undefined).length === 1, { + message: `Specify exactly one of the following options: 'userId' or 'userName'.`, + params: { + customCode: 'optionSet', + options: ['userId', 'userName'] } - - return true; - } - ); - } - - #initTypes(): void { - this.types.string.push('rosterId', 'userId', 'userName'); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/planner/commands/roster/roster-member-list.spec.ts b/src/m365/planner/commands/roster/roster-member-list.spec.ts index 055dc49a814..0422485baad 100644 --- a/src/m365/planner/commands/roster/roster-member-list.spec.ts +++ b/src/m365/planner/commands/roster/roster-member-list.spec.ts @@ -1,6 +1,8 @@ import assert from 'assert'; 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'; @@ -9,30 +11,32 @@ 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 './roster-member-list.js'; +import command, { options } from './roster-member-list.js'; describe(commands.ROSTER_MEMBER_LIST, () => { const rosterMemberResponse = { value: [ { - id: "78ccf530-bbf0-47e4-aae6-da5f8c6fb142", - userId: "78ccf530-bbf0-47e4-aae6-da5f8c6fb142", - tenantId: "0cac6cda-2e04-4a3d-9c16-9c91470d7022", + id: '78ccf530-bbf0-47e4-aae6-da5f8c6fb142', + userId: '78ccf530-bbf0-47e4-aae6-da5f8c6fb142', + tenantId: '0cac6cda-2e04-4a3d-9c16-9c91470d7022', roles: [] }, { - id: "eb77fbcf-6fe8-458b-985d-1747284793bc", - userId: "eb77fbcf-6fe8-458b-985d-1747284793bc", - tenantId: "0cac6cda-2e04-4a3d-9c16-9c91470d7022", + id: 'eb77fbcf-6fe8-458b-985d-1747284793bc', + userId: 'eb77fbcf-6fe8-458b-985d-1747284793bc', + tenantId: '0cac6cda-2e04-4a3d-9c16-9c91470d7022', roles: [] } ] }; - const validRosterId = "iryDKm9VLku2HIoC2G-TX5gABJw0"; + const validRosterId = 'iryDKm9VLku2HIoC2G-TX5gABJw0'; let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -40,6 +44,8 @@ describe(commands.ROSTER_MEMBER_LIST, () => { sinon.stub(pid, 'getProcessName').returns(''); sinon.stub(session, 'getId').returns(''); auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -77,6 +83,21 @@ describe(commands.ROSTER_MEMBER_LIST, () => { assert.notStrictEqual(command.description, null); }); + it('passes validation with valid rosterId', () => { + const actual = commandOptionsSchema.safeParse({ + rosterId: validRosterId + }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + rosterId: validRosterId, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); + }); + it('retrieves members from a roster', async () => { sinon.stub(request, 'get').callsFake(async opts => { if ((opts.url === `https://graph.microsoft.com/beta/planner/rosters/${validRosterId}/members`)) { @@ -86,11 +107,10 @@ describe(commands.ROSTER_MEMBER_LIST, () => { throw `Invalid request ${opts.url}`; }); - await command.action(logger, { options: { rosterId: validRosterId, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ rosterId: validRosterId, verbose: true }) }); assert(loggerLogSpy.calledWith(rosterMemberResponse.value)); }); - it('correctly handles random API error', async () => { const error = { error: { @@ -100,9 +120,9 @@ describe(commands.ROSTER_MEMBER_LIST, () => { sinon.stub(request, 'get').rejects(error); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ rosterId: validRosterId - } + }) }), new CommandError('The requested item is not found.')); }); -}); \ No newline at end of file +}); diff --git a/src/m365/planner/commands/roster/roster-member-list.ts b/src/m365/planner/commands/roster/roster-member-list.ts index 006ba392ccb..0a46326577a 100644 --- a/src/m365/planner/commands/roster/roster-member-list.ts +++ b/src/m365/planner/commands/roster/roster-member-list.ts @@ -1,17 +1,21 @@ +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 GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + rosterId: z.string() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - rosterId: string; -} - class PlannerRosterMemberListCommand extends GraphCommand { public get name(): string { return commands.ROSTER_MEMBER_LIST; @@ -21,24 +25,8 @@ class PlannerRosterMemberListCommand extends GraphCommand { return 'Lists members of the specified Microsoft Planner Roster'; } - constructor() { - super(); - - this.#initOptions(); - this.#initTypes(); - } - - - #initOptions(): void { - this.options.unshift( - { - option: '--rosterId ' - } - ); - } - - #initTypes(): void { - this.types.string.push('rosterId'); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/planner/commands/roster/roster-member-remove.spec.ts b/src/m365/planner/commands/roster/roster-member-remove.spec.ts index 82d46c9ee36..8bf11c5a335 100644 --- a/src/m365/planner/commands/roster/roster-member-remove.spec.ts +++ b/src/m365/planner/commands/roster/roster-member-remove.spec.ts @@ -12,11 +12,12 @@ 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 './roster-member-remove.js'; +import command, { options } from './roster-member-remove.js'; import { ConfirmationConfig } from '../../../../utils/prompt.js'; describe(commands.ROSTER_MEMBER_REMOVE, () => { let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; //#region Mocked Responses const validRosterId = 'iryDKm9VLku2HIoC2G-TX5gABJw0'; const validUserId = '2056d2f6-3257-4253-8cfc-b73393e414e5'; @@ -53,6 +54,7 @@ describe(commands.ROSTER_MEMBER_REMOVE, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -97,42 +99,63 @@ describe(commands.ROSTER_MEMBER_REMOVE, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if userId is not a valid guid', async () => { - const actual = await command.validate({ - options: { - rosterId: validRosterId, - userId: 'Invalid GUID' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userId is not a valid guid', () => { + const actual = commandOptionsSchema.safeParse({ + rosterId: validRosterId, + userId: 'Invalid GUID' + }); + assert.strictEqual(actual.success, false); }); - it('fails validation if userName is not a valid upn', async () => { - const actual = await command.validate({ - options: { - rosterId: validRosterId, - userName: 'John Doe' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userName is not a valid upn', () => { + const actual = commandOptionsSchema.safeParse({ + rosterId: validRosterId, + userName: 'John Doe' + }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation when neither userId nor userName is specified', () => { + const actual = commandOptionsSchema.safeParse({ + rosterId: validRosterId + }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation when both userId and userName are specified', () => { + const actual = commandOptionsSchema.safeParse({ + rosterId: validRosterId, + userId: validUserId, + userName: validUserName + }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation if required options specified (id)', () => { + const actual = commandOptionsSchema.safeParse({ rosterId: validRosterId, userId: validUserId }); + assert.strictEqual(actual.success, true); }); - it('passes validation if required options specified (id)', async () => { - const actual = await command.validate({ options: { rosterId: validRosterId, userId: validUserId } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if required options specified (name)', () => { + const actual = commandOptionsSchema.safeParse({ rosterId: validRosterId, userName: validUserName }); + assert.strictEqual(actual.success, true); }); - it('passes validation if required options specified (name)', async () => { - const actual = await command.validate({ options: { rosterId: validRosterId, userName: validUserName } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + rosterId: validRosterId, + userId: validUserId, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); }); it('prompts before removing the specified roster member when force option not passed', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ rosterId: validRosterId, userId: validUserId - } + }) }); assert(promptIssued); @@ -160,10 +183,10 @@ describe(commands.ROSTER_MEMBER_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ rosterId: validRosterId, userId: validUserId - } + }) }); let promptIssued = false; @@ -179,10 +202,10 @@ describe(commands.ROSTER_MEMBER_REMOVE, () => { const deleteSpy = sinon.spy(request, 'delete'); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ rosterId: validRosterId, userId: validUserId - } + }) }); assert(deleteSpy.notCalled); @@ -213,11 +236,11 @@ describe(commands.ROSTER_MEMBER_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, rosterId: validRosterId, userName: validUserName - } + }) }); assert(deleteSpy.called); @@ -248,11 +271,11 @@ describe(commands.ROSTER_MEMBER_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, rosterId: validRosterId, userName: validUserName - } + }) }); assert(deleteSpy.called); @@ -268,12 +291,12 @@ describe(commands.ROSTER_MEMBER_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, rosterId: validRosterId, userId: validUserId, force: true - } + }) }); assert(deleteSpy.called); @@ -288,11 +311,11 @@ describe(commands.ROSTER_MEMBER_REMOVE, () => { sinon.stub(request, 'delete').rejects(error); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ rosterId: validRosterId, userId: validUserId, force: true - } + }) }), new CommandError('The roster member cannot be found.')); }); }); diff --git a/src/m365/planner/commands/roster/roster-member-remove.ts b/src/m365/planner/commands/roster/roster-member-remove.ts index cec718b5c26..08f608461dd 100644 --- a/src/m365/planner/commands/roster/roster-member-remove.ts +++ b/src/m365/planner/commands/roster/roster-member-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 { entraUser } from '../../../../utils/entraUser.js'; import { odata } from '../../../../utils/odata.js'; @@ -8,17 +9,28 @@ import { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + rosterId: z.string(), + userId: z.string() + .refine(val => validation.isValidGuid(val), { + message: 'The value is not a valid GUID.' + }) + .optional(), + userName: z.string() + .refine(val => validation.isValidUserPrincipalName(val), { + message: 'The value is not a valid user principal name (UPN).' + }) + .optional(), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - rosterId: string; - userId?: string; - userName?: string; - force?: boolean; -} - class PlannerRosterMemberRemoveCommand extends GraphCommand { public get name(): string { return commands.ROSTER_MEMBER_REMOVE; @@ -28,67 +40,19 @@ class PlannerRosterMemberRemoveCommand extends GraphCommand { return 'Removes a member from a Microsoft Planner Roster'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - userId: typeof args.options.userId !== 'undefined', - userName: typeof args.options.userName !== 'undefined', - force: !!args.options.force - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '--rosterId ' - }, - { - option: '--userId [userId]' - }, - { - option: '--userName [userName]' - }, - { - option: '-f, --force' - } - ); + public get schema(): z.ZodType | undefined { + return options; } - #initOptionSets(): void { - this.optionSets.push( - { options: ['userId', 'userName'] } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.userId && !validation.isValidGuid(args.options.userId)) { - return `${args.options.userId} is not a valid GUID`; + public getRefinedSchema(schema: typeof options): z.ZodType | undefined { + return schema + .refine(opts => [opts.userId, opts.userName].filter(x => x !== undefined).length === 1, { + message: `Specify exactly one of the following options: 'userId' or 'userName'.`, + params: { + customCode: 'optionSet', + options: ['userId', 'userName'] } - - if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) { - return `${args.options.userName} is not a valid userName`; - } - - return true; - } - ); - } - - #initTypes(): void { - this.types.string.push('rosterId', 'userId', 'userName'); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { @@ -142,7 +106,7 @@ class PlannerRosterMemberRemoveCommand extends GraphCommand { if (!args.options.force) { const rosterMembers = await odata.getAllItems(`${this.resource}/beta/planner/rosters/${args.options.rosterId}/members?$select=Id`); if (rosterMembers.length === 1) { - const result = await cli.promptForConfirmation({ message: `You are about to remove the last member of this Roster. When this happens, the Roster and all its contents will be deleted within 30 days. Are you sure you want to proceed?` }); + const result = await cli.promptForConfirmation({ message: 'You are about to remove the last member of this Roster. When this happens, the Roster and all its contents will be deleted within 30 days. Are you sure you want to proceed?' }); return result; } diff --git a/src/m365/planner/commands/roster/roster-plan-list.spec.ts b/src/m365/planner/commands/roster/roster-plan-list.spec.ts index 60077a55f92..aac077b3a58 100644 --- a/src/m365/planner/commands/roster/roster-plan-list.spec.ts +++ b/src/m365/planner/commands/roster/roster-plan-list.spec.ts @@ -12,36 +12,36 @@ 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 './roster-plan-list.js'; +import command, { options } from './roster-plan-list.js'; describe(commands.ROSTER_PLAN_LIST, () => { const userId = '59f80e08-24b1-41f8-8586-16765fd830d3'; const userName = 'john.doe@contoso.com'; const rosterUserPlanListResponse = { - "value": [ + value: [ { - "createdDateTime": "2023-04-06T14:41:49.8676617Z", - "owner": "59f80e08-24b1-41f8-8586-16765fd830d3", - "title": "My Planner Plan", - "creationSource": null, - "id": "_5GY9MJpZU2vb3DC46CP3MkACr8m", - "createdBy": { - "user": { - "displayName": null, - "id": "59f80e08-24b1-41f8-8586-16765fd830d3" + createdDateTime: '2023-04-06T14:41:49.8676617Z', + owner: '59f80e08-24b1-41f8-8586-16765fd830d3', + title: 'My Planner Plan', + creationSource: null, + id: '_5GY9MJpZU2vb3DC46CP3MkACr8m', + createdBy: { + user: { + displayName: null, + id: '59f80e08-24b1-41f8-8586-16765fd830d3' }, - "application": { - "displayName": null, - "id": "31359c7f-bd7e-475c-86db-fdb8c937548e" + application: { + displayName: null, + id: '31359c7f-bd7e-475c-86db-fdb8c937548e' } }, - "container": { - "containerId": "_5GY9MJpZU2vb3DC46CP3MkACr8m", - "type": "unknownFutureValue", - "url": "https://graph.microsoft.com/beta/planner/rosters/_5GY9MJpZU2vb3DC46CP3MkACr8m" + container: { + containerId: '_5GY9MJpZU2vb3DC46CP3MkACr8m', + type: 'unknownFutureValue', + url: 'https://graph.microsoft.com/beta/planner/rosters/_5GY9MJpZU2vb3DC46CP3MkACr8m' }, - "contexts": {}, - "sharedWithContainers": [] + contexts: {}, + sharedWithContainers: [] } ] }; @@ -50,6 +50,7 @@ describe(commands.ROSTER_PLAN_LIST, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -62,6 +63,7 @@ describe(commands.ROSTER_PLAN_LIST, () => { expiresOn: new Date() }; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -102,24 +104,42 @@ describe(commands.ROSTER_PLAN_LIST, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if userId is not a valid GUID', async () => { - const actual = await command.validate({ options: { userId: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userId is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ userId: 'invalid' }); + assert.strictEqual(actual.success, false); }); - it('validates for a correct input with a userId defined', async () => { - const actual = await command.validate({ options: { userId: userId } }, commandInfo); - assert.strictEqual(actual, true); + it('validates for a correct input with a userId defined', () => { + const actual = commandOptionsSchema.safeParse({ userId: userId }); + assert.strictEqual(actual.success, true); }); - it('fails validation if userName is not a valid UPN', async () => { - const actual = await command.validate({ options: { userName: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userName is not a valid UPN', () => { + const actual = commandOptionsSchema.safeParse({ userName: 'invalid' }); + assert.strictEqual(actual.success, false); }); - it('validates for a correct input with a userName defined', async () => { - const actual = await command.validate({ options: { userName: userName } }, commandInfo); - assert.strictEqual(actual, true); + it('validates for a correct input with a userName defined', () => { + const actual = commandOptionsSchema.safeParse({ userName: userName }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation when both userId and userName are specified', () => { + const actual = commandOptionsSchema.safeParse({ userId, userName }); + assert.strictEqual(actual.success, false); + }); + + 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({ + userId, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); }); it('defines correct properties for the default output', () => { @@ -129,14 +149,14 @@ describe(commands.ROSTER_PLAN_LIST, () => { it('retrieves all planner plans contained in roster where current logged in user is member of', async () => { sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false); sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/beta/me/planner/rosterPlans`) { + if (opts.url === 'https://graph.microsoft.com/beta/me/planner/rosterPlans') { return rosterUserPlanListResponse; } throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true }) }); assert(loggerLogSpy.calledWith(rosterUserPlanListResponse.value)); }); @@ -150,7 +170,7 @@ describe(commands.ROSTER_PLAN_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true, userName: userName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, userName }) }); assert(loggerLogSpy.calledWith(rosterUserPlanListResponse.value)); }); @@ -164,7 +184,7 @@ describe(commands.ROSTER_PLAN_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true, userId: userId } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, userId }) }); assert(loggerLogSpy.calledWith(rosterUserPlanListResponse.value)); }); @@ -172,26 +192,26 @@ describe(commands.ROSTER_PLAN_LIST, () => { sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); await assert.rejects(command.action(logger, { - options: {} + options: commandOptionsSchema.parse({}) }), new CommandError(`Specify at least 'userId' or 'userName' when using application permissions.`)); }); it('throws an error when passing userId using delegated permissions', async () => { sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false); await assert.rejects(command.action(logger, { - options: { userId: userId } + options: commandOptionsSchema.parse({ userId }) }), new CommandError(`The options 'userId' or 'userName' cannot be used when obtaining Microsoft Planner Roster plans using delegated permissions.`)); }); it('handles error when retrieving all planner plans contained in roster', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/beta/me/planner/rosterPlans`) { + if (opts.url === 'https://graph.microsoft.com/beta/me/planner/rosterPlans') { throw { error: { error: { message: 'An error has occurred' } } }; } throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: {} }), new CommandError('An error has occurred')); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({}) }), new CommandError('An error has occurred')); }); -}); \ No newline at end of file +}); diff --git a/src/m365/planner/commands/roster/roster-plan-list.ts b/src/m365/planner/commands/roster/roster-plan-list.ts index 04ea2a62ffb..43b68bcf3f2 100644 --- a/src/m365/planner/commands/roster/roster-plan-list.ts +++ b/src/m365/planner/commands/roster/roster-plan-list.ts @@ -1,21 +1,33 @@ import auth from '../../../../Auth.js'; +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; import GraphCommand from '../../../base/GraphCommand.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; import commands from '../../commands.js'; import { validation } from '../../../../utils/validation.js'; import { accessToken } from '../../../../utils/accessToken.js'; import { odata } from '../../../../utils/odata.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + userId: z.string() + .refine(val => validation.isValidGuid(val), { + message: 'The value is not a valid GUID.' + }) + .optional(), + userName: z.string() + .refine(val => validation.isValidUserPrincipalName(val), { + message: 'The value is not a valid user principal name (UPN).' + }) + .optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - userId?: string; - userName?: string; -} - class PlannerRosterPlanListCommand extends GraphCommand { public get name(): string { return commands.ROSTER_PLAN_LIST; @@ -25,67 +37,25 @@ class PlannerRosterPlanListCommand extends GraphCommand { return 'Lists all Microsoft Planner Roster plans for a specific user'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - userId: typeof args.options.userId !== 'undefined', - userName: typeof args.options.userName !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '--userId [userId]' - }, - { - option: '--userName [userName]' - } - ); + public get schema(): z.ZodType | undefined { + return options; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.userId && !validation.isValidGuid(args.options.userId)) { - return `${args.options.userId} is not a valid GUID`; + public getRefinedSchema(schema: typeof options): z.ZodType | undefined { + return schema + .refine(opts => !opts.userId || !opts.userName, { + message: `Specify either 'userId' or 'userName', but not both.`, + params: { + customCode: 'optionSet', + options: ['userId', 'userName'] } - - if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) { - return `${args.options.userName} is not a valid user principal name (UPN)`; - } - - return true; - } - ); - } - - #initTypes(): void { - this.types.string.push('userId', 'userName'); + }); } public defaultProperties(): string[] | undefined { return ['id', 'title', 'createdDateTime', 'owner']; } - #initOptionSets(): void { - this.optionSets.push({ - options: ['userId', 'userName'], - runsWhen: (args) => args.options.userId || args.options.userName - }); - } - public async commandAction(logger: Logger, args: CommandArgs): Promise { const isAppOnlyAccessToken = accessToken.isAppOnlyAccessToken(auth.connection.accessTokens[this.resource].accessToken); if (isAppOnlyAccessToken && !args.options.userId && !args.options.userName) { @@ -106,7 +76,7 @@ class PlannerRosterPlanListCommand extends GraphCommand { else { requestUrl += 'me'; } - requestUrl += `/planner/rosterPlans`; + requestUrl += '/planner/rosterPlans'; try { const items = await odata.getAllItems(requestUrl); @@ -118,4 +88,4 @@ class PlannerRosterPlanListCommand extends GraphCommand { } } -export default new PlannerRosterPlanListCommand(); \ No newline at end of file +export default new PlannerRosterPlanListCommand(); diff --git a/src/m365/planner/commands/roster/roster-remove.spec.ts b/src/m365/planner/commands/roster/roster-remove.spec.ts index f8df544317f..7589cab18e8 100644 --- a/src/m365/planner/commands/roster/roster-remove.spec.ts +++ b/src/m365/planner/commands/roster/roster-remove.spec.ts @@ -2,6 +2,7 @@ import assert from 'assert'; 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'; @@ -10,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 command from './roster-remove.js'; +import command, { options } from './roster-remove.js'; -describe(commands.PLAN_REMOVE, () => { +describe(commands.ROSTER_REMOVE, () => { const validRosterId = 'CRp0hFSovEedkXtcX3WnS5gAGgch'; let log: string[]; let logger: Logger; let promptIssued: boolean = false; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -29,6 +32,8 @@ describe(commands.PLAN_REMOVE, () => { accessToken: 'abc', expiresOn: new Date() }; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -73,23 +78,37 @@ describe(commands.PLAN_REMOVE, () => { assert.notStrictEqual(command.description, null); }); + it('passes validation with valid id', () => { + const actual = commandOptionsSchema.safeParse({ + id: validRosterId + }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + id: validRosterId, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); + }); + it('prompts before removing the specified Roster when force option not passed', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ id: validRosterId - } + }) }); - assert(promptIssued); }); it('aborts removing the specified Roster when force option not passed and prompt not confirmed', async () => { const deleteSpy = sinon.spy(request, 'delete'); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ id: validRosterId - } + }) }); assert(deleteSpy.notCalled); }); @@ -104,11 +123,11 @@ describe(commands.PLAN_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, id: validRosterId, force: true - } + }) }); }); @@ -125,9 +144,9 @@ describe(commands.PLAN_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ id: validRosterId - } + }) }); }); @@ -139,10 +158,10 @@ describe(commands.PLAN_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ id: validRosterId, force: true - } + }) }), new CommandError('The requested item is not found.')); }); }); diff --git a/src/m365/planner/commands/roster/roster-remove.ts b/src/m365/planner/commands/roster/roster-remove.ts index 7ea62eb305b..0924b7c6e21 100644 --- a/src/m365/planner/commands/roster/roster-remove.ts +++ b/src/m365/planner/commands/roster/roster-remove.ts @@ -1,19 +1,23 @@ +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 GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.string(), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id: string; - force?: boolean; -} - class PlannerRosterRemoveCommand extends GraphCommand { public get name(): string { return commands.ROSTER_REMOVE; @@ -23,35 +27,8 @@ class PlannerRosterRemoveCommand extends GraphCommand { return 'Removes a Microsoft Planner Roster'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - force: !!args.options.force - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '--id ' - }, - { - option: '-f, --force' - } - ); - } - - #initTypes(): void { - this.types.string.push('id'); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { @@ -88,4 +65,4 @@ class PlannerRosterRemoveCommand extends GraphCommand { } } -export default new PlannerRosterRemoveCommand(); \ No newline at end of file +export default new PlannerRosterRemoveCommand(); diff --git a/src/m365/planner/commands/task/task-add.spec.ts b/src/m365/planner/commands/task/task-add.spec.ts index 6026784c32c..8b6472ec4d7 100644 --- a/src/m365/planner/commands/task/task-add.spec.ts +++ b/src/m365/planner/commands/task/task-add.spec.ts @@ -12,7 +12,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 './task-add.js'; +import command, { options } from './task-add.js'; import { settingsNames } from '../../../../settingsNames.js'; describe(commands.TASK_ADD, () => { @@ -130,6 +130,7 @@ describe(commands.TASK_ADD, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -142,6 +143,7 @@ describe(commands.TASK_ADD, () => { expiresOn: new Date() }; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -191,6 +193,15 @@ describe(commands.TASK_ADD, () => { it('has a description', () => { assert.notStrictEqual(command.description, null); }); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + title: 'value', + planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); + }); it('fails validation if neither the planId, planTitle, nor rosterId are provided.', async () => { sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { @@ -201,13 +212,11 @@ describe(commands.TASK_ADD, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - title: 'My Planner Task', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno' + }); + assert.strictEqual(actual.success, false); }); it('fails validation when both planId, planTitle, and rosterId are specified', async () => { @@ -219,16 +228,14 @@ describe(commands.TASK_ADD, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', - planTitle: 'My Planner', - rosterId: 'DjL5xiKO10qut8LQgztpKskABWna', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', + planTitle: 'My Planner', + rosterId: 'DjL5xiKO10qut8LQgztpKskABWna', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno' + }); + assert.strictEqual(actual.success, false); }); it('fails validation when planTitle is specified without ownerGroupId or ownerGroupName', async () => { @@ -240,14 +247,12 @@ describe(commands.TASK_ADD, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planTitle: 'My Planner Plan', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planTitle: 'My Planner Plan', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno' + }); + assert.strictEqual(actual.success, false); }); it('fails validation when planTitle is specified with both ownerGroupId and ownerGroupName', async () => { @@ -259,63 +264,53 @@ describe(commands.TASK_ADD, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planTitle: 'My Planner Plan', - ownerGroupId: '0d0402ee-970f-4951-90b5-2f24519d2e40', - ownerGroupName: 'My Planner Group', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planTitle: 'My Planner Plan', + ownerGroupId: '0d0402ee-970f-4951-90b5-2f24519d2e40', + ownerGroupName: 'My Planner Group', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno' + }); + assert.strictEqual(actual.success, false); }); it('passes validation when valid title, planId, and bucketId specified', async () => { - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno' - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno' + }); + assert.strictEqual(actual.success, true); }); it('passes validation when valid title, planTitle, and ownerGroupId are specified', async () => { - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planTitle: 'My Planner Plan', - ownerGroupId: '0d0402ee-970f-4951-90b5-2f24519d2e40', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno' - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planTitle: 'My Planner Plan', + ownerGroupId: '0d0402ee-970f-4951-90b5-2f24519d2e40', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno' + }); + assert.strictEqual(actual.success, true); }); it('passes validation when valid title, planTitle, ownerGroupName, and bucketId are specified', async () => { - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planTitle: 'My Planner Plan', - ownerGroupName: 'My Planner Group', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno' - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planTitle: 'My Planner Plan', + ownerGroupName: 'My Planner Group', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno' + }); + assert.strictEqual(actual.success, true); }); it('fails validation if the ownerGroupId is not a valid guid.', async () => { - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planTitle: 'My Planner Plan', - ownerGroupId: 'not-c49b-4fd4-8223-28f0ac3a6402', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planTitle: 'My Planner Plan', + ownerGroupId: 'not-c49b-4fd4-8223-28f0ac3a6402', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno' + }); + assert.strictEqual(actual.success, false); }); it('fails validation if neither the bucketId nor bucketName are provided.', async () => { @@ -327,13 +322,11 @@ describe(commands.TASK_ADD, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planId: '8QZEH7b3wkS_bGQobscsM5gADCBb' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planId: '8QZEH7b3wkS_bGQobscsM5gADCBb' + }); + assert.strictEqual(actual.success, false); }); it('fails validation when both bucketId and bucketName are specified', async () => { @@ -345,173 +338,145 @@ describe(commands.TASK_ADD, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', - bucketName: 'My Bucket' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', + bucketName: 'My Bucket' + }); + assert.strictEqual(actual.success, false); }); it('fails validation if startDateTime contains invalid format.', async () => { - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', - startDateTime: '2021-99-99' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', + startDateTime: '2021-99-99' + }); + assert.strictEqual(actual.success, false); }); it('fails validation if dueDateTime contains invalid format.', async () => { - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', - dueDateTime: '2021-99-99' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', + dueDateTime: '2021-99-99' + }); + assert.strictEqual(actual.success, false); }); it('fails validation if percentComplete contains invalid format.', async () => { - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', - percentComplete: 'Not A Number' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', + percentComplete: 'Not A Number' + }); + assert.strictEqual(actual.success, false); }); it('fails validation if percentComplete is not between 0 and 100.', async () => { - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', - percentComplete: 599 - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', + percentComplete: '599' + }); + assert.strictEqual(actual.success, false); }); it('fails validation if assignedToUserIds contains invalid guid.', async () => { - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', - assignedToUserIds: "2e42fe76-3f42-4884-b325-aefd7a905446,8d1ff29c-a6f4-4786-b316-test" - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', + assignedToUserIds: "2e42fe76-3f42-4884-b325-aefd7a905446,8d1ff29c-a6f4-4786-b316-test" + }); + assert.strictEqual(actual.success, false); }); it('fails validation if assignedToUserNames contains invalid user principal name', async () => { const assignedToUserNames = ['john.doe@contoso.com', 'foo']; - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', - assignedToUserNames: assignedToUserNames.join(',') - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', + assignedToUserNames: assignedToUserNames.join(',') + }); + assert.strictEqual(actual.success, false); }); it('fails validation when both assignedToUserIds and assignedToUserNames are specified', async () => { - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', - assignedToUserIds: "2e42fe76-3f42-4884-b325-aefd7a905446,8d1ff29c-a6f4-4786-b316-eb6030e1a09e", - assignedToUserNames: "Allan.Carroll@contoso.onmicrosoft.com,Ida.Stevens@contoso.onmicrosoft.com" - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', + assignedToUserIds: "2e42fe76-3f42-4884-b325-aefd7a905446,8d1ff29c-a6f4-4786-b316-eb6030e1a09e", + assignedToUserNames: "Allan.Carroll@contoso.onmicrosoft.com,Ida.Stevens@contoso.onmicrosoft.com" + }); + assert.strictEqual(actual.success, false); }); it('fails validation if incorrect appliedCategory is specified.', async () => { - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', - appliedCategories: "category1,category9" - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', + appliedCategories: "category1,category9" + }); + assert.strictEqual(actual.success, false); }); it('fails validation if priority lower than 0 is specified.', async () => { - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', - priority: -1 - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', + priority: '-1' + }); + assert.strictEqual(actual.success, false); }); it('fails validation if incorrect previewType is specified.', async () => { - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', - previewType: "test" - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', + previewType: "test" + }); + assert.strictEqual(actual.success, false); }); it('fails validation if priority higher than 10 is specified.', async () => { - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', - priority: 11 - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', + priority: '11' + }); + assert.strictEqual(actual.success, false); }); it('fails validation if priority is specified which is a number with decimals.', async () => { - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', - priority: 5.6 - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', + priority: '5.6' + }); + assert.strictEqual(actual.success, false); }); it('fails validation if unknown priority label is specified.', async () => { - const actual = await command.validate({ - options: { - title: 'My Planner Task', - planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', - priority: 'invalid' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: 'My Planner Task', + planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', + priority: 'invalid' + }); + assert.strictEqual(actual.success, false); }); it('correctly adds planner task with title, planId, and bucketId', async () => { @@ -521,7 +486,29 @@ describe(commands.TASK_ADD, () => { bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); + assert(loggerLogSpy.calledWith(taskAddResponse)); + }); + + it('correctly adds planner task with title, planId, bucketId, and percentComplete', async () => { + sinonUtil.restore(request.post); + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks`) { + assert.strictEqual(opts.data.percentComplete, 50); + return taskAddResponse; + } + + throw 'Invalid Request'; + }); + + await command.action(logger, { + options: commandOptionsSchema.parse({ + title: 'My Planner Task', + planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', + percentComplete: '50' + }) + }); assert(loggerLogSpy.calledWith(taskAddResponse)); }); @@ -553,7 +540,7 @@ describe(commands.TASK_ADD, () => { ownerGroupName: 'My Planner Group' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskAddResponse)); }); @@ -581,7 +568,7 @@ describe(commands.TASK_ADD, () => { ownerGroupId: '0d0402ee-970f-4951-90b5-2f24519d2e40' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskAddResponse)); }); @@ -608,7 +595,7 @@ describe(commands.TASK_ADD, () => { bucketName: 'My Planner Bucket' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskAddResponse)); }); @@ -620,7 +607,7 @@ describe(commands.TASK_ADD, () => { assignedToUserIds: '949b16c1-a032-453e-a8ae-89a52bfc1d8a' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskAddResponse)); }); @@ -658,7 +645,7 @@ describe(commands.TASK_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskAddResponseWithAssignments)); }); @@ -671,7 +658,7 @@ describe(commands.TASK_ADD, () => { planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', assignedToUserNames: 'user@contoso.onmicrosoft.com', - appliedCategories: "category1 category2" + appliedCategories: 'category1, category2' }; sinon.stub(request, 'get').callsFake(async (opts) => { @@ -696,7 +683,7 @@ describe(commands.TASK_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskAddResponseWithAssignments)); }); @@ -739,7 +726,7 @@ describe(commands.TASK_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskAddResponseWithDetails)); }); @@ -778,7 +765,7 @@ describe(commands.TASK_ADD, () => { bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskAddResponse)); }); @@ -794,7 +781,7 @@ describe(commands.TASK_ADD, () => { priority: 'Urgent' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert.strictEqual(requestPostStub.lastCall.args[0].data.priority, 1); }); @@ -810,7 +797,7 @@ describe(commands.TASK_ADD, () => { priority: 'Important' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert.strictEqual(requestPostStub.lastCall.args[0].data.priority, 3); }); @@ -826,7 +813,7 @@ describe(commands.TASK_ADD, () => { priority: 'Medium' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert.strictEqual(requestPostStub.lastCall.args[0].data.priority, 5); }); @@ -842,7 +829,7 @@ describe(commands.TASK_ADD, () => { priority: 'Low' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert.strictEqual(requestPostStub.lastCall.args[0].data.priority, 9); }); @@ -864,7 +851,7 @@ describe(commands.TASK_ADD, () => { bucketName: 'My Planner Bucket' }; - await assert.rejects(command.action(logger, { options: options } as any), new CommandError(`The specified bucket 'My Planner Bucket' does not exist.`)); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse(options) }), new CommandError(`The specified bucket 'My Planner Bucket' does not exist.`)); }); it('fails when an invalid user is specified', async () => { @@ -895,7 +882,7 @@ describe(commands.TASK_ADD, () => { assignedToUserNames: 'user@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' }; - await assert.rejects(command.action(logger, { options: options } as any), new CommandError('Cannot proceed with planner task creation. The following users provided are invalid : user2@contoso.onmicrosoft.com')); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse(options) }), new CommandError('Cannot proceed with planner task creation. The following users provided are invalid : user2@contoso.onmicrosoft.com')); }); it('fails validation when ownerGroupName not found', async () => { @@ -908,11 +895,12 @@ describe(commands.TASK_ADD, () => { }); await assert.rejects(command.action(logger, { - options: { - name: 'My Planner Bucket', + options: commandOptionsSchema.parse({ + title: 'My Planner Task', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', planTitle: 'My Planner Plan', ownerGroupName: 'foo' - } + }) }), new CommandError(`The specified group 'foo' does not exist.`)); }); @@ -928,19 +916,25 @@ describe(commands.TASK_ADD, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: 'My Planner Task', planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', description: 'My Task Description' - } + }) }), new CommandError(`Error fetching task details`)); }); it('correctly handles random API error', async () => { - sinonUtil.restore(request.get); - sinon.stub(request, 'get').rejects(new Error('An error has occurred')); + sinonUtil.restore(request.post); + sinon.stub(request, 'post').rejects(new Error('An error has occurred')); - await assert.rejects(command.action(logger, { options: {} } as any), new CommandError('An error has occurred')); + await assert.rejects(command.action(logger, { + options: commandOptionsSchema.parse({ + title: 'My Planner Task', + planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno' + }) + }), new CommandError('An error has occurred')); }); }); \ No newline at end of file diff --git a/src/m365/planner/commands/task/task-add.ts b/src/m365/planner/commands/task/task-add.ts index b8aa24e1686..acc53ae15ff 100644 --- a/src/m365/planner/commands/task/task-add.ts +++ b/src/m365/planner/commands/task/task-add.ts @@ -1,6 +1,7 @@ import { PlannerTask, PlannerTaskDetails, User } from '@microsoft/microsoft-graph-types'; +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { formatting } from '../../../../utils/formatting.js'; @@ -11,37 +12,89 @@ import { AppliedCategories } from '../../AppliedCategories.js'; import commands from '../../commands.js'; import { taskPriority } from '../../taskPriority.js'; +const allowedAppliedCategories = ['category1', 'category2', 'category3', 'category4', 'category5', 'category6']; +const allowedPreviewTypes = ['automatic', 'nopreview', 'checklist', 'description', 'reference']; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + title: z.string().alias('t'), + planId: z.string().optional(), + planTitle: z.string().optional(), + rosterId: z.string().optional(), + ownerGroupId: z.string() + .refine(val => validation.isValidGuid(val), { + message: 'The value is not a valid GUID.' + }) + .optional(), + ownerGroupName: z.string().optional(), + bucketId: z.string().optional(), + bucketName: z.string().optional(), + startDateTime: z.string() + .refine(val => validation.isValidISODateTime(val), { + message: 'The startDateTime is not a valid ISO date string.' + }) + .optional(), + dueDateTime: z.string() + .refine(val => validation.isValidISODateTime(val), { + message: 'The dueDateTime is not a valid ISO date string.' + }) + .optional(), + percentComplete: z.string() + .refine(val => !isNaN(Number(val)) && Number(val) >= 0 && Number(val) <= 100, { + message: 'percentComplete should be between 0 and 100.' + }) + .optional(), + assignedToUserIds: z.string() + .superRefine((val, ctx) => { + const result = validation.isValidGuidArray(val); + if (result !== true) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `The following GUIDs are invalid for the option 'assignedToUserIds': ${result}.` }); + } + }) + .optional(), + assignedToUserNames: z.string() + .superRefine((val, ctx) => { + const result = validation.isValidUserPrincipalNameArray(val); + if (result !== true) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `The following user principal names are invalid for the option 'assignedToUserNames': ${result}.` }); + } + }) + .optional(), + assigneePriority: z.string().optional(), + description: z.string().optional(), + appliedCategories: z.string() + .refine(val => val.split(',').every(cat => allowedAppliedCategories.includes(cat.toLocaleLowerCase().trim())), { + message: `The appliedCategories contains invalid value. Specify either ${allowedAppliedCategories.join(', ')} as properties.` + }) + .optional(), + previewType: z.string() + .refine(val => allowedPreviewTypes.includes(val.toLocaleLowerCase()), { + message: `The value is not a valid preview type. Allowed values are ${allowedPreviewTypes.join(', ')}.` + }) + .optional(), + orderHint: z.string().optional(), + priority: z.string() + .refine(val => { + const num = Number(val); + if (!isNaN(num)) { + return num >= 0 && num <= 10 && Number.isInteger(num); + } + return taskPriority.priorityValues.map(l => l.toLowerCase()).includes(val.toLowerCase()); + }, { + message: `The value is not a valid priority. Allowed values are 0-10 or ${taskPriority.priorityValues.join('|')}.` + }) + .optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - planId?: string; - planTitle?: string; - rosterId?: string; - ownerGroupId?: string; - ownerGroupName?: string; - bucketId?: string; - bucketName?: string; - title: string; - startDateTime?: string; - dueDateTime?: string; - percentComplete?: number; - assignedToUserIds?: string; - assignedToUserNames?: string; - assigneePriority?: string; - description?: string; - appliedCategories?: string; - previewType?: string; - orderHint?: string; - priority?: number | string; -} - class PlannerTaskAddCommand extends GraphCommand { private planId: string | undefined; private bucketId: string | undefined; - private allowedAppliedCategories: string[] = ['category1', 'category2', 'category3', 'category4', 'category5', 'category6']; - private allowedPreviewTypes: string[] = ['automatic', 'nopreview', 'checklist', 'description', 'reference']; public get name(): string { return commands.TASK_ADD; @@ -51,151 +104,28 @@ class PlannerTaskAddCommand extends GraphCommand { return 'Adds a new Microsoft Planner Task'; } - 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, { - planId: typeof args.options.planId !== 'undefined', - planTitle: typeof args.options.planTitle !== 'undefined', - rosterId: typeof args.options.rosterId !== 'undefined', - ownerGroupId: typeof args.options.ownerGroupId !== 'undefined', - ownerGroupName: typeof args.options.ownerGroupName !== 'undefined', - bucketId: typeof args.options.bucketId !== 'undefined', - bucketName: typeof args.options.bucketName !== 'undefined', - startDateTime: typeof args.options.startDateTime !== 'undefined', - dueDateTime: typeof args.options.dueDateTime !== 'undefined', - percentComplete: typeof args.options.percentComplete !== 'undefined', - assignedToUserIds: typeof args.options.assignedToUserIds !== 'undefined', - assignedToUserNames: typeof args.options.assignedToUserNames !== 'undefined', - assigneePriority: typeof args.options.assigneePriority !== 'undefined', - description: typeof args.options.description !== 'undefined', - appliedCategories: typeof args.options.appliedCategories !== 'undefined', - previewType: typeof args.options.previewType !== 'undefined', - orderHint: typeof args.options.orderHint !== 'undefined', - priority: typeof args.options.priority !== 'undefined' + public getRefinedSchema(schema: typeof options): z.ZodType | undefined { + return schema + .refine(opts => [opts.planId, opts.planTitle, opts.rosterId].filter(x => x !== undefined).length === 1, { + message: `Specify exactly one of the following options: 'planId', 'planTitle' or 'rosterId'.`, + params: { customCode: 'optionSet', options: ['planId', 'planTitle', 'rosterId'] } + }) + .refine(opts => [opts.bucketId, opts.bucketName].filter(x => x !== undefined).length === 1, { + message: `Specify exactly one of the following options: 'bucketId' or 'bucketName'.`, + params: { customCode: 'optionSet', options: ['bucketId', 'bucketName'] } + }) + .refine(opts => !opts.planTitle || [opts.ownerGroupId, opts.ownerGroupName].filter(x => x !== undefined).length === 1, { + message: `Specify exactly one of the following options: 'ownerGroupId' or 'ownerGroupName'.`, + params: { customCode: 'optionSet', options: ['ownerGroupId', 'ownerGroupName'] } + }) + .refine(opts => !(opts.assignedToUserIds && opts.assignedToUserNames), { + message: 'Specify either assignedToUserIds or assignedToUserNames but not both.', + params: { customCode: 'optionSet', options: ['assignedToUserIds', 'assignedToUserNames'] } }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '-t, --title ' }, - { option: '--planId [planId]' }, - { option: '--planTitle [planTitle]' }, - { option: '--rosterId [rosterId]' }, - { option: '--ownerGroupId [ownerGroupId]' }, - { option: '--ownerGroupName [ownerGroupName]' }, - { option: '--bucketId [bucketId]' }, - { option: '--bucketName [bucketName]' }, - { option: '--startDateTime [startDateTime]' }, - { option: '--dueDateTime [dueDateTime]' }, - { option: '--percentComplete [percentComplete]' }, - { option: '--assignedToUserIds [assignedToUserIds]' }, - { option: '--assignedToUserNames [assignedToUserNames]' }, - { option: '--assigneePriority [assigneePriority]' }, - { option: '--description [description]' }, - { - option: '--appliedCategories [appliedCategories]', - autocomplete: this.allowedAppliedCategories - }, - { - option: '--previewType [previewType]', - autocomplete: this.allowedPreviewTypes - }, - { option: '--orderHint [orderHint]' }, - { option: '--priority [priority]', autocomplete: taskPriority.priorityValues } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.ownerGroupId && !validation.isValidGuid(args.options.ownerGroupId as string)) { - return `${args.options.ownerGroupId} is not a valid GUID`; - } - - if (args.options.startDateTime && !validation.isValidISODateTime(args.options.startDateTime)) { - return 'The startDateTime is not a valid ISO date string'; - } - - if (args.options.dueDateTime && !validation.isValidISODateTime(args.options.dueDateTime)) { - return 'The dueDateTime is not a valid ISO date string'; - } - - if (args.options.percentComplete && isNaN(args.options.percentComplete)) { - return `percentComplete is not a number`; - } - - if (args.options.percentComplete && (args.options.percentComplete < 0 || args.options.percentComplete > 100)) { - return `percentComplete should be between 0 and 100`; - } - - if (args.options.assignedToUserIds) { - const isValidGUIDArrayResult = validation.isValidGuidArray(args.options.assignedToUserIds); - if (isValidGUIDArrayResult !== true) { - return `The following GUIDs are invalid for the option 'assignedToUserIds': ${isValidGUIDArrayResult}.`; - } - } - - if (args.options.assignedToUserIds && args.options.assignedToUserNames) { - return 'Specify either assignedToUserIds or assignedToUserNames but not both'; - } - - if (args.options.assignedToUserNames) { - const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(args.options.assignedToUserNames); - if (isValidUPNArrayResult !== true) { - return `The following user principal names are invalid for the option 'assignedToUserNames': ${isValidUPNArrayResult}.`; - } - } - - if (args.options.appliedCategories && args.options.appliedCategories.split(',').filter(category => this.allowedAppliedCategories.indexOf(category.toLocaleLowerCase()) < 0).length !== 0) { - return `The appliedCategories contains invalid value. Specify either ${this.allowedAppliedCategories.join(', ')} as properties`; - } - - if (args.options.previewType && this.allowedPreviewTypes.indexOf(args.options.previewType.toLocaleLowerCase()) === -1) { - return `${args.options.previewType} is not a valid preview type value. Allowed values are ${this.allowedPreviewTypes.join(', ')}`; - } - - if (args.options.priority !== undefined) { - if (typeof args.options.priority === "number") { - if (isNaN(args.options.priority) || args.options.priority < 0 || args.options.priority > 10 || !Number.isInteger(args.options.priority)) { - return 'priority should be an integer between 0 and 10.'; - } - } - else if (taskPriority.priorityValues.map(l => l.toLowerCase()).indexOf(args.options.priority.toString().toLowerCase()) === -1) { - return `${args.options.priority} is not a valid priority value. Allowed values are ${taskPriority.priorityValues.join('|')}.`; - } - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { options: ['planId', 'planTitle', 'rosterId'] }, - { options: ['bucketId', 'bucketName'] }, - { - options: ['ownerGroupId', 'ownerGroupName'], - runsWhen: (args) => { - return args.options.planTitle !== undefined; - } - } - ); - } - - #initTypes(): void { - this.types.string.push('title', 'planId', 'planTitle', 'rosterId', 'ownerGroupId', 'ownerGroupName', 'bucketId', 'bucketName', 'startDateTime', 'dueDateTime', 'assignedToUserIds', 'assignedToUserNames', 'appliedCategories', 'previewType', 'description', 'assigneePriority', 'orderHint'); } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { @@ -218,7 +148,7 @@ class PlannerTaskAddCommand extends GraphCommand { title: args.options.title, startDateTime: args.options.startDateTime, dueDateTime: args.options.dueDateTime, - percentComplete: args.options.percentComplete, + percentComplete: args.options.percentComplete ? Number(args.options.percentComplete) : undefined, assignments: assignments, orderHint: args.options.orderHint, assigneePriority: args.options.assigneePriority, @@ -260,7 +190,7 @@ class PlannerTaskAddCommand extends GraphCommand { } const categories: AppliedCategories = {}; - options.appliedCategories.toLocaleLowerCase().split(',').forEach(x => categories[x] = true); + options.appliedCategories.toLocaleLowerCase().split(',').forEach(x => categories[x.trim()] = true); return categories; } @@ -342,7 +272,6 @@ class PlannerTaskAddCommand extends GraphCommand { return options.assignedToUserIds.split(','); } - // Hitting this section means assignedToUserNames won't be undefined const userNames = options.assignedToUserNames as string; const userArr: string[] = userNames.split(',').map(o => o.trim()); @@ -365,7 +294,6 @@ class PlannerTaskAddCommand extends GraphCommand { userUpns = usersRes.map(res => res.value[0]?.userPrincipalName as string); const userIds = usersRes.map(res => res.value[0]?.id as string); - // Find the members where no graph response was found const invalidUsers = userArr.filter(user => !userUpns.some((upn) => upn?.toLowerCase() === user.toLowerCase())); if (invalidUsers && invalidUsers.length > 0) { @@ -376,4 +304,4 @@ class PlannerTaskAddCommand extends GraphCommand { } } -export default new PlannerTaskAddCommand(); \ No newline at end of file +export default new PlannerTaskAddCommand(); diff --git a/src/m365/planner/commands/task/task-checklistitem-add.spec.ts b/src/m365/planner/commands/task/task-checklistitem-add.spec.ts index 092cf154121..079f71ca670 100644 --- a/src/m365/planner/commands/task/task-checklistitem-add.spec.ts +++ b/src/m365/planner/commands/task/task-checklistitem-add.spec.ts @@ -1,6 +1,8 @@ import assert from 'assert'; 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'; @@ -10,7 +12,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 './task-checklistitem-add.js'; +import command, { options } from './task-checklistitem-add.js'; describe(commands.TASK_CHECKLISTITEM_ADD, () => { const validTaskId = 'BC3L9DGJ5UG2UQn4MlEbcZcALpqb'; @@ -34,6 +36,8 @@ describe(commands.TASK_CHECKLISTITEM_ADD, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -45,6 +49,8 @@ describe(commands.TASK_CHECKLISTITEM_ADD, () => { accessToken: 'abc', expiresOn: new Date() }; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -85,6 +91,15 @@ describe(commands.TASK_CHECKLISTITEM_ADD, () => { assert.notStrictEqual(command.description, null); }); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + taskId: validTaskId, + title: validTitle, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); + }); + it('correctly adds checklist item', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks/${formatting.encodeQueryParameter(validTaskId)}/details`) { @@ -102,11 +117,11 @@ describe(commands.TASK_CHECKLISTITEM_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ taskId: validTaskId, title: validTitle, output: 'json' - } + }) }); assert(loggerLogSpy.calledWith(taskDetailsWithChecklistResponse.checklist)); }); @@ -128,11 +143,11 @@ describe(commands.TASK_CHECKLISTITEM_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ taskId: validTaskId, title: validTitle, output: 'text' - } + }) }); assert(loggerLogSpy.calledWith([{ id: '00000000-0000-0000-0000-000000000000', title: validTitle, isChecked: false }])); }); @@ -154,10 +169,10 @@ describe(commands.TASK_CHECKLISTITEM_ADD, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ taskId: validTaskId, title: validTitle - } + }) }), new CommandError('Something went wrong.')); }); @@ -171,10 +186,10 @@ describe(commands.TASK_CHECKLISTITEM_ADD, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ taskId: validTaskId, title: validTitle - } + }) }), new CommandError('The request item is not found.')); }); }); diff --git a/src/m365/planner/commands/task/task-checklistitem-add.ts b/src/m365/planner/commands/task/task-checklistitem-add.ts index d3827861914..10eb9b4d7d7 100644 --- a/src/m365/planner/commands/task/task-checklistitem-add.ts +++ b/src/m365/planner/commands/task/task-checklistitem-add.ts @@ -1,23 +1,27 @@ import { PlannerTaskDetails } from '@microsoft/microsoft-graph-types'; import { v4 } from 'uuid'; +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 GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + taskId: z.string().alias('i'), + title: z.string().alias('t'), + isChecked: z.boolean().optional() +}); + +declare type Options = z.infer<typeof options>; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - taskId: string; - title: string; - isChecked?: boolean; -} - class PlannerTaskChecklistItemAddCommand extends GraphCommand { public get name(): string { return commands.TASK_CHECKLISTITEM_ADD; @@ -27,32 +31,8 @@ class PlannerTaskChecklistItemAddCommand extends GraphCommand { return 'Adds a new checklist item to a Planner task.'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - isChecked: !!args.options.isChecked - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '-i, --taskId <taskId>' }, - { option: '-t, --title <title>' }, - { option: '--isChecked' } - ); - } - - #initTypes(): void { - this.types.string.push('title', 'taskId'); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { @@ -109,4 +89,4 @@ class PlannerTaskChecklistItemAddCommand extends GraphCommand { } } -export default new PlannerTaskChecklistItemAddCommand(); \ No newline at end of file +export default new PlannerTaskChecklistItemAddCommand(); diff --git a/src/m365/planner/commands/task/task-checklistitem-list.spec.ts b/src/m365/planner/commands/task/task-checklistitem-list.spec.ts index e1bc48f2cad..34cb79ab5b6 100644 --- a/src/m365/planner/commands/task/task-checklistitem-list.spec.ts +++ b/src/m365/planner/commands/task/task-checklistitem-list.spec.ts @@ -1,6 +1,8 @@ import assert from 'assert'; 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'; @@ -9,70 +11,73 @@ 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 './task-checklistitem-list.js'; +import command, { options } from './task-checklistitem-list.js'; describe(commands.TASK_CHECKLISTITEM_LIST, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; const jsonOutput = { - "checklist": { - "33224": { - "isChecked": false, - "title": "Some checklist", - "orderHint": "8585576049720396756P(", - "lastModifiedDateTime": "2022-02-04T19:12:53.4692149Z", - "lastModifiedBy": { - "user": { - "displayName": null, - "id": "88e85b64-e687-4e0b-bbf4-f42f5f8e674e" + checklist: { + '33224': { + isChecked: false, + title: 'Some checklist', + orderHint: '8585576049720396756P(', + lastModifiedDateTime: '2022-02-04T19:12:53.4692149Z', + lastModifiedBy: { + user: { + displayName: null, + id: '88e85b64-e687-4e0b-bbf4-f42f5f8e674e' } } }, - "69115": { - "isChecked": false, - "title": "Some checklist more", - "orderHint": "85855760494@", - "lastModifiedDateTime": "2022-02-04T19:12:55.4735671Z", - "lastModifiedBy": { - "user": { - "displayName": null, - "id": "88e85b64-e687-4e0b-bbf4-f42f5f8e674e" + '69115': { + isChecked: false, + title: 'Some checklist more', + orderHint: '85855760494@', + lastModifiedDateTime: '2022-02-04T19:12:55.4735671Z', + lastModifiedBy: { + user: { + displayName: null, + id: '88e85b64-e687-4e0b-bbf4-f42f5f8e674e' } } } } }; const textOutput = { - "checklist": [{ - "id": "33224", - "isChecked": false, - "title": "Some checklist", - "orderHint": "8585576049720396756P(", - "lastModifiedDateTime": "2022-02-04T19:12:53.4692149Z", - "lastModifiedBy": { - "user": { - "displayName": null, - "id": "88e85b64-e687-4e0b-bbf4-f42f5f8e674e" + checklist: [{ + id: '33224', + isChecked: false, + title: 'Some checklist', + orderHint: '8585576049720396756P(', + lastModifiedDateTime: '2022-02-04T19:12:53.4692149Z', + lastModifiedBy: { + user: { + displayName: null, + id: '88e85b64-e687-4e0b-bbf4-f42f5f8e674e' } } }, { - "id": "69115", - "isChecked": false, - "title": "Some checklist more", - "orderHint": "85855760494@", - "lastModifiedDateTime": "2022-02-04T19:12:55.4735671Z", - "lastModifiedBy": { - "user": { - "displayName": null, - "id": "88e85b64-e687-4e0b-bbf4-f42f5f8e674e" + id: '69115', + isChecked: false, + title: 'Some checklist more', + orderHint: '85855760494@', + lastModifiedDateTime: '2022-02-04T19:12:55.4735671Z', + lastModifiedBy: { + user: { + displayName: null, + id: '88e85b64-e687-4e0b-bbf4-f42f5f8e674e' } } } ] }; + const validTaskId = 'vzCcZoOv-U27PwydxHB8opcADJo-'; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -84,6 +89,8 @@ describe(commands.TASK_CHECKLISTITEM_LIST, () => { accessToken: 'abc', expiresOn: new Date() }; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -127,9 +134,17 @@ describe(commands.TASK_CHECKLISTITEM_LIST, () => { assert.deepStrictEqual(command.defaultProperties(), ['id', 'title', 'isChecked']); }); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + taskId: validTaskId, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); + }); + it('successfully handles item found(JSON)', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks/vzCcZoOv-U27PwydxHB8opcADJo-/details?$select=checklist`) { + if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks/${validTaskId}/details?$select=checklist`) { return jsonOutput; } @@ -137,16 +152,17 @@ describe(commands.TASK_CHECKLISTITEM_LIST, () => { }); await command.action(logger, { - options: { - taskId: 'vzCcZoOv-U27PwydxHB8opcADJo-', debug: true - } + options: commandOptionsSchema.parse({ + taskId: validTaskId, + debug: true + }) }); assert(loggerLogSpy.calledWith(jsonOutput.checklist)); }); it('successfully handles item found(TEXT)', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks/vzCcZoOv-U27PwydxHB8opcADJo-/details?$select=checklist`) { + if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks/${validTaskId}/details?$select=checklist`) { return jsonOutput; } @@ -154,9 +170,11 @@ describe(commands.TASK_CHECKLISTITEM_LIST, () => { }); await command.action(logger, { - options: { - taskId: 'vzCcZoOv-U27PwydxHB8opcADJo-', debug: true, output: 'text' - } + options: commandOptionsSchema.parse({ + taskId: validTaskId, + debug: true, + output: 'text' + }) }); assert(loggerLogSpy.calledWith(textOutput.checklist)); }); @@ -165,13 +183,21 @@ describe(commands.TASK_CHECKLISTITEM_LIST, () => { sinonUtil.restore(request.get); sinon.stub(request, 'get').rejects(new Error('The requested item is not found.')); - await assert.rejects(command.action(logger, { options: {} } as any), new CommandError('The requested item is not found.')); + await assert.rejects(command.action(logger, { + options: commandOptionsSchema.parse({ + taskId: validTaskId + }) + }), new CommandError('The requested item is not found.')); }); it('correctly handles random API error', async () => { sinonUtil.restore(request.get); sinon.stub(request, 'get').rejects(new Error('An error has occurred')); - await assert.rejects(command.action(logger, { options: {} } as any), new CommandError('An error has occurred')); + await assert.rejects(command.action(logger, { + options: commandOptionsSchema.parse({ + taskId: validTaskId + }) + }), new CommandError('An error has occurred')); }); }); diff --git a/src/m365/planner/commands/task/task-checklistitem-list.ts b/src/m365/planner/commands/task/task-checklistitem-list.ts index 43867406a9b..8e73e725caf 100644 --- a/src/m365/planner/commands/task/task-checklistitem-list.ts +++ b/src/m365/planner/commands/task/task-checklistitem-list.ts @@ -1,19 +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 GraphCommand from "../../../base/GraphCommand.js"; -import commands from "../../commands.js"; +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.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 GraphCommand from '../../../base/GraphCommand.js'; +import commands from '../../commands.js'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + taskId: z.string().alias('i') +}); + +declare type Options = z.infer<typeof options>; interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - taskId: string; -} - class PlannerTaskChecklistItemListCommand extends GraphCommand { public get name(): string { return commands.TASK_CHECKLISTITEM_LIST; @@ -23,36 +27,21 @@ class PlannerTaskChecklistItemListCommand extends GraphCommand { return 'Lists the checklist items of a Planner task.'; } - public defaultProperties(): string[] | undefined { - return ['id', 'title', 'isChecked']; - } - - constructor() { - super(); - - this.#initOptions(); - this.#initTypes(); + public get schema(): z.ZodType | undefined { + return options; } - #initOptions(): void { - this.options.unshift( - { - option: "-i, --taskId <taskId>" - } - ); - } - - #initTypes(): void { - this.types.string.push('taskId'); + public defaultProperties(): string[] | undefined { + return ['id', 'title', 'isChecked']; } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { const requestOptions: CliRequestOptions = { url: `${this.resource}/v1.0/planner/tasks/${formatting.encodeQueryParameter(args.options.taskId)}/details?$select=checklist`, headers: { - accept: "application/json;odata.metadata=none" + accept: 'application/json;odata.metadata=none' }, - responseType: "json" + responseType: 'json' }; try { @@ -72,4 +61,4 @@ class PlannerTaskChecklistItemListCommand extends GraphCommand { } } -export default new PlannerTaskChecklistItemListCommand(); \ No newline at end of file +export default new PlannerTaskChecklistItemListCommand(); diff --git a/src/m365/planner/commands/task/task-checklistitem-remove.spec.ts b/src/m365/planner/commands/task/task-checklistitem-remove.spec.ts index c9ff1a3a6ae..f75a78e7b66 100644 --- a/src/m365/planner/commands/task/task-checklistitem-remove.spec.ts +++ b/src/m365/planner/commands/task/task-checklistitem-remove.spec.ts @@ -12,30 +12,30 @@ 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 './task-checklistitem-remove.js'; +import command, { options } from './task-checklistitem-remove.js'; describe(commands.TASK_CHECKLISTITEM_REMOVE, () => { let log: string[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; let promptIssued: boolean = false; const validTaskId = '2Vf8JHgsBUiIf-nuvBtv-ZgAAYw2'; const validId = '71175'; const responseChecklistWithId = { - "71175": { - "isChecked": false, - "title": "test 2" + '71175': { + isChecked: false, + title: 'test 2' } }; const responseChecklistWithNoId = { - "71176": { - "isChecked": false, - "title": "test 2" + '71176': { + isChecked: false, + title: 'test 2' } }; - before(() => { sinon.stub(auth, 'restoreAuth').resolves(); sinon.stub(telemetry, 'trackEvent').resolves(); @@ -48,6 +48,7 @@ describe(commands.TASK_CHECKLISTITEM_REMOVE, () => { }; sinon.stub(cli.getConfig(), 'all').value({}); commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -93,6 +94,15 @@ describe(commands.TASK_CHECKLISTITEM_REMOVE, () => { assert.notStrictEqual(command.description, null); }); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + taskId: validTaskId, + id: validId, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); + }); + it('prompts before removal when force option not passed', async () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').callsFake(() => { @@ -101,30 +111,28 @@ describe(commands.TASK_CHECKLISTITEM_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ taskId: validTaskId, id: validId - } + }) }); assert(promptIssued); }); - it('passes validation when valid options specified', async () => { - const actual = await command.validate({ - options: { - taskId: validTaskId, - id: validId - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when valid options specified', () => { + const actual = commandOptionsSchema.safeParse({ + taskId: validTaskId, + id: validId + }); + assert.strictEqual(actual.success, true); }); it('correctly deletes checklist item', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks/${formatting.encodeQueryParameter(validTaskId)}/details?$select=checklist`) { return { - "@odata.etag": "TestEtag", + '@odata.etag': 'TestEtag', checklist: responseChecklistWithId }; } @@ -138,11 +146,11 @@ describe(commands.TASK_CHECKLISTITEM_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ taskId: validTaskId, id: validId, force: true - } + }) }); }); @@ -150,7 +158,7 @@ describe(commands.TASK_CHECKLISTITEM_REMOVE, () => { sinon.stub(request, 'get').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks/${formatting.encodeQueryParameter(validTaskId)}/details?$select=checklist`) { return { - "@odata.etag": "TestEtag", + '@odata.etag': 'TestEtag', checklist: responseChecklistWithId }; } @@ -164,10 +172,10 @@ describe(commands.TASK_CHECKLISTITEM_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ taskId: validTaskId, id: validId - } + }) }); }); @@ -175,7 +183,7 @@ describe(commands.TASK_CHECKLISTITEM_REMOVE, () => { sinon.stub(request, 'get').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks/${formatting.encodeQueryParameter(validTaskId)}/details?$select=checklist`) { return { - "@odata.etag": "TestEtag", + '@odata.etag': 'TestEtag', checklist: responseChecklistWithNoId }; } @@ -184,10 +192,10 @@ describe(commands.TASK_CHECKLISTITEM_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ taskId: validTaskId, id: validId - } + }) }), new CommandError(`The specified checklist item with id ${validId} does not exist`)); }); @@ -195,6 +203,12 @@ describe(commands.TASK_CHECKLISTITEM_REMOVE, () => { sinonUtil.restore(request.get); sinon.stub(request, 'get').rejects(new Error('An error has occurred')); - await assert.rejects(command.action(logger, { options: {} } as any), new CommandError('An error has occurred')); + await assert.rejects(command.action(logger, { + options: commandOptionsSchema.parse({ + taskId: validTaskId, + id: validId, + force: true + }) + }), new CommandError('An error has occurred')); }); }); diff --git a/src/m365/planner/commands/task/task-checklistitem-remove.ts b/src/m365/planner/commands/task/task-checklistitem-remove.ts index e663e34c35d..e35e920eb27 100644 --- a/src/m365/planner/commands/task/task-checklistitem-remove.ts +++ b/src/m365/planner/commands/task/task-checklistitem-remove.ts @@ -1,22 +1,26 @@ import { PlannerTaskDetails } from '@microsoft/microsoft-graph-types'; +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 GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.string().alias('i'), + taskId: z.string(), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer<typeof options>; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id: string; - taskId: string; - force?: boolean; -} - class PlannerTaskChecklistItemRemoveCommand extends GraphCommand { public get name(): string { return commands.TASK_CHECKLISTITEM_REMOVE; @@ -26,32 +30,8 @@ class PlannerTaskChecklistItemRemoveCommand extends GraphCommand { return 'Removes the checklist item from the planner task'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - force: !!args.options.force - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '-i, --id <id>' }, - { option: '--taskId <taskId>' }, - { option: '-f, --force' } - ); - } - - #initTypes(): void { - this.types.string.push('id', 'taskId'); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { @@ -109,4 +89,4 @@ class PlannerTaskChecklistItemRemoveCommand extends GraphCommand { } } -export default new PlannerTaskChecklistItemRemoveCommand(); \ No newline at end of file +export default new PlannerTaskChecklistItemRemoveCommand(); diff --git a/src/m365/planner/commands/task/task-get.spec.ts b/src/m365/planner/commands/task/task-get.spec.ts index dcd15422613..7c3b2d1f41a 100644 --- a/src/m365/planner/commands/task/task-get.spec.ts +++ b/src/m365/planner/commands/task/task-get.spec.ts @@ -12,7 +12,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 './task-get.js'; +import command, { options } from './task-get.js'; import { settingsNames } from '../../../../settingsNames.js'; describe(commands.TASK_GET, () => { @@ -20,6 +20,7 @@ describe(commands.TASK_GET, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; const validTaskId = '2Vf8JHgsBUiIf-nuvBtv-ZgAAYw2'; const validTaskTitle = 'Task name'; const validBucketId = 'vncYUXCRBke28qMLB-d4xJcACtNz'; @@ -141,6 +142,7 @@ describe(commands.TASK_GET, () => { expiresOn: new Date() }; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName: string, defaultValue: any) => { if (settingName === 'prompt') { return false; @@ -188,6 +190,13 @@ describe(commands.TASK_GET, () => { it('has a description', () => { assert.notStrictEqual(command.description, null); }); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'value', + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); + }); it('fails validation when bucket name is used without id', async () => { sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { @@ -198,13 +207,11 @@ describe(commands.TASK_GET, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - id: validTaskId, - bucketName: validBucketName - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: validTaskId, + bucketName: validBucketName + }); + assert.strictEqual(actual.success, false); }); it('fails validation when title is used without bucket id', async () => { @@ -216,12 +223,10 @@ describe(commands.TASK_GET, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - title: validTaskTitle - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: validTaskTitle + }); + assert.strictEqual(actual.success, false); }); it('fails validation when title is used with both bucket id and bucketname', async () => { @@ -233,14 +238,12 @@ describe(commands.TASK_GET, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - title: validTaskTitle, - bucketId: validBucketId, - bucketName: validBucketName - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: validTaskTitle, + bucketId: validBucketId, + bucketName: validBucketName + }); + assert.strictEqual(actual.success, false); }); it('fails validation when bucket name is used without plan title, plan id, or roster id', async () => { @@ -252,13 +255,11 @@ describe(commands.TASK_GET, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - title: validTaskTitle, - bucketName: validBucketName - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: validTaskTitle, + bucketName: validBucketName + }); + assert.strictEqual(actual.success, false); }); it('fails validation when bucket name is used with both plan title, plan id, and roster id', async () => { @@ -270,16 +271,14 @@ describe(commands.TASK_GET, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - title: validTaskTitle, - bucketName: validBucketName, - planId: validPlanId, - planTitle: validPlanTitle, - rosterId: validRosterId - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: validTaskTitle, + bucketName: validBucketName, + planId: validPlanId, + planTitle: validPlanTitle, + rosterId: validRosterId + }); + assert.strictEqual(actual.success, false); }); it('fails validation when plan title is used without owner group name or owner group id', async () => { @@ -291,14 +290,12 @@ describe(commands.TASK_GET, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - title: validTaskTitle, - bucketName: validBucketName, - planTitle: validPlanTitle - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: validTaskTitle, + bucketName: validBucketName, + planTitle: validPlanTitle + }); + assert.strictEqual(actual.success, false); }); it('fails validation when plan title is used with both owner group name and owner group id', async () => { @@ -310,59 +307,49 @@ describe(commands.TASK_GET, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - title: validTaskTitle, - bucketName: validBucketName, - planTitle: validPlanTitle, - ownerGroupName: validOwnerGroupName, - ownerGroupId: validOwnerGroupId - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: validTaskTitle, + bucketName: validBucketName, + planTitle: validPlanTitle, + ownerGroupName: validOwnerGroupName, + ownerGroupId: validOwnerGroupId + }); + assert.strictEqual(actual.success, false); }); it('fails validation when id and plan details are specified', async () => { - const actual = await command.validate({ - options: { - id: validBucketId, - planId: validPlanId - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: validBucketId, + planId: validPlanId + }); + assert.strictEqual(actual.success, false); }); it('fails validation when owner group id is not a guid', async () => { - const actual = await command.validate({ - options: { - title: validTaskTitle, - bucketName: validBucketName, - planTitle: validPlanTitle, - ownerGroupId: invalidOwnerGroupId - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: validTaskTitle, + bucketName: validBucketName, + planTitle: validPlanTitle, + ownerGroupId: invalidOwnerGroupId + }); + assert.strictEqual(actual.success, false); }); it('validates for a correct input with id', async () => { - const actual = await command.validate({ - options: { - id: validTaskId - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: validTaskId + }); + assert.strictEqual(actual.success, true); }); it('validates for a correct input with name', async () => { - const actual = await command.validate({ - options: { - title: validTaskTitle, - bucketName: validBucketName, - planTitle: validPlanTitle, - ownerGroupName: validOwnerGroupName - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: validTaskTitle, + bucketName: validBucketName, + planTitle: validPlanTitle, + ownerGroupName: validOwnerGroupName + }); + assert.strictEqual(actual.success, true); }); it('fails validation when no groups found', async () => { @@ -375,12 +362,12 @@ describe(commands.TASK_GET, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, bucketName: validBucketName, planTitle: validPlanTitle, ownerGroupName: validOwnerGroupName - } + }) }), new CommandError(`The specified group '${validOwnerGroupName}' does not exist.`)); }); @@ -402,12 +389,12 @@ describe(commands.TASK_GET, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, bucketName: validBucketName, planTitle: validPlanTitle, ownerGroupName: validOwnerGroupName - } + }) }), new CommandError("Multiple groups with name 'Group name' found. Found: 00000000-0000-0000-0000-000000000000.")); }); @@ -421,11 +408,11 @@ describe(commands.TASK_GET, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, bucketName: validBucketName, planId: validPlanId - } + }) }), new CommandError(`The specified bucket '${validBucketName}' does not exist.`)); }); @@ -447,11 +434,11 @@ describe(commands.TASK_GET, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, bucketName: validBucketName, planId: validPlanId - } + }) }), new CommandError("Multiple buckets with name 'Bucket name' found. Found: vncYUXCRBke28qMLB-d4xJcACtNz.")); }); @@ -482,18 +469,45 @@ describe(commands.TASK_GET, () => { sinon.stub(cli, 'handleMultipleResultsFound').resolves(singleBucketByNameResponse.value[0]); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, bucketName: validBucketName, planTitle: validPlanTitle, ownerGroupName: validOwnerGroupName + }) + }); + assert(loggerLogSpy.calledWith(outputResponse)); + }); + + it('gets a task by title and bucketId', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/planner/buckets/${validBucketId}/tasks?$select=id,title`) { + return singleTaskByTitleResponse; } + if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks/${formatting.encodeQueryParameter(validTaskId)}`) { + return taskResponse; + } + if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks/${formatting.encodeQueryParameter(validTaskId)}/details`) { + return taskDetailsResponse; + } + + throw 'Invalid Request'; + }); + + await command.action(logger, { + options: commandOptionsSchema.parse({ + title: validTaskTitle, + bucketId: validBucketId + }) }); assert(loggerLogSpy.calledWith(outputResponse)); }); it('fails validation when no tasks found', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/planner/plans/${validPlanId}/buckets?$select=id,name`) { + return singleBucketByNameResponse; + } if (opts.url === `https://graph.microsoft.com/v1.0/planner/buckets/${validBucketId}/tasks?$select=id,title`) { return { "value": [{ "id": "" }] }; } @@ -502,10 +516,11 @@ describe(commands.TASK_GET, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, - bucketId: validBucketId - } + bucketName: validBucketName, + planId: validPlanId + }) }), new CommandError(`The specified task ${validTaskTitle} does not exist`)); }); @@ -519,6 +534,9 @@ describe(commands.TASK_GET, () => { }); sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/planner/plans/${validPlanId}/buckets?$select=id,name`) { + return singleBucketByNameResponse; + } if (opts.url === `https://graph.microsoft.com/v1.0/planner/buckets/${validBucketId}/tasks?$select=id,title`) { return multipleTasksByTitleResponse; } @@ -527,10 +545,11 @@ describe(commands.TASK_GET, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, - bucketId: validBucketId - } + bucketName: validBucketName, + planId: validPlanId + }) }), new CommandError("Multiple tasks with title 'Task name' found. Found: 2Vf8JHgsBUiIf-nuvBtv-ZgAAYw2.")); }); @@ -561,12 +580,12 @@ describe(commands.TASK_GET, () => { sinon.stub(cli, 'handleMultipleResultsFound').resolves(singleTaskByTitleResponse.value[0]); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, bucketName: validBucketName, planTitle: validPlanTitle, ownerGroupName: validOwnerGroupName - } + }) }); assert(loggerLogSpy.calledWith(outputResponse)); }); @@ -596,12 +615,12 @@ describe(commands.TASK_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, bucketName: validBucketName, planTitle: validPlanTitle, ownerGroupName: validOwnerGroupName - } + }) }); assert(loggerLogSpy.calledWith(outputResponse)); }); @@ -628,12 +647,12 @@ describe(commands.TASK_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, bucketName: validBucketName, planTitle: validPlanTitle, ownerGroupId: validOwnerGroupId - } + }) }); assert(loggerLogSpy.calledWith(outputResponse)); }); @@ -651,9 +670,9 @@ describe(commands.TASK_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ id: validTaskId - } + }) }); assert(loggerLogSpy.calledWith(outputResponse)); }); @@ -680,11 +699,11 @@ describe(commands.TASK_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, bucketName: validBucketName, rosterId: validRosterId - } + }) }); assert(loggerLogSpy.calledWith(outputResponse)); }); @@ -693,13 +712,17 @@ describe(commands.TASK_GET, () => { sinonUtil.restore(request.get); sinon.stub(request, 'get').rejects(new Error('The requested item is not found.')); - await assert.rejects(command.action(logger, { options: {} } as any), new CommandError('The requested item is not found.')); + await assert.rejects(command.action(logger, { + options: commandOptionsSchema.parse({ id: validTaskId }) + }), new CommandError('The requested item is not found.')); }); it('correctly handles random API error', async () => { sinonUtil.restore(request.get); sinon.stub(request, 'get').rejects(new Error('An error has occurred')); - await assert.rejects(command.action(logger, { options: {} } as any), new CommandError('An error has occurred')); + await assert.rejects(command.action(logger, { + options: commandOptionsSchema.parse({ id: validTaskId }) + }), new CommandError('An error has occurred')); }); }); diff --git a/src/m365/planner/commands/task/task-get.ts b/src/m365/planner/commands/task/task-get.ts index f4a19e0fd12..b878bfd905c 100644 --- a/src/m365/planner/commands/task/task-get.ts +++ b/src/m365/planner/commands/task/task-get.ts @@ -1,6 +1,7 @@ import { PlannerTask, PlannerTaskDetails } from '@microsoft/microsoft-graph-types'; +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { formatting } from '../../../../utils/formatting.js'; @@ -10,22 +11,29 @@ import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; import { cli } from '../../../../cli/cli.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.string().optional().alias('i'), + title: z.string().optional().alias('t'), + bucketId: z.string().optional(), + bucketName: z.string().optional(), + planId: z.string().optional(), + planTitle: z.string().optional(), + rosterId: z.string().optional(), + ownerGroupId: z.string() + .refine(val => validation.isValidGuid(val), { + message: 'The value is not a valid GUID.' + }) + .optional(), + ownerGroupName: z.string().optional() +}); + +declare type Options = z.infer<typeof options>; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id?: string; - title?: string; - bucketId?: string; - bucketName?: string; - planId?: string; - planTitle?: string; - rosterId?: string; - ownerGroupId?: string; - ownerGroupName?: string; -} - class PlannerTaskGetCommand extends GraphCommand { public get name(): string { return commands.TASK_GET; @@ -35,98 +43,35 @@ class PlannerTaskGetCommand extends GraphCommand { return 'Retrieve the specified planner task'; } - 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', - bucketId: typeof args.options.bucketId !== 'undefined', - bucketName: typeof args.options.bucketName !== 'undefined', - planId: typeof args.options.planId !== 'undefined', - planTitle: typeof args.options.planTitle !== 'undefined', - rosterId: typeof args.options.rosterId !== 'undefined', - ownerGroupId: typeof args.options.ownerGroupId !== 'undefined', - ownerGroupName: typeof args.options.ownerGroupName !== 'undefined' + public getRefinedSchema(schema: typeof options): z.ZodType | undefined { + return schema + .refine(opts => [opts.id, opts.title].filter(x => x !== undefined).length === 1, { + message: `Specify exactly one of the following options: 'id' or 'title'.`, + params: { customCode: 'optionSet', options: ['id', 'title'] } + }) + .refine(opts => opts.id !== undefined || opts.bucketId !== undefined || [opts.planId, opts.planTitle, opts.rosterId].filter(x => x !== undefined).length === 1, { + message: `Specify exactly one of the following options: 'planId', 'planTitle' or 'rosterId'.`, + params: { customCode: 'optionSet', options: ['planId', 'planTitle', 'rosterId'] } + }) + .refine(opts => opts.id !== undefined || [opts.bucketId, opts.bucketName].filter(x => x !== undefined).length === 1, { + message: `Specify exactly one of the following options: 'bucketId' or 'bucketName'.`, + params: { customCode: 'optionSet', options: ['bucketId', 'bucketName'] } + }) + .refine(opts => !(opts.bucketName && !opts.rosterId) || [opts.planId, opts.planTitle].filter(x => x !== undefined).length === 1, { + message: `Specify exactly one of the following options: 'planId' or 'planTitle'.`, + params: { customCode: 'optionSet', options: ['planId', 'planTitle'] } + }) + .refine(opts => !opts.planTitle || [opts.ownerGroupId, opts.ownerGroupName].filter(x => x !== undefined).length === 1, { + message: `Specify exactly one of the following options: 'ownerGroupId' or 'ownerGroupName'.`, + params: { customCode: 'optionSet', options: ['ownerGroupId', 'ownerGroupName'] } + }) + .refine(opts => !opts.id || !(opts.bucketId || opts.bucketName || opts.planId || opts.planTitle || opts.rosterId || opts.ownerGroupId || opts.ownerGroupName), { + message: `Don't specify bucketId, bucketName, planId, planTitle, rosterId, ownerGroupId or ownerGroupName when using id.` }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '-i, --id [id]' }, - { option: '-t, --title [title]' }, - { option: '--bucketId [bucketId]' }, - { option: '--bucketName [bucketName]' }, - { option: '--planId [planId]' }, - { option: '--planTitle [planTitle]' }, - { option: '--rosterId [rosterId]' }, - { option: '--ownerGroupId [ownerGroupId]' }, - { option: '--ownerGroupName [ownerGroupName]' } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.id) { - if (args.options.bucketId || args.options.bucketName || - args.options.planId || args.options.planTitle || args.options.rosterId || - args.options.ownerGroupId || args.options.ownerGroupName) { - return 'Don\'t specify bucketId, bucketName, planId, planTitle, rosterId, ownerGroupId or ownerGroupName when using id'; - } - } - - if (args.options.ownerGroupId && !validation.isValidGuid(args.options.ownerGroupId as string)) { - return `${args.options.ownerGroupId} is not a valid GUID`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { options: ['id', 'title'] }, - { - options: ['planId', 'planTitle', 'rosterId'], - runsWhen: (args) => { - return args.options.id === undefined; - } - }, - { - options: ['bucketId', 'bucketName'], - runsWhen: (args) => { - return args.options.title !== undefined; - } - }, - { - options: ['planId', 'planTitle'], - runsWhen: (args) => { - return args.options.bucketName !== undefined && args.options.rosterId === undefined; - } - }, - { - options: ['ownerGroupId', 'ownerGroupName'], - runsWhen: (args) => { - return args.options.planTitle !== undefined; - } - } - ); - } - - #initTypes(): void { - this.types.string.push('id', 'title', 'planId', 'planTitle', 'ownerGroupId', 'ownerGroupName', 'bucketId', 'bucketName', 'rosterId'); } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { @@ -231,4 +176,4 @@ class PlannerTaskGetCommand extends GraphCommand { } } -export default new PlannerTaskGetCommand(); \ No newline at end of file +export default new PlannerTaskGetCommand(); diff --git a/src/m365/planner/commands/task/task-list.spec.ts b/src/m365/planner/commands/task/task-list.spec.ts index 7c0ea209d78..64812cfcf8f 100644 --- a/src/m365/planner/commands/task/task-list.spec.ts +++ b/src/m365/planner/commands/task/task-list.spec.ts @@ -12,7 +12,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 './task-list.js'; +import command, { options } from './task-list.js'; import { settingsNames } from '../../../../settingsNames.js'; describe(commands.TASK_LIST, () => { @@ -196,6 +196,7 @@ describe(commands.TASK_LIST, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -208,6 +209,7 @@ describe(commands.TASK_LIST, () => { expiresOn: new Date() }; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName: string, defaultValue: any) => { if (settingName === 'prompt') { return false; @@ -288,6 +290,19 @@ describe(commands.TASK_LIST, () => { assert.notStrictEqual(command.description, null); }); + 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({ + bucketId: 'value', + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); + }); + it('defines correct properties for the default output', () => { assert.deepStrictEqual(command.defaultProperties(), ['id', 'title', 'startDateTime', 'dueDateTime', 'completedDateTime']); }); @@ -301,13 +316,11 @@ describe(commands.TASK_LIST, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - bucketId: 'FtzysDykv0-9s9toWiZhdskAD67z', - bucketName: 'Planner Bucket A' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + bucketId: 'FtzysDykv0-9s9toWiZhdskAD67z', + bucketName: 'Planner Bucket A' + }); + assert.strictEqual(actual.success, false); }); it('fails validation when bucketName is specified without planId, planTitle, or rosterId', async () => { @@ -319,12 +332,10 @@ describe(commands.TASK_LIST, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - bucketName: 'Planner Bucket A' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + bucketName: 'Planner Bucket A' + }); + assert.strictEqual(actual.success, false); }); it('fails validation when bucketName is specified with planId, planTitle, and rosterId', async () => { @@ -336,15 +347,13 @@ describe(commands.TASK_LIST, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - bucketName: 'Planner Bucket A', - planId: 'iVPMIgdku0uFlou-KLNg6MkAE1O2', - planTitle: 'My Planner Plan', - rosterId: 'DjL5xiKO10qut8LQgztpKskABWna' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + bucketName: 'Planner Bucket A', + planId: 'iVPMIgdku0uFlou-KLNg6MkAE1O2', + planTitle: 'My Planner Plan', + rosterId: 'DjL5xiKO10qut8LQgztpKskABWna' + }); + assert.strictEqual(actual.success, false); }); it('fails validation when bucketName is specified with neither the planId, planTitle, nor rosterId', async () => { @@ -356,13 +365,11 @@ describe(commands.TASK_LIST, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - debug: true, - bucketName: 'Planner Bucket A' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + debug: true, + bucketName: 'Planner Bucket A' + }); + assert.strictEqual(actual.success, false); }); it('fails validation when planId, planTitle, and rosterId are specified', async () => { @@ -374,15 +381,13 @@ describe(commands.TASK_LIST, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - bucketName: 'Planner Bucket A', - planId: 'iVPMIgdku0uFlou-KLNg6MkAE1O2', - planTitle: 'My Planner', - rosterId: 'DjL5xiKO10qut8LQgztpKskABWna' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + bucketName: 'Planner Bucket A', + planId: 'iVPMIgdku0uFlou-KLNg6MkAE1O2', + planTitle: 'My Planner', + rosterId: 'DjL5xiKO10qut8LQgztpKskABWna' + }); + assert.strictEqual(actual.success, false); }); it('fails validation when planTitle is specified without ownerGroupId or ownerGroupName', async () => { @@ -394,13 +399,11 @@ describe(commands.TASK_LIST, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - bucketName: 'Planner Bucket A', - planTitle: 'My Planner Plan' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + bucketName: 'Planner Bucket A', + planTitle: 'My Planner Plan' + }); + assert.strictEqual(actual.success, false); }); it('fails validation when planTitle is specified with both ownerGroupId and ownerGroupName', async () => { @@ -412,97 +415,79 @@ describe(commands.TASK_LIST, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - bucketName: 'Planner Bucket A', - planTitle: 'My Planner Plan', - ownerGroupId: '0d0402ee-970f-4951-90b5-2f24519d2e40', - ownerGroupName: 'My Planner Group' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + bucketName: 'Planner Bucket A', + planTitle: 'My Planner Plan', + ownerGroupId: '0d0402ee-970f-4951-90b5-2f24519d2e40', + ownerGroupName: 'My Planner Group' + }); + assert.strictEqual(actual.success, false); }); it('fails validation when owner group id is not a valid guid', async () => { - const actual = await command.validate({ - options: { - planTitle: 'My Planner Plan', - bucketName: 'Planner Bucket A', - ownerGroupId: 'Invalid' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + planTitle: 'My Planner Plan', + bucketName: 'Planner Bucket A', + ownerGroupId: 'Invalid' + }); + assert.strictEqual(actual.success, false); }); it('passes validation when valid planId is specified', async () => { - const actual = await command.validate({ - options: { - planId: 'iVPMIgdku0uFlou-KLNg6MkAE1O2', - bucketName: 'Planner Bucket A' - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + planId: 'iVPMIgdku0uFlou-KLNg6MkAE1O2', + bucketName: 'Planner Bucket A' + }); + assert.strictEqual(actual.success, true); }); it('passes validation when valid planTitle and ownerGroupId are specified', async () => { - const actual = await command.validate({ - options: { - planTitle: 'My Planner Plan', - bucketName: 'Planner Bucket A', - ownerGroupId: '0d0402ee-970f-4951-90b5-2f24519d2e40' - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + planTitle: 'My Planner Plan', + bucketName: 'Planner Bucket A', + ownerGroupId: '0d0402ee-970f-4951-90b5-2f24519d2e40' + }); + assert.strictEqual(actual.success, true); }); it('passes validation when valid planTitle and ownerGroupName are specified', async () => { - const actual = await command.validate({ - options: { - planTitle: 'My Planner Plan', - bucketName: 'Planner Bucket A', - ownerGroupName: 'My Planner Group' - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + planTitle: 'My Planner Plan', + bucketName: 'Planner Bucket A', + ownerGroupName: 'My Planner Group' + }); + assert.strictEqual(actual.success, true); }); it('passes validation when bucketName and planId are specified', async () => { - const actual = await command.validate({ - options: { - planId: 'iVPMIgdku0uFlou-KLNg6MkAE1O2', - bucketName: 'Planner Bucket A' - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + planId: 'iVPMIgdku0uFlou-KLNg6MkAE1O2', + bucketName: 'Planner Bucket A' + }); + assert.strictEqual(actual.success, true); }); it('passes validation when bucketName, planTitle, and ownerGroupId are specified', async () => { - const actual = await command.validate({ - options: { - planTitle: 'My Planner Plan', - bucketName: 'Planner Bucket A', - ownerGroupId: '0d0402ee-970f-4951-90b5-2f24519d2e40' - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + planTitle: 'My Planner Plan', + bucketName: 'Planner Bucket A', + ownerGroupId: '0d0402ee-970f-4951-90b5-2f24519d2e40' + }); + assert.strictEqual(actual.success, true); }); it('passes validation when bucketName, planTitle, and ownerGroupName are specified', async () => { - const actual = await command.validate({ - options: { - planTitle: 'My Planner Plan', - bucketName: 'Planner Bucket A', - ownerGroupName: 'My Planner Group' - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + planTitle: 'My Planner Plan', + bucketName: 'Planner Bucket A', + ownerGroupName: 'My Planner Group' + }); + assert.strictEqual(actual.success, true); }); it('passes validation when no arguments are specified', async () => { - const actual = await command.validate({ - options: {} - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({}); + assert.strictEqual(actual.success, true); }); it('fails validation if the ownerGroupId is not a valid guid.', async () => { @@ -514,13 +499,11 @@ describe(commands.TASK_LIST, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - planTitle: 'My Planner Plan', - ownerGroupId: 'not-c49b-4fd4-8223-28f0ac3a6402' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + planTitle: 'My Planner Plan', + ownerGroupId: 'not-c49b-4fd4-8223-28f0ac3a6402' + }); + assert.strictEqual(actual.success, false); }); it('fails validation when ownerGroupName not found', async () => { @@ -533,10 +516,10 @@ describe(commands.TASK_LIST, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ planTitle: 'My Planner Plan', ownerGroupName: 'foo' - } + }) }), new CommandError(`The specified group 'foo' does not exist.`)); }); @@ -577,16 +560,18 @@ describe(commands.TASK_LIST, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ bucketName: 'foo', planTitle: 'My Planner Plan', ownerGroupName: 'My Planner Group' - } + }) }), new CommandError(`The specified bucket 'foo' does not exist.`)); }); it('lists planner tasks of the current logged in user', async () => { - await command.action(logger, { options: {} }); + await command.action(logger, { + options: commandOptionsSchema.parse({}) + }); assert(loggerLogSpy.called); }); @@ -596,7 +581,7 @@ describe(commands.TASK_LIST, () => { ownerGroupId: '0d0402ee-970f-4951-90b5-2f24519d2e40' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskListResponseBetaValue)); }); @@ -606,7 +591,7 @@ describe(commands.TASK_LIST, () => { ownerGroupName: 'My Planner Group' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskListResponseBetaValue)); }); @@ -615,7 +600,7 @@ describe(commands.TASK_LIST, () => { bucketId: 'FtzysDykv0-9s9toWiZhdskAD67z' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskListResponseBetaValue)); }); @@ -626,7 +611,7 @@ describe(commands.TASK_LIST, () => { planId: 'iVPMIgdku0uFlou-KLNg6MkAE1O2' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskListResponseBetaValue)); }); @@ -637,7 +622,7 @@ describe(commands.TASK_LIST, () => { ownerGroupId: '0d0402ee-970f-4951-90b5-2f24519d2e40' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskListResponseBetaValue)); }); @@ -648,7 +633,7 @@ describe(commands.TASK_LIST, () => { ownerGroupName: 'My Planner Group' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskListResponseBetaValue)); }); @@ -658,7 +643,7 @@ describe(commands.TASK_LIST, () => { rosterId: 'DjL5xiKO10qut8LQgztpKskABWna' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskListResponseBetaValue)); }); @@ -666,6 +651,8 @@ describe(commands.TASK_LIST, () => { sinonUtil.restore(request.get); sinon.stub(request, 'get').rejects(new Error('An error has occurred')); - await assert.rejects(command.action(logger, { options: {} } as any), new CommandError('An error has occurred')); + await assert.rejects(command.action(logger, { + options: commandOptionsSchema.parse({}) + }), new CommandError('An error has occurred')); }); }); diff --git a/src/m365/planner/commands/task/task-list.ts b/src/m365/planner/commands/task/task-list.ts index 06f6dd79f2e..0f6dd635489 100644 --- a/src/m365/planner/commands/task/task-list.ts +++ b/src/m365/planner/commands/task/task-list.ts @@ -1,6 +1,7 @@ import { PlannerTask } from '@microsoft/microsoft-graph-types'; +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { formatting } from '../../../../utils/formatting.js'; import { odata } from '../../../../utils/odata.js'; @@ -9,20 +10,27 @@ import { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + bucketId: z.string().optional(), + bucketName: z.string().optional(), + planId: z.string().optional(), + planTitle: z.string().optional(), + rosterId: z.string().optional(), + ownerGroupId: z.string() + .refine(val => validation.isValidGuid(val), { + message: 'The value is not a valid GUID.' + }) + .optional(), + ownerGroupName: z.string().optional() +}); + +declare type Options = z.infer<typeof options>; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - bucketId?: string; - bucketName?: string; - planId?: string; - planTitle?: string; - rosterId?: string; - ownerGroupId?: string; - ownerGroupName?: string; -} - class PlannerTaskListCommand extends GraphCommand { public get name(): string { return commands.TASK_LIST; @@ -32,97 +40,28 @@ class PlannerTaskListCommand extends GraphCommand { return 'Lists planner tasks in a bucket, plan, or tasks for the currently logged in user'; } - public defaultProperties(): string[] | undefined { - return ['id', 'title', 'startDateTime', 'dueDateTime', 'completedDateTime']; + public get schema(): z.ZodType | undefined { + return options; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); + public defaultProperties(): string[] | undefined { + return ['id', 'title', 'startDateTime', 'dueDateTime', 'completedDateTime']; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - bucketId: typeof args.options.bucketId !== 'undefined', - bucketName: typeof args.options.bucketName !== 'undefined', - planId: typeof args.options.planId !== 'undefined', - planTitle: typeof args.options.planTitle !== 'undefined', - rosterId: typeof args.options.rosterId !== 'undefined', - ownerGroupId: typeof args.options.ownerGroupId !== 'undefined', - ownerGroupName: typeof args.options.ownerGroupName !== 'undefined' + public getRefinedSchema(schema: typeof options): z.ZodType | undefined { + return schema + .refine(opts => !(opts.bucketId && opts.bucketName), { + message: `Specify either 'bucketId' or 'bucketName', but not both.`, + params: { customCode: 'optionSet', options: ['bucketId', 'bucketName'] } + }) + .refine(opts => !(opts.bucketName && !opts.rosterId) || [opts.planId, opts.planTitle].filter(x => x !== undefined).length === 1, { + message: `Specify exactly one of the following options: 'planId' or 'planTitle'.`, + params: { customCode: 'optionSet', options: ['planId', 'planTitle'] } + }) + .refine(opts => !opts.planTitle || [opts.ownerGroupId, opts.ownerGroupName].filter(x => x !== undefined).length === 1, { + message: `Specify exactly one of the following options: 'ownerGroupId' or 'ownerGroupName'.`, + params: { customCode: 'optionSet', options: ['ownerGroupId', 'ownerGroupName'] } }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '--bucketId [bucketId]' - }, - { - option: '--bucketName [bucketName]' - }, - { - option: '--planId [planId]' - }, - { - option: '--planTitle [planTitle]' - }, - { - option: '--rosterId [rosterId]' - }, - { - option: '--ownerGroupId [ownerGroupId]' - }, - { - option: '--ownerGroupName [ownerGroupName]' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.ownerGroupId && !validation.isValidGuid(args.options.ownerGroupId as string)) { - return `${args.options.ownerGroupId} is not a valid GUID`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { - options: ['bucketId', 'bucketName'], - runsWhen: (args) => { - return args.options.bucketId !== undefined || args.options.bucketName !== undefined; - } - }, - { - options: ['planId', 'planTitle'], - runsWhen: (args) => { - return args.options.bucketName !== undefined && args.options.rosterId === undefined; - } - }, - { - options: ['ownerGroupId', 'ownerGroupName'], - runsWhen: (args) => { - return args.options.planTitle !== undefined; - } - } - ); - } - - #initTypes(): void { - this.types.string.push('planId', 'planTitle', 'ownerGroupId', 'ownerGroupName', 'bucketId', 'bucketName', 'rosterId'); } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { @@ -205,4 +144,4 @@ class PlannerTaskListCommand extends GraphCommand { } } -export default new PlannerTaskListCommand(); \ No newline at end of file +export default new PlannerTaskListCommand(); diff --git a/src/m365/planner/commands/task/task-reference-add.spec.ts b/src/m365/planner/commands/task/task-reference-add.spec.ts index 1e71c4b8d3b..bd09c375b2b 100644 --- a/src/m365/planner/commands/task/task-reference-add.spec.ts +++ b/src/m365/planner/commands/task/task-reference-add.spec.ts @@ -9,31 +9,32 @@ 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 commands from '../../commands.js'; -import command from './task-reference-add.js'; -import { session } from '../../../../utils/session.js'; +import command, { options } from './task-reference-add.js'; describe(commands.TASK_REFERENCE_ADD, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; const validTaskId = '2Vf8JHgsBUiIf-nuvBtv-ZgAAYw2'; const validUrl = 'https://www.microsoft.com'; const validAlias = 'Test'; const validType = 'Word'; const referenceResponse = { - "https%3A//www%2Emicrosoft%2Ecom": { - "alias": "Test", - "type": "Word", - "previewPriority": "8585493318091789098Pa", - "lastModifiedDateTime": "2022-05-11T13:18:56.3142944Z", - "lastModifiedBy": { - "user": { - "displayName": null, - "id": "dd8b99a7-77c6-4238-a609-396d27844921" + 'https%3A//www%2Emicrosoft%2Ecom': { + alias: 'Test', + type: 'Word', + previewPriority: '8585493318091789098Pa', + lastModifiedDateTime: '2022-05-11T13:18:56.3142944Z', + lastModifiedBy: { + user: { + displayName: null, + id: 'dd8b99a7-77c6-4238-a609-396d27844921' } } } @@ -50,6 +51,7 @@ describe(commands.TASK_REFERENCE_ADD, () => { expiresOn: new Date() }; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -89,25 +91,30 @@ describe(commands.TASK_REFERENCE_ADD, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if incorrect type is specified.', async () => { - const actual = await command.validate({ - options: { - taskId: validTaskId, - url: validUrl, - type: "wrong" - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if incorrect type is specified.', () => { + const actual = commandOptionsSchema.safeParse({ + taskId: validTaskId, + url: validUrl, + type: 'wrong' + }); + assert.strictEqual(actual.success, false); }); - it('passes validation when valid options specified', async () => { - const actual = await command.validate({ - options: { - taskId: validTaskId, - url: validUrl - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when valid options specified', () => { + const actual = commandOptionsSchema.safeParse({ + taskId: validTaskId, + url: validUrl + }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + taskId: validTaskId, + url: validUrl, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); }); it('correctly adds reference', async () => { @@ -122,20 +129,20 @@ describe(commands.TASK_REFERENCE_ADD, () => { sinon.stub(request, 'get').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks/${formatting.encodeQueryParameter(validTaskId)}/details` && JSON.stringify(opts.headers) === JSON.stringify({ - 'accept': 'application/json' + accept: 'application/json' })) { - return { "@odata.etag": "TestEtag" }; + return { '@odata.etag': 'TestEtag' }; } throw 'Invalid Request'; }); - const options: any = { - taskId: validTaskId, - url: validUrl - }; - - await command.action(logger, { options: options } as any); + await command.action(logger, { + options: commandOptionsSchema.parse({ + taskId: validTaskId, + url: validUrl + }) + }); assert(loggerLogSpy.calledWith(referenceResponse)); }); @@ -151,22 +158,22 @@ describe(commands.TASK_REFERENCE_ADD, () => { sinon.stub(request, 'get').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks/${formatting.encodeQueryParameter(validTaskId)}/details` && JSON.stringify(opts.headers) === JSON.stringify({ - 'accept': 'application/json' + accept: 'application/json' })) { - return { "@odata.etag": "TestEtag" }; + return { '@odata.etag': 'TestEtag' }; } throw 'Invalid Request'; }); - const options: any = { - taskId: validTaskId, - url: validUrl, - alias: validAlias, - type: validType - }; - - await command.action(logger, { options: options } as any); + await command.action(logger, { + options: commandOptionsSchema.parse({ + taskId: validTaskId, + url: validUrl, + alias: validAlias, + type: validType + }) + }); assert(loggerLogSpy.calledWith(referenceResponse)); }); @@ -174,6 +181,11 @@ describe(commands.TASK_REFERENCE_ADD, () => { sinonUtil.restore(request.get); sinon.stub(request, 'get').rejects(new Error('An error has occurred')); - await assert.rejects(command.action(logger, { options: {} } as any), new CommandError('An error has occurred')); + await assert.rejects(command.action(logger, { + options: commandOptionsSchema.parse({ + taskId: validTaskId, + url: validUrl + }) + }), new CommandError('An error has occurred')); }); }); diff --git a/src/m365/planner/commands/task/task-reference-add.ts b/src/m365/planner/commands/task/task-reference-add.ts index a81cdd452a0..972fd12c3db 100644 --- a/src/m365/planner/commands/task/task-reference-add.ts +++ b/src/m365/planner/commands/task/task-reference-add.ts @@ -1,21 +1,29 @@ +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.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 GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + taskId: z.string().alias('i'), + url: z.string().alias('u'), + alias: z.string().optional(), + type: z.string() + .refine(val => ['powerpoint', 'word', 'excel', 'other'].includes(val.toLocaleLowerCase()), { + message: 'The type must be one of the following: PowerPoint, Word, Excel, Other.' + }) + .optional() +}); + +declare type Options = z.infer<typeof options>; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - taskId: string; - url: string; - alias?: string; - type?: string; -} - class PlannerTaskReferenceAddCommand extends GraphCommand { public get name(): string { return commands.TASK_REFERENCE_ADD; @@ -25,50 +33,8 @@ class PlannerTaskReferenceAddCommand extends GraphCommand { return 'Adds a new reference to a Planner task'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - alias: typeof args.options.alias !== 'undefined', - type: args.options.type - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '-i, --taskId <taskId>' }, - { option: '-u, --url <url>' }, - { option: '--alias [alias]' }, - { - option: '--type [type]', - autocomplete: ['PowerPoint', 'Word', 'Excel', 'Other'] - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.type && ['powerpoint', 'word', 'excel', 'other'].indexOf(args.options.type.toLocaleLowerCase()) === -1) { - return `${args.options.type} is not a valid type value. Allowed values PowerPoint|Word|Excel|Other`; - } - - return true; - } - ); - } - - #initTypes(): void { - this.types.string.push('taskId', 'url', 'alias', 'type'); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { diff --git a/src/m365/planner/commands/task/task-reference-list.spec.ts b/src/m365/planner/commands/task/task-reference-list.spec.ts index 6c37865a544..343f6998ccb 100644 --- a/src/m365/planner/commands/task/task-reference-list.spec.ts +++ b/src/m365/planner/commands/task/task-reference-list.spec.ts @@ -1,6 +1,8 @@ import assert from 'assert'; 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'; @@ -10,31 +12,31 @@ 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 './task-reference-list.js'; +import command, { options } from './task-reference-list.js'; describe(commands.TASK_REFERENCE_LIST, () => { const referenceListResponse = { - "https%3A//contoso%2Esharepoint%2Ecom/sites/HRPlan/Shared Documents/Sample.pdf": { - "alias": "Sample.pdf", - "type": "Pdf", - "previewPriority": "[>", - "lastModifiedDateTime": "2022-05-15T16:20:31.8649232Z", - "lastModifiedBy": { - "user": { - "displayName": null, - "id": "fe36f75f-c103-410b-a18a-2bf6df06ac3a" + 'https%3A//contoso%2Esharepoint%2Ecom/sites/HRPlan/Shared Documents/Sample.pdf': { + alias: 'Sample.pdf', + type: 'Pdf', + previewPriority: '[>', + lastModifiedDateTime: '2022-05-15T16:20:31.8649232Z', + lastModifiedBy: { + user: { + displayName: null, + id: 'fe36f75f-c103-410b-a18a-2bf6df06ac3a' } } }, - "https%3A//contoso%2Esharepoint%2Ecom/sites/HRPlan/Shared Documents/Sample.png": { - "alias": "Sample.png", - "type": "Other", - "previewPriority": "8585492445655664725P(", - "lastModifiedDateTime": "2022-05-12T13:32:59.9267487Z", - "lastModifiedBy": { - "user": { - "displayName": null, - "id": "fe36f75f-c103-410b-a18a-2bf6df06ac3a" + 'https%3A//contoso%2Esharepoint%2Ecom/sites/HRPlan/Shared Documents/Sample.png': { + alias: 'Sample.png', + type: 'Other', + previewPriority: '8585492445655664725P(', + lastModifiedDateTime: '2022-05-12T13:32:59.9267487Z', + lastModifiedBy: { + user: { + displayName: null, + id: 'fe36f75f-c103-410b-a18a-2bf6df06ac3a' } } } @@ -49,6 +51,9 @@ describe(commands.TASK_REFERENCE_LIST, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; + const validTaskId = 'uBk5fK_MHkeyuPYlCo4OFpcAMowf'; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -60,6 +65,8 @@ describe(commands.TASK_REFERENCE_LIST, () => { accessToken: 'abc', expiresOn: new Date() }; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -99,9 +106,17 @@ describe(commands.TASK_REFERENCE_LIST, () => { assert.notStrictEqual(command.description, null); }); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + taskId: validTaskId, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); + }); + it('successfully handles item found', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks/${formatting.encodeQueryParameter("uBk5fK_MHkeyuPYlCo4OFpcAMowf")}/details?$select=references`) { + if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks/${formatting.encodeQueryParameter(validTaskId)}/details?$select=references`) { return references; } @@ -109,9 +124,9 @@ describe(commands.TASK_REFERENCE_LIST, () => { }); await command.action(logger, { - options: { - taskId: 'uBk5fK_MHkeyuPYlCo4OFpcAMowf' - } + options: commandOptionsSchema.parse({ + taskId: validTaskId + }) }); assert(loggerLogSpy.calledWith(references.references)); }); @@ -119,6 +134,10 @@ describe(commands.TASK_REFERENCE_LIST, () => { it('handles error correctly', async () => { sinon.stub(request, 'get').rejects(new Error('An error has occurred')); - await assert.rejects(command.action(logger, { options: { taskId: 'uBk5fK_MHkeyuPYlCo4OFpcAMowf' } } as any), new CommandError('An error has occurred')); + await assert.rejects(command.action(logger, { + options: commandOptionsSchema.parse({ + taskId: validTaskId + }) + }), new CommandError('An error has occurred')); }); }); diff --git a/src/m365/planner/commands/task/task-reference-list.ts b/src/m365/planner/commands/task/task-reference-list.ts index e762bfd3f30..c7f64cd9c57 100644 --- a/src/m365/planner/commands/task/task-reference-list.ts +++ b/src/m365/planner/commands/task/task-reference-list.ts @@ -1,18 +1,22 @@ +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.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 GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + taskId: z.string().alias('i') +}); + +declare type Options = z.infer<typeof options>; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - taskId: string; -} - class PlannerTaskReferenceListCommand extends GraphCommand { public get name(): string { return commands.TASK_REFERENCE_LIST; @@ -22,23 +26,8 @@ class PlannerTaskReferenceListCommand extends GraphCommand { return 'Retrieve the references of the specified planner task'; } - constructor() { - super(); - - this.#initOptions(); - this.#initTypes(); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-i, --taskId <taskId>' - } - ); - } - - #initTypes(): void { - this.types.string.push('taskId'); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { diff --git a/src/m365/planner/commands/task/task-reference-remove.spec.ts b/src/m365/planner/commands/task/task-reference-remove.spec.ts index 85411fc9e81..f9e7668aeb4 100644 --- a/src/m365/planner/commands/task/task-reference-remove.spec.ts +++ b/src/m365/planner/commands/task/task-reference-remove.spec.ts @@ -12,41 +12,42 @@ 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 './task-reference-remove.js'; +import command, { options } from './task-reference-remove.js'; describe(commands.TASK_REFERENCE_REMOVE, () => { let log: string[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; let promptIssued: boolean = false; const validTaskId = '2Vf8JHgsBUiIf-nuvBtv-ZgAAYw2'; const validUrl = 'https://www.microsoft.com'; const validAlias = 'Test'; const referenceResponse = { - "https%3A//www%2Emicrosoft%2Ecom": { - "alias": "Test", - "type": "Word" + 'https%3A//www%2Emicrosoft%2Ecom': { + alias: 'Test', + type: 'Word' } }; const multiReferencesResponseNoAlias = { - "https%3A//www%2Emicrosoft%2Ecom": { - "type": "Word" + 'https%3A//www%2Emicrosoft%2Ecom': { + type: 'Word' }, - "https%3A//www%2Emicrosoft2%2Ecom": { - "type": "Word" + 'https%3A//www%2Emicrosoft2%2Ecom': { + type: 'Word' } }; const multiReferencesResponse = { - "https%3A//www%2Emicrosoft%2Ecom": { - "alias": "Test", - "type": "Word" + 'https%3A//www%2Emicrosoft%2Ecom': { + alias: 'Test', + type: 'Word' }, - "https%3A//www%2Emicrosoft2%2Ecom": { - "alias": "Test", - "type": "Word" + 'https%3A//www%2Emicrosoft2%2Ecom': { + alias: 'Test', + type: 'Word' } }; @@ -62,6 +63,7 @@ describe(commands.TASK_REFERENCE_REMOVE, () => { }; sinon.stub(cli.getConfig(), 'all').value({}); commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -108,34 +110,37 @@ describe(commands.TASK_REFERENCE_REMOVE, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if url does not contain http or https', async () => { - const actual = await command.validate({ - options: { - taskId: validTaskId, - url: 'www.microsoft.com' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if url does not contain http or https', () => { + const actual = commandOptionsSchema.safeParse({ + taskId: validTaskId, + url: 'www.microsoft.com' + }); + assert.strictEqual(actual.success, false); }); - it('passes validation when valid url with http specified', async () => { - const actual = await command.validate({ - options: { - taskId: validTaskId, - url: 'http://www.microsoft.com' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when valid url with http specified', () => { + const actual = commandOptionsSchema.safeParse({ + taskId: validTaskId, + url: 'http://www.microsoft.com' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation when valid url with https specified', async () => { - const actual = await command.validate({ - options: { - taskId: validTaskId, - url: 'https://www.microsoft.com' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when valid url with https specified', () => { + const actual = commandOptionsSchema.safeParse({ + taskId: validTaskId, + url: 'https://www.microsoft.com' + }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + taskId: validTaskId, + url: validUrl, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); }); it('prompts before removal when force option not passed', async () => { @@ -146,23 +151,21 @@ describe(commands.TASK_REFERENCE_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ taskId: validTaskId, url: validUrl - } + }) }); assert(promptIssued); }); - it('passes validation when valid options specified', async () => { - const actual = await command.validate({ - options: { - taskId: validTaskId, - url: validUrl - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when valid options specified', () => { + const actual = commandOptionsSchema.safeParse({ + taskId: validTaskId, + url: validUrl + }); + assert.strictEqual(actual.success, true); }); it('correctly removes reference', async () => { @@ -177,21 +180,21 @@ describe(commands.TASK_REFERENCE_REMOVE, () => { sinon.stub(request, 'get').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks/${formatting.encodeQueryParameter(validTaskId)}/details` && JSON.stringify(opts.headers) === JSON.stringify({ - 'accept': 'application/json' + accept: 'application/json' })) { - return { "@odata.etag": "TestEtag" }; + return { '@odata.etag': 'TestEtag' }; } throw 'Invalid Request'; }); - const options: any = { - taskId: validTaskId, - url: validUrl, - force: true - }; - - await command.action(logger, { options: options } as any); + await command.action(logger, { + options: commandOptionsSchema.parse({ + taskId: validTaskId, + url: validUrl, + force: true + }) + }); }); it('correctly removes reference by alias with prompting', async () => { @@ -206,10 +209,10 @@ describe(commands.TASK_REFERENCE_REMOVE, () => { sinon.stub(request, 'get').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks/${formatting.encodeQueryParameter(validTaskId)}/details` && JSON.stringify(opts.headers) === JSON.stringify({ - 'accept': 'application/json' + accept: 'application/json' })) { return { - "@odata.etag": "TestEtag", + '@odata.etag': 'TestEtag', references: referenceResponse }; } @@ -217,22 +220,22 @@ describe(commands.TASK_REFERENCE_REMOVE, () => { throw 'Invalid Request'; }); - const options: any = { - taskId: validTaskId, - alias: validAlias - }; - - await command.action(logger, { options: options } as any); + await command.action(logger, { + options: commandOptionsSchema.parse({ + taskId: validTaskId, + alias: validAlias + }) + }); }); it('fails validation when no references found', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks/${formatting.encodeQueryParameter(validTaskId)}/details` && JSON.stringify(opts.headers) === JSON.stringify({ - 'accept': 'application/json' + accept: 'application/json' })) { return { - "@odata.etag": "TestEtag", + '@odata.etag': 'TestEtag', references: {} }; } @@ -241,10 +244,10 @@ describe(commands.TASK_REFERENCE_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ taskId: validTaskId, alias: validAlias - } + }) }), new CommandError(`The specified reference with alias ${validAlias} does not exist`)); }); @@ -252,10 +255,10 @@ describe(commands.TASK_REFERENCE_REMOVE, () => { sinon.stub(request, 'get').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks/${formatting.encodeQueryParameter(validTaskId)}/details` && JSON.stringify(opts.headers) === JSON.stringify({ - 'accept': 'application/json' + accept: 'application/json' })) { return { - "@odata.etag": "TestEtag", + '@odata.etag': 'TestEtag', references: multiReferencesResponseNoAlias }; } @@ -264,10 +267,10 @@ describe(commands.TASK_REFERENCE_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ taskId: validTaskId, alias: validAlias - } + }) }), new CommandError(`The specified reference with alias ${validAlias} does not exist`)); }); @@ -275,10 +278,10 @@ describe(commands.TASK_REFERENCE_REMOVE, () => { sinon.stub(request, 'get').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks/${formatting.encodeQueryParameter(validTaskId)}/details` && JSON.stringify(opts.headers) === JSON.stringify({ - 'accept': 'application/json' + accept: 'application/json' })) { return { - "@odata.etag": "TestEtag", + '@odata.etag': 'TestEtag', references: multiReferencesResponse }; } @@ -287,16 +290,22 @@ describe(commands.TASK_REFERENCE_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ taskId: validTaskId, alias: validAlias - } + }) }), new CommandError(`Multiple references with alias ${validAlias} found. Pass one of the following urls within the "--url" option : https://www.microsoft.com,https://www.microsoft2.com`)); }); it('correctly handles random API error', async () => { sinon.stub(request, 'get').rejects(new Error('An error has occurred')); - await assert.rejects(command.action(logger, { options: {} } as any), new CommandError('An error has occurred')); + await assert.rejects(command.action(logger, { + options: commandOptionsSchema.parse({ + taskId: validTaskId, + url: validUrl, + force: true + }) + }), new CommandError('An error has occurred')); }); }); diff --git a/src/m365/planner/commands/task/task-reference-remove.ts b/src/m365/planner/commands/task/task-reference-remove.ts index 1ae227d7dcc..a5e662d83d5 100644 --- a/src/m365/planner/commands/task/task-reference-remove.ts +++ b/src/m365/planner/commands/task/task-reference-remove.ts @@ -1,23 +1,32 @@ import { PlannerTaskDetails } from '@microsoft/microsoft-graph-types'; +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 GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + url: z.string() + .refine(val => val.indexOf('https://') === 0 || val.indexOf('http://') === 0, { + message: 'The url option should contain a valid URL. A valid URL starts with http(s)://' + }) + .optional() + .alias('u'), + alias: z.string().optional(), + taskId: z.string().alias('i'), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer<typeof options>; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - url?: string; - alias?: string; - taskId: string; - force?: boolean; -} - class PlannerTaskReferenceRemoveCommand extends GraphCommand { public get name(): string { return commands.TASK_REFERENCE_REMOVE; @@ -27,55 +36,19 @@ class PlannerTaskReferenceRemoveCommand extends GraphCommand { return 'Removes the reference from the Planner task'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - url: typeof args.options.url !== 'undefined', - alias: typeof args.options.alias !== 'undefined', - force: !!args.options.force - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '-u, --url [url]' }, - { option: '--alias [alias]' }, - { option: '-i, --taskId <taskId>' }, - { option: '-f, --force' } - ); + public get schema(): z.ZodType | undefined { + return options; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.url && args.options.url.indexOf('https://') !== 0 && args.options.url.indexOf('http://') !== 0) { - return 'The url option should contain a valid URL. A valid URL starts with http(s)://'; + public getRefinedSchema(schema: typeof options): z.ZodType | undefined { + return schema + .refine(opts => [opts.url, opts.alias].filter(x => x !== undefined).length === 1, { + message: `Specify exactly one of the following options: 'url' or 'alias'.`, + params: { + customCode: 'optionSet', + options: ['url', 'alias'] } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { options: ['url', 'alias'] } - ); - } - - #initTypes(): void { - this.types.string.push('url', 'taskId', 'alias'); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { @@ -154,4 +127,4 @@ class PlannerTaskReferenceRemoveCommand extends GraphCommand { } } -export default new PlannerTaskReferenceRemoveCommand(); \ No newline at end of file +export default new PlannerTaskReferenceRemoveCommand(); diff --git a/src/m365/planner/commands/task/task-remove.spec.ts b/src/m365/planner/commands/task/task-remove.spec.ts index e25de368aab..546b9be57f5 100644 --- a/src/m365/planner/commands/task/task-remove.spec.ts +++ b/src/m365/planner/commands/task/task-remove.spec.ts @@ -12,13 +12,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 './task-remove.js'; +import command, { options } from './task-remove.js'; import { settingsNames } from '../../../../settingsNames.js'; describe(commands.TASK_REMOVE, () => { let log: string[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; let promptIssued: boolean = false; const validTaskId = '2Vf8JHgsBUiIf-nuvBtv-ZgAAYw2'; @@ -128,6 +129,7 @@ describe(commands.TASK_REMOVE, () => { expiresOn: new Date() }; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -174,6 +176,13 @@ describe(commands.TASK_REMOVE, () => { it('has a description', () => { assert.notStrictEqual(command.description, null); }); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'value', + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); + }); it('fails validation when title and id is used', async () => { sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { @@ -184,13 +193,11 @@ describe(commands.TASK_REMOVE, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - id: validTaskId, - title: validTaskTitle - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: validTaskId, + title: validTaskTitle + }); + assert.strictEqual(actual.success, false); }); it('fails validation when title is used without bucket id', async () => { @@ -202,12 +209,10 @@ describe(commands.TASK_REMOVE, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - title: validTaskTitle - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: validTaskTitle + }); + assert.strictEqual(actual.success, false); }); it('fails validation when title is used with both bucket id and bucketname', async () => { @@ -219,14 +224,12 @@ describe(commands.TASK_REMOVE, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - title: validTaskTitle, - bucketId: validBucketId, - bucketName: validBucketName - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: validTaskTitle, + bucketId: validBucketId, + bucketName: validBucketName + }); + assert.strictEqual(actual.success, false); }); it('fails validation when bucket name is used without plan name, plan id, or roster id', async () => { @@ -238,13 +241,11 @@ describe(commands.TASK_REMOVE, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - title: validTaskTitle, - bucketName: validBucketName - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: validTaskTitle, + bucketName: validBucketName + }); + assert.strictEqual(actual.success, false); }); it('fails validation when bucket name is used with both plan name, plan id, and roster id', async () => { @@ -256,16 +257,14 @@ describe(commands.TASK_REMOVE, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - title: validTaskTitle, - bucketName: validBucketName, - planId: validPlanId, - planTitle: validPlanTitle, - rosterId: validRosterId - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: validTaskTitle, + bucketName: validBucketName, + planId: validPlanId, + planTitle: validPlanTitle, + rosterId: validRosterId + }); + assert.strictEqual(actual.success, false); }); it('fails validation when plan name is used without owner group name or owner group id', async () => { @@ -277,14 +276,12 @@ describe(commands.TASK_REMOVE, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - title: validTaskTitle, - bucketName: validBucketName, - planTitle: validPlanTitle - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: validTaskTitle, + bucketName: validBucketName, + planTitle: validPlanTitle + }); + assert.strictEqual(actual.success, false); }); it('fails validation when plan name is used with both owner group name and owner group id', async () => { @@ -296,59 +293,49 @@ describe(commands.TASK_REMOVE, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - title: validTaskTitle, - bucketName: validBucketName, - planTitle: validPlanTitle, - ownerGroupName: validOwnerGroupName, - ownerGroupId: validOwnerGroupId - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: validTaskTitle, + bucketName: validBucketName, + planTitle: validPlanTitle, + ownerGroupName: validOwnerGroupName, + ownerGroupId: validOwnerGroupId + }); + assert.strictEqual(actual.success, false); }); it('fails validation when owner group id is not a guid', async () => { - const actual = await command.validate({ - options: { - title: validTaskTitle, - bucketName: validBucketName, - planTitle: validPlanTitle, - ownerGroupId: invalidOwnerGroupId - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: validTaskTitle, + bucketName: validBucketName, + planTitle: validPlanTitle, + ownerGroupId: invalidOwnerGroupId + }); + assert.strictEqual(actual.success, false); }); it('fails validation when task id is used with bucket id', async () => { - const actual = await command.validate({ - options: { - id: validTaskId, - bucketId: validBucketId - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: validTaskId, + bucketId: validBucketId + }); + assert.strictEqual(actual.success, false); }); it('validates for a correct input with id', async () => { - const actual = await command.validate({ - options: { - id: validTaskId - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: validTaskId + }); + assert.strictEqual(actual.success, true); }); it('validates for a correct input with title', async () => { - const actual = await command.validate({ - options: { - title: validTaskTitle, - bucketName: validBucketName, - planTitle: validPlanTitle, - ownerGroupName: validOwnerGroupName - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: validTaskTitle, + bucketName: validBucketName, + planTitle: validPlanTitle, + ownerGroupName: validOwnerGroupName + }); + assert.strictEqual(actual.success, true); }); it('fails validation when no groups found', async () => { @@ -361,13 +348,13 @@ describe(commands.TASK_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, bucketName: validBucketName, planTitle: validPlanTitle, ownerGroupName: validOwnerGroupName, force: true - } + }) }), new CommandError(`The specified group '${validOwnerGroupName}' does not exist.`)); }); @@ -389,13 +376,13 @@ describe(commands.TASK_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, bucketName: validBucketName, planTitle: validPlanTitle, ownerGroupName: validOwnerGroupName, force: true - } + }) }), new CommandError("Multiple groups with name 'Group name' found. Found: 00000000-0000-0000-0000-000000000000.")); }); @@ -409,12 +396,12 @@ describe(commands.TASK_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, bucketName: validBucketName, planId: validPlanId, force: true - } + }) }), new CommandError(`The specified bucket '${validBucketName}' does not exist.`)); }); @@ -436,12 +423,12 @@ describe(commands.TASK_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, bucketName: validBucketName, planId: validPlanId, force: true - } + }) }), new CommandError("Multiple buckets with name 'Bucket name' found. Found: vncYUXCRBke28qMLB-d4xJcACtNz.")); }); @@ -482,18 +469,21 @@ describe(commands.TASK_REMOVE, () => { sinon.stub(cli, 'handleMultipleResultsFound').resolves(singleBucketByNameResponse.value[0]); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, bucketName: validBucketName, planTitle: validPlanTitle, ownerGroupName: validOwnerGroupName - } + }) }); assert(removeRequestIssued); }); it('fails validation when no tasks found', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/planner/plans/${validPlanId}/buckets?$select=id,name`) { + return singleBucketByNameResponse; + } if (opts.url === `https://graph.microsoft.com/v1.0/planner/buckets/${validBucketId}/tasks?$select=title,id`) { return { "value": [] }; } @@ -502,11 +492,12 @@ describe(commands.TASK_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, - bucketId: validBucketId, + bucketName: validBucketName, + planId: validPlanId, force: true - } + }) }), new CommandError(`The specified task ${validTaskTitle} does not exist`)); }); @@ -520,6 +511,9 @@ describe(commands.TASK_REMOVE, () => { }); sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/planner/plans/${validPlanId}/buckets?$select=id,name`) { + return singleBucketByNameResponse; + } if (opts.url === `https://graph.microsoft.com/v1.0/planner/buckets/${validBucketId}/tasks?$select=title,id`) { return multipleTasksByTitleResponse; } @@ -528,11 +522,12 @@ describe(commands.TASK_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, - bucketId: validBucketId, + bucketName: validBucketName, + planId: validPlanId, force: true - } + }) }), new CommandError("Multiple tasks with title 'Task name' found. Found: 2Vf8JHgsBUiIf-nuvBtv-ZgAAYw2.")); }); @@ -573,21 +568,21 @@ describe(commands.TASK_REMOVE, () => { sinon.stub(cli, 'handleMultipleResultsFound').resolves(singleTaskByTitleResponse.value[0]); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, bucketName: validBucketName, planTitle: validPlanTitle, ownerGroupName: validOwnerGroupName - } + }) }); assert(removeRequestIssued); }); it('prompts before removing the specified task when force option not passed with id', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ id: validTaskId - } + }) }); @@ -598,9 +593,9 @@ describe(commands.TASK_REMOVE, () => { const postSpy = sinon.spy(request, 'delete'); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ id: validTaskId - } + }) }); assert(postSpy.notCalled); @@ -624,10 +619,38 @@ describe(commands.TASK_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ id: validTaskId, force: true + }) + }); + }); + + it('correctly deletes task by title with bucketId', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/planner/buckets/${validBucketId}/tasks?$select=title,id`) { + return singleTaskByTitleResponse; + } + if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks/${validTaskId}`) { + return singleTaskByIdResponse; + } + throw 'Invalid Request'; + }); + + sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/planner/tasks/${validTaskId}`) { + return; } + + throw 'Invalid Request'; + }); + + await command.action(logger, { + options: commandOptionsSchema.parse({ + title: validTaskTitle, + bucketId: validBucketId, + force: true + }) }); }); @@ -660,12 +683,12 @@ describe(commands.TASK_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, bucketName: validBucketName, planTitle: validPlanTitle, ownerGroupId: validOwnerGroupId - } + }) }); }); @@ -701,12 +724,12 @@ describe(commands.TASK_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, bucketName: validBucketName, planTitle: validPlanTitle, ownerGroupName: validOwnerGroupName - } + }) }); }); @@ -739,12 +762,12 @@ describe(commands.TASK_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: validTaskTitle, bucketName: validBucketName, rosterId: validRosterId, verbose: true - } + }) }); }); }); diff --git a/src/m365/planner/commands/task/task-remove.ts b/src/m365/planner/commands/task/task-remove.ts index 6ca95d79d61..1bef7bfea43 100644 --- a/src/m365/planner/commands/task/task-remove.ts +++ b/src/m365/planner/commands/task/task-remove.ts @@ -1,7 +1,8 @@ import { PlannerTask } from '@microsoft/microsoft-graph-types'; +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 { entraGroup } from '../../../../utils/entraGroup.js'; import { odata } from '../../../../utils/odata.js'; @@ -11,23 +12,30 @@ import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; import { formatting } from '../../../../utils/formatting.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.string().optional().alias('i'), + title: z.string().optional().alias('t'), + bucketId: z.string().optional(), + bucketName: z.string().optional(), + planId: z.string().optional(), + planTitle: z.string().optional(), + rosterId: z.string().optional(), + ownerGroupId: z.string() + .refine(val => validation.isValidGuid(val), { + message: 'The value is not a valid GUID.' + }) + .optional(), + ownerGroupName: z.string().optional(), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer<typeof options>; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id?: string; - title?: string; - bucketId?: string; - bucketName?: string; - planId?: string; - planTitle?: string; - rosterId?: string; - ownerGroupId?: string; - ownerGroupName?: string; - force?: boolean; -} - class PlannerTaskRemoveCommand extends GraphCommand { public get name(): string { return commands.TASK_REMOVE; @@ -37,100 +45,35 @@ class PlannerTaskRemoveCommand extends GraphCommand { return 'Removes the Microsoft Planner task from a plan'; } - 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', - bucketId: typeof args.options.bucketId !== 'undefined', - bucketName: typeof args.options.bucketName !== 'undefined', - planId: typeof args.options.planId !== 'undefined', - planTitle: typeof args.options.planTitle !== 'undefined', - rosterId: typeof args.options.rosterId !== 'undefined', - ownerGroupId: typeof args.options.ownerGroupId !== 'undefined', - ownerGroupName: typeof args.options.ownerGroupName !== 'undefined', - force: !!args.options.force + public getRefinedSchema(schema: typeof options): z.ZodType | undefined { + return schema + .refine(opts => [opts.id, opts.title].filter(x => x !== undefined).length === 1, { + message: `Specify exactly one of the following options: 'id' or 'title'.`, + params: { customCode: 'optionSet', options: ['id', 'title'] } + }) + .refine(opts => opts.id !== undefined || opts.bucketId !== undefined || [opts.planId, opts.planTitle, opts.rosterId].filter(x => x !== undefined).length === 1, { + message: `Specify exactly one of the following options: 'planId', 'planTitle' or 'rosterId'.`, + params: { customCode: 'optionSet', options: ['planId', 'planTitle', 'rosterId'] } + }) + .refine(opts => opts.id !== undefined || [opts.bucketId, opts.bucketName].filter(x => x !== undefined).length === 1, { + message: `Specify exactly one of the following options: 'bucketId' or 'bucketName'.`, + params: { customCode: 'optionSet', options: ['bucketId', 'bucketName'] } + }) + .refine(opts => !(opts.bucketName && !opts.rosterId) || [opts.planId, opts.planTitle].filter(x => x !== undefined).length === 1, { + message: `Specify exactly one of the following options: 'planId' or 'planTitle'.`, + params: { customCode: 'optionSet', options: ['planId', 'planTitle'] } + }) + .refine(opts => !opts.planTitle || [opts.ownerGroupId, opts.ownerGroupName].filter(x => x !== undefined).length === 1, { + message: `Specify exactly one of the following options: 'ownerGroupId' or 'ownerGroupName'.`, + params: { customCode: 'optionSet', options: ['ownerGroupId', 'ownerGroupName'] } + }) + .refine(opts => !opts.id || !(opts.bucketId || opts.bucketName || opts.planId || opts.planTitle || opts.rosterId || opts.ownerGroupId || opts.ownerGroupName), { + message: `Don't specify bucketId, bucketName, planId, planTitle, rosterId, ownerGroupId or ownerGroupName when using id.` }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '-i, --id [id]' }, - { option: '-t, --title [title]' }, - { option: '--bucketId [bucketId]' }, - { option: '--bucketName [bucketName]' }, - { option: '--planId [planId]' }, - { option: '--planTitle [planTitle]' }, - { option: '--rosterId [rosterId]' }, - { option: '--ownerGroupId [ownerGroupId]' }, - { option: '--ownerGroupName [ownerGroupName]' }, - { option: '-f, --force' } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.id) { - if (args.options.bucketId || args.options.bucketName || - args.options.planId || args.options.planTitle || args.options.rosterId || - args.options.ownerGroupId || args.options.ownerGroupName) { - return 'Don\'t specify bucketId,bucketName, planId, planTitle, rosterId, ownerGroupId, or ownerGroupName when using id'; - } - } - - if (args.options.ownerGroupId && !validation.isValidGuid(args.options.ownerGroupId as string)) { - return `${args.options.ownerGroupId} is not a valid GUID`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { options: ['id', 'title'] }, - { - options: ['planId', 'planTitle', 'rosterId'], - runsWhen: (args) => { - return args.options.id === undefined; - } - }, - { - options: ['bucketId', 'bucketName'], - runsWhen: (args) => { - return args.options.title !== undefined; - } - }, - { - options: ['planId', 'planTitle'], - runsWhen: (args) => { - return args.options.bucketName !== undefined && args.options.rosterId === undefined; - } - }, - { - options: ['ownerGroupId', 'ownerGroupName'], - runsWhen: (args) => { - return args.options.planTitle !== undefined; - } - } - ); - } - - #initTypes(): void { - this.types.string.push('id', 'title', 'planId', 'planTitle', 'ownerGroupId', 'ownerGroupName', 'bucketId', 'bucketName', 'rosterId'); } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { @@ -187,7 +130,6 @@ class PlannerTaskRemoveCommand extends GraphCommand { const bucketId = await this.getBucketId(options); - // $filter is not working on the buckets/{bucketId}/tasks endpoint, hence it is not being used. const tasks = await odata.getAllItems<PlannerTask>(`${this.resource}/v1.0/planner/buckets/${bucketId}/tasks?$select=title,id`, 'minimal'); const filteredTasks = tasks.filter(b => title!.toLocaleLowerCase() === b.title!.toLocaleLowerCase()); @@ -241,4 +183,4 @@ class PlannerTaskRemoveCommand extends GraphCommand { } } -export default new PlannerTaskRemoveCommand(); \ No newline at end of file +export default new PlannerTaskRemoveCommand(); diff --git a/src/m365/planner/commands/task/task-set.spec.ts b/src/m365/planner/commands/task/task-set.spec.ts index c89838d007a..d22a739a2a1 100644 --- a/src/m365/planner/commands/task/task-set.spec.ts +++ b/src/m365/planner/commands/task/task-set.spec.ts @@ -12,7 +12,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 './task-set.js'; +import command, { options } from './task-set.js'; import { settingsNames } from '../../../../settingsNames.js'; describe(commands.TASK_SET, () => { @@ -139,6 +139,7 @@ describe(commands.TASK_SET, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -151,6 +152,7 @@ describe(commands.TASK_SET, () => { expiresOn: new Date() }; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName: string, defaultValue: any) => { if (settingName === 'prompt') { return false; @@ -201,6 +203,13 @@ describe(commands.TASK_SET, () => { it('has a description', () => { assert.notStrictEqual(command.description, null); }); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'value', + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); + }); it('fails validation when both bucketId and bucketName are specified', async () => { sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { @@ -211,14 +220,12 @@ describe(commands.TASK_SET, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', - bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', - bucketName: 'My Bucket' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', + bucketId: 'IK8tuFTwQEa5vTonM7ZMRZgAKdno', + bucketName: 'My Bucket' + }); + assert.strictEqual(actual.success, false); }); it('fails validation when bucketName is specified but not planId, planTitle, or rosterId', async () => { @@ -230,13 +237,11 @@ describe(commands.TASK_SET, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', - bucketName: 'My Bucket' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', + bucketName: 'My Bucket' + }); + assert.strictEqual(actual.success, false); }); it('fails validation when bucketName is specified but planId, planTitle, and rosterId are specified', async () => { @@ -248,16 +253,14 @@ describe(commands.TASK_SET, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', - bucketName: 'My Bucket', - planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', - planTitle: 'My Planner', - rosterId: 'DjL5xiKO10qut8LQgztpKskABWna' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', + bucketName: 'My Bucket', + planId: '8QZEH7b3wkS_bGQobscsM5gADCBb', + planTitle: 'My Planner', + rosterId: 'DjL5xiKO10qut8LQgztpKskABWna' + }); + assert.strictEqual(actual.success, false); }); it('fails validation when planTitle is specified without ownerGroupId or ownerGroupName', async () => { @@ -269,14 +272,12 @@ describe(commands.TASK_SET, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', - bucketName: 'My Bucket', - planTitle: 'My Planner Plan' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', + bucketName: 'My Bucket', + planTitle: 'My Planner Plan' + }); + assert.strictEqual(actual.success, false); }); it('fails validation when planTitle is specified with both ownerGroupId and ownerGroupName', async () => { @@ -288,89 +289,73 @@ describe(commands.TASK_SET, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', - bucketName: 'My Bucket', - planTitle: 'My Planner Plan', - ownerGroupId: '0d0402ee-970f-4951-90b5-2f24519d2e40', - ownerGroupName: 'My Planner Group' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', + bucketName: 'My Bucket', + planTitle: 'My Planner Plan', + ownerGroupId: '0d0402ee-970f-4951-90b5-2f24519d2e40', + ownerGroupName: 'My Planner Group' + }); + assert.strictEqual(actual.success, false); }); it('fails validation if the ownerGroupId is not a valid guid.', async () => { - const actual = await command.validate({ - options: { - id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', - bucketName: 'My Bucket', - planTitle: 'My Planner Plan', - ownerGroupId: 'not-c49b-4fd4-8223-28f0ac3a6402' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', + bucketName: 'My Bucket', + planTitle: 'My Planner Plan', + ownerGroupId: 'not-c49b-4fd4-8223-28f0ac3a6402' + }); + assert.strictEqual(actual.success, false); }); it('fails validation if startDateTime contains invalid format.', async () => { - const actual = await command.validate({ - options: { - id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', - startDateTime: '2021-99-99' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', + startDateTime: '2021-99-99' + }); + assert.strictEqual(actual.success, false); }); it('fails validation if dueDateTime contains invalid format.', async () => { - const actual = await command.validate({ - options: { - id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', - dueDateTime: '2021-99-99' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', + dueDateTime: '2021-99-99' + }); + assert.strictEqual(actual.success, false); }); it('fails validation if percentComplete contains invalid format.', async () => { - const actual = await command.validate({ - options: { - id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', - percentComplete: 'Not A Number' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', + percentComplete: 'Not A Number' + }); + assert.strictEqual(actual.success, false); }); it('fails validation if percentComplete is not between 0 and 100.', async () => { - const actual = await command.validate({ - options: { - id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', - percentComplete: 599 - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', + percentComplete: '599' + }); + assert.strictEqual(actual.success, false); }); it('fails validation if assignedToUserIds contains invalid guid.', async () => { - const actual = await command.validate({ - options: { - id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', - assignedToUserIds: "2e42fe76-3f42-4884-b325-aefd7a905446,8d1ff29c-a6f4-4786-b316-test" - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', + assignedToUserIds: "2e42fe76-3f42-4884-b325-aefd7a905446,8d1ff29c-a6f4-4786-b316-test" + }); + assert.strictEqual(actual.success, false); }); it('fails validation if assignedToUserNames contains invalid user principal name', async () => { const assignedToUserNames = ['john.doe@contoso.com', 'foo']; - const actual = await command.validate({ - options: { - id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', - assignedToUserNames: assignedToUserNames.join(',') - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', + assignedToUserNames: assignedToUserNames.join(',') + }); + assert.strictEqual(actual.success, false); }); it('fails validation when both assignedToUserIds and assignedToUserNames are specified', async () => { @@ -382,74 +367,60 @@ describe(commands.TASK_SET, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', - assignedToUserIds: "2e42fe76-3f42-4884-b325-aefd7a905446,8d1ff29c-a6f4-4786-b316-eb6030e1a09e", - assignedToUserNames: "Allan.Carroll@contoso.onmicrosoft.com,Ida.Stevens@contoso.onmicrosoft.com" - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', + assignedToUserIds: "2e42fe76-3f42-4884-b325-aefd7a905446,8d1ff29c-a6f4-4786-b316-eb6030e1a09e", + assignedToUserNames: "Allan.Carroll@contoso.onmicrosoft.com,Ida.Stevens@contoso.onmicrosoft.com" + }); + assert.strictEqual(actual.success, false); }); it('fails validation if incorrect appliedCategory is specified.', async () => { - const actual = await command.validate({ - options: { - id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', - appliedCategories: "category1,category9" - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', + appliedCategories: "category1,category9" + }); + assert.strictEqual(actual.success, false); }); it('fails validation if priority lower than 0 is specified.', async () => { - const actual = await command.validate({ - options: { - id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', - priority: -1 - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', + priority: '-1' + }); + assert.strictEqual(actual.success, false); }); it('fails validation if priority higher than 10 is specified.', async () => { - const actual = await command.validate({ - options: { - id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', - priority: 11 - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', + priority: '11' + }); + assert.strictEqual(actual.success, false); }); it('fails validation if priority is specified which is a number with decimals.', async () => { - const actual = await command.validate({ - options: { - id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', - priority: 5.6 - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', + priority: '5.6' + }); + assert.strictEqual(actual.success, false); }); it('fails validation if unknown priority label is specified.', async () => { - const actual = await command.validate({ - options: { - id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', - priority: 'invalid' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', + priority: 'invalid' + }); + assert.strictEqual(actual.success, false); }); it('passes validation when valid options specified', async () => { - const actual = await command.validate({ - options: { - id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', - title: 'My Planner Task' - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', + title: 'My Planner Task' + }); + assert.strictEqual(actual.success, true); }); it('correctly updates planner task with title', async () => { @@ -477,7 +448,7 @@ describe(commands.TASK_SET, () => { title: 'My Planner Task' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskResponse)); }); @@ -497,7 +468,7 @@ describe(commands.TASK_SET, () => { priority: 'Urgent' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert.strictEqual(requestPatchStub.lastCall.args[0].data.priority, 1); }); @@ -517,7 +488,7 @@ describe(commands.TASK_SET, () => { priority: 'Important' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert.strictEqual(requestPatchStub.lastCall.args[0].data.priority, 3); }); @@ -537,7 +508,7 @@ describe(commands.TASK_SET, () => { priority: 'Medium' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert.strictEqual(requestPatchStub.lastCall.args[0].data.priority, 5); }); @@ -557,7 +528,7 @@ describe(commands.TASK_SET, () => { priority: 'Low' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert.strictEqual(requestPatchStub.lastCall.args[0].data.priority, 9); }); @@ -629,7 +600,7 @@ describe(commands.TASK_SET, () => { ownerGroupName: 'My Planner Group' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskResponse)); }); @@ -682,7 +653,7 @@ describe(commands.TASK_SET, () => { ownerGroupId: '0d0402ee-970f-4951-90b5-2f24519d2e40' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskResponse)); }); @@ -723,7 +694,7 @@ describe(commands.TASK_SET, () => { planId: '8QZEH7b3wkS_bGQobscsM5gADCBb' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskResponse)); }); @@ -775,7 +746,7 @@ describe(commands.TASK_SET, () => { bucketName: 'My Planner Bucket' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskResponse)); }); @@ -805,7 +776,7 @@ describe(commands.TASK_SET, () => { assignedToUserIds: '949b16c1-a032-453e-a8ae-89a52bfc1d8a' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskResponseWithAssignments)); }); @@ -845,7 +816,7 @@ describe(commands.TASK_SET, () => { assignedToUserNames: 'user@contoso.onmicrosoft.com' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskResponseWithAssignments)); }); @@ -893,7 +864,7 @@ describe(commands.TASK_SET, () => { description: 'My Task Description' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskResponseWithDetails)); }); @@ -926,10 +897,10 @@ describe(commands.TASK_SET, () => { percentComplete: '50', assigneePriority: ' !', orderHint: ' !', - priority: 3 + priority: '3' }; - await command.action(logger, { options: options } as any); + await command.action(logger, { options: commandOptionsSchema.parse(options) }); assert(loggerLogSpy.calledWith(taskResponse)); }); @@ -948,7 +919,7 @@ describe(commands.TASK_SET, () => { planId: '8QZEH7b3wkS_bGQobscsM5gADCBb' }; - await assert.rejects(command.action(logger, { options: options } as any), new CommandError(`The specified bucket 'My Planner Bucket' does not exist.`)); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse(options) }), new CommandError(`The specified bucket 'My Planner Bucket' does not exist.`)); }); it('fails when an invalid user is specified', async () => { @@ -976,7 +947,7 @@ describe(commands.TASK_SET, () => { assignedToUserNames: 'user@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' }; - await assert.rejects(command.action(logger, { options: options } as any) + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse(options) }) , new CommandError('Cannot proceed with planner task update. The following users provided are invalid : user2@contoso.onmicrosoft.com')); }); @@ -989,12 +960,12 @@ describe(commands.TASK_SET, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', bucketName: 'My Planner Bucket', planTitle: 'My Planner Plan', ownerGroupName: 'foo' - } + }) }), new CommandError(`The specified group 'foo' does not exist.`)); }); @@ -1024,10 +995,10 @@ describe(commands.TASK_SET, () => { await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', title: 'My Planner Task' - } + }) }), new CommandError(`Error fetching task`)); }); @@ -1057,10 +1028,10 @@ describe(commands.TASK_SET, () => { await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o', description: 'My Task Description' - } + }) }), new CommandError(`Error fetching task details`)); }); @@ -1068,6 +1039,10 @@ describe(commands.TASK_SET, () => { sinonUtil.restore(request.get); sinon.stub(request, 'get').rejects(new Error('An error has occurred')); - await assert.rejects(command.action(logger, { options: {} } as any), new CommandError('An error has occurred')); + await assert.rejects(command.action(logger, { + options: commandOptionsSchema.parse({ + id: 'Z-RLQGfppU6H3663DBzfs5gAMD3o' + }) + }), new CommandError('An error has occurred')); }); }); \ No newline at end of file diff --git a/src/m365/planner/commands/task/task-set.ts b/src/m365/planner/commands/task/task-set.ts index 9aba7a7792a..abba3c97800 100644 --- a/src/m365/planner/commands/task/task-set.ts +++ b/src/m365/planner/commands/task/task-set.ts @@ -1,6 +1,7 @@ import { PlannerTask, PlannerTaskDetails, User } from '@microsoft/microsoft-graph-types'; +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { formatting } from '../../../../utils/formatting.js'; @@ -11,37 +12,84 @@ import { AppliedCategories } from '../../AppliedCategories.js'; import commands from '../../commands.js'; import { taskPriority } from '../../taskPriority.js'; +const allowedAppliedCategories = ['category1', 'category2', 'category3', 'category4', 'category5', 'category6']; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.string().alias('i'), + title: z.string().optional().alias('t'), + planId: z.string().optional(), + planTitle: z.string().optional(), + rosterId: z.string().optional(), + ownerGroupId: z.string() + .refine(val => validation.isValidGuid(val), { + message: 'The value is not a valid GUID.' + }) + .optional(), + ownerGroupName: z.string().optional(), + bucketId: z.string().optional(), + bucketName: z.string().optional(), + startDateTime: z.string() + .refine(val => validation.isValidISODateTime(val), { + message: 'The startDateTime is not a valid ISO date string.' + }) + .optional(), + dueDateTime: z.string() + .refine(val => validation.isValidISODateTime(val), { + message: 'The dueDateTime is not a valid ISO date string.' + }) + .optional(), + percentComplete: z.string() + .refine(val => !isNaN(Number(val)) && Number(val) >= 0 && Number(val) <= 100, { + message: 'percentComplete should be between 0 and 100.' + }) + .optional(), + assignedToUserIds: z.string() + .superRefine((val, ctx) => { + const result = validation.isValidGuidArray(val); + if (result !== true) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `The following GUIDs are invalid for the option 'assignedToUserIds': ${result}.` }); + } + }) + .optional(), + assignedToUserNames: z.string() + .superRefine((val, ctx) => { + const result = validation.isValidUserPrincipalNameArray(val); + if (result !== true) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `The following user principal names are invalid for the option 'assignedToUserNames': ${result}.` }); + } + }) + .optional(), + assigneePriority: z.string().optional(), + description: z.string().optional(), + appliedCategories: z.string() + .refine(val => val.split(',').every(cat => allowedAppliedCategories.includes(cat.toLocaleLowerCase().trim())), { + message: `The appliedCategories contains invalid value. Specify either ${allowedAppliedCategories.join(', ')} as properties.` + }) + .optional(), + orderHint: z.string().optional(), + priority: z.string() + .refine(val => { + const num = Number(val); + if (!isNaN(num)) { + return num >= 0 && num <= 10 && Number.isInteger(num); + } + return taskPriority.priorityValues.map(l => l.toLowerCase()).includes(val.toLowerCase()); + }, { + message: `The value is not a valid priority. Allowed values are 0-10 or ${taskPriority.priorityValues.join('|')}.` + }) + .optional() +}); + +declare type Options = z.infer<typeof options>; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id: string; - title?: string; - planId?: string; - planTitle?: string; - rosterId?: string; - ownerGroupId?: string; - ownerGroupName?: string; - bucketId?: string; - bucketName?: string; - startDateTime?: string; - dueDateTime?: string; - percentComplete?: number; - assignedToUserIds?: string; - assignedToUserNames?: string; - assigneePriority?: string; - assignments?: string; - description?: string; - appliedCategories?: string; - orderHint?: string; - priority?: number | string; -} - class PlannerTaskSetCommand extends GraphCommand { private assignments: { [userId: string]: { [property: string]: string; }; } | undefined; private bucketId: string | undefined; - private allowedAppliedCategories: string[] = ['category1', 'category2', 'category3', 'category4', 'category5', 'category6']; public get name(): string { return commands.TASK_SET; @@ -51,153 +99,28 @@ class PlannerTaskSetCommand extends GraphCommand { return 'Updates a Microsoft Planner Task'; } - 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, { - title: typeof args.options.title !== 'undefined', - planId: typeof args.options.planId !== 'undefined', - planTitle: typeof args.options.planTitle !== 'undefined', - rosterId: typeof args.options.rosterId !== 'undefined', - ownerGroupId: typeof args.options.ownerGroupId !== 'undefined', - ownerGroupName: typeof args.options.ownerGroupName !== 'undefined', - bucketId: typeof args.options.bucketId !== 'undefined', - bucketName: typeof args.options.bucketName !== 'undefined', - startDateTime: typeof args.options.startDateTime !== 'undefined', - dueDateTime: typeof args.options.dueDateTime !== 'undefined', - percentComplete: typeof args.options.percentComplete !== 'undefined', - assignedToUserIds: typeof args.options.assignedToUserIds !== 'undefined', - assignedToUserNames: typeof args.options.assignedToUserNames !== 'undefined', - assigneePriority: typeof args.options.assigneePriority !== 'undefined', - description: typeof args.options.description !== 'undefined', - appliedCategories: typeof args.options.appliedCategories !== 'undefined', - orderHint: typeof args.options.orderHint !== 'undefined', - priority: typeof args.options.priority !== 'undefined' + public getRefinedSchema(schema: typeof options): z.ZodType | undefined { + return schema + .refine(opts => !(opts.bucketId && opts.bucketName), { + message: `Specify either 'bucketId' or 'bucketName', but not both.`, + params: { customCode: 'optionSet', options: ['bucketId', 'bucketName'] } + }) + .refine(opts => !opts.bucketName || [opts.planId, opts.planTitle, opts.rosterId].filter(x => x !== undefined).length === 1, { + message: `Specify exactly one of the following options: 'planId', 'planTitle' or 'rosterId'.`, + params: { customCode: 'optionSet', options: ['planId', 'planTitle', 'rosterId'] } + }) + .refine(opts => !opts.planTitle || [opts.ownerGroupId, opts.ownerGroupName].filter(x => x !== undefined).length === 1, { + message: `Specify exactly one of the following options: 'ownerGroupId' or 'ownerGroupName'.`, + params: { customCode: 'optionSet', options: ['ownerGroupId', 'ownerGroupName'] } + }) + .refine(opts => !(opts.assignedToUserIds && opts.assignedToUserNames), { + message: 'Specify either assignedToUserIds or assignedToUserNames but not both.', + params: { customCode: 'optionSet', options: ['assignedToUserIds', 'assignedToUserNames'] } }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '-i, --id <id>' }, - { option: '-t, --title [title]' }, - { option: '--planId [planId]' }, - { option: '--planTitle [planTitle]' }, - { option: '--rosterId [rosterId]' }, - { option: '--ownerGroupId [ownerGroupId]' }, - { option: '--ownerGroupName [ownerGroupName]' }, - { option: '--bucketId [bucketId]' }, - { option: '--bucketName [bucketName]' }, - { option: '--startDateTime [startDateTime]' }, - { option: '--dueDateTime [dueDateTime]' }, - { option: '--percentComplete [percentComplete]' }, - { option: '--assignedToUserIds [assignedToUserIds]' }, - { option: '--assignedToUserNames [assignedToUserNames]' }, - { option: '--assigneePriority [assigneePriority]' }, - { option: '--description [description]' }, - { option: '--appliedCategories [appliedCategories]' }, - { option: '--orderHint [orderHint]' }, - { option: '--priority [priority]', autocomplete: taskPriority.priorityValues } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.ownerGroupId && !validation.isValidGuid(args.options.ownerGroupId as string)) { - return `${args.options.ownerGroupId} is not a valid GUID`; - } - - if (args.options.startDateTime && !validation.isValidISODateTime(args.options.startDateTime)) { - return 'The startDateTime is not a valid ISO date string'; - } - - if (args.options.dueDateTime && !validation.isValidISODateTime(args.options.dueDateTime)) { - return 'The dueDateTime is not a valid ISO date string'; - } - - if (args.options.percentComplete && isNaN(args.options.percentComplete)) { - return `percentComplete is not a number`; - } - - if (args.options.percentComplete && (args.options.percentComplete < 0 || args.options.percentComplete > 100)) { - return `percentComplete should be between 0 and 100`; - } - - if (args.options.assignedToUserIds) { - const isValidGUIDArrayResult = validation.isValidGuidArray(args.options.assignedToUserIds); - if (isValidGUIDArrayResult !== true) { - return `The following GUIDs are invalid for the option 'assignedToUserIds': ${isValidGUIDArrayResult}.`; - } - } - - if (args.options.assignedToUserNames) { - const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(args.options.assignedToUserNames); - if (isValidUPNArrayResult !== true) { - return `The following user principal names are invalid for the option 'assignedToUserNames': ${isValidUPNArrayResult}.`; - } - } - - if (args.options.appliedCategories && args.options.appliedCategories.split(',').filter(category => this.allowedAppliedCategories.indexOf(category.toLocaleLowerCase()) < 0).length !== 0) { - return 'The appliedCategories contains invalid value. Specify either category1, category2, category3, category4, category5 and/or category6 as properties'; - } - - if (args.options.priority !== undefined) { - if (typeof args.options.priority === "number") { - if (isNaN(args.options.priority) || args.options.priority < 0 || args.options.priority > 10 || !Number.isInteger(args.options.priority)) { - return 'priority should be an integer between 0 and 10.'; - } - } - else if (taskPriority.priorityValues.map(l => l.toLowerCase()).indexOf(args.options.priority.toString().toLowerCase()) === -1) { - return `${args.options.priority} is not a valid priority value. Allowed values are ${taskPriority.priorityValues.join('|')}.`; - } - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { - options: ['bucketId', 'bucketName'], - runsWhen: (args) => { - return args.options.bucketId || args.options.bucketName; - } - }, - { - options: ['planId', 'planTitle', 'rosterId'], - runsWhen: (args) => { - return args.options.bucketName !== undefined; - } - }, - { - options: ['ownerGroupId', 'ownerGroupName'], - runsWhen: (args) => { - return args.options.planTitle !== undefined; - } - }, - { - options: ['assignedToUserIds', 'assignedToUserNames'], - runsWhen: (args) => { - return args.options.assignedToUserIds || args.options.assignedToUserNames; - } - } - ); - } - - #initTypes(): void { - this.types.string.push('id', 'title', 'planId', 'planTitle', 'ownerGroupId', 'ownerGroupName', 'bucketId', 'bucketName', 'description', 'assigneePriority', 'orderHint', 'rosterId', 'startDateTime', 'dueDateTime', 'assignedToUserIds', 'assignedToUserNames', 'assignments', 'appliedCategories'); } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { @@ -285,7 +208,7 @@ class PlannerTaskSetCommand extends GraphCommand { } const categories: AppliedCategories = {}; - options.appliedCategories.toLocaleLowerCase().split(',').forEach(x => categories[x] = true); + options.appliedCategories.toLocaleLowerCase().split(',').forEach(x => categories[x.trim()] = true); return categories; } @@ -310,7 +233,6 @@ class PlannerTaskSetCommand extends GraphCommand { return options.assignedToUserIds.split(',').map(o => o.trim()); } - // Hitting this section means assignedToUserNames won't be undefined const userNames = options.assignedToUserNames as string; const userArr: string[] = userNames.split(',').map(o => o.trim()); @@ -333,7 +255,6 @@ class PlannerTaskSetCommand extends GraphCommand { userUpns = usersRes.map(res => res.value[0]?.userPrincipalName as string); const userIds = usersRes.map(res => res.value[0]?.id as string); - // Find the members where no graph response was found const invalidUsers = userArr.filter(user => !userUpns.some((upn) => upn?.toLowerCase() === user.toLowerCase())); if (invalidUsers && invalidUsers.length > 0) { @@ -354,7 +275,6 @@ class PlannerTaskSetCommand extends GraphCommand { const planId = await this.getPlanId(options); return planner.getBucketIdByTitle(options.bucketName, planId); - } private async getPlanId(options: Options): Promise<string> { @@ -399,7 +319,7 @@ class PlannerTaskSetCommand extends GraphCommand { } if (options.percentComplete) { - requestBody.percentComplete = options.percentComplete; + requestBody.percentComplete = Number(options.percentComplete); } if (this.assignments && Object.keys(this.assignments).length > 0) { @@ -426,4 +346,4 @@ class PlannerTaskSetCommand extends GraphCommand { } } -export default new PlannerTaskSetCommand(); \ No newline at end of file +export default new PlannerTaskSetCommand();