/* * SonarQube * Copyright (C) 2009-2025 SonarSource SA * mailto:info AT sonarsource DOT com * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3 of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ package org.sonar.alm.client.bitbucketserver; import com.tngtech.java.junit.dataprovider.DataProvider; import com.tngtech.java.junit.dataprovider.DataProviderRunner; import com.tngtech.java.junit.dataprovider.UseDataProvider; import java.io.IOException; import java.util.function.Function; import java.util.stream.IntStream; import okhttp3.MediaType; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.SocketPolicy; import okio.Buffer; import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.sonar.alm.client.ConstantTimeoutConfiguration; import org.sonar.api.testfixtures.log.LogTester; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.tuple; @RunWith(DataProviderRunner.class) public class BitbucketServerRestClientTest { private final MockWebServer server = new MockWebServer(); private static final String REPOS_BODY = """ { "isLastPage": true, "values": [ { "slug": "banana", "id": 2, "name": "banana", "project": { "key": "HOY", "id": 2, "name": "hoy" } }, { "slug": "potato", "id": 1, "name": "potato", "project": { "key": "HEY", "id": 1, "name": "hey" } } ] } """; private static final String STATUS_BODY = "{\"state\": \"RUNNING\"}"; @Rule public LogTester logTester = new LogTester(); private BitbucketServerRestClient underTest; @Before public void prepare() throws IOException { server.start(); underTest = new BitbucketServerRestClient(new ConstantTimeoutConfiguration(500)); } @After public void stopServer() throws IOException { server.shutdown(); } @Test public void get_repos() { server.enqueue(new MockResponse() .setHeader("Content-Type", "application/json;charset=UTF-8") .setBody(""" { "isLastPage": true, "values": [ { "slug": "banana", "id": 2, "name": "banana", "project": { "key": "HOY", "id": 2, "name": "hoy" } }, { "slug": "potato", "id": 1, "name": "potato", "project": { "key": "HEY", "id": 1, "name": "hey" } } ] } """)); RepositoryList gsonBBSRepoList = underTest.getRepos(server.url("/").toString(), "token", "", "", null, 25); assertThat(gsonBBSRepoList.isLastPage()).isTrue(); assertThat(gsonBBSRepoList.getValues()).hasSize(2); assertThat(gsonBBSRepoList.getValues()).extracting(Repository::getId, Repository::getName, Repository::getSlug, g -> g.getProject().getId(), g -> g.getProject().getKey(), g -> g.getProject().getName()) .containsExactlyInAnyOrder( tuple(2L, "banana", "banana", 2L, "HOY", "hoy"), tuple(1L, "potato", "potato", 1L, "HEY", "hey")); } @Test public void get_recent_repos() { server.enqueue(new MockResponse() .setHeader("Content-Type", "application/json;charset=UTF-8") .setBody(""" { "isLastPage": true, "values": [ { "slug": "banana", "id": 2, "name": "banana", "project": { "key": "HOY", "id": 2, "name": "hoy" } }, { "slug": "potato", "id": 1, "name": "potato", "project": { "key": "HEY", "id": 1, "name": "hey" } } ] } """)); RepositoryList gsonBBSRepoList = underTest.getRecentRepo(server.url("/").toString(), "token"); assertThat(gsonBBSRepoList.isLastPage()).isTrue(); assertThat(gsonBBSRepoList.getValues()).hasSize(2); assertThat(gsonBBSRepoList.getValues()).extracting(Repository::getId, Repository::getName, Repository::getSlug, g -> g.getProject().getId(), g -> g.getProject().getKey(), g -> g.getProject().getName()) .containsExactlyInAnyOrder( tuple(2L, "banana", "banana", 2L, "HOY", "hoy"), tuple(1L, "potato", "potato", 1L, "HEY", "hey")); } @Test public void get_repo() { server.enqueue(new MockResponse() .setHeader("Content-Type", "application/json;charset=UTF-8") .setBody(""" { "slug": "banana-slug", "id": 2, "name": "banana", "project": { "key": "HOY", "id": 3, "name": "hoy" } } """)); Repository repository = underTest.getRepo(server.url("/").toString(), "token", "", ""); assertThat(repository.getId()).isEqualTo(2L); assertThat(repository.getName()).isEqualTo("banana"); assertThat(repository.getSlug()).isEqualTo("banana-slug"); assertThat(repository.getProject()) .extracting(Project::getId, Project::getKey, Project::getName) .contains(3L, "HOY", "hoy"); } @Test public void get_projects() { server.enqueue(new MockResponse() .setHeader("Content-Type", "application/json;charset=UTF-8") .setBody(""" { "isLastPage": true, "values": [ { "key": "HEY", "id": 1, "name": "hey" }, { "key": "HOY", "id": 2, "name": "hoy" } ] } """)); final ProjectList gsonBBSProjectList = underTest.getProjects(server.url("/").toString(), "token", null, 25); assertThat(gsonBBSProjectList.getValues()).hasSize(2); assertThat(gsonBBSProjectList.getValues()).extracting(Project::getId, Project::getKey, Project::getName) .containsExactlyInAnyOrder( tuple(1L, "HEY", "hey"), tuple(2L, "HOY", "hoy")); } @Test public void get_projects_failed() { server.enqueue(new MockResponse() .setBody(new Buffer().write(new byte[4096])) .setSocketPolicy(SocketPolicy.DISCONNECT_DURING_RESPONSE_BODY)); String serverUrl = server.url("/").toString(); assertThatThrownBy(() -> underTest.getProjects(serverUrl, "token", null, 25)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unable to contact Bitbucket server"); assertThat(String.join(", ", logTester.logs())).contains("Unable to contact Bitbucket server"); } @Test public void getBranches_given0Branches_returnEmptyList() { String bodyWith0Branches = """ { "size": 0, "limit": 25, "isLastPage": true, "values": [], "start": 0 } """; server.enqueue(new MockResponse() .setHeader("Content-Type", "application/json;charset=UTF-8") .setBody(bodyWith0Branches)); BranchesList branches = underTest.getBranches(server.url("/").toString(), "token", "projectSlug", "repoSlug"); assertThat(branches.getBranches()).isEmpty(); } @Test public void getBranches_given1Branch_returnListWithOneBranch() { String bodyWith1Branch = """ { "size": 1, "limit": 25, "isLastPage": true, "values": [{ "id": "refs/heads/demo", "displayId": "demo", "type": "BRANCH", "latestCommit": "3e30a6701af6f29f976e9a6609a6076b32a69ac3", "latestChangeset": "3e30a6701af6f29f976e9a6609a6076b32a69ac3", "isDefault": false }], "start": 0 } """; server.enqueue(new MockResponse() .setHeader("Content-Type", "application/json;charset=UTF-8") .setBody(bodyWith1Branch)); BranchesList branches = underTest.getBranches(server.url("/").toString(), "token", "projectSlug", "repoSlug"); assertThat(branches.getBranches()).hasSize(1); Branch branch = branches.getBranches().get(0); assertThat(branch.getName()).isEqualTo("demo"); assertThat(branch.isDefault()).isFalse(); } @Test public void getBranches_given2Branches_returnListWithTwoBranches() { String bodyWith2Branches = """ { "size": 2, "limit": 25, "isLastPage": true, "values": [{ "id": "refs/heads/demo", "displayId": "demo", "type": "BRANCH", "latestCommit": "3e30a6701af6f29f976e9a6609a6076b32a69ac3", "latestChangeset": "3e30a6701af6f29f976e9a6609a6076b32a69ac3", "isDefault": false }, { "id": "refs/heads/master", "displayId": "master", "type": "BRANCH", "latestCommit": "66633864d27c531ff43892f6dfea6d91632682fa", "latestChangeset": "66633864d27c531ff43892f6dfea6d91632682fa", "isDefault": true }], "start": 0 } """; server.enqueue(new MockResponse() .setHeader("Content-Type", "application/json;charset=UTF-8") .setBody(bodyWith2Branches)); BranchesList branches = underTest.getBranches(server.url("/").toString(), "token", "projectSlug", "repoSlug"); assertThat(branches.getBranches()).hasSize(2); } @Test public void invalid_empty_url() { assertThatThrownBy(() -> BitbucketServerRestClient.buildUrl(null, "")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("url must start with http:// or https://"); } @Test public void invalid_url() { assertThatThrownBy(() -> BitbucketServerRestClient.buildUrl("file://wrong-url", "")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("url must start with http:// or https://"); } @Test public void malformed_json() { server.enqueue(new MockResponse() .setHeader("Content-Type", "application/json;charset=UTF-8") .setBody("I'm malformed JSON")); String serverUrl = server.url("/").toString(); assertThatThrownBy(() -> underTest.getRepo(serverUrl, "token", "", "")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unexpected response from Bitbucket server"); assertThat(String.join(", ", logTester.logs())) .contains("Unexpected response from Bitbucket server : [I'm malformed JSON]"); } @Test public void fail_json_error_handling() { assertThatThrownBy(() -> BitbucketServerRestClient.applyHandler(body -> BitbucketServerRestClient.buildGson().fromJson(body, Object.class), "not json")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unable to contact Bitbucket server, got an unexpected response"); assertThat(String.join(", ", logTester.logs())) .contains("Unable to contact Bitbucket server. Unexpected body response was : [not json]"); } @Test public void validate_handler_call_on_empty_body() { server.enqueue(new MockResponse().setResponseCode(200) .setBody("")); assertThat(underTest.doGet("token", server.url("/"), Function.identity())) .isEmpty(); } @Test public void error_handling() { server.enqueue(new MockResponse() .setHeader("Content-Type", "application/json;charset=UTF-8") .setResponseCode(400) .setBody(""" { "errors": [ { "context": null, "message": "Bad message", "exceptionName": "com.atlassian.bitbucket.auth.BadException" } ] } """)); String serverUrl = server.url("/").toString(); assertThatThrownBy(() -> underTest.getRepo(serverUrl, "token", "", "")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unable to contact Bitbucket server"); } @Test public void unauthorized_error() { server.enqueue(new MockResponse() .setHeader("Content-Type", "application/json;charset=UTF-8") .setResponseCode(401) .setBody(""" { "errors": [ { "context": null, "message": "Bad message", "exceptionName": "com.atlassian.bitbucket.auth.BadException" } ] } """)); String serverUrl = server.url("/").toString(); assertThatThrownBy(() -> underTest.getRepo(serverUrl, "token", "", "")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Invalid personal access token"); } @DataProvider public static Object[][] expectedErrorMessageFromHttpNoJsonBody() { return new Object[][] { {200, "content ready", "application/json;charset=UTF-8", "Unexpected response from Bitbucket server"}, {201, "content ready!", "application/xhtml+xml", "Unexpected response from Bitbucket server"}, {401, "
unauthorized
", "application/json;charset=UTF-8", "Invalid personal access token"}, {401, "unauthorized
", "application/json", "Invalid personal access token"}, {401, "forbidden
", "application/json;charset=UTF-8", "Unable to contact Bitbucket server"}, {404, "not found
", "application/json;charset=UTF-8", "Error 404. The requested Bitbucket server is unreachable."}, {406, "not accepted
", "application/json;charset=UTF-8", "Unable to contact Bitbucket server"}, {409, "conflict
", "application/json;charset=UTF-8", "Unable to contact Bitbucket server"} }; } @Test @UseDataProvider("expectedErrorMessageFromHttpNoJsonBody") public void fail_response_when_http_no_json_body(int responseCode, String body, String headerContent, String expectedErrorMessage) { server.enqueue(new MockResponse() .setHeader("Content-Type", headerContent) .setResponseCode(responseCode) .setBody(body)); String serverUrl = server.url("/").toString(); assertThatThrownBy(() -> underTest.getRepo(serverUrl, "token", "", "")) .isInstanceOf(IllegalArgumentException.class) .hasMessage(expectedErrorMessage); } @Test public void fail_validate_on_io_exception() throws IOException { server.shutdown(); String serverUrl = server.url("/").toString(); assertThatThrownBy(() -> underTest.validateUrl(serverUrl)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unable to contact Bitbucket server"); assertThat(String.join(", ", logTester.logs())).contains("Unable to contact Bitbucket server"); } @Test public void fail_validate_url_on_non_json_result_log_correctly_the_response() { server.enqueue(new MockResponse() .setHeader("Content-Type", "application/json;charset=UTF-8") .setResponseCode(500) .setBody("not json")); String serverUrl = server.url("/").toString(); assertThatThrownBy(() -> underTest.validateReadPermission(serverUrl, "token")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unable to contact Bitbucket server"); assertThat(String.join(", ", logTester.logs())).contains("Unable to contact Bitbucket server: 500 not json"); } @Test public void fail_validate_url_on_text_result_log_the_returned_payload() { server.enqueue(new MockResponse() .setResponseCode(500) .setBody("this is a text payload")); String serverUrl = server.url("/").toString(); assertThatThrownBy(() -> underTest.validateReadPermission(serverUrl, "token")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unable to contact Bitbucket server"); assertThat(String.join(", ", logTester.logs())).contains("Unable to contact Bitbucket server: 500 this is a text payload"); } @Test public void validate_url_success() { server.enqueue(new MockResponse().setResponseCode(200) .setBody(STATUS_BODY)); underTest.validateUrl(server.url("/").toString()); } @Test public void validate_url_fail_when_not_starting_with_protocol() { assertThatThrownBy(() -> underTest.validateUrl("any_url_not_starting_with_http.com")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("url must start with http:// or https://"); } @Test public void validate_token_success() { server.enqueue(new MockResponse().setResponseCode(200) .setBody(""" { "size":10, "limit":25, "isLastPage":true, "values":[ { "name":"jean.michel", "emailAddress":"jean.michel@sonarsource.com", "id":2, "displayName":"Jean Michel", "active":true, "slug":"jean.michel", "type":"NORMAL", "links":{ "self":[ { "href":"https://bitbucket-testing.valiantys.sonarsource.com/users/jean.michel" } ] } }, { "name":"prince.de.lu", "emailAddress":"prince.de.lu@sonarsource.com", "id":103, "displayName":"Prince de Lu", "active":true, "slug":"prince.de.lu", "type":"NORMAL", "links":{ "self":[ { "href":"https://bitbucket-testing.valiantys.sonarsource.com/users/prince.de.lu" } ] } }, ], "start":0 } """)); underTest.validateToken(server.url("/").toString(), "token"); } @Test public void validate_read_permission_success() { server.enqueue(new MockResponse().setResponseCode(200) .setBody(REPOS_BODY)); underTest.validateReadPermission(server.url("/").toString(), "token"); } @Test public void fail_validate_url_when_on_http_error() { server.enqueue(new MockResponse().setResponseCode(500) .setBody("something unexpected")); String serverUrl = server.url("/").toString(); assertThatThrownBy(() -> underTest.validateUrl(serverUrl)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unable to contact Bitbucket server"); } @Test public void fail_validate_url_when_not_found_is_returned() { server.enqueue(new MockResponse().setResponseCode(404) .setBody("something unexpected")); String serverUrl = server.url("/").toString(); assertThatThrownBy(() -> underTest.validateUrl(serverUrl)) .isInstanceOf(BitbucketServerException.class) .hasMessage("Error 404. The requested Bitbucket server is unreachable.") .extracting(e -> ((BitbucketServerException) e).getHttpStatus()).isEqualTo(404); } @Test public void fail_validate_url_when_body_is_empty() { server.enqueue(new MockResponse().setResponseCode(404).setBody("")); String serverUrl = server.url("/").toString(); assertThatThrownBy(() -> underTest.validateUrl(serverUrl)) .isInstanceOf(BitbucketServerException.class) .hasMessage("Error 404. The requested Bitbucket server is unreachable.") .extracting(e -> ((BitbucketServerException) e).getHttpStatus()).isEqualTo(404); } @Test public void fail_validate_url_when_validate_url_return_non_json_payload() { server.enqueue(new MockResponse().setResponseCode(400) .setBody("this is not a json payload")); String serverUrl = server.url("/").toString(); assertThatThrownBy(() -> underTest.validateUrl(serverUrl)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unable to contact Bitbucket server"); } @Test public void fail_validate_url_when_returning_non_json_payload_with_a_200_code() { server.enqueue(new MockResponse().setResponseCode(200) .setBody("this is not a json payload")); String serverUrl = server.url("/").toString(); assertThatThrownBy(() -> underTest.validateUrl(serverUrl)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unexpected response from Bitbucket server"); assertThat(String.join(", ", logTester.logs())) .contains("Unexpected response from Bitbucket server : [this is not a json payload]"); } @Test public void fail_validate_token_when_server_return_non_json_payload() { server.enqueue(new MockResponse().setResponseCode(400) .setBody("this is not a json payload")); String serverUrl = server.url("/").toString(); assertThatThrownBy(() -> underTest.validateToken(serverUrl, "token")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unable to contact Bitbucket server"); } @Test public void fail_validate_token_when_returning_non_json_payload_with_a_200_code() { server.enqueue(new MockResponse().setResponseCode(200) .setBody("this is not a json payload")); String serverUrl = server.url("/").toString(); assertThatThrownBy(() -> underTest.validateToken(serverUrl, "token")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unexpected response from Bitbucket server"); assertThat(String.join(", ", logTester.logs())) .contains("Unexpected response from Bitbucket server : [this is not a json payload]"); } @Test public void fail_validate_token_when_using_an_invalid_token() { server.enqueue(new MockResponse().setResponseCode(401) .setBody("com.atlassian.bitbucket.AuthorisationException You are not permitted to access this resource")); String serverUrl = server.url("/").toString(); assertThatThrownBy(() -> underTest.validateToken(serverUrl, "token")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Invalid personal access token"); } @Test public void fail_validate_read_permission_when_server_return_non_json_payload() { server.enqueue(new MockResponse().setResponseCode(400) .setBody("this is not a json payload")); String serverUrl = server.url("/").toString(); assertThatThrownBy(() -> underTest.validateReadPermission(serverUrl, "token")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unable to contact Bitbucket server"); } @Test public void fail_validate_read_permission_when_returning_non_json_payload_with_a_200_code() { server.enqueue(new MockResponse().setResponseCode(200) .setBody("this is not a json payload")); String serverUrl = server.url("/").toString(); assertThatThrownBy(() -> underTest.validateReadPermission(serverUrl, "token")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unexpected response from Bitbucket server"); assertThat(String.join(", ", logTester.logs())) .contains("Unexpected response from Bitbucket server : [this is not a json payload]"); } @Test public void fail_validate_read_permission_when_permissions_are_not_granted() { server.enqueue(new MockResponse().setResponseCode(401) .setBody("com.atlassian.bitbucket.AuthorisationException You are not permitted to access this resource")); String serverUrl = server.url("/").toString(); assertThatThrownBy(() -> underTest.validateReadPermission(serverUrl, "token")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Invalid personal access token"); } @Test public void check_mediaTypes_equality() { assertThat(BitbucketServerRestClient.equals(null, null)).isFalse(); assertThat(BitbucketServerRestClient.equals(MediaType.parse("application/json"), null)).isFalse(); assertThat(BitbucketServerRestClient.equals(null, MediaType.parse("application/json"))).isFalse(); assertThat(BitbucketServerRestClient.equals(MediaType.parse("application/ json"), MediaType.parse("text/html; charset=UTF-8"))).isFalse(); assertThat(BitbucketServerRestClient.equals(MediaType.parse("application/Json"), MediaType.parse("application/JSON"))).isTrue(); } @Test @UseDataProvider("validStartAndPageSizeProvider") public void get_repositories_with_pagination(int start, int pageSize, boolean isLastPage, int totalRepos) { String body = generateRepositoriesResponse(totalRepos, start, pageSize, isLastPage); server.enqueue(new MockResponse() .setHeader("Content-Type", "application/json;charset=UTF-8") .setBody(body)); RepositoryList gsonBBSRepoList = underTest.getRepos(server.url("/").toString(), "token", null, null, start, pageSize); int expectedSize = Math.min(pageSize, totalRepos - start + 1); assertThat(gsonBBSRepoList.getValues()).hasSize(expectedSize); // Verify that the correct items are returned IntStream.rangeClosed(start, start + expectedSize - 1).forEach(i -> { assertThat(gsonBBSRepoList.getValues()) .extracting(Repository::getId, Repository::getName, Repository::getSlug) .contains(tuple((long) i, "Repository_" + i, "repo_" + i)); }); assertThat(gsonBBSRepoList.isLastPage()).isEqualTo(isLastPage); assertThat(gsonBBSRepoList.getNextPageStart()).isEqualTo(isLastPage ? start : start + expectedSize); } private String generateRepositoriesResponse(int totalRepos, int start, int pageSize, boolean isLastPage) { int end = Math.min(totalRepos, start + pageSize - 1); StringBuilder values = new StringBuilder(); for (int i = start; i <= end; i++) { values.append(""" { "slug": "repo_%d", "id": %d, "name": "Repository_%d", "project": { "key": "KEY_%d", "id": %d, "name": "Project_%d" } } """.formatted(i, i, i, i, i, i)); if (i < end) { values.append(","); } } return """ { "isLastPage": %s, "nextPageStart": %s, "values": [%s] } """.formatted(isLastPage, isLastPage ? start : end + 1, values.toString()); } @Test @UseDataProvider("validStartAndPageSizeProvider") public void get_projects_with_pagination(int start, int pageSize, boolean isLastPage, int totalProjects) { String body = generateProjectsResponse(totalProjects, start, pageSize, isLastPage); server.enqueue(new MockResponse() .setHeader("Content-Type", "application/json;charset=UTF-8") .setBody(body)); ProjectList gsonBBSProjectList = underTest.getProjects(server.url("/").toString(), "token", start, pageSize); int expectedSize = Math.min(pageSize, totalProjects - start + 1); assertThat(gsonBBSProjectList.getValues()).hasSize(expectedSize); // Verify that the correct items are returned IntStream.rangeClosed(start, start + expectedSize - 1).forEach(i -> { assertThat(gsonBBSProjectList.getValues()) .extracting(Project::getId, Project::getKey, Project::getName) .contains(tuple((long) i, "KEY_" + i, "Project_" + i)); }); assertThat(gsonBBSProjectList.isLastPage()).isEqualTo(isLastPage); assertThat(gsonBBSProjectList.getNextPageStart()).isEqualTo(isLastPage ? start : start + expectedSize); } private String generateProjectsResponse(int totalProjects, int start, int pageSize, boolean isLastPage) { int end = Math.min(totalProjects, start + pageSize - 1); StringBuilder values = new StringBuilder(); for (int i = start; i <= end; i++) { values.append(""" { "key": "KEY_%d", "id": %d, "name": "Project_%d" } """.formatted(i, i, i)); if (i < end) { values.append(","); } } return """ { "isLastPage": %s, "nextPageStart": %s, "values": [%s] } """.formatted(isLastPage, isLastPage ? start : end + 1, values.toString()); } @DataProvider public static Object[][] validStartAndPageSizeProvider() { return new Object[][] { {1, 25, false, 50}, // First 25 items, not last {26, 25, true, 50}, // Remaining items {1, 10, false, 15}, // 10 items, more remaining {11, 10, true, 15}, // Last 5 items {21, 10, false, 100}, // Middle range of items }; } }