@isTest private class TokenServiceTest { private class Mock implements HttpCalloutMock { Integer status; String body; Mock(Integer s, String b) { this.status = s; this.body = b; } public HTTPResponse respond(HTTPRequest req) { HttpResponse r = new HttpResponse(); r.setHeader('Content-Type', 'application/json'); r.setStatusCode(status); r.setBody(body); return r; } } private static void seed(String access, String refresh) { insert new HIC_ShopifySync__Configuration__c( Name = 'Default', HIC_ShopifySync__ClientId__c = 'cid', HIC_ShopifySync__ClientSecret__c = 'csec', HIC_ShopifySync__Access_Token__c = access, HIC_ShopifySync__Refresh_Token__c = refresh ); } private static HIC_ShopifySync__Configuration__c reload() { return [SELECT HIC_ShopifySync__Access_Token__c, HIC_ShopifySync__Refresh_Token__c FROM HIC_ShopifySync__Configuration__c WHERE Name = 'Default' LIMIT 1]; } // 200 with a rotated refresh token → both persisted, new access returned. @isTest static void success_rotatesAndPersistsBothTokens() { seed('old_access', 'old_refresh'); Test.setMock(HttpCalloutMock.class, new Mock(200, '{"access_token":"new_access","refresh_token":"new_refresh"}')); Test.startTest(); String token = TokenService.getFreshAccessToken('old_access', true); Test.stopTest(); System.assertEquals('new_access', token); HIC_ShopifySync__Configuration__c c = reload(); System.assertEquals('new_access', c.HIC_ShopifySync__Access_Token__c); System.assertEquals('new_refresh', c.HIC_ShopifySync__Refresh_Token__c); } // Single-flight: a peer already rotated (stored access ≠ the one we failed on) → reuse, no callout. @isTest static void singleFlight_reusesPeerToken() { seed('peer_access', 'r'); Test.startTest(); String token = TokenService.getFreshAccessToken('my_stale_access', false); Test.stopTest(); System.assertEquals('peer_access', token); } // invalid_grant → ReauthRequired, ACCESS token invalidated, REFRESH token PRESERVED. // The refresh token is deliberately kept: blanking it is irreversible, destroys the evidence needed // to diagnose why the connection failed, and forces a manual re-initialize even for the recoverable // conditions that also surface as invalid_grant. @isTest static void invalidGrant_preservesRefreshToken_reauthRequired() { seed('a', 'r'); Test.setMock(HttpCalloutMock.class, new Mock(400, '{"error":"invalid_grant","error_description":"expired access/refresh token"}')); Boolean reauth = false; Test.startTest(); try { TokenService.getFreshAccessToken('a', true); } catch (TokenService.ReauthRequiredException e) { reauth = true; } Test.stopTest(); System.assert(reauth, 'expected ReauthRequiredException'); HIC_ShopifySync__Configuration__c c = reload(); System.assertEquals('r', c.HIC_ShopifySync__Refresh_Token__c, 'refresh token must be PRESERVED so the connection can self-heal without re-initialize'); System.assert(String.isBlank(c.HIC_ShopifySync__Access_Token__c), 'access token must be invalidated so every later caller re-attempts a refresh'); } // A non-fatal invalid_grant reason (deactivated user) must NOT be treated as a dead grant. @isTest static void invalidGrant_inactiveUser_transient_noChange() { seed('a', 'r'); Test.setMock(HttpCalloutMock.class, new Mock(400, '{"error":"invalid_grant","error_description":"inactive user"}')); Boolean transientErr = false; Test.startTest(); try { TokenService.getFreshAccessToken('a', true); } catch (TokenService.TransientRefreshException e) { transientErr = true; } catch (TokenService.ReauthRequiredException e) { System.assert(false, 'must not treat inactive user as dead grant'); } Test.stopTest(); System.assert(transientErr, 'expected TransientRefreshException'); HIC_ShopifySync__Configuration__c c = reload(); System.assertEquals('r', c.HIC_ShopifySync__Refresh_Token__c); System.assertEquals('a', c.HIC_ShopifySync__Access_Token__c, 'nothing may change on a transient signal'); } // invalid_grant that is actually an IP-allowlist rejection → transient, tokens untouched. @isTest static void ipRestrict_transient_noWipe() { seed('a', 'r'); Test.setMock(HttpCalloutMock.class, new Mock(400, '{"error":"invalid_grant","error_description":"ip restricted"}')); Boolean transientErr = false; Test.startTest(); try { TokenService.getFreshAccessToken('a', true); } catch (TokenService.TransientRefreshException e) { transientErr = true; } catch (TokenService.ReauthRequiredException e) { System.assert(false, 'must NOT wipe on ip restrict'); } Test.stopTest(); System.assert(transientErr, 'expected TransientRefreshException'); System.assertEquals('r', reload().HIC_ShopifySync__Refresh_Token__c, 'token must be untouched'); } // 200 that omits refresh_token (rotation expected) → transient, stored tokens untouched. @isTest static void twoHundredWithoutRefresh_transient_noChange() { seed('a', 'r'); Test.setMock(HttpCalloutMock.class, new Mock(200, '{"access_token":"x"}')); Boolean transientErr = false; Test.startTest(); try { TokenService.getFreshAccessToken('a', true); } catch (TokenService.TransientRefreshException e) { transientErr = true; } Test.stopTest(); System.assert(transientErr, 'expected TransientRefreshException'); HIC_ShopifySync__Configuration__c c = reload(); System.assertEquals('r', c.HIC_ShopifySync__Refresh_Token__c); System.assertEquals('a', c.HIC_ShopifySync__Access_Token__c); } // 5xx → transient, tokens untouched. @isTest static void serverError_transient() { seed('a', 'r'); Test.setMock(HttpCalloutMock.class, new Mock(503, 'unavailable')); Boolean transientErr = false; Test.startTest(); try { TokenService.getFreshAccessToken('a', true); } catch (TokenService.TransientRefreshException e) { transientErr = true; } Test.stopTest(); System.assert(transientErr); System.assertEquals('r', reload().HIC_ShopifySync__Refresh_Token__c); } @isTest static void noConfig_reauthRequired() { Boolean reauth = false; Test.startTest(); try { TokenService.getFreshAccessToken(null, false); } catch (TokenService.ReauthRequiredException e) { reauth = true; } Test.stopTest(); System.assert(reauth); } @isTest static void blankRefresh_reauthRequired() { seed('a', null); Boolean reauth = false; Test.startTest(); try { TokenService.getFreshAccessToken('a', true); } // force → skip single-flight → blank refresh catch (TokenService.ReauthRequiredException e) { reauth = true; } Test.stopTest(); System.assert(reauth); } @isTest static void tryRefresh_falseWhenReauth() { seed('a', null); Test.startTest(); Boolean ok = TokenService.tryRefresh('a'); Test.stopTest(); System.assertEquals(false, ok); } @isTest static void tryRefresh_trueOnSuccess() { seed('old', 'r'); Test.setMock(HttpCalloutMock.class, new Mock(200, '{"access_token":"fresh","refresh_token":"fresh_r"}')); Test.startTest(); Boolean ok = TokenService.tryRefresh('old'); Test.stopTest(); System.assertEquals(true, ok); System.assertEquals('fresh', reload().HIC_ShopifySync__Access_Token__c); } }