You need to sign in to do that
Don't have an account?
Nicholas Sewitz 9
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
please help!