diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 9626593..8dacadc 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -143,9 +143,133 @@ jobs: run: | sf project convert source --root-dir force-app --output-dir mdapi_out + - name: Run tests and check code coverage + run: | + echo "Running tests for package-specific test classes: NebulaAdapter_Test, RestLibTests" + + # Run only the test classes that are part of this package + set +e # Don't exit on error, we'll handle it manually + sf apex test run --class-names NebulaAdapter_Test,RestLibTests --target-org pkgorg --result-format json --code-coverage --wait 10 > test_result.json 2>&1 + TEST_EXIT_CODE=$? + set -e # Re-enable exit on error + + echo "Test execution exit code: $TEST_EXIT_CODE" + echo "Test execution output:" + # Read from file to avoid broken pipe issues + cat test_result.json + + # Check if test execution was successful + if [ $TEST_EXIT_CODE -ne 0 ]; then + echo "❌ ERROR: Test execution failed with exit code $TEST_EXIT_CODE" + echo "Raw output:" + cat test_result.json + exit 1 + fi + + # Check if we have valid JSON output + if ! jq -e '.result' test_result.json > /dev/null 2>&1; then + echo "❌ ERROR: Invalid test result format" + echo "Raw output:" + cat test_result.json + exit 1 + fi + + # Extract test summary + if jq -e '.result.summary' test_result.json > /dev/null 2>&1; then + SUMMARY=$(jq -r '.result.summary' test_result.json) + echo "Test Summary: $SUMMARY" + + # Show detailed test counts + PASSING=$(jq -r '.result.summary.passing // 0' test_result.json) + FAILING=$(jq -r '.result.summary.failing // 0' test_result.json) + SKIPPED=$(jq -r '.result.summary.skipped // 0' test_result.json) + TESTS_RAN=$(jq -r '.result.summary.testsRan // 0' test_result.json) + + echo "📈 Test Results: $PASSING passed, $FAILING failed, $SKIPPED skipped (Total: $TESTS_RAN)" + fi + + # Extract coverage percentage - try multiple possible locations in the JSON + COVERAGE="" + if jq -e '.result.summary.testRunCoverage' test_result.json > /dev/null 2>&1; then + COVERAGE=$(jq -r '.result.summary.testRunCoverage' test_result.json | sed 's/%//') + echo "Code coverage (test run): $COVERAGE%" + elif jq -e '.result.coverage.coverage' test_result.json > /dev/null 2>&1; then + COVERAGE=$(jq -r '.result.coverage.coverage[].percent' test_result.json) + echo "Code coverage (per class): $COVERAGE%" + else + echo "⚠️ WARNING: Could not extract coverage information from test results" + fi + + # Check if coverage meets minimum requirement (75%) + if [ -n "$COVERAGE" ]; then + MIN_COVERAGE=75 + if (( $(echo "$COVERAGE < $MIN_COVERAGE" | bc -l) )); then + echo "" + echo "❌ COVERAGE FAILURE: Code coverage $COVERAGE% is below minimum requirement of $MIN_COVERAGE%" + echo "" + echo "📊 COVERAGE ANALYSIS:" + echo " Current Coverage: $COVERAGE%" + echo " Required Coverage: $MIN_COVERAGE%" + echo " Coverage Gap: $((MIN_COVERAGE - COVERAGE))%" + echo "" + echo "🔍 TEST CLASSES BEING VALIDATED:" + echo " - NebulaAdapter_Test" + echo " - RestLibTests" + echo "" + echo "💡 TO FIX THIS ISSUE:" + echo " 1. Add more test methods to cover uncovered code paths" + echo " 2. Improve existing test methods to cover more scenarios" + echo " 3. Review uncovered lines in the test results above" + echo " 4. Ensure all public methods and critical code paths are tested" + echo "" + echo "📋 NEXT STEPS:" + echo " - Check the detailed coverage information in the test output above" + echo " - Look for 'uncoveredLines' in the JSON output to see which lines need testing" + echo " - Add test methods for any missing scenarios" + echo " - Re-run the workflow after improving test coverage" + echo "" + exit 1 + else + echo "✅ SUCCESS: Code coverage $COVERAGE% meets minimum requirement of $MIN_COVERAGE%" + fi + fi + + # Check for test failures + if jq -e '.result.failures' test_result.json > /dev/null 2>&1; then + FAILURES=$(jq -r '.result.failures' test_result.json) + if [ "$FAILURES" != "0" ] && [ "$FAILURES" != "null" ]; then + echo "❌ ERROR: $FAILURES test(s) failed" + echo "Test failures details:" + jq -r '.result.tests[] | select(.Outcome == "Fail") | " - \(.MethodName): \(.Message)"' test_result.json 2>/dev/null || echo "Could not extract failure details" + exit 1 + fi + fi + + # Clean up temporary test result file + rm -f test_result.json + - name: Deploy to packaging org run: | - sf project deploy start --metadata-dir mdapi_out --target-org pkgorg --wait 60 --ignore-conflicts + echo "Deploying converted metadata to packaging org..." + echo "Source directory: mdapi_out" + echo "Target org: pkgorg" + + set +e # Don't exit on error, we'll handle it manually + DEPLOY_OUTPUT=$(sf project deploy start --metadata-dir mdapi_out --target-org pkgorg --wait 60 --ignore-conflicts 2>&1) + DEPLOY_EXIT_CODE=$? + set -e # Re-enable exit on error + + echo "Deploy exit code: $DEPLOY_EXIT_CODE" + echo "Deploy output:" + echo "$DEPLOY_OUTPUT" + + if [ $DEPLOY_EXIT_CODE -eq 0 ]; then + echo "✅ Successfully deployed to packaging org" + else + echo "❌ ERROR: Deployment failed with exit code $DEPLOY_EXIT_CODE" + echo "💡 Check the output above for specific error details" + exit 1 + fi - name: Create package and version id: package_version @@ -215,19 +339,23 @@ jobs: echo "Creating package version for package: $SF_PACKAGE1_ID" echo "Command: sf package version create --package '$SF_PACKAGE1_ID' --installation-key-bypass --wait 10 --target-dev-hub pkgorg --json" + # Run the command and capture both output and exit code + set +e # Don't exit on error, we'll handle it manually PACKAGE_VERSION_OUTPUT=$(sf package version create \ --package "$SF_PACKAGE1_ID" \ --installation-key-bypass \ --wait 10 \ --target-dev-hub pkgorg \ --json 2>&1) + PACKAGE_VERSION_EXIT_CODE=$? + set -e # Re-enable exit on error - echo "Package version create exit code: $?" + echo "Package version create exit code: $PACKAGE_VERSION_EXIT_CODE" echo "Package version create output:" echo "$PACKAGE_VERSION_OUTPUT" # Check if the command was successful - if echo "$PACKAGE_VERSION_OUTPUT" | jq -e '.result.SubscriberPackageVersionId' > /dev/null 2>&1; then + if [ $PACKAGE_VERSION_EXIT_CODE -eq 0 ] && echo "$PACKAGE_VERSION_OUTPUT" | jq -e '.result.SubscriberPackageVersionId' > /dev/null 2>&1; then # Extract the package version ID and create installation URLs PACKAGE_VERSION_ID=$(echo "$PACKAGE_VERSION_OUTPUT" | jq -r '.result.SubscriberPackageVersionId') PRODUCTION_URL="https://login.salesforce.com/packaging/installPackage.apexp?p0=$PACKAGE_VERSION_ID" @@ -238,12 +366,38 @@ jobs: echo "sandbox_url=$SANDBOX_URL" >> $GITHUB_OUTPUT echo "package_id=$SF_PACKAGE1_ID" >> $GITHUB_OUTPUT - echo "Created package version: $PACKAGE_VERSION_ID" + echo "✅ Created package version: $PACKAGE_VERSION_ID" echo "Production URL: $PRODUCTION_URL" echo "Sandbox URL: $SANDBOX_URL" else - echo "ERROR: Failed to create package version" + echo "❌ ERROR: Failed to create package version" + echo "Exit code: $PACKAGE_VERSION_EXIT_CODE" echo "Full output: $PACKAGE_VERSION_OUTPUT" + + # Try to extract error details from JSON output + if echo "$PACKAGE_VERSION_OUTPUT" | jq -e '.message' > /dev/null 2>&1; then + ERROR_MESSAGE=$(echo "$PACKAGE_VERSION_OUTPUT" | jq -r '.message') + echo "Error message: $ERROR_MESSAGE" + fi + + if echo "$PACKAGE_VERSION_OUTPUT" | jq -e '.result[0].error' > /dev/null 2>&1; then + ERROR_DETAILS=$(echo "$PACKAGE_VERSION_OUTPUT" | jq -r '.result[0].error') + echo "Error details: $ERROR_DETAILS" + fi + + # Check for common issues + if echo "$PACKAGE_VERSION_OUTPUT" | grep -i "permission" > /dev/null; then + echo "💡 Possible permission issue - check if the user has Package Creation permissions" + fi + + if echo "$PACKAGE_VERSION_OUTPUT" | grep -i "validation" > /dev/null; then + echo "💡 Possible validation error - check if all metadata is valid" + fi + + if echo "$PACKAGE_VERSION_OUTPUT" | grep -i "coverage" > /dev/null; then + echo "💡 Possible code coverage issue - ensure all code has adequate test coverage" + fi + exit 1 fi diff --git a/force-app/main/default/classes/NebulaAdapter_Test.cls b/force-app/main/default/classes/NebulaAdapter_Test.cls index ba427b3..5bff388 100644 --- a/force-app/main/default/classes/NebulaAdapter_Test.cls +++ b/force-app/main/default/classes/NebulaAdapter_Test.cls @@ -1,18 +1,18 @@ @IsTest private class NebulaAdapter_Test { - // Simple mock that captures the last request so we can assert on it. - private class DummyHttpMock implements HttpCalloutMock { - static HttpRequest lastRequest; + // Mock that implements HttpCalloutMock and uses HttpCalloutMockFactory for response generation + public class RequestCapturingMock implements HttpCalloutMock { + public HttpRequest lastRequest; + private HttpResponse mockResponse; + + @SuppressWarnings('PMD.ExcessiveParameterList') + public RequestCapturingMock(Integer code, String status, String bodyAsString, Map headers) { + this.mockResponse = HttpCalloutMockFactory.generateHttpResponse(code, status, bodyAsString, headers); + } - public HTTPResponse respond(HTTPRequest req) { + public HttpResponse respond(HttpRequest req) { lastRequest = req; - - HttpResponse res = new HttpResponse(); - res.setStatusCode(200); - res.setStatus('OK'); - res.setBody('{"ok":true}'); - res.setHeader('Content-Type', 'application/json'); - return res; + return mockResponse; } } @@ -26,20 +26,26 @@ private class NebulaAdapter_Test { @IsTest static void testPostRequestNoNebulaPresent() { // Arrange - Test.setMock(HttpCalloutMock.class, new DummyHttpMock()); + RequestCapturingMock mock = new RequestCapturingMock( + 200, + 'OK', + '{"ok":true}', + new Map{'Content-Type' => 'application/json'} + ); + Test.setMock(HttpCalloutMock.class, mock); Account a = makeAccount(); // Build the api call - RestLibApiCall call = new RestLibApiCall(); - call.method = HttpVerb.POST; - call.path = '/v1/foo'; - call.encodedQuery = '?x=1'; - call.hasBody = true; - call.body = '{"hello":"world"}'; - call.functionalHeaders = new Map{ - 'Content-Type' => 'application/json', - 'X-Custom' => 'abc123' - }; + RestLibApiCall call = new RestLibApiCall( + HttpVerb.POST, + '/v1/foo', + '?x=1', + '{"hello":"world"}', + new Map{ + 'Content-Type' => 'application/json', + 'X-Custom' => 'abc123' + } + ); // Create your service instance RestClientLib svc = new RestClientLib('My_NC'); @@ -50,19 +56,19 @@ private class NebulaAdapter_Test { // Assert — response System.assertEquals(200, res.getStatusCode(), 'Should get 200 OK'); - System.assert(res.getBody().contains('"ok":true')); + System.assert(res.getBody().contains('"ok":true'), 'Response body should contain ok:true'); // Assert — request captured by mock - System.assertEquals('POST', DummyHttpMock.lastRequest.getMethod()); - System.assert(DummyHttpMock.lastRequest.getEndpoint().startsWith('callout:'), + System.assertEquals('POST', mock.lastRequest.getMethod(), 'Should use POST method'); + System.assert(mock.lastRequest.getEndpoint().startsWith('callout:'), 'Endpoint should use callout:NamedCredential...'); - System.assertEquals('{"hello":"world"}', DummyHttpMock.lastRequest.getBody()); - System.assertEquals('application/json', DummyHttpMock.lastRequest.getHeader('Content-Type')); - System.assertEquals('abc123', DummyHttpMock.lastRequest.getHeader('X-Custom')); + System.assertEquals('{"hello":"world"}', mock.lastRequest.getBody(), 'Should have correct body'); + System.assertEquals('application/json', mock.lastRequest.getHeader('Content-Type'), 'Should have correct Content-Type'); + System.assertEquals('abc123', mock.lastRequest.getHeader('X-Custom'), 'Should have correct X-Custom header'); // Nebula adapter should safely no-op (no exception thrown) NebulaAdapter.info('Just a test log', a); - NebulaAdapter.logHttpRequest('Req', DummyHttpMock.lastRequest, new List{'X-Custom'}, a); + NebulaAdapter.logHttpRequest('Req', mock.lastRequest, new List{'X-Custom'}, a); NebulaAdapter.logHttpResponse('Res', res, a); // If Nebula isn't installed, the above calls should silently do nothing. } @@ -70,17 +76,24 @@ private class NebulaAdapter_Test { @IsTest static void testDeleteRequestNoBodyNoNebulaPresent() { // Arrange - Test.setMock(HttpCalloutMock.class, new DummyHttpMock()); + RequestCapturingMock mock = new RequestCapturingMock( + 200, + 'OK', + '{"ok":true}', + new Map{'Content-Type' => 'application/json'} + ); + Test.setMock(HttpCalloutMock.class, mock); Account a = makeAccount(); - RestLibApiCall call = new RestLibApiCall(); - call.method = HttpVerb.DEL; // your code maps DEL -> 'DELETE' - call.path = '/v1/resource/123'; - call.encodedQuery = ''; - call.hasBody = false; - call.functionalHeaders = new Map{ - 'Accept' => 'application/json' - }; + RestLibApiCall call = new RestLibApiCall( + HttpVerb.DEL, // your code maps DEL -> 'DELETE' + '/v1/resource/123', + '', + '', + new Map{ + 'Accept' => 'application/json' + } + ); RestClientLib svc = new RestClientLib('My_NC'); @@ -89,14 +102,148 @@ private class NebulaAdapter_Test { Test.stopTest(); // Assert — response - System.assertEquals(200, res.getStatusCode()); + System.assertEquals(200, res.getStatusCode(), 'Should get 200 OK'); // Assert — request - System.assertEquals('DELETE', DummyHttpMock.lastRequest.getMethod(), 'DEL should map to DELETE'); - System.assertEquals('application/json', DummyHttpMock.lastRequest.getHeader('Accept')); - System.assertEquals(null, DummyHttpMock.lastRequest.getBody(), 'DELETE should not have a body by default'); + System.assertEquals('DELETE', mock.lastRequest.getMethod(), 'DEL should map to DELETE'); + System.assertEquals('application/json', mock.lastRequest.getHeader('Accept'), 'Should have correct Accept header'); + System.assert(String.isBlank(mock.lastRequest.getBody()), 'DELETE should not have a body by default'); // Nebula adapter no-ops again NebulaAdapter.debug('Another test log', a); } + + @IsTest + static void testNebulaAdapterAvailability() { + // Test isAvailable method + Boolean isAvailable = NebulaAdapter.isAvailable(); + System.assert(!isAvailable, 'Nebula should not be available in test context'); + } + + @IsTest + static void testNebulaAdapterErrorLogging() { + Account a = makeAccount(); + Exception testEx = new DmlException('Test exception'); + + // Test error logging without Nebula + Test.startTest(); + NebulaAdapter.error('Test error message', a, testEx); + Test.stopTest(); + + // Should not throw exception, just fall back to System.debug + System.assert(true, 'Error logging should complete without exception'); + } + + @IsTest + static void testNebulaAdapterErrorLoggingWithoutRecord() { + Exception testEx = new DmlException('Test exception'); + + // Test error logging without record + Test.startTest(); + NebulaAdapter.error('Test error message', null, testEx); + Test.stopTest(); + + // Should not throw exception + System.assert(true, 'Error logging should complete without exception'); + } + + @IsTest + static void testNebulaAdapterErrorLoggingWithoutException() { + Account a = makeAccount(); + + // Test error logging without exception + Test.startTest(); + NebulaAdapter.error('Test error message', a, null); + Test.stopTest(); + + // Should not throw exception + System.assert(true, 'Error logging should complete without exception'); + } + + @IsTest + static void testNebulaAdapterHttpRequestLogging() { + Account a = makeAccount(); + HttpRequest req = new HttpRequest(); + req.setMethod('POST'); + req.setEndpoint('https://test.com/api'); + req.setBody('{"test":"data"}'); + req.setHeader('Content-Type', 'application/json'); + req.setHeader('Authorization', 'Bearer token123'); + + List headersToLog = new List{'Content-Type', 'Authorization'}; + + Test.startTest(); + NebulaAdapter.logHttpRequest('Test Request', req, headersToLog, a); + Test.stopTest(); + + // Should not throw exception + System.assert(true, 'HTTP request logging should complete without exception'); + } + + @IsTest + static void testNebulaAdapterHttpRequestLoggingWithoutHeaders() { + Account a = makeAccount(); + HttpRequest req = new HttpRequest(); + req.setMethod('GET'); + req.setEndpoint('https://test.com/api'); + + Test.startTest(); + NebulaAdapter.logHttpRequest('Test Request', req, null, a); + Test.stopTest(); + + // Should not throw exception + System.assert(true, 'HTTP request logging should complete without exception'); + } + + @IsTest + static void testNebulaAdapterHttpResponseLogging() { + Account a = makeAccount(); + HttpResponse res = new HttpResponse(); + res.setStatusCode(200); + res.setStatus('OK'); + res.setBody('{"success":true}'); + + Test.startTest(); + NebulaAdapter.logHttpResponse('Test Response', res, a); + Test.stopTest(); + + // Should not throw exception + System.assert(true, 'HTTP response logging should complete without exception'); + } + + @IsTest + static void testNebulaAdapterHttpResponseLoggingWithoutBody() { + Account a = makeAccount(); + HttpResponse res = new HttpResponse(); + res.setStatusCode(404); + res.setStatus('Not Found'); + + Test.startTest(); + NebulaAdapter.logHttpResponse('Test Response', res, a); + Test.stopTest(); + + // Should not throw exception + System.assert(true, 'HTTP response logging should complete without exception'); + } + + @IsTest + static void testNebulaAdapterSave() { + Test.startTest(); + NebulaAdapter.save(); + Test.stopTest(); + + // Should not throw exception + System.assert(true, 'Save should complete without exception'); + } + + @IsTest + static void testNebulaAdapterLoggingWithoutRecord() { + Test.startTest(); + NebulaAdapter.info('Test message', null); + NebulaAdapter.debug('Test debug message', null); + Test.stopTest(); + + // Should not throw exception + System.assert(true, 'Logging without record should complete without exception'); + } } diff --git a/force-app/main/default/classes/RestLibTests.cls b/force-app/main/default/classes/RestLibTests.cls index d90d307..d414350 100644 --- a/force-app/main/default/classes/RestLibTests.cls +++ b/force-app/main/default/classes/RestLibTests.cls @@ -469,4 +469,553 @@ private class RestLibTests { 'Expected the result to end with a slash' ); } + + @isTest + private static void testEnsureStringEndsInSlashAlreadyHasSlash() { + Test.startTest(); + String result = RestLibApiCall.ensureStringEndsInSlash('alreadySlash/'); + Test.stopTest(); + Assert.areEqual('alreadySlash/', result, 'Should return unchanged when already ends with slash'); + } + + @isTest + private static void testRestLibApiCallFluentInterfaceWithDelete() { + Test.startTest(); + RestLibApiCall apiCall = RestLibApiCall.create() + .usingDelete() + .withPath('/resource/123') + .withQuery('param=value'); + Test.stopTest(); + + Assert.areEqual(HttpVerb.DEL, apiCall.method, 'Method should be DEL'); + Assert.areEqual('/resource/123/', apiCall.path, 'Path should be correct'); + Assert.areEqual('param=value', apiCall.query, 'Query should be set'); + } + + @isTest + private static void testRestLibApiCallFluentInterfaceWithHead() { + Test.startTest(); + RestLibApiCall apiCall = RestLibApiCall.create() + .usingHead() + .withPath('/status') + .withHeader('Custom-Header', 'Value'); + Test.stopTest(); + + Assert.areEqual(HttpVerb.HEAD, apiCall.method, 'Method should be HEAD'); + Assert.areEqual('/status/', apiCall.path, 'Path should be correct'); + Assert.areEqual('Value', apiCall.functionalHeaders.get('Custom-Header'), 'Custom header should be set'); + } + + @isTest + private static void testRestLibApiCallFluentInterfaceWithTimeout() { + Test.startTest(); + RestLibApiCall apiCall = RestLibApiCall.create() + .usingGet() + .withPath('/test') + .withTimeout(5000); + Test.stopTest(); + + Assert.areEqual(5000, apiCall.timeout, 'Timeout should be set to 5000ms'); + } + + @isTest + private static void testRestLibApiCallFluentInterfaceWithHeaders() { + Map customHeaders = new Map{ + 'Authorization' => 'Bearer token123', + 'X-API-Key' => 'key456' + }; + + Test.startTest(); + RestLibApiCall apiCall = RestLibApiCall.create() + .usingPost() + .withPath('/api') + .withHeaders(customHeaders); + Test.stopTest(); + + Assert.areEqual('Bearer token123', apiCall.functionalHeaders.get('Authorization'), 'Authorization header should be set'); + Assert.areEqual('key456', apiCall.functionalHeaders.get('X-API-Key'), 'API key header should be set'); + } + + @isTest + private static void testRestLibApiCallFluentInterfaceWithSingleHeader() { + Test.startTest(); + RestLibApiCall apiCall = RestLibApiCall.create() + .usingGet() + .withPath('/test') + .withHeader('Accept', 'application/xml') + .withHeader('User-Agent', 'MyApp/1.0'); + Test.stopTest(); + + Assert.areEqual('application/xml', apiCall.functionalHeaders.get('Accept'), 'Accept header should be set'); + Assert.areEqual('MyApp/1.0', apiCall.functionalHeaders.get('User-Agent'), 'User-Agent header should be set'); + } + + @isTest + private static void testRestLibApiCallMethodStringProperty() { + Test.startTest(); + RestLibApiCall getCall = RestLibApiCall.create().usingGet(); + RestLibApiCall delCall = RestLibApiCall.create().usingDelete(); + Test.stopTest(); + + Assert.areEqual('GET', getCall.methodString, 'GET method string should be GET'); + Assert.areEqual('DELETE', delCall.methodString, 'DEL method string should be DELETE'); + } + + @isTest + private static void testRestLibApiCallHasBodyProperty() { + Test.startTest(); + RestLibApiCall postWithBody = RestLibApiCall.create() + .usingPost() + .withBody('{"test":"data"}'); + RestLibApiCall getWithoutBody = RestLibApiCall.create() + .usingGet(); + RestLibApiCall putWithBody = RestLibApiCall.create() + .usingPut() + .withBody('{"update":"data"}'); + Test.stopTest(); + + Assert.isTrue(postWithBody.hasBody, 'POST with body should have hasBody=true'); + Assert.isFalse(getWithoutBody.hasBody, 'GET without body should have hasBody=false'); + Assert.isTrue(putWithBody.hasBody, 'PUT with body should have hasBody=true'); + } + + @isTest + private static void testRestLibApiCallPathHandling() { + Test.startTest(); + RestLibApiCall call1 = RestLibApiCall.create().withPath('noSlash'); + RestLibApiCall call2 = RestLibApiCall.create().withPath('/withSlash'); + RestLibApiCall call3 = RestLibApiCall.create().withPath(''); + Test.stopTest(); + + Assert.areEqual('/noSlash/', call1.path, 'Path without slash should get leading and trailing slashes'); + Assert.areEqual('/withSlash/', call2.path, 'Path with leading slash should get trailing slash'); + Assert.areEqual('/', call3.path, 'Empty path should default to /'); + } + + @isTest + private static void testRestLibApiCallQueryHandling() { + Test.startTest(); + RestLibApiCall call1 = RestLibApiCall.create().withQuery('param=value'); + RestLibApiCall call2 = RestLibApiCall.create().withQuery('?prefixed'); + RestLibApiCall call3 = RestLibApiCall.create().withQuery(''); + Test.stopTest(); + + Assert.areEqual('param=value', call1.query, 'Query should be stored as-is'); + Assert.areEqual('?prefixed', call1.encodedQuery, 'Query should be prefixed with ?'); + Assert.areEqual('?prefixed', call2.encodedQuery, 'Pre-prefixed query should remain unchanged'); + Assert.areEqual(null, call3.encodedQuery, 'Empty query should return null for encodedQuery'); + } + + @isTest + private static void testRestLibApiCallDefaultHeaders() { + Test.startTest(); + RestLibApiCall call = RestLibApiCall.create(); + Test.stopTest(); + + Assert.areEqual('application/json', call.functionalHeaders.get('Content-Type'), 'Should have default Content-Type'); + Assert.areEqual('application/json', call.functionalHeaders.get('Accept'), 'Should have default Accept'); + } + + @isTest + private static void testRestLibApiCallConstructorWithAllParams() { + Map headers = new Map{'Custom' => 'Value'}; + + Test.startTest(); + RestLibApiCall call = new RestLibApiCall( + HttpVerb.POST, + '/test', + 'param=value', + '{"data":"test"}', + headers + ); + Test.stopTest(); + + Assert.areEqual(HttpVerb.POST, call.method, 'Method should be POST'); + Assert.areEqual('/test/', call.path, 'Path should be correct'); + Assert.areEqual('param=value', call.query, 'Query should be set'); + Assert.areEqual('{"data":"test"}', call.body, 'Body should be set'); + Assert.areEqual('Value', call.functionalHeaders.get('Custom'), 'Custom header should be set'); + } + + @isTest + private static void testRestLibApiCallConstructorWithoutHeaders() { + Test.startTest(); + RestLibApiCall call = new RestLibApiCall( + HttpVerb.GET, + '/api', + 'id=123', + '' + ); + Test.stopTest(); + + Assert.areEqual(HttpVerb.GET, call.method, 'Method should be GET'); + Assert.areEqual('/api/', call.path, 'Path should be correct'); + Assert.areEqual('id=123', call.query, 'Query should be set'); + Assert.areEqual('', call.body, 'Body should be empty'); + Assert.areEqual('application/json', call.functionalHeaders.get('Content-Type'), 'Should use default headers'); + } + + @isTest + private static void testRestLibApiCallPatchConstructor() { + Test.startTest(); + RestLibApiCall call = new RestLibApiCall( + HttpVerb.PATCH, + '/resource/123', + 'param=value', + '{"update":"data"}' + ); + Test.stopTest(); + + Assert.areEqual(HttpVerb.POST, call.method, 'PATCH should be converted to POST'); + Assert.areEqual('/resource/123/', call.path, 'Path should be correct'); + Assert.isTrue(call.encodedQuery.contains('_HttpMethod=PATCH'), 'Should contain PATCH parameter'); + Assert.areEqual('{"update":"data"}', call.body, 'Body should be set'); + } + + @isTest + private static void testRestLibApiCallWithMethodPatch() { + Test.startTest(); + RestLibApiCall call = RestLibApiCall.create() + .withMethod(HttpVerb.PATCH) + .withPath('/test') + .withQuery('existing=param'); + Test.stopTest(); + + Assert.areEqual(HttpVerb.POST, call.method, 'PATCH should be converted to POST'); + Assert.areEqual('/test/', call.path, 'Path should be correct'); + Assert.isTrue(call.encodedQuery.contains('_HttpMethod=PATCH'), 'Should contain PATCH parameter'); + Assert.isTrue(call.encodedQuery.contains('existing=param'), 'Should preserve existing query params'); + } + + @isTest + private static void testRestLibApiCallWithMethodPatchNoExistingQuery() { + Test.startTest(); + RestLibApiCall call = RestLibApiCall.create() + .withMethod(HttpVerb.PATCH) + .withPath('/test'); + Test.stopTest(); + + Assert.areEqual(HttpVerb.POST, call.method, 'PATCH should be converted to POST'); + Assert.areEqual('/test/', call.path, 'Path should be correct'); + Assert.areEqual('?_HttpMethod=PATCH', call.encodedQuery, 'Should have only PATCH parameter'); + } + + @isTest + private static void testRestLibApiCallTimeoutProperty() { + Test.startTest(); + RestLibApiCall call1 = RestLibApiCall.create(); + RestLibApiCall call2 = RestLibApiCall.create().withTimeout(30000); + Test.stopTest(); + + Assert.areEqual(RestLibApiCall.DEFAULT_TIMEOUT, call1.timeout, 'Should use default timeout'); + Assert.areEqual(30000, call2.timeout, 'Should use custom timeout'); + } + + @isTest + private static void testRestLibApiCallPathPropertyEdgeCases() { + Test.startTest(); + RestLibApiCall call1 = RestLibApiCall.create().withPath(''); + RestLibApiCall call2 = RestLibApiCall.create().withPath(null); + RestLibApiCall call3 = RestLibApiCall.create().withPath('noSlash'); + RestLibApiCall call4 = RestLibApiCall.create().withPath('/withSlash'); + Test.stopTest(); + + Assert.areEqual('/', call1.path, 'Empty path should default to /'); + Assert.areEqual('/', call2.path, 'Null path should default to /'); + Assert.areEqual('/noSlash/', call3.path, 'Path without slash should get leading and trailing slashes'); + Assert.areEqual('/withSlash/', call4.path, 'Path with leading slash should get trailing slash'); + } + + @isTest + private static void testRestLibApiCallQueryPropertyEdgeCases() { + Test.startTest(); + RestLibApiCall call1 = RestLibApiCall.create().withQuery(''); + RestLibApiCall call2 = RestLibApiCall.create().withQuery(null); + RestLibApiCall call3 = RestLibApiCall.create().withQuery('param=value'); + RestLibApiCall call4 = RestLibApiCall.create().withQuery('?prefixed'); + Test.stopTest(); + + Assert.areEqual(null, call1.encodedQuery, 'Empty query should return null for encodedQuery'); + Assert.areEqual(null, call2.encodedQuery, 'Null query should return null for encodedQuery'); + Assert.areEqual('?param%3Dvalue', call3.encodedQuery, 'Query should be URL encoded'); + Assert.areEqual('?prefixed', call4.encodedQuery, 'Pre-prefixed query should remain unchanged'); + } + + @isTest + private static void testRestLibApiCallHeadersProperty() { + Test.startTest(); + RestLibApiCall call1 = RestLibApiCall.create(); + RestLibApiCall call2 = RestLibApiCall.create().withHeaders(new Map{'Custom' => 'Value'}); + Test.stopTest(); + + Assert.areEqual('application/json', call1.functionalHeaders.get('Content-Type'), 'Should use default headers'); + Assert.areEqual('application/json', call1.functionalHeaders.get('Accept'), 'Should use default headers'); + Assert.areEqual('Value', call2.functionalHeaders.get('Custom'), 'Should use custom headers'); + Assert.areEqual('application/json', call2.functionalHeaders.get('Content-Type'), 'Should not have default headers when custom provided'); + } + + @isTest + private static void testRestLibApiCallHasBodyPropertyEdgeCases() { + Test.startTest(); + RestLibApiCall getWithBody = RestLibApiCall.create() + .usingGet() + .withBody('{"test":"data"}'); + RestLibApiCall postWithoutBody = RestLibApiCall.create() + .usingPost(); + RestLibApiCall putWithEmptyBody = RestLibApiCall.create() + .usingPut() + .withBody(''); + RestLibApiCall patchWithBody = RestLibApiCall.create() + .usingPatch() + .withBody('{"test":"data"}'); + Test.stopTest(); + + Assert.isFalse(getWithBody.hasBody, 'GET with body should have hasBody=false'); + Assert.isFalse(postWithoutBody.hasBody, 'POST without body should have hasBody=false'); + Assert.isFalse(putWithEmptyBody.hasBody, 'PUT with empty body should have hasBody=false'); + Assert.isTrue(patchWithBody.hasBody, 'PATCH with body should have hasBody=true (converted to POST)'); + } + + @isTest + private static void testRestLibApiCallMethodStringPropertyAllMethods() { + Test.startTest(); + RestLibApiCall getCall = RestLibApiCall.create().usingGet(); + RestLibApiCall postCall = RestLibApiCall.create().usingPost(); + RestLibApiCall putCall = RestLibApiCall.create().usingPut(); + RestLibApiCall patchCall = RestLibApiCall.create().usingPatch(); + RestLibApiCall delCall = RestLibApiCall.create().usingDelete(); + RestLibApiCall headCall = RestLibApiCall.create().usingHead(); + Test.stopTest(); + + Assert.areEqual('GET', getCall.methodString, 'GET method string should be GET'); + Assert.areEqual('POST', postCall.methodString, 'POST method string should be POST'); + Assert.areEqual('PUT', putCall.methodString, 'PUT method string should be PUT'); + Assert.areEqual('POST', patchCall.methodString, 'PATCH method string should be POST (converted)'); + Assert.areEqual('DELETE', delCall.methodString, 'DEL method string should be DELETE'); + Assert.areEqual('HEAD', headCall.methodString, 'HEAD method string should be HEAD'); + } + + @isTest + private static void testRestLibApiCallWithHeaderChaining() { + Test.startTest(); + RestLibApiCall call = RestLibApiCall.create() + .usingPost() + .withPath('/api') + .withHeader('Authorization', 'Bearer token123') + .withHeader('X-Custom', 'Value1') + .withHeader('X-Another', 'Value2'); + Test.stopTest(); + + Assert.areEqual('Bearer token123', call.functionalHeaders.get('Authorization'), 'First header should be set'); + Assert.areEqual('Value1', call.functionalHeaders.get('X-Custom'), 'Second header should be set'); + Assert.areEqual('Value2', call.functionalHeaders.get('X-Another'), 'Third header should be set'); + Assert.areEqual(3, call.functionalHeaders.size(), 'Should have 3 custom headers'); + } + + @isTest + private static void testRestLibApiCallWithHeadersOverwrite() { + Map initialHeaders = new Map{ + 'Content-Type' => 'application/xml', + 'Accept' => 'application/xml' + }; + Map newHeaders = new Map{ + 'Authorization' => 'Bearer token', + 'X-API-Key' => 'key123' + }; + + Test.startTest(); + RestLibApiCall call = RestLibApiCall.create() + .usingPost() + .withHeaders(initialHeaders) + .withHeaders(newHeaders); + Test.stopTest(); + + Assert.areEqual('Bearer token', call.functionalHeaders.get('Authorization'), 'New headers should be set'); + Assert.areEqual('key123', call.functionalHeaders.get('X-API-Key'), 'New headers should be set'); + Assert.areEqual(null, call.functionalHeaders.get('Content-Type'), 'Old headers should be overwritten'); + Assert.areEqual(null, call.functionalHeaders.get('Accept'), 'Old headers should be overwritten'); + } + + @isTest + private static void testRestLibApiCallConstructorWithNullHeaders() { + Test.startTest(); + RestLibApiCall call = new RestLibApiCall( + HttpVerb.GET, + '/test', + 'param=value', + '{"data":"test"}', + null + ); + Test.stopTest(); + + Assert.areEqual(HttpVerb.GET, call.method, 'Method should be GET'); + Assert.areEqual('/test/', call.path, 'Path should be correct'); + Assert.areEqual('param=value', call.query, 'Query should be set'); + Assert.areEqual('{"data":"test"}', call.body, 'Body should be set'); + Assert.areEqual('application/json', call.functionalHeaders.get('Content-Type'), 'Should use default headers when null provided'); + } + + @isTest + private static void testRestLibApiCallFluentInterfaceComplexScenario() { + Map customHeaders = new Map{ + 'Authorization' => 'Bearer token123', + 'X-Request-ID' => 'req-456' + }; + + Test.startTest(); + RestLibApiCall call = RestLibApiCall.create() + .usingPost() + .withPath('/api/v1/users') + .withQuery('include=profile,settings') + .withBody('{"name":"John Doe","email":"john@example.com"}') + .withHeaders(customHeaders) + .withHeader('X-Custom', 'AdditionalValue') + .withTimeout(45000); + Test.stopTest(); + + Assert.areEqual(HttpVerb.POST, call.method, 'Method should be POST'); + Assert.areEqual('/api/v1/users/', call.path, 'Path should be correct'); + Assert.areEqual('include=profile,settings', call.query, 'Query should be set'); + Assert.areEqual('{"name":"John Doe","email":"john@example.com"}', call.body, 'Body should be set'); + Assert.areEqual(45000, call.timeout, 'Timeout should be set'); + Assert.areEqual('Bearer token123', call.functionalHeaders.get('Authorization'), 'Authorization header should be set'); + Assert.areEqual('req-456', call.functionalHeaders.get('X-Request-ID'), 'Request ID header should be set'); + Assert.areEqual('AdditionalValue', call.functionalHeaders.get('X-Custom'), 'Custom header should be set'); + Assert.isTrue(call.hasBody, 'Should have body for POST with body'); + } + + @isTest + private static void testRestClientLibConvenienceMethodsWithLogging() { + RestClientLib rc = new RestClientLib('DummyNamedCredential'); + HttpCalloutMockFactory mock = new HttpCalloutMockFactory( + 200, + 'OK', + 'OK', + new Map() + ); + Test.setMock(HttpCalloutMock.class, mock); + Account a = makeAccount(); + + Test.startTest(); + // Test all convenience methods with logging context + HttpResponse getRes = rc.get('/', a); + HttpResponse getWithQueryRes = rc.get('/', 'param=value', a); + HttpResponse delRes = rc.del('/', a); + HttpResponse delWithQueryRes = rc.del('/', 'param=value', a); + HttpResponse postRes = rc.post('/', '{"test":"data"}', a); + HttpResponse postWithQueryRes = rc.post('/', 'param=value', '{"test":"data"}', a); + HttpResponse putRes = rc.put('/', '{"test":"data"}', a); + HttpResponse putWithQueryRes = rc.put('/', 'param=value', '{"test":"data"}', a); + HttpResponse patchRes = rc.patch('/', '{"test":"data"}', a); + HttpResponse patchWithQueryRes = rc.patch('/', 'param=value', '{"test":"data"}', a); + Test.stopTest(); + + // All should return 200 OK + Assert.areEqual(200, getRes.getStatusCode(), 'GET with logging should work'); + Assert.areEqual(200, getWithQueryRes.getStatusCode(), 'GET with query and logging should work'); + Assert.areEqual(200, delRes.getStatusCode(), 'DELETE with logging should work'); + Assert.areEqual(200, delWithQueryRes.getStatusCode(), 'DELETE with query and logging should work'); + Assert.areEqual(200, postRes.getStatusCode(), 'POST with logging should work'); + Assert.areEqual(200, postWithQueryRes.getStatusCode(), 'POST with query and logging should work'); + Assert.areEqual(200, putRes.getStatusCode(), 'PUT with logging should work'); + Assert.areEqual(200, putWithQueryRes.getStatusCode(), 'PUT with query and logging should work'); + Assert.areEqual(200, patchRes.getStatusCode(), 'PATCH with logging should work'); + Assert.areEqual(200, patchWithQueryRes.getStatusCode(), 'PATCH with query and logging should work'); + } + + @isTest + private static void testRestClientLibConvenienceMethodsWithoutLogging() { + RestClientLib rc = new RestClientLib('DummyNamedCredential'); + HttpCalloutMockFactory mock = new HttpCalloutMockFactory( + 200, + 'OK', + 'OK', + new Map() + ); + Test.setMock(HttpCalloutMock.class, mock); + + Test.startTest(); + // Test all convenience methods without logging context + HttpResponse getRes = rc.get('/'); + HttpResponse getWithQueryRes = rc.get('/', 'param=value'); + HttpResponse delRes = rc.del('/'); + HttpResponse delWithQueryRes = rc.del('/', 'param=value'); + HttpResponse postRes = rc.post('/', '{"test":"data"}'); + HttpResponse postWithQueryRes = rc.post('/', 'param=value', '{"test":"data"}'); + HttpResponse putRes = rc.put('/', '{"test":"data"}'); + HttpResponse putWithQueryRes = rc.put('/', 'param=value', '{"test":"data"}'); + HttpResponse patchRes = rc.patch('/', '{"test":"data"}'); + HttpResponse patchWithQueryRes = rc.patch('/', 'param=value', '{"test":"data"}'); + Test.stopTest(); + + // All should return 200 OK + Assert.areEqual(200, getRes.getStatusCode(), 'GET without logging should work'); + Assert.areEqual(200, getWithQueryRes.getStatusCode(), 'GET with query without logging should work'); + Assert.areEqual(200, delRes.getStatusCode(), 'DELETE without logging should work'); + Assert.areEqual(200, delWithQueryRes.getStatusCode(), 'DELETE with query without logging should work'); + Assert.areEqual(200, postRes.getStatusCode(), 'POST without logging should work'); + Assert.areEqual(200, postWithQueryRes.getStatusCode(), 'POST with query without logging should work'); + Assert.areEqual(200, putRes.getStatusCode(), 'PUT without logging should work'); + Assert.areEqual(200, putWithQueryRes.getStatusCode(), 'PUT with query without logging should work'); + Assert.areEqual(200, patchRes.getStatusCode(), 'PATCH without logging should work'); + Assert.areEqual(200, patchWithQueryRes.getStatusCode(), 'PATCH with query without logging should work'); + } + + @isTest + private static void testRestClientLibMakeApiCallOverloads() { + RestClientLib rc = new RestClientLib('DummyNamedCredential'); + HttpCalloutMockFactory mock = new HttpCalloutMockFactory( + 200, + 'OK', + 'OK', + new Map() + ); + Test.setMock(HttpCalloutMock.class, mock); + Account a = makeAccount(); + + Test.startTest(); + // Test all makeApiCall overloads + HttpResponse res1 = rc.makeApiCall(HttpVerb.GET, '/test'); + HttpResponse res2 = rc.makeApiCall(HttpVerb.GET, '/test', a); + HttpResponse res3 = rc.makeApiCall(HttpVerb.GET, '/test', 'param=value'); + HttpResponse res4 = rc.makeApiCall(HttpVerb.GET, '/test', 'param=value', a); + HttpResponse res5 = rc.makeApiCall(HttpVerb.POST, '/test', 'param=value', '{"data":"test"}'); + HttpResponse res6 = rc.makeApiCall(HttpVerb.POST, '/test', 'param=value', '{"data":"test"}', a); + Test.stopTest(); + + // All should return 200 OK + Assert.areEqual(200, res1.getStatusCode(), 'makeApiCall with method and path should work'); + Assert.areEqual(200, res2.getStatusCode(), 'makeApiCall with method, path, and record should work'); + Assert.areEqual(200, res3.getStatusCode(), 'makeApiCall with method, path, and query should work'); + Assert.areEqual(200, res4.getStatusCode(), 'makeApiCall with method, path, query, and record should work'); + Assert.areEqual(200, res5.getStatusCode(), 'makeApiCall with method, path, query, and body should work'); + Assert.areEqual(200, res6.getStatusCode(), 'makeApiCall with method, path, query, body, and record should work'); + } + + @isTest + private static void testRestClientLibConstructor() { + Test.startTest(); + RestClientLib rc = new RestClientLib('TestNamedCredential'); + Test.stopTest(); + + Assert.areNotEqual(null, rc, 'RestClientLib should be created'); + Assert.areEqual('TestNamedCredential', rc.namedCredentialName, 'Named credential should be set'); + } + + @isTest + private static void testRestLibApiCallEnsureStringEndsInSlashEdgeCases() { + Test.startTest(); + String result1 = RestLibApiCall.ensureStringEndsInSlash(''); + String result2 = RestLibApiCall.ensureStringEndsInSlash(null); + String result3 = RestLibApiCall.ensureStringEndsInSlash('noSlash'); + String result4 = RestLibApiCall.ensureStringEndsInSlash('/withSlash'); + String result5 = RestLibApiCall.ensureStringEndsInSlash('multiple/slashes/'); + Test.stopTest(); + + Assert.areEqual('/', result1, 'Empty string should return /'); + Assert.areEqual('/', result2, 'Null string should return /'); + Assert.areEqual('noSlash/', result3, 'String without slash should get trailing slash'); + Assert.areEqual('/withSlash/', result4, 'String with leading slash should get trailing slash'); + Assert.areEqual('multiple/slashes/', result5, 'String already ending with slash should remain unchanged'); + } }