function readOnly(count){ }
Starting November 20, the site will be set to read-only. On December 4, 2023,
forum discussions will move to the Trailblazer Community.
+ Start a Discussion
Nicholas Sewitz 9Nicholas 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
 
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 9Nicholas Sewitz 9
please help!