-
ChatterFeed
-
0Best Answers
-
0Likes Received
-
0Likes Given
-
4Questions
-
3Replies
After Update Test Class Not Triggering After Update TriggerHandler
Hi!
I have an child object `Lifecycle_History__c` to `Contact`. Everytime the field`Contact.lifecycle__c` is changed I create a new `Lifecycle_History__c` object that captures some data from the `Contact`. Essentially it is a snapshot object for reporting purposes.
I wrote some Apex for this and when I change the lifecycle on a contact in the sandbox everything works as expected. For example if a create a new contact with a `lifecycle__c` then there should be 1 related `Lifecycle_History__c` object. If I edit that contact and change the `lifecycle__c` field, then there are 2 `Lifecycle_History__c` objects.
However in my Apex Test `testcontactUpdate()` my assertion that there should be 3 `Lifecycle_History__c` is failing and saying there is only 1. When I debug I can see that when I run the test it's the `lifecycle__c` on `c` is not updating even after I call update and in turn not triggering the creation of a new `Lifecycle_History__c`.
Apex Trigger:
Apex Class:
Apex Test:
I have an child object `Lifecycle_History__c` to `Contact`. Everytime the field`Contact.lifecycle__c` is changed I create a new `Lifecycle_History__c` object that captures some data from the `Contact`. Essentially it is a snapshot object for reporting purposes.
I wrote some Apex for this and when I change the lifecycle on a contact in the sandbox everything works as expected. For example if a create a new contact with a `lifecycle__c` then there should be 1 related `Lifecycle_History__c` object. If I edit that contact and change the `lifecycle__c` field, then there are 2 `Lifecycle_History__c` objects.
However in my Apex Test `testcontactUpdate()` my assertion that there should be 3 `Lifecycle_History__c` is failing and saying there is only 1. When I debug I can see that when I run the test it's the `lifecycle__c` on `c` is not updating even after I call update and in turn not triggering the creation of a new `Lifecycle_History__c`.
Apex Trigger:
trigger ContactTrigger on Contact (after insert, after update,after delete){ TriggerSupport__c objCustomSetting= TriggerSupport__c.getInstance('ContactTrigger'); if(objCustomSetting.TriggerOff__c == false) { return; } else if(Trigger.isAfter) { if(Trigger.isUpdate){ if(LeadTriggerHandler.isFirstTimeAfter){ LeadTriggerHandler.isFirstTimeAfter = false; System.debug('inafterupdatetrigger'); ContactTriggerHandler.afterUpdate(Trigger.New, Trigger.oldMap); } } else if(Trigger.isInsert){ ContactTriggerHandler.afterInsert(Trigger.New); } else if (Trigger.isDelete){ ContactTriggerHandler.afterDelete(Trigger.Old); } } }
Apex Class:
public with sharing class ContactTriggerHandler { // public static Boolean isFirstTimeAfter = true; /** * after delete handler for merging patrons */ public static void afterDelete(List<Contact> deletedcontacts) { Map<Id,Contact> contactidmap = new Map<Id,Contact>(); // this is a map of deleted contact id to merged contact map for (Contact c : deletedcontacts) { Database.DMLOptions dlo = new Database.DMLOptions(); dlo.EmailHeader.triggerUserEmail = false; dlo.EmailHeader.triggerAutoResponseEmail = false; c.setOptions(dlo); if (c.MasterRecordId != null) { // this is a merge, setup a map of old, new id contactidmap.put(c.MasterRecordId,c); System.debug('contact map' + contactidmap); System.debug('contact id' + c.Id ); System.debug('master record' + c.MasterRecordId); } } if (!contactidmap.keySet().isEmpty()) { // now loop through all the patrons looking up to the old contact and reassign to new contact List<Patron__c> patlist = [select Id,Contact__c from Patron__c where Contact__c in :contactidmap.keySet()]; // List<Lifecycle_History__c> mqllist = [select Id,Contact__c from Lifecycle_History__c where Contact__c in :contactidmap.keySet()]; for (Patron__c pat : patlist) { Database.DMLOptions dlo = new Database.DMLOptions(); dlo.EmailHeader.triggerUserEmail = false; dlo.EmailHeader.triggerAutoResponseEmail = false; pat.setOptions(dlo); pat.Contact__c = contactidmap.get(pat.Contact__c).MasterRecordId; } /* for (Lifecycle_History__c mql : mqllist) { Database.DMLOptions dlo = new Database.DMLOptions(); dlo.EmailHeader.triggerUserEmail = false; dlo.EmailHeader.triggerAutoResponseEmail = false; mql.setOptions(dlo); mql.Contact__c = contactidmap.get(mql.Contact__c).MasterRecordId; }*/ System.debug('patron list ' + patlist); update patlist; // System.debug('patron list ' + mqllist); // update mqllist; dupcheck.dc3Api api = new dupcheck.dc3Api(); api.domerge(patlist); } } /** * after insert handler for attaching patron and applications * @param newcons [description] */ public static void afterInsert(List<Contact> newcons) { Set<Id> cons = new Set<Id>(); // contacts for (Contact c : newcons) { cons.add(c.Id); } // query more information on contacts for patron // loop through contacts and create patrons List<Patron__c> newpatlist = new List<Patron__c>(); List<Lifecycle_History__c> newlifecyclehistorylist = new List<Lifecycle_History__c>(); for (Contact c : [Select Id, Marketing_Qualified_Detail_Most_Recent__c, Marketing_Qualified_Reason_Most_Recent__c, Datetime_Marketing_Qualified_Most_Recent__c, Datetime_Sales_Accepted_Most_Recent__c, Datetime_Sales_Qualified_Most_Recent__c, Datetime_Recycled_Most_Recent__c, Datetime_Disqualified_Most_Recent__c, Datetime_Won_Most_Recent__c, Datetime_Lost_Most_Recent__c, Recycled_Reason__c, Recycle_Until_Date__c, Disqualified_Reason__c, Account.AccountSource, utm_source_Most_Recent__c, utm_campaign_Most_Recent__c, utm_content_Most_Recent__c, utm_medium_Most_Recent__c, utm_term_Most_Recent__c, OwnerId, Email, Phone, Count_of_Patron_Contacts__c, Created_via_Conversion_Process__c, lifecycle__c, Abbreviated_Channel__c, Anything_else__c, Application_Mode__c, Account.Name, How_did_you_learn_about_Artsy__c, Interests__c, AccountId, Learned_from_detail__c, Name, Datetime_of_Partnership_Application__c from Contact where Id in :cons]) if (c.Created_via_Conversion_Process__c == FALSE) { Patron__c pat = new Patron__c(); PatronUtil.populatePatronfromContact(pat, c); newpatlist.add(pat); if(c.lifecycle__c != null) { Lifecycle_History__c lifecyclehistory = new Lifecycle_History__c(); LifecycleUtil.populatelifecyclefromContact(lifecyclehistory, c); newlifecyclehistorylist.add(lifecyclehistory); } } if (!newpatlist.isEmpty()) { insert newpatlist; } if (!newlifecyclehistorylist.isEmpty()) { insert newlifecyclehistorylist; System.debug('inclassinsert'); System.debug(JSON.serializePretty(newlifecyclehistorylist)); } } /* * update children patron records after contact updates * @param newconmap [description] * @param oldmap [description] */ public static void afterUpdate(List<Contact> updatedcons, Map<Id,Contact> oldmap) { System.debug('inafterupdatehandler'); // select all necessary data Map<Id, Contact> updatedconmap = new Map<Id,Contact>([Select Id, Marketing_Qualified_Detail_Most_Recent__c, Marketing_Qualified_Reason_Most_Recent__c, Datetime_Marketing_Qualified_Most_Recent__c, Datetime_Sales_Accepted_Most_Recent__c, Datetime_Sales_Qualified_Most_Recent__c, Datetime_Recycled_Most_Recent__c, Datetime_Disqualified_Most_Recent__c, Datetime_Won_Most_Recent__c, Datetime_Lost_Most_Recent__c, Recycled_Reason__c, Recycle_Until_Date__c, Disqualified_Reason__c, Account.AccountSource, utm_source_Most_Recent__c, utm_campaign_Most_Recent__c, utm_content_Most_Recent__c, utm_medium_Most_Recent__c, utm_term_Most_Recent__c, OwnerId, Email, Phone, Count_of_Patron_Contacts__c, Created_via_Conversion_Process__c, lifecycle__c, Abbreviated_Channel__c, Anything_else__c, Application_Mode__c, Account.Name, How_did_you_learn_about_Artsy__c, Interests__c, AccountId, Learned_from_detail__c, Name, Datetime_of_Partnership_Application__c from Contact where Id in :updatedcons]); // find patrons List<Patron__c> patstoupdate = [Select Contact__c from Patron__c where Contact__c in :updatedconmap.keySet()]; for (Patron__c pat : patstoupdate) { PatronUtil.populatePatronfromContact(pat, updatedconmap.get(pat.Contact__c)); } update patstoupdate; for (Contact c : updatedcons) { System.debug('inafterupdatehandlerloopbeginning'); System.debug(c.lifecycle__c); System.debug(oldMap.get(c.Id).lifecycle__c); if (c.lifecycle__c != oldMap.get(c.Id).lifecycle__c) { // find lifecycles List<Lifecycle_History__c> lifecyclestoinsert = new List<Lifecycle_History__c>(); System.debug('inafterupdatehandlerloop'); for (Id contactid : updatedconmap.keySet()) { Lifecycle_History__c lifecyclehistory = new Lifecycle_History__c(); LifecycleUtil.populateLifecyclefromContact(lifecyclehistory, updatedconmap.get(contactid)); lifecyclestoinsert.add(lifecyclehistory); System.debug('inafterupdatehandlerloop2'); } if (!lifecyclestoinsert.isEmpty()) { insert lifecyclestoinsert; System.debug('inclassupdate'); System.debug(JSON.serializePretty(lifecyclestoinsert)); } } } } }
Apex Test:
@isTest private class ContactTriggerHandlerTest { @isTest static void testContactUpdate() { // Create custom setting TriggerSupport__c ts = new TriggerSupport__c(); ts.Name='ContactTrigger'; ts.TriggerOff__c= TRUE; insert ts; Account a = new Account(Name='my account'); insert a; Contact c = new Contact(FirstName='test',LastName='contact',AccountId=a.Id, Email='test@testdlkfjsdlkfj.com', Phone='8189432003', Abbreviated_Channel__c='Inbound ', lifecycle__c='Marketing Qualified', Created_via_Conversion_Process__c=False,Datetime_of_Partnership_Application__c=Date.today(), DateTime_Marketing_Qualified_Most_Recent__c=Date.today()); insert c; Test.startTest(); c.lifecycle__c = 'Sales Accepted'; update c; Test.stopTest(); List<Lifecycle_History__c> lifecyclelist1 = [SELECT Id, Contact__c FROM Lifecycle_History__c]; List<Contact> contactlist1 = [SELECT Id, lifecycle__c FROM Contact]; System.debug(c.lifecycle__c); System.debug(c.Id); System.debug(JSON.serializePretty(lifecyclelist1)); System.debug(JSON.serializePretty(contactlist1)); c.lifecycle__c = 'Known'; update c; System.debug(c.lifecycle__c); List<Lifecycle_History__c> lifecyclelist = [SELECT Id FROM Lifecycle_History__c WHERE Contact__c = :c.Id]; System.assertEquals(3,lifecyclelist.size()); } }
-
- Nicholas Sewitz 9
- March 12, 2019
- Like
- 0
Batch Matching Apex Job only merging one of the matches
Hi I have a batch matching job running in salesforce. It was working and then I did something and now it has stopped functioning as I intended. I've tried debugging but for the life of me can't figure out what is going on. The goal of the script is to take one list of opportunities that match a certain criteria and another list of opportunities on the same account that match another criteria, map them, and then merge them.
It seems like it properly brings in the two lists of opportunities but when it comes time to match and merge it works on 1 mapped records in the entire list. So If there are 12 opps in each list, one of them will be merge the rest will remain untouched.
It seems like it properly brings in the two lists of opportunities but when it comes time to match and merge it works on 1 mapped records in the entire list. So If there are 12 opps in each list, one of them will be merge the rest will remain untouched.
/** * called by AccountOpptyMatchingScheduler scheduler to perform opportunity matching and consolidation * * 1. get all the opportunities inserted by redshift today (with subscription id) and is not a matched opportunity (Matched_Opportunity__c = false) * 2. look for opportunities under the same account without subscription id (salesforce manually inserted) * 3. sf opportunity with the highest attempt__c and most recent createdDate and same Type and same recordtype will be the match * 4. if sf oppty with highest attempt__c is not the most recent or most recent does not have highest attempt, email nicholas * 5. otherwise, perform merge */ global class OpportunityMatchingBatch implements Database.Batchable<sObject>, Database.Stateful { String query; List<Opportunity> matchedopptys; // holds all the oppty matched today List<String> erroropptys; // holds all the opptys that isn't highest attempt or most recent Date cdate; global OpportunityMatchingBatch() { this(null); } global OpportunityMatchingBatch(Date cdate) { this.cdate = cdate; String datestr = 'CreatedDate = LAST_N_DAYS:14'; if (cdate != null) datestr = 'CreatedDate >= :cdate'; /* This was what it was like before tried to slim down.... * * Date_of_Subscription_Start__c,Date_of_Subscription_End__c,' + 'Total_Value__c,Virtual_MRR__c,Is_Last_Subscription__c,Active_Subscription_Number__c,' + 'Number_of_Failed_Charges__c,Relative_Size__c,Is_Active_Subscription__c,Type,' + 'Duration__c,Payment_Frequency__c,Ended_Early__c,Months_Paid_Up_Front__c,Total_Charges__c,' + 'Last_Subscription_MRR__c,Partner_Slug__c,Pending_Charges__c,Pending_Charges_Dollar_Value__c,' + 'Completed_Charges__c,Completed_Charges_Dollar_Value__c,Partner_Subscription_ID__c,RecordTypeId ' */ query = 'SELECT AccountId,Name,Type,RecordTypeId,Partner_Subscription_ID__c ' + 'FROM Opportunity WHERE ' + datestr + ' AND Partner_Subscription_ID__c <> null AND Matched_Opportunity__c = false'; matchedopptys = new List<Opportunity>(); erroropptys = new List<String>(); System.debug(Logginglevel.INFO, 'Step 1'); } global Database.QueryLocator start(Database.BatchableContext BC) { System.debug(Logginglevel.INFO, 'Step 2'); return Database.getQueryLocator(query); } /** * record comes in one at a time */ global void execute(Database.BatchableContext BC, List<Opportunity> opplist) { Opportunity redshifta = opplist[0]; System.debug(Logginglevel.INFO, 'Step 3'); // should only match with the highest attempt and most recently created sf opportunity for (Opportunity opp : [SELECT Name, CreatedDate, AccountId FROM Opportunity WHERE AccountId = :redshifta.AccountId AND Partner_Subscription_ID__c = null ORDER BY CreatedDate DESC LIMIT 1]) { // got here, it's okay to write soql in here because we are only matching with one opportunity, there won't be a loop // get the highst attempt and most recent createddate from this account //String highestattempt = [SELECT Attempt__c FROM Opportunity WHERE AccountId = :opp.AccountId AND Type = :redshifta.Type AND RecordTypeId // = :redshifta.RecordTypeId AND Partner_Subscription_ID__c = null ORDER BY Attempt__c DESC LIMIT 1].Attempt__c; Datetime mostrecentdate = [SELECT CreatedDate FROM Opportunity WHERE AccountId = :opp.AccountId AND Type = :redshifta.Type AND Partner_Subscription_ID__c = null ORDER BY CreatedDate DESC LIMIT 1].CreatedDate; // only merge if it is highest attempt and most recent if (mostrecentdate == opp.CreatedDate) { // create match result reason String matchedon = null; opp.Matching_Result__c = matchedon; // merge fields opp.Name = redshifta.Name; /*opp.Date_of_Subscription_Start__c = redshifta.Date_of_Subscription_Start__c; opp.Date_of_Subscription_End__c = redshifta.Date_of_Subscription_End__c; opp.Total_Value__c = redshifta.Total_Value__c; opp.Virtual_MRR__c = redshifta.Virtual_MRR__c; opp.Is_Last_Subscription__c = redshifta.Is_Last_Subscription__c; opp.Active_Subscription_Number__c = redshifta.Active_Subscription_Number__c; opp.Number_of_Failed_Charges__c = redshifta.Number_of_Failed_Charges__c; opp.Relative_Size__c = redshifta.Relative_Size__c; opp.Is_Active_Subscription__c = redshifta.Is_Active_Subscription__c; opp.Type = redshifta.Type; opp.Duration__c = redshifta.Duration__c; opp.Payment_Frequency__c = redshifta.Payment_Frequency__c; opp.Ended_Early__c = redshifta.Ended_Early__c; opp.Months_Paid_Up_Front__c = redshifta.Months_Paid_Up_Front__c; opp.Total_Charges__c = redshifta.Total_Charges__c; opp.Last_Subscription_MRR__c = redshifta.Last_Subscription_MRR__c; opp.Partner_Slug__c = redshifta.Partner_Slug__c; opp.Pending_Charges__c = redshifta.Pending_Charges__c; opp.Pending_Charges_Dollar_Value__c = redshifta.Pending_Charges_Dollar_Value__c; opp.Completed_Charges__c = redshifta.Completed_Charges__c; opp.Completed_Charges_Dollar_Value__c = redshifta.Completed_Charges_Dollar_Value__c;*/ opp.Partner_Subscription_ID__c = redshifta.Partner_Subscription_ID__c; opp.Matched_Opportunity__c = true; update opp; delete redshifta; matchedopptys.add(opp); } else { // error erroropptys.add('SF Name: ' + opp.Name + ', SF Id: ' + opp.Id + ', Partner Sub Name: ' + redshifta.Name + ', Redshift Id: ' + redshifta.Id + ' SF CreatedDate: ' + opp.createdDate.format('MM/dd/YYYY HH:mm:ss') + ' Most Recent CreatedDate: ' + mostrecentdate.format('MM/dd/YYYY HH:mm:ss')); } } } /** * after batch is done, email nicholas two reports, one is the opportunity ids merged, one is the error report * @param BC [description] * @return [description] */ global void finish(Database.BatchableContext BC) { List<Messaging.SingleEmailMessage> emailList = new List<Messaging.SingleEmailMessage>(); // send matched opportunity email if (!matchedopptys.isEmpty()) { Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage(); mail.setToAddresses(new String[] {'nicholas@artsy.net','matt@gsdcompany.com'}); mail.setSenderDisplayName('Artsy Matching Agent'); mail.setUseSignature(false); mail.setSubject('Opportunity Matching Result'); String body = Datetime.now().format('MM/dd/YYYY') + ' opportunity match result:\n\n'; for (Opportunity opp : matchedopptys) { body += 'Name: ' + opp.Name + ' | SF ID: ' + opp.Id + ' | Partner Subscription ID: ' + opp.Partner_Subscription_ID__c + '\n'; } mail.setPlainTextBody(body); emailList.add(mail); } // send error email if (!erroropptys.isEmpty()) { Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage(); //'nicholas@artsy.net', mail.setToAddresses(new String[] {'nicholas@artsy.net','matt@gsdcompany.com'}); mail.setSenderDisplayName('Artsy Matching Agent'); mail.setUseSignature(false); mail.setSubject('Opportunity Match Errors (Not Highest Attempt or Not Most Recent'); String body = Datetime.now().format('MM/dd/YYYY') + ' Opportunity Match Errors (Not Highest Attempt or Not Most Recent):\n\n'; for (String e : erroropptys) { body += e + '\n\n'; } mail.setPlainTextBody(body); emailList.add(mail); } Messaging.sendEmail(emailList); } }
-
- Nicholas Sewitz 9
- February 02, 2017
- Like
- 0
Apex Test Class for Google Authentication Controller
Hey I am trying to write a test class for my google Authentication Controller. Even though it compiles when I try and run it, it doesn't work. I may be way off here to begin with if I could please get some help.
Controller Class
Test Controller
Controller Class
public with sharing class googleAuthorization_Controller { public string googleEmail {get;set;} //to store our code for dynamic rendering public string code {get;set;} //to store our user record public User u {get;set;} public googleAuthorization_Controller() { googleEmail = userInfo.getUserEmail(); } //page action public pagereference doOnLoad(){ //retrieve current page Pagereference p = ApexPages.currentPage(); //does it have a code as parameter? code = p.getParameters().get('code'); //no? then stop if (string.isBlank(code)) return null; //it had one! get the state, aka email we passed //note you don't want to use googleEmail here //since we came back to the page, it reloaded and //the controller was reinstantiated, overriding our //input with the user's email string passedEmail = p.getParameters().get('state'); //query for the user, with token fields so we can modify u = [select id, Google_Access_Token__c, Google_Refresh_Token__c from User where id = :userInfo.getUserId()]; //call our api method to get tokens parsed into user u = googleCalendar_API.obtainAccessToken(u, code, googleCalendar_API.SF_AUTH_PAGE); //if we had no error if (u.Google_Access_Token__c != 'error'){ //set the google email u.google_email__c = passedEmail; //update the user and display success message update u; ApexPages.addMessage(new ApexPages.message(ApexPages.severity.confirm,'Authorized Successfully!')); } else{ //had an error? well then let us know <sadface> ApexPages.addMessage(new ApexPages.message(ApexPages.severity.error,'Authorization Error.')); } //stay here, not going anywhere! return null; } public pagereference requestAuthorization(){ return googleCalendar_API.loginRequestPage( googleCalendar_API.SF_AUTH_PAGE, googleEmail); } }
Test Controller
@istest public class googleControllerTest { public String body { get; set; } public String method { get; set; } public String postParam { get; set; } public String url { get; set; } public String message { get; set; } public List<SelectOption> methodList { get { if(methodList==null) { methodList = new List<SelectOption>(); methodList.add(new SelectOption('GET','GET')); methodList.add(new SelectOption('POST','POST')); methodList.add(new SelectOption('PUT','PUT')); } return methodList; } set; } private Map<String,User> oauthServices { get { if(oauthServices==null) { oauthServices = new Map<String,User>(); for(User u : [ SELECT name, id, Google_Access_Token__c, Google_Refresh_Token__c FROM User]) { oauthServices.put(u.name,u); } } return oauthServices; } set; } public String selectedService { get { if(selectedService==null && oauthServices.size()>0) { selectedService = oauthServices.values()[0].name; } return selectedService; } set; } public List<SelectOption> services { get { services = new List<SelectOption>(); for(User obj : oauthServices.values()) { services.add(new SelectOption(obj.name,obj.name)); } return services; } set; } public PageReference execute() { System.debug('Method: '+method+', Service: '+selectedService+'. URL: '+url); Http h = new Http(); HttpRequest req = new HttpRequest(); req.setMethod(method); req.setEndpoint(url); if(method=='POST' || method=='PUT') { if(postParam!=null & postParam!='') { req.setBody(postParam); req.setHeader('Content-Type','application/x-www-form-urlencoded'); } else { req.setBody(body); } } System.debug('Sending request...'); HttpResponse res = h.send(req); body = res.getBody(); System.debug('Received response ('+res.getStatusCode()+' '+res.getStatus()+')'); message = ''; return null; } }
-
- Nicholas Sewitz 9
- October 17, 2016
- Like
- 0
Test Apex Class for Google Calendar Batch HTTP Callout Class Mock
Hey I am trying to implement a batch system that sends salesforce events to google calendar's api. I have successfully implemented this process in sandbox but am having trouble getting code coverage.
Below is my Callout Class followed by my batch class as well as my google api authorization controller. I have test coverage for none. At the bottom is my attempt at writing test coverage which essentially follows Salesforce's documentation. I seem to be having particular trouble because my HTTP CALLOUT is a POST.
Callout Class
Batch Class
Authorization Controller
Mock Callout Class
Test Class
Below is my Callout Class followed by my batch class as well as my google api authorization controller. I have test coverage for none. At the bottom is my attempt at writing test coverage which essentially follows Salesforce's documentation. I seem to be having particular trouble because my HTTP CALLOUT is a POST.
Callout Class
public with sharing class googleCalendar_API { /********************** START CONSTANTS ***************************/ static String GOOGLE_API_CLIENT_ID = '555540635024-5kincbt5uhpfh4g8faq6atmj4hmmbb3h.apps.googleusercontent.com'; static String GOOGLE_API_CLIENT_SECRET = 'W5G3H0qkpNi0ac1kvfsOzkWK'; static String GOOGLE_CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar'; static String GOOGLE_CALENDAR_BASE_URL = 'https://www.googleapis.com/calendar/v3/calendars/'; static String GOOGLE_CALENDAR_EVENTS_PATH = '/events'; public static String SF_AUTH_PAGE = 'https://-------artdev--c.cs62.visual.force.com/apex/googleAuthorization'; static Map<String,String> operationMap = new Map<String,String>{'INSERT'=>'POST','UPDATE'=>'PATCH','DELETE'=>'DELETE'}; static map<id,User> userMap = new map<id,User>([select id, name, google_Email__c, Google_Access_Token__c, Google_Refresh_Token__c from User where isActive=true]); //carriage return static String cr = '\r\n'; /********************** END CONSTANTS ***************************/ static TimeZone tz = UserInfo.getTimeZone(); public static String convertDateTimeToString(DateTime dt){ Integer x = tz.getOffset(dt)/3600000; String z = ''; if ( x > 0 ) z += '+'; else z += '-'; if ( x > 9 || x < -9 ) z += math.abs(x); else z += '0'+math.abs(x); z += ':00'; return dt.format('yyyy-MM-dd\'T\'HH:mm:ss'+z); } public static httpResponse callGoogle(String endpoint, String method, String body){ HttpRequest req = new HttpRequest(); req.setEndpoint(endpoint); req.setMethod(method); req.setCompressed(false); req.setHeader('User-Agent','learnApex API'); req.setHeader('Encoding','iso-8859-1'); req.setHeader('Content-Type','application/x-www-form-urlencoded'); req.setTimeout(120000); if( body != null ){ req.setBody(body); req.setHeader('Content-length',string.valueOf(body.length())); } HttpResponse res = new http().send(req); system.debug(res.getBody()); return res; } public static User parseGoogleAuth(String body, User u){ jsonParser parser = json.createParser(body); while ( parser.nextToken() != null ){ if ( parser.getCurrentToken() == JSONToken.FIELD_NAME && parser.getText() != null && parser.getText() == 'access_token' ){ parser.nextToken(); u.Google_Access_Token__c = parser.getText(); } else if ( parser.getCurrentToken() == JSONToken.FIELD_NAME && parser.getText() != null && parser.getText() == 'refresh_token' ){ parser.nextToken(); u.Google_Refresh_Token__c = parser.getText(); } } return u; } public static PageReference loginRequestPage (String redirectURI, String state){ PageReference p = new PageReference('https://accounts.google.com/o/oauth2/auth'); p.getParameters().put('response_type','code'); //Determines if the Google Authorization Server returns an authorization code (code), or an opaque access token (token) p.getParameters().put('client_id',GOOGLE_API_CLIENT_ID); p.getParameters().put('redirect_uri',redirectURI); p.getParameters().put('approval_prompt','force'); p.getParameters().put('scope',GOOGLE_CALENDAR_SCOPE); p.getParameters().put('state',state); //This optional parameter indicates any state which may be useful to your application upon receipt of the response. The Google Authorization Server roundtrips this parameter, so your application receives the same value it sent. Possible uses include redirecting the user to the correct resource in your site, nonces, and cross-site-request-forgery mitigations. p.getParameters().put('access_type','offline'); return p; } public static User obtainAccessToken(User u, String code, String redirectURL){ PageReference p = new PageReference('https://accounts.google.com/o/oauth2/auth'); p.getParameters().put('client_id',GOOGLE_API_CLIENT_ID); p.getParameters().put('client_secret',GOOGLE_API_CLIENT_SECRET); p.getParameters().put('scope',''); p.getParameters().put('redirect_uri',redirectURL); p.getParameters().put('grant_type','authorization_code'); p.getParameters().put('code',code); String body = p.getURL(); body = body.subStringAfter('?'); httpResponse googleAuth = callGoogle('https://accounts.google.com/o/oauth2/token','POST',body); if ( googleAuth.getStatusCode() == 200 ){ u = parseGoogleAuth(googleAuth.getBody(), u); } else u.Google_Access_Token__c ='error'; return u; } public static User refreshToken(User u){ PageReference p = new PageReference('https://accounts.google.com/o/oauth2/auth'); p.getParameters().put('client_id',GOOGLE_API_CLIENT_ID); p.getParameters().put('client_secret',GOOGLE_API_CLIENT_SECRET); p.getParameters().put('refresh_token',u.Google_Refresh_Token__c); p.getParameters().put('grant_type','refresh_token'); String body = p.getURL(); body = body.subStringAfter('?'); httpResponse googleAuth = callGoogle('https://accounts.google.com/o/oauth2/token','POST',body); if ( googleAuth.getStatusCode() == 200 ){ u = parseGoogleAuth(googleAuth.getBody(), u); } return u; } public class calloutWrapper{ public String body {get;set;} public String endpoint {get;set;} public String googleCalendarEmail {get;set;} public String googleEventId {get;set;} public String method {get;set;} public String ownerName {get;set;} public Id salesforceEventId {get;set;} public Id salesforceOwnerId {get;set;} public calloutWrapper(Event e){ ownerName = usermap.get(e.OwnerId).Name; googleCalendarEmail = usermap.get(e.ownerid).google_Email__c; salesforceOwnerId = e.OwnerId; salesforceEventId = e.Id; if ( string.isNotBlank(e.Google_Id__c) ){ googleEventId = e.Google_Id__c; } body = compileBodyFromEvent(e); } } public static String compileBodyFromEvent(Event e){ //we’re building a JSON body manually! String body = '{'+cr+' "end": {'+cr; if (e.isalldayevent){ body += ' "date": "'+ e.StartDateTime.formatgmt('yyyy-MM-dd') +'"'+cr; } else { body += ' "dateTime": "'+ convertDateTimeToString(e.EndDateTime) +'"'+cr; } body += ' },'+cr+' "start": {'+cr; if (e.isalldayevent){ body += ' "date": "'+ e.StartDateTime.formatgmt('yyyy-MM-dd') +'"'+cr; } else{ body += ' "dateTime": "'+ convertDateTimeToString(e.StartDateTime) +'"'+cr; } body += ' },'+cr; if ( string.isNotBlank(e.Subject) ){ body += ' "summary": "'+ e.Subject +'",'+cr; } if ( string.isNotBlank(e.Description) ){ body += ' "description": "'+ e.Description.replace('\n','\\n').replace('\r','\\r') +'",'+cr; } if ( string.isNotBlank( e.Location ) ){ body += ' "location": "'+ e.Location +'",'+cr; } //we've been blindly adding returns body = body.subStringBeforeLast(','); body += '}'+cr; return body; } public static void processEventList(list<Event> eventList, boolean deleting){ //generate a map of all events by ownerid //we'll need this because Google only lets us work with 1 user at a time map<String, list<calloutWrapper>> eventsByOwnerId = wrapEventsByOwner(eventlist, deleting); //list to collect events for update List<Event> eventUpdates = new List<Event>(); for (string userId : eventsByOwnerId.keyset()){ //refresh user Credentials, and store in map userMap.put(userid,refreshToken(usermap.get(userid))); //send the request in one fel swoop httpResponse res = new http().send(buildRequest(userMap.get(userid), eventsByOwnerId.get(userid))); //retrieve response body for work String resBody = res.getBody(); //debug the response system.debug(resbody); //what's the boundary Google is using? String googBoundary = resBody.subStringBefore('Content-Type:'); system.debug(googBoundary); //use that boundary to split the response List<String> parts = resBody.split(googBoundary); //for every split part of the response by boundary for ( String p : parts ){ //if this is an event response if ( p.contains('Content-ID: <response-') ){ //add event to list for update with it's new Google Id Event e = new Event(Id=p.subStringBetween('Content-ID: <response-','>')); e.Google_Id__c = p.subStringBetween('"id": "','"'); eventUpdates.add(e); } } //if we were inserting events. if (!eventUpdates.isEmpty() && !deleting) update eventUpdates; } } public static map<String, list<calloutWrapper>> wrapEventsByOwner(List<Event> eventList, boolean deleting){ map<String, list<calloutWrapper>> ownerMap = new map<String, list<calloutWrapper>>(); for ( Event e : eventList ){ if ( e.StartDateTime != null && e.EndDateTime != null ){ calloutWrapper w = new calloutWrapper(e); w.Method = (string.isnotBlank(w.googleEventId))?((deleting)?'DELETE':'PATCH'):'POST'; if ( ownerMap.containsKey(e.OwnerId)) ownerMap.get(e.OwnerId).add(w); else ownerMap.put(e.OwnerId, new list<calloutWrapper>{w}); } } return ownerMap; } public static HttpRequest buildRequest(User u, list<calloutWrapper> eventList){ httpRequest req = new httpRequest(); //boundary to be used to denote individual events in our batch //this can be anything you like, but since this is a use case, foobar :) String boundary = '______________batch_foobarbaz'; //let Google know what our boundary is so it knows when to break things up req.setHeader('Content-Type','multipart/mixed; boundary='+boundary); //add the access token as our authentication req.setHeader('Authorization','Bearer '+u.Google_Access_Token__c); req.setMethod('POST'); //we're sending a batch request, so we have a special endpoint req.setEndpoint('https://www.googleapis.com/batch'); //max timeout req.setTimeout(120000); //construct our body String reqBody = ''; //for every wrapped event for ( calloutWrapper e : eventList ){ //start every event with a boundary reqBody += '--'+boundary+cr; //define type reqBody += 'Content-Type: application/http'+cr; //identify with our Salesforce id reqBody += 'Content-ID: <'+e.salesforceEventId+'>'+cr+cr; //what are we doing to this event? insert,update,delete? //aka post,patch,delete reqBody += e.Method+' '; //identify the calendar reqBody += '/calendar/v3/calendars/'+encodingUtil.urlEncode(u.google_email__c,'UTF-8'); //add in the path for events on this calendar (static variable from documentation) reqBody += GOOGLE_CALENDAR_EVENTS_PATH; //if we're updating or deleting the Google event... we need to provide its id if ( string.isNotBlank(e.GoogleEventId) && (e.Method == 'PATCH' || e.Method == 'DELETE')){ reqBody += '/'+e.googleEventId; } reqBody += cr+'Content-Type: application/json; charset=UTF-8'+cr; //delete requests don't need these if ( e.method != 'DELETE' ){ reqBody += 'Content-Length: '+e.Body.length()+cr; reqBody += cr; reqBody += e.Body; } reqBody += cr; } //close off our batch request with a boundary reqBody += '--'+boundary+'--'; // for debugging, let's see what we've got system.debug(reqBody); //set the body req.setBody(reqBody); //be good and set required length header req.setHeader('Content-Length',string.valueOf(reqBody.length())); return req; } }
Batch Class
global class batch_GoogleCalendar_Sync implements Database.Batchable<sObject>, Database.AllowsCallouts{ //class variables for use during processing global final string queryString; global final boolean deleting; global final dateTime lastSync; global final dateTime lastDelete; //constructor taking in our infamous deletion boolean global batch_GoogleCalendar_Sync(boolean del) { //retrieve our custom setting for last sync/delete times GoogleCalendar__c gcBatchSync = GoogleCalendar__c.getInstance('BatchSync'); lastSync = gcBatchSync.LastSync__c; lastDelete = gcBatchSync.LastDelete__c; //if there has never been a sync/deletion set a //time long, long ago, in a galaxy far, far away if (lastSync==null) lastSync = dateTime.newinstance(2016,1,1); if (lastDelete==null) lastDelete = dateTime.newinstance(2016,1,1); //just copying our constructor instance variable to //class level deleting = del; //construct the query string to include necessary fields //this is the same as our execute anonymous if (string.isBlank(queryString)){ string temp = 'Select Subject, StartDateTime, OwnerId, Location, IsAllDayEvent, Id, EndDateTime, DurationInMinutes, Description, ActivityDateTime, ActivityDate, google_id__c From Event'; //if deleting is true, our query is different //we have to add the isDeleted attribute if (deleting){ temp += ' where lastModifiedDate > :lastDelete AND isDeleted = true'; //and the query ALL ROWS flag //which enables us to query deleted records in the //Recycle Bin; if they have been removed from the //Recycle Bin, we can't query them anymore temp += ' ALL ROWS'; } //if not deleting, just get modified date else temp += ' where lastModifiedDate > :lastSync'; //this will become clearer in chapter 9 if (test.isRunningTest()) temp += ' limit 1'; //assign the query string and debug for debug… queryString = temp; system.debug(queryString); } //set lastSync / lastDelete based on operation if(deleting) gcBatchSync.lastDelete__c = system.now(); else gcBatchSync.lastSync__c = system.now(); //update our custom setting to preserve latest times update gcBatchSync; } //batch functional method to get next chunk global Database.QueryLocator start(Database.BatchableContext bc){ return Database.getQueryLocator(queryString); } //the execute method where we do our logic for every chunk global void execute(Database.BatchableContext bc, list<Event> scope){ //call our handy Google API method to process the events //passing in our trusty deleting boolean googleCalendar_API.processEventList(scope, deleting); } //batch functional method when we're done with the entirety of //the batch; we're going to use this method to cause our batch //to run infinitely; deletes should run instantly after syncs, //and then pause before the next sync global void finish(Database.BatchableContext bc){ GoogleCalendar__c gcBatchSync = GoogleCalendar__c.getInstance('BatchSync'); decimal delayMin = gcBatchSync.frequency_min__c; if (delayMin == null || delayMin < 0) delayMin = 0; if(deleting) startBatchDelay(false,integer.valueof(delayMin)); else startBatch(true); } //utility method for starting the batch instantly with //deleting boolean global static void startBatch(boolean d){ batch_GoogleCalendar_Sync job = new batch_GoogleCalendar_Sync(d); database.executeBatch(job,5); } //utility method for starting the batch on a delay //with deleting boolean; specify delay in whole integer //minutes global static void startBatchDelay(boolean d, integer min){ batch_GoogleCalendar_Sync job = new batch_GoogleCalendar_Sync(d); system.scheduleBatch( job, 'GoogleCalendarSync-'+((d)?'del':'upsert'),min,50); } }
Authorization Controller
public with sharing class googleAuthorization_Controller { public string googleEmail {get;set;} //to store our code for dynamic rendering public string code {get;set;} //to store our user record public User u {get;set;} public googleAuthorization_Controller() { googleEmail = userInfo.getUserEmail(); } //page action public pagereference doOnLoad(){ //retrieve current page Pagereference p = ApexPages.currentPage(); //does it have a code as parameter? code = p.getParameters().get('code'); //no? then stop if (string.isBlank(code)) return null; //it had one! get the state, aka email we passed //note you don't want to use googleEmail here //since we came back to the page, it reloaded and //the controller was reinstantiated, overriding our //input with the user's email string passedEmail = p.getParameters().get('state'); //query for the user, with token fields so we can modify u = [select id, Google_Access_Token__c, Google_Refresh_Token__c from User where id = :userInfo.getUserId()]; //call our api method to get tokens parsed into user u = googleCalendar_API.obtainAccessToken(u, code, googleCalendar_API.SF_AUTH_PAGE); //if we had no error if (u.Google_Access_Token__c != 'error'){ //set the google email u.google_email__c = passedEmail; //update the user and display success message update u; ApexPages.addMessage(new ApexPages.message(ApexPages.severity.confirm,'Authorized Successfully!')); } else{ //had an error? well then let us know <sadface> ApexPages.addMessage(new ApexPages.message(ApexPages.severity.error,'Authorization Error.')); } //stay here, not going anywhere! return null; } public pagereference requestAuthorization(){ return googleCalendar_API.loginRequestPage( googleCalendar_API.SF_AUTH_PAGE, googleEmail); } }
Mock Callout Class
@istest global class GoogleCalendarHTTPRequestMock implements HttpCalloutMock { // Implement this interface method global HTTPResponse respond(HTTPRequest req) { // Optionally, only send a mock response for a specific endpoint // and method. System.assertEquals('https://www.googleapis.com/batch', req.getEndpoint()); System.assertEquals('POST', req.getMethod()); // Create a fake response HttpResponse res = new HttpResponse(); res.setHeader('Content-Type', 'application/json'); res.setBody('{"foo":"bar"}'); res.setStatusCode(200); return res; } }
Test Class
@isTest class googleCalendarBatchTest { @isTest static void initViewLoadTest() { // Set mock callout class Test.setMock(HttpCalloutMock.class, new GoogleCalendarHTTPRequestMock()); // Call method to test. // This causes a fake response to be sent // from the class that implements HttpCalloutMock. HttpResponse res = googleCalendar_API.httpRequest(); // Verify response received contains fake values String contentType = res.getHeader('Content-Type'); System.assert(contentType == 'application/json'); String actualValue = res.getBody(); String expectedValue = '{"foo":"bar"}'; System.assertEquals(actualValue, expectedValue); System.assertEquals(200, res.getStatusCode()); } }
-
- Nicholas Sewitz 9
- October 14, 2016
- Like
- 0
Batch Matching Apex Job only merging one of the matches
Hi I have a batch matching job running in salesforce. It was working and then I did something and now it has stopped functioning as I intended. I've tried debugging but for the life of me can't figure out what is going on. The goal of the script is to take one list of opportunities that match a certain criteria and another list of opportunities on the same account that match another criteria, map them, and then merge them.
It seems like it properly brings in the two lists of opportunities but when it comes time to match and merge it works on 1 mapped records in the entire list. So If there are 12 opps in each list, one of them will be merge the rest will remain untouched.
It seems like it properly brings in the two lists of opportunities but when it comes time to match and merge it works on 1 mapped records in the entire list. So If there are 12 opps in each list, one of them will be merge the rest will remain untouched.
/** * called by AccountOpptyMatchingScheduler scheduler to perform opportunity matching and consolidation * * 1. get all the opportunities inserted by redshift today (with subscription id) and is not a matched opportunity (Matched_Opportunity__c = false) * 2. look for opportunities under the same account without subscription id (salesforce manually inserted) * 3. sf opportunity with the highest attempt__c and most recent createdDate and same Type and same recordtype will be the match * 4. if sf oppty with highest attempt__c is not the most recent or most recent does not have highest attempt, email nicholas * 5. otherwise, perform merge */ global class OpportunityMatchingBatch implements Database.Batchable<sObject>, Database.Stateful { String query; List<Opportunity> matchedopptys; // holds all the oppty matched today List<String> erroropptys; // holds all the opptys that isn't highest attempt or most recent Date cdate; global OpportunityMatchingBatch() { this(null); } global OpportunityMatchingBatch(Date cdate) { this.cdate = cdate; String datestr = 'CreatedDate = LAST_N_DAYS:14'; if (cdate != null) datestr = 'CreatedDate >= :cdate'; /* This was what it was like before tried to slim down.... * * Date_of_Subscription_Start__c,Date_of_Subscription_End__c,' + 'Total_Value__c,Virtual_MRR__c,Is_Last_Subscription__c,Active_Subscription_Number__c,' + 'Number_of_Failed_Charges__c,Relative_Size__c,Is_Active_Subscription__c,Type,' + 'Duration__c,Payment_Frequency__c,Ended_Early__c,Months_Paid_Up_Front__c,Total_Charges__c,' + 'Last_Subscription_MRR__c,Partner_Slug__c,Pending_Charges__c,Pending_Charges_Dollar_Value__c,' + 'Completed_Charges__c,Completed_Charges_Dollar_Value__c,Partner_Subscription_ID__c,RecordTypeId ' */ query = 'SELECT AccountId,Name,Type,RecordTypeId,Partner_Subscription_ID__c ' + 'FROM Opportunity WHERE ' + datestr + ' AND Partner_Subscription_ID__c <> null AND Matched_Opportunity__c = false'; matchedopptys = new List<Opportunity>(); erroropptys = new List<String>(); System.debug(Logginglevel.INFO, 'Step 1'); } global Database.QueryLocator start(Database.BatchableContext BC) { System.debug(Logginglevel.INFO, 'Step 2'); return Database.getQueryLocator(query); } /** * record comes in one at a time */ global void execute(Database.BatchableContext BC, List<Opportunity> opplist) { Opportunity redshifta = opplist[0]; System.debug(Logginglevel.INFO, 'Step 3'); // should only match with the highest attempt and most recently created sf opportunity for (Opportunity opp : [SELECT Name, CreatedDate, AccountId FROM Opportunity WHERE AccountId = :redshifta.AccountId AND Partner_Subscription_ID__c = null ORDER BY CreatedDate DESC LIMIT 1]) { // got here, it's okay to write soql in here because we are only matching with one opportunity, there won't be a loop // get the highst attempt and most recent createddate from this account //String highestattempt = [SELECT Attempt__c FROM Opportunity WHERE AccountId = :opp.AccountId AND Type = :redshifta.Type AND RecordTypeId // = :redshifta.RecordTypeId AND Partner_Subscription_ID__c = null ORDER BY Attempt__c DESC LIMIT 1].Attempt__c; Datetime mostrecentdate = [SELECT CreatedDate FROM Opportunity WHERE AccountId = :opp.AccountId AND Type = :redshifta.Type AND Partner_Subscription_ID__c = null ORDER BY CreatedDate DESC LIMIT 1].CreatedDate; // only merge if it is highest attempt and most recent if (mostrecentdate == opp.CreatedDate) { // create match result reason String matchedon = null; opp.Matching_Result__c = matchedon; // merge fields opp.Name = redshifta.Name; /*opp.Date_of_Subscription_Start__c = redshifta.Date_of_Subscription_Start__c; opp.Date_of_Subscription_End__c = redshifta.Date_of_Subscription_End__c; opp.Total_Value__c = redshifta.Total_Value__c; opp.Virtual_MRR__c = redshifta.Virtual_MRR__c; opp.Is_Last_Subscription__c = redshifta.Is_Last_Subscription__c; opp.Active_Subscription_Number__c = redshifta.Active_Subscription_Number__c; opp.Number_of_Failed_Charges__c = redshifta.Number_of_Failed_Charges__c; opp.Relative_Size__c = redshifta.Relative_Size__c; opp.Is_Active_Subscription__c = redshifta.Is_Active_Subscription__c; opp.Type = redshifta.Type; opp.Duration__c = redshifta.Duration__c; opp.Payment_Frequency__c = redshifta.Payment_Frequency__c; opp.Ended_Early__c = redshifta.Ended_Early__c; opp.Months_Paid_Up_Front__c = redshifta.Months_Paid_Up_Front__c; opp.Total_Charges__c = redshifta.Total_Charges__c; opp.Last_Subscription_MRR__c = redshifta.Last_Subscription_MRR__c; opp.Partner_Slug__c = redshifta.Partner_Slug__c; opp.Pending_Charges__c = redshifta.Pending_Charges__c; opp.Pending_Charges_Dollar_Value__c = redshifta.Pending_Charges_Dollar_Value__c; opp.Completed_Charges__c = redshifta.Completed_Charges__c; opp.Completed_Charges_Dollar_Value__c = redshifta.Completed_Charges_Dollar_Value__c;*/ opp.Partner_Subscription_ID__c = redshifta.Partner_Subscription_ID__c; opp.Matched_Opportunity__c = true; update opp; delete redshifta; matchedopptys.add(opp); } else { // error erroropptys.add('SF Name: ' + opp.Name + ', SF Id: ' + opp.Id + ', Partner Sub Name: ' + redshifta.Name + ', Redshift Id: ' + redshifta.Id + ' SF CreatedDate: ' + opp.createdDate.format('MM/dd/YYYY HH:mm:ss') + ' Most Recent CreatedDate: ' + mostrecentdate.format('MM/dd/YYYY HH:mm:ss')); } } } /** * after batch is done, email nicholas two reports, one is the opportunity ids merged, one is the error report * @param BC [description] * @return [description] */ global void finish(Database.BatchableContext BC) { List<Messaging.SingleEmailMessage> emailList = new List<Messaging.SingleEmailMessage>(); // send matched opportunity email if (!matchedopptys.isEmpty()) { Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage(); mail.setToAddresses(new String[] {'nicholas@artsy.net','matt@gsdcompany.com'}); mail.setSenderDisplayName('Artsy Matching Agent'); mail.setUseSignature(false); mail.setSubject('Opportunity Matching Result'); String body = Datetime.now().format('MM/dd/YYYY') + ' opportunity match result:\n\n'; for (Opportunity opp : matchedopptys) { body += 'Name: ' + opp.Name + ' | SF ID: ' + opp.Id + ' | Partner Subscription ID: ' + opp.Partner_Subscription_ID__c + '\n'; } mail.setPlainTextBody(body); emailList.add(mail); } // send error email if (!erroropptys.isEmpty()) { Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage(); //'nicholas@artsy.net', mail.setToAddresses(new String[] {'nicholas@artsy.net','matt@gsdcompany.com'}); mail.setSenderDisplayName('Artsy Matching Agent'); mail.setUseSignature(false); mail.setSubject('Opportunity Match Errors (Not Highest Attempt or Not Most Recent'); String body = Datetime.now().format('MM/dd/YYYY') + ' Opportunity Match Errors (Not Highest Attempt or Not Most Recent):\n\n'; for (String e : erroropptys) { body += e + '\n\n'; } mail.setPlainTextBody(body); emailList.add(mail); } Messaging.sendEmail(emailList); } }
- Nicholas Sewitz 9
- February 02, 2017
- Like
- 0
Test Apex Class for Google Calendar Batch HTTP Callout Class Mock
Hey I am trying to implement a batch system that sends salesforce events to google calendar's api. I have successfully implemented this process in sandbox but am having trouble getting code coverage.
Below is my Callout Class followed by my batch class as well as my google api authorization controller. I have test coverage for none. At the bottom is my attempt at writing test coverage which essentially follows Salesforce's documentation. I seem to be having particular trouble because my HTTP CALLOUT is a POST.
Callout Class
Batch Class
Authorization Controller
Mock Callout Class
Test Class
Below is my Callout Class followed by my batch class as well as my google api authorization controller. I have test coverage for none. At the bottom is my attempt at writing test coverage which essentially follows Salesforce's documentation. I seem to be having particular trouble because my HTTP CALLOUT is a POST.
Callout Class
public with sharing class googleCalendar_API { /********************** START CONSTANTS ***************************/ static String GOOGLE_API_CLIENT_ID = '555540635024-5kincbt5uhpfh4g8faq6atmj4hmmbb3h.apps.googleusercontent.com'; static String GOOGLE_API_CLIENT_SECRET = 'W5G3H0qkpNi0ac1kvfsOzkWK'; static String GOOGLE_CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar'; static String GOOGLE_CALENDAR_BASE_URL = 'https://www.googleapis.com/calendar/v3/calendars/'; static String GOOGLE_CALENDAR_EVENTS_PATH = '/events'; public static String SF_AUTH_PAGE = 'https://-------artdev--c.cs62.visual.force.com/apex/googleAuthorization'; static Map<String,String> operationMap = new Map<String,String>{'INSERT'=>'POST','UPDATE'=>'PATCH','DELETE'=>'DELETE'}; static map<id,User> userMap = new map<id,User>([select id, name, google_Email__c, Google_Access_Token__c, Google_Refresh_Token__c from User where isActive=true]); //carriage return static String cr = '\r\n'; /********************** END CONSTANTS ***************************/ static TimeZone tz = UserInfo.getTimeZone(); public static String convertDateTimeToString(DateTime dt){ Integer x = tz.getOffset(dt)/3600000; String z = ''; if ( x > 0 ) z += '+'; else z += '-'; if ( x > 9 || x < -9 ) z += math.abs(x); else z += '0'+math.abs(x); z += ':00'; return dt.format('yyyy-MM-dd\'T\'HH:mm:ss'+z); } public static httpResponse callGoogle(String endpoint, String method, String body){ HttpRequest req = new HttpRequest(); req.setEndpoint(endpoint); req.setMethod(method); req.setCompressed(false); req.setHeader('User-Agent','learnApex API'); req.setHeader('Encoding','iso-8859-1'); req.setHeader('Content-Type','application/x-www-form-urlencoded'); req.setTimeout(120000); if( body != null ){ req.setBody(body); req.setHeader('Content-length',string.valueOf(body.length())); } HttpResponse res = new http().send(req); system.debug(res.getBody()); return res; } public static User parseGoogleAuth(String body, User u){ jsonParser parser = json.createParser(body); while ( parser.nextToken() != null ){ if ( parser.getCurrentToken() == JSONToken.FIELD_NAME && parser.getText() != null && parser.getText() == 'access_token' ){ parser.nextToken(); u.Google_Access_Token__c = parser.getText(); } else if ( parser.getCurrentToken() == JSONToken.FIELD_NAME && parser.getText() != null && parser.getText() == 'refresh_token' ){ parser.nextToken(); u.Google_Refresh_Token__c = parser.getText(); } } return u; } public static PageReference loginRequestPage (String redirectURI, String state){ PageReference p = new PageReference('https://accounts.google.com/o/oauth2/auth'); p.getParameters().put('response_type','code'); //Determines if the Google Authorization Server returns an authorization code (code), or an opaque access token (token) p.getParameters().put('client_id',GOOGLE_API_CLIENT_ID); p.getParameters().put('redirect_uri',redirectURI); p.getParameters().put('approval_prompt','force'); p.getParameters().put('scope',GOOGLE_CALENDAR_SCOPE); p.getParameters().put('state',state); //This optional parameter indicates any state which may be useful to your application upon receipt of the response. The Google Authorization Server roundtrips this parameter, so your application receives the same value it sent. Possible uses include redirecting the user to the correct resource in your site, nonces, and cross-site-request-forgery mitigations. p.getParameters().put('access_type','offline'); return p; } public static User obtainAccessToken(User u, String code, String redirectURL){ PageReference p = new PageReference('https://accounts.google.com/o/oauth2/auth'); p.getParameters().put('client_id',GOOGLE_API_CLIENT_ID); p.getParameters().put('client_secret',GOOGLE_API_CLIENT_SECRET); p.getParameters().put('scope',''); p.getParameters().put('redirect_uri',redirectURL); p.getParameters().put('grant_type','authorization_code'); p.getParameters().put('code',code); String body = p.getURL(); body = body.subStringAfter('?'); httpResponse googleAuth = callGoogle('https://accounts.google.com/o/oauth2/token','POST',body); if ( googleAuth.getStatusCode() == 200 ){ u = parseGoogleAuth(googleAuth.getBody(), u); } else u.Google_Access_Token__c ='error'; return u; } public static User refreshToken(User u){ PageReference p = new PageReference('https://accounts.google.com/o/oauth2/auth'); p.getParameters().put('client_id',GOOGLE_API_CLIENT_ID); p.getParameters().put('client_secret',GOOGLE_API_CLIENT_SECRET); p.getParameters().put('refresh_token',u.Google_Refresh_Token__c); p.getParameters().put('grant_type','refresh_token'); String body = p.getURL(); body = body.subStringAfter('?'); httpResponse googleAuth = callGoogle('https://accounts.google.com/o/oauth2/token','POST',body); if ( googleAuth.getStatusCode() == 200 ){ u = parseGoogleAuth(googleAuth.getBody(), u); } return u; } public class calloutWrapper{ public String body {get;set;} public String endpoint {get;set;} public String googleCalendarEmail {get;set;} public String googleEventId {get;set;} public String method {get;set;} public String ownerName {get;set;} public Id salesforceEventId {get;set;} public Id salesforceOwnerId {get;set;} public calloutWrapper(Event e){ ownerName = usermap.get(e.OwnerId).Name; googleCalendarEmail = usermap.get(e.ownerid).google_Email__c; salesforceOwnerId = e.OwnerId; salesforceEventId = e.Id; if ( string.isNotBlank(e.Google_Id__c) ){ googleEventId = e.Google_Id__c; } body = compileBodyFromEvent(e); } } public static String compileBodyFromEvent(Event e){ //we’re building a JSON body manually! String body = '{'+cr+' "end": {'+cr; if (e.isalldayevent){ body += ' "date": "'+ e.StartDateTime.formatgmt('yyyy-MM-dd') +'"'+cr; } else { body += ' "dateTime": "'+ convertDateTimeToString(e.EndDateTime) +'"'+cr; } body += ' },'+cr+' "start": {'+cr; if (e.isalldayevent){ body += ' "date": "'+ e.StartDateTime.formatgmt('yyyy-MM-dd') +'"'+cr; } else{ body += ' "dateTime": "'+ convertDateTimeToString(e.StartDateTime) +'"'+cr; } body += ' },'+cr; if ( string.isNotBlank(e.Subject) ){ body += ' "summary": "'+ e.Subject +'",'+cr; } if ( string.isNotBlank(e.Description) ){ body += ' "description": "'+ e.Description.replace('\n','\\n').replace('\r','\\r') +'",'+cr; } if ( string.isNotBlank( e.Location ) ){ body += ' "location": "'+ e.Location +'",'+cr; } //we've been blindly adding returns body = body.subStringBeforeLast(','); body += '}'+cr; return body; } public static void processEventList(list<Event> eventList, boolean deleting){ //generate a map of all events by ownerid //we'll need this because Google only lets us work with 1 user at a time map<String, list<calloutWrapper>> eventsByOwnerId = wrapEventsByOwner(eventlist, deleting); //list to collect events for update List<Event> eventUpdates = new List<Event>(); for (string userId : eventsByOwnerId.keyset()){ //refresh user Credentials, and store in map userMap.put(userid,refreshToken(usermap.get(userid))); //send the request in one fel swoop httpResponse res = new http().send(buildRequest(userMap.get(userid), eventsByOwnerId.get(userid))); //retrieve response body for work String resBody = res.getBody(); //debug the response system.debug(resbody); //what's the boundary Google is using? String googBoundary = resBody.subStringBefore('Content-Type:'); system.debug(googBoundary); //use that boundary to split the response List<String> parts = resBody.split(googBoundary); //for every split part of the response by boundary for ( String p : parts ){ //if this is an event response if ( p.contains('Content-ID: <response-') ){ //add event to list for update with it's new Google Id Event e = new Event(Id=p.subStringBetween('Content-ID: <response-','>')); e.Google_Id__c = p.subStringBetween('"id": "','"'); eventUpdates.add(e); } } //if we were inserting events. if (!eventUpdates.isEmpty() && !deleting) update eventUpdates; } } public static map<String, list<calloutWrapper>> wrapEventsByOwner(List<Event> eventList, boolean deleting){ map<String, list<calloutWrapper>> ownerMap = new map<String, list<calloutWrapper>>(); for ( Event e : eventList ){ if ( e.StartDateTime != null && e.EndDateTime != null ){ calloutWrapper w = new calloutWrapper(e); w.Method = (string.isnotBlank(w.googleEventId))?((deleting)?'DELETE':'PATCH'):'POST'; if ( ownerMap.containsKey(e.OwnerId)) ownerMap.get(e.OwnerId).add(w); else ownerMap.put(e.OwnerId, new list<calloutWrapper>{w}); } } return ownerMap; } public static HttpRequest buildRequest(User u, list<calloutWrapper> eventList){ httpRequest req = new httpRequest(); //boundary to be used to denote individual events in our batch //this can be anything you like, but since this is a use case, foobar :) String boundary = '______________batch_foobarbaz'; //let Google know what our boundary is so it knows when to break things up req.setHeader('Content-Type','multipart/mixed; boundary='+boundary); //add the access token as our authentication req.setHeader('Authorization','Bearer '+u.Google_Access_Token__c); req.setMethod('POST'); //we're sending a batch request, so we have a special endpoint req.setEndpoint('https://www.googleapis.com/batch'); //max timeout req.setTimeout(120000); //construct our body String reqBody = ''; //for every wrapped event for ( calloutWrapper e : eventList ){ //start every event with a boundary reqBody += '--'+boundary+cr; //define type reqBody += 'Content-Type: application/http'+cr; //identify with our Salesforce id reqBody += 'Content-ID: <'+e.salesforceEventId+'>'+cr+cr; //what are we doing to this event? insert,update,delete? //aka post,patch,delete reqBody += e.Method+' '; //identify the calendar reqBody += '/calendar/v3/calendars/'+encodingUtil.urlEncode(u.google_email__c,'UTF-8'); //add in the path for events on this calendar (static variable from documentation) reqBody += GOOGLE_CALENDAR_EVENTS_PATH; //if we're updating or deleting the Google event... we need to provide its id if ( string.isNotBlank(e.GoogleEventId) && (e.Method == 'PATCH' || e.Method == 'DELETE')){ reqBody += '/'+e.googleEventId; } reqBody += cr+'Content-Type: application/json; charset=UTF-8'+cr; //delete requests don't need these if ( e.method != 'DELETE' ){ reqBody += 'Content-Length: '+e.Body.length()+cr; reqBody += cr; reqBody += e.Body; } reqBody += cr; } //close off our batch request with a boundary reqBody += '--'+boundary+'--'; // for debugging, let's see what we've got system.debug(reqBody); //set the body req.setBody(reqBody); //be good and set required length header req.setHeader('Content-Length',string.valueOf(reqBody.length())); return req; } }
Batch Class
global class batch_GoogleCalendar_Sync implements Database.Batchable<sObject>, Database.AllowsCallouts{ //class variables for use during processing global final string queryString; global final boolean deleting; global final dateTime lastSync; global final dateTime lastDelete; //constructor taking in our infamous deletion boolean global batch_GoogleCalendar_Sync(boolean del) { //retrieve our custom setting for last sync/delete times GoogleCalendar__c gcBatchSync = GoogleCalendar__c.getInstance('BatchSync'); lastSync = gcBatchSync.LastSync__c; lastDelete = gcBatchSync.LastDelete__c; //if there has never been a sync/deletion set a //time long, long ago, in a galaxy far, far away if (lastSync==null) lastSync = dateTime.newinstance(2016,1,1); if (lastDelete==null) lastDelete = dateTime.newinstance(2016,1,1); //just copying our constructor instance variable to //class level deleting = del; //construct the query string to include necessary fields //this is the same as our execute anonymous if (string.isBlank(queryString)){ string temp = 'Select Subject, StartDateTime, OwnerId, Location, IsAllDayEvent, Id, EndDateTime, DurationInMinutes, Description, ActivityDateTime, ActivityDate, google_id__c From Event'; //if deleting is true, our query is different //we have to add the isDeleted attribute if (deleting){ temp += ' where lastModifiedDate > :lastDelete AND isDeleted = true'; //and the query ALL ROWS flag //which enables us to query deleted records in the //Recycle Bin; if they have been removed from the //Recycle Bin, we can't query them anymore temp += ' ALL ROWS'; } //if not deleting, just get modified date else temp += ' where lastModifiedDate > :lastSync'; //this will become clearer in chapter 9 if (test.isRunningTest()) temp += ' limit 1'; //assign the query string and debug for debug… queryString = temp; system.debug(queryString); } //set lastSync / lastDelete based on operation if(deleting) gcBatchSync.lastDelete__c = system.now(); else gcBatchSync.lastSync__c = system.now(); //update our custom setting to preserve latest times update gcBatchSync; } //batch functional method to get next chunk global Database.QueryLocator start(Database.BatchableContext bc){ return Database.getQueryLocator(queryString); } //the execute method where we do our logic for every chunk global void execute(Database.BatchableContext bc, list<Event> scope){ //call our handy Google API method to process the events //passing in our trusty deleting boolean googleCalendar_API.processEventList(scope, deleting); } //batch functional method when we're done with the entirety of //the batch; we're going to use this method to cause our batch //to run infinitely; deletes should run instantly after syncs, //and then pause before the next sync global void finish(Database.BatchableContext bc){ GoogleCalendar__c gcBatchSync = GoogleCalendar__c.getInstance('BatchSync'); decimal delayMin = gcBatchSync.frequency_min__c; if (delayMin == null || delayMin < 0) delayMin = 0; if(deleting) startBatchDelay(false,integer.valueof(delayMin)); else startBatch(true); } //utility method for starting the batch instantly with //deleting boolean global static void startBatch(boolean d){ batch_GoogleCalendar_Sync job = new batch_GoogleCalendar_Sync(d); database.executeBatch(job,5); } //utility method for starting the batch on a delay //with deleting boolean; specify delay in whole integer //minutes global static void startBatchDelay(boolean d, integer min){ batch_GoogleCalendar_Sync job = new batch_GoogleCalendar_Sync(d); system.scheduleBatch( job, 'GoogleCalendarSync-'+((d)?'del':'upsert'),min,50); } }
Authorization Controller
public with sharing class googleAuthorization_Controller { public string googleEmail {get;set;} //to store our code for dynamic rendering public string code {get;set;} //to store our user record public User u {get;set;} public googleAuthorization_Controller() { googleEmail = userInfo.getUserEmail(); } //page action public pagereference doOnLoad(){ //retrieve current page Pagereference p = ApexPages.currentPage(); //does it have a code as parameter? code = p.getParameters().get('code'); //no? then stop if (string.isBlank(code)) return null; //it had one! get the state, aka email we passed //note you don't want to use googleEmail here //since we came back to the page, it reloaded and //the controller was reinstantiated, overriding our //input with the user's email string passedEmail = p.getParameters().get('state'); //query for the user, with token fields so we can modify u = [select id, Google_Access_Token__c, Google_Refresh_Token__c from User where id = :userInfo.getUserId()]; //call our api method to get tokens parsed into user u = googleCalendar_API.obtainAccessToken(u, code, googleCalendar_API.SF_AUTH_PAGE); //if we had no error if (u.Google_Access_Token__c != 'error'){ //set the google email u.google_email__c = passedEmail; //update the user and display success message update u; ApexPages.addMessage(new ApexPages.message(ApexPages.severity.confirm,'Authorized Successfully!')); } else{ //had an error? well then let us know <sadface> ApexPages.addMessage(new ApexPages.message(ApexPages.severity.error,'Authorization Error.')); } //stay here, not going anywhere! return null; } public pagereference requestAuthorization(){ return googleCalendar_API.loginRequestPage( googleCalendar_API.SF_AUTH_PAGE, googleEmail); } }
Mock Callout Class
@istest global class GoogleCalendarHTTPRequestMock implements HttpCalloutMock { // Implement this interface method global HTTPResponse respond(HTTPRequest req) { // Optionally, only send a mock response for a specific endpoint // and method. System.assertEquals('https://www.googleapis.com/batch', req.getEndpoint()); System.assertEquals('POST', req.getMethod()); // Create a fake response HttpResponse res = new HttpResponse(); res.setHeader('Content-Type', 'application/json'); res.setBody('{"foo":"bar"}'); res.setStatusCode(200); return res; } }
Test Class
@isTest class googleCalendarBatchTest { @isTest static void initViewLoadTest() { // Set mock callout class Test.setMock(HttpCalloutMock.class, new GoogleCalendarHTTPRequestMock()); // Call method to test. // This causes a fake response to be sent // from the class that implements HttpCalloutMock. HttpResponse res = googleCalendar_API.httpRequest(); // Verify response received contains fake values String contentType = res.getHeader('Content-Type'); System.assert(contentType == 'application/json'); String actualValue = res.getBody(); String expectedValue = '{"foo":"bar"}'; System.assertEquals(actualValue, expectedValue); System.assertEquals(200, res.getStatusCode()); } }
- Nicholas Sewitz 9
- October 14, 2016
- Like
- 0
When converting a lead to an account, I want the new account's OwnerId copied to another field, but I've encountered a problem I can't figure out (code inside)
Hi all,
Here is some very basic before insert code I am using in an account trigger - my intent is to copy the OwnerId (or OwnerName) to a text field called Original_Account_Owner__c. This field would retain a historical record of who the original account owner was so that we can always report on that data even if the current owner is changed.
This works perfectly if I convert a lead for myself, with myself set as the Record Owner during the conversion process...
However:
If I have two employees (Emp A and Emp B) and Emp A is converting a lead but during the conversion process he/she sets the Record Owner to Emp B, the end result after my trigger runs is that the "Original Account Owner" is showing Emp A and the "Account Owner" is showing Emp B when in reality I want the "Original Account Owner" and the "Account Owner" to BOTH be Emp B because that was the Record Owner selected during the conversion.
My assumption was that if the record owner is selected during conversion, it would be the one that the new account record is created with - so my trigger should just pick up the Account Owner prior to insert and set it on my custom field... instead, it looks like it assumes that I am the Record Owner during insert and then quickly changes it afterwards?
Is there any way I can combat this and get the end result I am looking for, or am I stuck because of the nature of the account creation/reassignment process during conversion?
Many thanks for your input everyone!
Here is some very basic before insert code I am using in an account trigger - my intent is to copy the OwnerId (or OwnerName) to a text field called Original_Account_Owner__c. This field would retain a historical record of who the original account owner was so that we can always report on that data even if the current owner is changed.
if (Trigger.isBefore && Trigger.isInsert) { for(Account acct : Trigger.new){ // Check that the owner is a user (not a queue) if( ((String)acct.OwnerId).substring(0,3) == '005' && acct.Original_Account_Owner__c == null ){ acct.Original_Account_Owner__c = acct.OwnerId; } else{ // In case of Queue (which shouldn't happen), System Debug. System.debug('found a queue when we shouldn't have...' + acct.OwnerId); } }
This works perfectly if I convert a lead for myself, with myself set as the Record Owner during the conversion process...
However:
If I have two employees (Emp A and Emp B) and Emp A is converting a lead but during the conversion process he/she sets the Record Owner to Emp B, the end result after my trigger runs is that the "Original Account Owner" is showing Emp A and the "Account Owner" is showing Emp B when in reality I want the "Original Account Owner" and the "Account Owner" to BOTH be Emp B because that was the Record Owner selected during the conversion.
My assumption was that if the record owner is selected during conversion, it would be the one that the new account record is created with - so my trigger should just pick up the Account Owner prior to insert and set it on my custom field... instead, it looks like it assumes that I am the Record Owner during insert and then quickly changes it afterwards?
Is there any way I can combat this and get the end result I am looking for, or am I stuck because of the nature of the account creation/reassignment process during conversion?
Many thanks for your input everyone!
- Morgan Marchese
- July 30, 2015
- Like
- 0