diff --git a/command-snapshot.json b/command-snapshot.json index de919e69a..078cc7f05 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -26,6 +26,7 @@ "json", "loglevel", "perflog", + "skip-assignment-rules", "sobject", "target-org", "use-tooling-api", @@ -236,6 +237,7 @@ "loglevel", "perflog", "record-id", + "skip-assignment-rules", "sobject", "target-org", "use-tooling-api", diff --git a/messages/record.create.md b/messages/record.create.md index c839f424b..003c03f7e 100644 --- a/messages/record.create.md +++ b/messages/record.create.md @@ -22,6 +22,10 @@ Values for the flags in the form =, separate multiple pairs wi Use Tooling API so you can insert a record in a Tooling API object. +# flags.skip-assignment-rules.summary + +Don't apply active assignment rules when creating the record; applies to Account, Case, and Lead records. + # examples - Insert a record into the Account object of your default org; only the required Name field has a value: diff --git a/messages/record.update.md b/messages/record.update.md index 57be9d89c..c862642e1 100644 --- a/messages/record.update.md +++ b/messages/record.update.md @@ -30,6 +30,10 @@ Use Tooling API so you can update a record in a Tooling API object. Fields that you're updating, in the format of = pairs. +# flags.skip-assignment-rules.summary + +Don't apply active assignment rules when updating the record; applies to Account, Case, and Lead records. + # examples - Update the Name field of an Account record with the specified (truncated) ID: diff --git a/src/commands/data/create/record.ts b/src/commands/data/create/record.ts index 4da5ee333..294486d41 100644 --- a/src/commands/data/create/record.ts +++ b/src/commands/data/create/record.ts @@ -50,6 +50,9 @@ export default class Create extends SfCommand { deprecateAliases: true, }), perflog: perflogFlag, + 'skip-assignment-rules': Flags.boolean({ + summary: messages.getMessage('flags.skip-assignment-rules.summary'), + }), }; public async run(): Promise { @@ -62,7 +65,10 @@ export default class Create extends SfCommand { : flags['target-org'].getConnection(flags['api-version']) ).sobject(flags.sobject); const values = stringToDictionary(flags.values); - const result = await sobject.insert(values); + const result = await sobject.insert( + values, + flags['skip-assignment-rules'] ? { headers: { 'Sforce-Auto-Assign': 'FALSE' } } : {} + ); if (result.success) { this.log(messages.getMessage('createSuccess', [result.id || 'unknown id'])); this.spinner.stop(); diff --git a/src/commands/data/update/record.ts b/src/commands/data/update/record.ts index 9876ac7ee..38a84ac9f 100644 --- a/src/commands/data/update/record.ts +++ b/src/commands/data/update/record.ts @@ -72,6 +72,9 @@ export default class Update extends SfCommand { version: '57', }, }), + 'skip-assignment-rules': Flags.boolean({ + summary: messages.getMessage('flags.skip-assignment-rules.summary'), + }), }; public async run(): Promise { @@ -86,7 +89,9 @@ export default class Update extends SfCommand { const sObjectId = flags['record-id'] ?? ((await query(conn, flags.sobject, flags.where as string)).Id as string); try { const updateObject = { ...stringToDictionary(flags.values), Id: sObjectId }; - const result = await conn.sobject(flags.sobject).update(updateObject); + const result = await conn + .sobject(flags.sobject) + .update(updateObject, flags['skip-assignment-rules'] ? { headers: { 'Sforce-Auto-Assign': 'FALSE' } } : {}); if (result.success) { this.log(messages.getMessage('updateSuccess', [sObjectId])); } else { diff --git a/test/commands/data/create/record.test.ts b/test/commands/data/create/record.test.ts index f8933f230..76e5bd426 100644 --- a/test/commands/data/create/record.test.ts +++ b/test/commands/data/create/record.test.ts @@ -30,13 +30,16 @@ describe('data:create:record', () => { const config = new Config({ root: resolve(dirname(fileURLToPath(import.meta.url)), '../../../package.json'), }); + let capturedHeaders: Record | undefined; beforeEach(async () => { await $$.stubAuths(testOrg); await config.load(); + capturedHeaders = undefined; $$.fakeConnectionRequest = (request: AnyJson): Promise => { const requestWithUrl = ensureJsonMap(request); if (request && ensureString(requestWithUrl.url).includes('Account')) { + capturedHeaders = requestWithUrl.headers as Record | undefined; return Promise.resolve({ id: sObjectId, success: true, @@ -56,4 +59,33 @@ describe('data:create:record', () => { const result = await cmd.run(); expect(result.id).to.equal(sObjectId); }); + + it('should not send the Sforce-Auto-Assign header by default', async () => { + const cmd = new Create( + ['--target-org', 'test@org.com', '--sobject', 'Account', '-v', '"Name=Acme"', '--json'], + config + ); + + await cmd.run(); + expect(capturedHeaders).to.not.have.property('Sforce-Auto-Assign'); + }); + + it('should send Sforce-Auto-Assign: FALSE when --skip-assignment-rules is set', async () => { + const cmd = new Create( + [ + '--target-org', + 'test@org.com', + '--sobject', + 'Account', + '-v', + '"Name=Acme"', + '--skip-assignment-rules', + '--json', + ], + config + ); + + await cmd.run(); + expect(capturedHeaders).to.have.property('Sforce-Auto-Assign', 'FALSE'); + }); }); diff --git a/test/commands/data/record/dataRecord.nut.ts b/test/commands/data/record/dataRecord.nut.ts index 45fd89876..0a648a61e 100644 --- a/test/commands/data/record/dataRecord.nut.ts +++ b/test/commands/data/record/dataRecord.nut.ts @@ -262,6 +262,91 @@ describe('data:record commands', () => { }); }); + describe('skip-assignment-rules flag', () => { + // The Case assignment rule in the test project routes Cases whose Subject starts with + // "AssignmentRuleTest" to TestCaseQueue. When --skip-assignment-rules is passed, the record + // should stay owned by the running user instead of being reassigned to the queue. + type CaseRecord = { Id: string; OwnerId: string; Subject: string }; + type QueueRecord = { records: Array<{ Id: string }> }; + + let queueId: string; + let runningUserId: string; + + before(() => { + const queue = execCmd( + "data:query --query \"SELECT Id FROM Group WHERE Type='Queue' AND DeveloperName='TestCaseQueue'\" --json", + { ensureExitCode: 0 } + ).jsonOutput?.result; + assert(queue?.records?.[0]?.Id, 'TestCaseQueue must exist in the scratch org'); + queueId = queue.records[0].Id; + + // Determine the running user by creating a Case that does NOT match the assignment rule + // and reading its OwnerId — avoids brittle username/alias lookups. + const seed = execCmd( + `data:create:record --sobject Case --values "Subject='NoRuleMatchSeed-${genUniqueString()}'" --json`, + { ensureExitCode: 0 } + ).jsonOutput?.result; + assert(seed?.id); + const seedRecord = execCmd(`data:get:record --sobject Case --record-id ${seed.id} --json`, { + ensureExitCode: 0, + }).jsonOutput?.result; + assert(seedRecord?.OwnerId); + runningUserId = seedRecord.OwnerId; + }); + + it('assigns Case to the queue when --skip-assignment-rules is NOT set', () => { + const subject = `AssignmentRuleTest-${genUniqueString()}`; + const createResponse = execCmd( + `data:create:record --sobject Case --values "Subject='${subject}'" --json`, + { ensureExitCode: 0 } + ).jsonOutput?.result; + assert(createResponse?.id); + + const getResponse = execCmd( + `data:get:record --sobject Case --record-id ${createResponse.id} --json`, + { ensureExitCode: 0 } + ).jsonOutput?.result; + expect(getResponse).to.have.property('OwnerId', queueId); + }); + + it('leaves Case owned by the running user when --skip-assignment-rules is set on create', () => { + const subject = `AssignmentRuleTest-${genUniqueString()}`; + const createResponse = execCmd( + `data:create:record --sobject Case --values "Subject='${subject}'" --skip-assignment-rules --json`, + { ensureExitCode: 0 } + ).jsonOutput?.result; + assert(createResponse?.id); + + const getResponse = execCmd( + `data:get:record --sobject Case --record-id ${createResponse.id} --json`, + { ensureExitCode: 0 } + ).jsonOutput?.result; + expect(getResponse).to.have.property('OwnerId', runningUserId); + }); + + it('leaves Case owner unchanged on update when --skip-assignment-rules is set', () => { + // Seed a Case that does NOT match the rule (so it's owned by the running user). + const seedSubject = `NoRuleMatch-${genUniqueString()}`; + const seed = execCmd( + `data:create:record --sobject Case --values "Subject='${seedSubject}'" --skip-assignment-rules --json`, + { ensureExitCode: 0 } + ).jsonOutput?.result; + assert(seed?.id); + + // Update to a subject that would match the assignment rule, but suppress it. + const newSubject = `AssignmentRuleTest-${genUniqueString()}`; + execCmd( + `data:update:record --sobject Case --record-id ${seed.id} --values "Subject='${newSubject}'" --skip-assignment-rules --json`, + { ensureExitCode: 0 } + ); + + const getResponse = execCmd(`data:get:record --sobject Case --record-id ${seed.id} --json`, { + ensureExitCode: 0, + }).jsonOutput?.result; + expect(getResponse).to.have.property('OwnerId', runningUserId); + }); + }); + describe('json parsing', () => { it('will parse JSON correctly for update', () => { const result = execCmd<{ records: Array<{ Id: string }> }>( diff --git a/test/commands/data/update/record.test.ts b/test/commands/data/update/record.test.ts index b4472172d..9de579f95 100644 --- a/test/commands/data/update/record.test.ts +++ b/test/commands/data/update/record.test.ts @@ -121,6 +121,71 @@ describe('data:update:record', () => { } }); + it('should not send the Sforce-Auto-Assign header by default', async () => { + let capturedHeaders: Record | undefined; + $$.fakeConnectionRequest = (request: AnyJson): Promise => { + const requestMap = ensureJsonMap(request); + if (ensureString(requestMap.url).includes('query')) { + return Promise.resolve({ records: [{ Id: sObjectId }] }); + } + if (ensureString(requestMap.url).includes('Account')) { + capturedHeaders = requestMap.headers as Record | undefined; + return Promise.resolve({ id: sObjectId, success: true, errors: [] }); + } + return Promise.resolve({}); + }; + + const cmd = new Update( + [ + '--target-org', + 'test@org.com', + '--sobject', + 'Account', + '--record-id', + sObjectId, + '-v', + '"Name=NewName"', + '--json', + ], + config + ); + await cmd.run(); + expect(capturedHeaders).to.not.have.property('Sforce-Auto-Assign'); + }); + + it('should send Sforce-Auto-Assign: FALSE when --skip-assignment-rules is set', async () => { + let capturedHeaders: Record | undefined; + $$.fakeConnectionRequest = (request: AnyJson): Promise => { + const requestMap = ensureJsonMap(request); + if (ensureString(requestMap.url).includes('query')) { + return Promise.resolve({ records: [{ Id: sObjectId }] }); + } + if (ensureString(requestMap.url).includes('Account')) { + capturedHeaders = requestMap.headers as Record | undefined; + return Promise.resolve({ id: sObjectId, success: true, errors: [] }); + } + return Promise.resolve({}); + }; + + const cmd = new Update( + [ + '--target-org', + 'test@org.com', + '--sobject', + 'Account', + '--record-id', + sObjectId, + '-v', + '"Name=NewName"', + '--skip-assignment-rules', + '--json', + ], + config + ); + await cmd.run(); + expect(capturedHeaders).to.have.property('Sforce-Auto-Assign', 'FALSE'); + }); + it('should throw an error if both --where and --record-id are provided', async () => { const cmd = new Update( [ diff --git a/test/test-files/data-project/force-app/main/default/assignmentRules/Case.assignmentRules-meta.xml b/test/test-files/data-project/force-app/main/default/assignmentRules/Case.assignmentRules-meta.xml new file mode 100644 index 000000000..ea3f6fa10 --- /dev/null +++ b/test/test-files/data-project/force-app/main/default/assignmentRules/Case.assignmentRules-meta.xml @@ -0,0 +1,17 @@ + + + + TestCaseAssignmentRule + true + + TestCaseQueue + Queue + 1 + + Case.Subject + startsWith + AssignmentRuleTest + + + + diff --git a/test/test-files/data-project/force-app/main/default/queues/TestCaseQueue.queue-meta.xml b/test/test-files/data-project/force-app/main/default/queues/TestCaseQueue.queue-meta.xml new file mode 100644 index 000000000..850313079 --- /dev/null +++ b/test/test-files/data-project/force-app/main/default/queues/TestCaseQueue.queue-meta.xml @@ -0,0 +1,8 @@ + + + Test Case Queue + false + + Case + +