Skip to content

Commit

Permalink
New command: viva engage community set
Browse files Browse the repository at this point in the history
  • Loading branch information
MartinM85 committed Sep 12, 2024
1 parent d9ec717 commit 48ce8ca
Show file tree
Hide file tree
Showing 5 changed files with 177 additions and 118 deletions.
3 changes: 2 additions & 1 deletion src/m365/viva/commands/engage/Community.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export interface Community {
id: string;
displayName: string;
description: string;
description?: string;
privacy: string;
groupId: string;
}
122 changes: 89 additions & 33 deletions src/m365/viva/commands/engage/engage-community-set.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from 'assert';
import sinon from 'sinon';
import auth from '../../../../Auth.js';
import { z } from 'zod';
import { Logger } from '../../../../cli/Logger.js';
import { CommandError } from '../../../../Command.js';
import request from '../../../../request.js';
Expand All @@ -17,9 +18,11 @@ import { cli } from '../../../../cli/cli.js';
describe(commands.ENGAGE_COMMUNITY_SET, () => {
const communityId = 'eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiI0NzY5MTM1ODIwOSJ9';
const displayName = 'Software Engineers';
const entraGroupId = '0bed8b86-5026-4a93-ac7d-56750cc099f1';
let log: string[];
let logger: Logger;
let commandInfo: CommandInfo;
let commandOptionsSchema: z.ZodTypeAny;

before(() => {
sinon.stub(auth, 'restoreAuth').resolves();
Expand All @@ -28,6 +31,7 @@ describe(commands.ENGAGE_COMMUNITY_SET, () => {
sinon.stub(session, 'getId').returns('');
auth.connection.active = true;
commandInfo = cli.getCommandInfo(command);
commandOptionsSchema = commandInfo.command.getSchemaToParse()!;
});

beforeEach(() => {
Expand Down Expand Up @@ -64,49 +68,87 @@ describe(commands.ENGAGE_COMMUNITY_SET, () => {
assert.notStrictEqual(command.description, null);
});

it('passes validation when id is specified', async () => {
const actual = await command.validate({ options: { id: communityId, description: 'Community for all devs' } }, commandInfo);
assert.strictEqual(actual, true);
it('fails validation if entraGroupId is not a valid GUID', () => {
const actual = commandOptionsSchema.safeParse({
entraGroupId: 'foo',
newDisplayName: 'Software Engineers'
});
assert.notStrictEqual(actual.success, true);
});

it('passes validation when displayName is specified', async () => {
const actual = await command.validate({ options: { displayName: 'Software Engineers', description: 'Community for all devs' } }, commandInfo);
assert.strictEqual(actual, true);
it('fails validation if neither id nor displayName nor entraGroupId is specified', () => {
const actual = commandOptionsSchema.safeParse({
newDisplayName: 'Software Engineers'
});
assert.notStrictEqual(actual.success, true);
});

it('fails validation when newDisplayName, description or privacy is not specified', async () => {
const actual = await command.validate({ options: { displayName: 'Software Engineers' } }, commandInfo);
assert.notStrictEqual(actual, true);
it('fails validation when newDisplayName, description or privacy is not specified', () => {
const actual = commandOptionsSchema.safeParse({
displayName: 'Software Engineers'
});
assert.notStrictEqual(actual.success, true);
});

it('fails validation if newDisplayName is more than 255 characters', async () => {
const actual = await command.validate({
options: {
id: communityId,
newDisplayName: "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries."
}
}, commandInfo);
assert.notStrictEqual(actual, true);
it('fails validation if newDisplayName is more than 255 characters', () => {
const actual = commandOptionsSchema.safeParse({
id: communityId,
newDisplayName: "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries."
});
assert.notStrictEqual(actual.success, true);
});

it('fails validation if description is more than 1024 characters', async () => {
const actual = await command.validate({
options: {
displayName: 'Software engineers',
description: `Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged.It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.There are many variations of passages of Lorem Ipsum available, but the majority have suffered alteration in some form, by injected humour, or randomised words which don't look even slightly believable. If you are going to use a passage of Lorem Ipsum, you need to be sure there isn't anything embarrassing hidden in the middle of text.All the Lorem Ipsum generators on the Internet tend to repeat predefined chunks as necessary, making this the first true generator on the Internet.`
}
}, commandInfo);
assert.notStrictEqual(actual, true);
it('fails validation if description is more than 1024 characters', () => {
const actual = commandOptionsSchema.safeParse({
displayName: 'Software engineers',
description: `Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged.It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.There are many variations of passages of Lorem Ipsum available, but the majority have suffered alteration in some form, by injected humour, or randomised words which don't look even slightly believable. If you are going to use a passage of Lorem Ipsum, you need to be sure there isn't anything embarrassing hidden in the middle of text.All the Lorem Ipsum generators on the Internet tend to repeat predefined chunks as necessary, making this the first true generator on the Internet.`
});
assert.notStrictEqual(actual.success, true);
});

it('fails validation when invalid privacy option is provided', async () => {
const actual = await command.validate({
options: {
displayName: 'Software engineers',
privacy: 'invalid'
}
}, commandInfo);
assert.notStrictEqual(actual, true);
it('fails validation when invalid privacy option is provided', () => {
const actual = commandOptionsSchema.safeParse({
displayName: 'Software engineers',
privacy: 'invalid'
});
assert.notStrictEqual(actual.success, true);
});

it('fails validation if id, displayName and entraGroupId is specified', () => {
const actual = commandOptionsSchema.safeParse({
id: communityId,
displayName: displayName,
entraGroupId: entraGroupId,
newDisplayName: 'Software Engineers'
});
assert.notStrictEqual(actual.success, true);
});

it('fails validation if id and displayName', () => {
const actual = commandOptionsSchema.safeParse({
id: communityId,
displayName: displayName,
newDisplayName: 'Software Engineers'
});
assert.notStrictEqual(actual.success, true);
});

it('fails validation if displayName and entraGroupId', () => {
const actual = commandOptionsSchema.safeParse({
displayName: displayName,
entraGroupId: entraGroupId,
newDisplayName: 'Software Engineers'
});
assert.notStrictEqual(actual.success, true);
});

it('fails validation if id and entraGroupId is specified', () => {
const actual = commandOptionsSchema.safeParse({
id: communityId,
entraGroupId: entraGroupId,
newDisplayName: 'Software Engineers'
});
assert.notStrictEqual(actual.success, true);
});

it('updates info about a community specified by id', async () => {
Expand Down Expand Up @@ -136,6 +178,20 @@ describe(commands.ENGAGE_COMMUNITY_SET, () => {
assert(patchRequestStub.called);
});

it('updates info about a community specified by entraGroupId', async () => {
sinon.stub(vivaEngage, 'getCommunityIdByEntraGroupId').resolves(communityId);
const patchRequestStub = sinon.stub(request, 'patch').callsFake(async (opts) => {
if (opts.url === `https://graph.microsoft.com/v1.0/employeeExperience/communities/${communityId}`) {
return;
}

throw 'Invalid request';
});

await command.action(logger, { options: { entraGroupId: entraGroupId, description: 'Community for all devs', privacy: 'Public', verbose: true } });
assert(patchRequestStub.called);
});

it('handles error when updating Viva Engage community failed', async () => {
sinon.stub(request, 'patch').callsFake(async (opts) => {
if (opts.url === `https://graph.microsoft.com/v1.0/employeeExperience/communities/${communityId}`) {
Expand Down
121 changes: 39 additions & 82 deletions src/m365/viva/commands/engage/engage-community-set.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
import GlobalOptions from '../../../../GlobalOptions.js';
import { z } from 'zod';
import { globalOptionsZod } from '../../../../Command.js';
import { Logger } from '../../../../cli/Logger.js';
import request, { CliRequestOptions } from '../../../../request.js';
import { vivaEngage } from '../../../../utils/vivaEngage.js';
import { zod } from '../../../../utils/zod.js';
import GraphCommand from '../../../base/GraphCommand.js';
import commands from '../../commands.js';
import { validation } from '../../../../utils/validation.js';

const options = globalOptionsZod
.extend({
id: zod.alias('i', z.string().optional()),
displayName: zod.alias('d', z.string().optional()),
entraGroupId: z.string().optional(),
newDisplayName: z.string().optional().refine(value => !value || value.length <= 255, {
message: 'The maximum amount of characters is 255.'
}),
description: z.string().optional().refine(value => !value || value.length <= 1024, {
message: 'The maximum amount of characters is 1024.'
}),
privacy: z.enum(['public', 'private']).optional()
})
.strict();
declare type Options = z.infer<typeof options>;

interface CommandArgs {
options: Options;
}

interface Options extends GlobalOptions {
id?: string;
displayName?: string;
newDisplayName?: string;
description?: string;
privacy?: string;
}

class VivaEngageCommunitySetCommand extends GraphCommand {
private privacyOptions: string[] = ['public', 'private'];

public get name(): string {
return commands.ENGAGE_COMMUNITY_SET;
}
Expand All @@ -28,79 +37,23 @@ class VivaEngageCommunitySetCommand extends GraphCommand {
return 'Updates an existing Viva Engage community';
}

constructor() {
super();

this.#initTelemetry();
this.#initOptions();
this.#initValidators();
this.#initTypes();
this.#initOptionSets();
}

#initTelemetry(): void {
this.telemetry.push((args: CommandArgs) => {
Object.assign(this.telemetryProperties, {
id: typeof args.options.id !== 'undefined',
displayName: typeof args.options.displayName !== 'undefined',
newDisplayName: typeof args.options.newDisplayName !== 'undefined',
description: typeof args.options.description !== 'undefined',
privacy: typeof args.options.privacy !== 'undefined'
});
});
}

#initOptions(): void {
this.options.unshift(
{
option: '-i, --id [id]'
},
{
option: '-d, --displayName [displayName]'
},
{
option: '--newDisplayName [newDisplayName]'
},
{
option: '--description [description]'
},
{
option: '--privacy [privacy]',
autocomplete: this.privacyOptions
}
);
}

#initValidators(): void {
this.validators.push(
async (args: CommandArgs) => {
if (args.options.newDisplayName && args.options.newDisplayName.length > 255) {
return `The maximum amount of characters for 'newDisplayName' is 255.`;
}

if (args.options.description && args.options.description.length > 1024) {
return `The maximum amount of characters for 'description' is 1024.`;
}

if (args.options.privacy && this.privacyOptions.map(x => x.toLowerCase()).indexOf(args.options.privacy.toLowerCase()) === -1) {
return `${args.options.privacy} is not a valid privacy. Allowed values are ${this.privacyOptions.join(', ')}`;
}

if (!args.options.newDisplayName && !args.options.description && !args.options.privacy) {
return 'Specify at least newDisplayName, description, or privacy.';
}

return true;
}
);
}

#initTypes(): void {
this.types.string.push('id', 'displayName', 'newDisplayName', 'description', 'privacy');
public get schema(): z.ZodTypeAny | undefined {
return options;
}

#initOptionSets(): void {
this.optionSets.push({ options: ['id', 'displayName'] });
public getRefinedSchema(schema: typeof options): z.ZodEffects<any> | undefined {
return schema
.refine(options => Object.values([options.id, options.displayName, options.entraGroupId]).filter(x => typeof x !== 'undefined').length === 1, {
message: `Specify either id, displayName, or entraGroupId, but not multiple.`
})
.refine(options => options.newDisplayName || options.description || options.privacy, {
message: 'Specify at least newDisplayName, description, or privacy.'
})
.refine(options => (!options.id && !options.displayName && !options.entraGroupId) || options.id || options.displayName ||
(options.entraGroupId && validation.isValidGuid(options.entraGroupId)), options => ({
message: `The '${options.entraGroupId}' must be a valid GUID`,
path: ['entraGroupId']
}));
}

public async commandAction(logger: Logger, args: CommandArgs): Promise<void> {
Expand All @@ -111,6 +64,10 @@ class VivaEngageCommunitySetCommand extends GraphCommand {
communityId = await vivaEngage.getCommunityIdByDisplayName(args.options.displayName);
}

if (args.options.entraGroupId) {
communityId = await vivaEngage.getCommunityIdByEntraGroupId(args.options.entraGroupId);
}

if (this.verbose) {
await logger.logToStderr(`Updating Viva Engage community with ID ${communityId}...`);
}
Expand Down
36 changes: 34 additions & 2 deletions src/utils/vivaEngage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,20 @@ import { settingsNames } from '../settingsNames.js';
describe('utils/vivaEngage', () => {
const displayName = 'All Company';
const invalidDisplayName = 'All Compayn';
const entraGroupId = '0bed8b86-5026-4a93-ac7d-56750cc099f1';
const communityResponse = {
"id": "eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiI0NzY5MTM1ODIwOSJ9",
"description": "This is the default group for everyone in the network",
"displayName": "All Company",
"privacy": "Public"
"privacy": "Public",
"groupId": "0bed8b86-5026-4a93-ac7d-56750cc099f1"
};
const anotherCommunityResponse = {
"id": "eyJfdHlwZ0NzY5SIwiIiSJ9IwO6IaWQiOIMTM1ODikdyb3Vw",
"description": "Test only",
"displayName": "All Company",
"privacy": "Private"
"privacy": "Private",
"groupId": "0bed8b86-5026-4a93-ac7d-56750cc099f1"
};

afterEach(() => {
Expand Down Expand Up @@ -105,4 +108,33 @@ describe('utils/vivaEngage', () => {
await assert.rejects(vivaEngage.getCommunityIdByDisplayName(displayName),
Error(`Multiple Viva Engage communities with name '${displayName}' found. Found: ${communityResponse.id}, ${anotherCommunityResponse.id}.`));
});

it('correctly get single community id by group id using getCommunityIdByEntraGroupId', async () => {
sinon.stub(request, 'get').callsFake(async opts => {
if (opts.url === 'https://graph.microsoft.com/v1.0/employeeExperience/communities?$select=id,groupId') {
return {
value: [
communityResponse
]
};
}

return 'Invalid Request';
});

const actual = await vivaEngage.getCommunityIdByEntraGroupId(entraGroupId);
assert.deepStrictEqual(actual, 'eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiI0NzY5MTM1ODIwOSJ9');
});

it('throws error message when no community was found using getCommunityIdByEntraGroupId', async () => {
sinon.stub(request, 'get').callsFake(async (opts) => {
if (opts.url === 'https://graph.microsoft.com/v1.0/employeeExperience/communities?$select=id,groupId') {
return { value: [] };
}

throw 'Invalid Request';
});

await assert.rejects(vivaEngage.getCommunityIdByEntraGroupId(entraGroupId)), Error(`The Microsoft Entra group with id '${entraGroupId}' is not associated with any Viva Engage community.`);
});
});
Loading

0 comments on commit 48ce8ca

Please sign in to comment.