diff --git a/src/main/java/com/twilio/base/Page.java b/src/main/java/com/twilio/base/Page.java index 259e86e480..ff580d0d37 100644 --- a/src/main/java/com/twilio/base/Page.java +++ b/src/main/java/com/twilio/base/Page.java @@ -9,18 +9,18 @@ import java.util.List; public class Page { - private final List records; - private final String firstPageUrl; - private final String firstPageUri; - private final String nextPageUrl; - private final String nextPageUri; - private final String previousPageUrl; - private final String previousPageUri; - private final String url; - private final String uri; - private final int pageSize; - - private Page(Builder b) { + protected final List records; + protected final String firstPageUrl; + protected final String firstPageUri; + protected final String nextPageUrl; + protected final String nextPageUri; + protected final String previousPageUrl; + protected final String previousPageUri; + protected final String url; + protected final String uri; + protected final int pageSize; + + protected Page(Builder b) { this.records = b.records; this.firstPageUri = b.firstPageUri; this.firstPageUrl = b.firstPageUrl; @@ -33,7 +33,7 @@ private Page(Builder b) { this.pageSize = b.pageSize; } - private String urlFromUri(String domain, String uri) { + protected String urlFromUri(String domain, String uri) { return "https://" + domain + ".twilio.com" + uri; } @@ -101,6 +101,14 @@ public String getUrl(String domain) { return urlFromUri(domain, uri); } + public String previousQueryString() { + return ""; + } + + public String nextQueryString() { + return ""; + } + public boolean hasNextPage() { return (nextPageUri != null && !nextPageUri.isEmpty()) || (nextPageUrl != null && !nextPageUrl.isEmpty()); } @@ -198,7 +206,7 @@ private static Page buildNextGenPage(JsonNode root, List results) { return builder.records(results).build(); } - private static class Builder { + protected static class Builder { private List records; private String firstPageUrl; private String firstPageUri; diff --git a/src/main/java/com/twilio/base/TokenPaginationPage.java b/src/main/java/com/twilio/base/TokenPaginationPage.java new file mode 100644 index 0000000000..07a90844f0 --- /dev/null +++ b/src/main/java/com/twilio/base/TokenPaginationPage.java @@ -0,0 +1,172 @@ +package com.twilio.base; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.twilio.exception.ApiConnectionException; +import com.twilio.exception.ApiException; +import lombok.Getter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class TokenPaginationPage extends Page { + @Getter + private final String key; + private final String nextToken; + private final String previousToken; + + private TokenPaginationPage(Builder b) { + super(b); + this.key = b.key; + this.nextToken = b.nextToken; + this.previousToken = b.previousToken; + } + + // adding custom getter and not using lombok to handle null token + // when token is null, lombok getter returns "null" not a null object + public String getNextToken() { + return nextToken; + } + + public String getPreviousToken() { + return previousToken; + } + + @Override + public String previousQueryString() { + return getQueryString(previousToken); + } + + @Override + public String nextQueryString() { + return getQueryString(nextToken); + } + + private void addQueryOperators(StringBuilder query) { + if(query.length() == 0) { + query.append("?"); + } else { + query.append("&"); + } + } + + private String getQueryString(String pageToken) { + StringBuilder query = new StringBuilder(); + if (pageSize > 0) { + addQueryOperators(query); + query.append("pageSize=").append(pageSize); + } + if(pageToken != null && !pageToken.isEmpty()) { + addQueryOperators(query); + query.append("pageToken=").append(pageToken); + } + return query.toString(); + } + + /** + * Checks if there is a next page of records available. + * + * @return true if a next page is available, false otherwise + */ + @Override + public boolean hasNextPage() { + return (nextToken != null && !nextToken.isEmpty()); + } + + + /** + * Create a new page of data from a json blob. + * + * @param recordKey key which holds the records + * @param json json blob + * @param recordType resource type + * @param mapper json parser + * @param record class type + * @return a page of records of type T + */ + public static TokenPaginationPage fromJson(String recordKey, String json, Class recordType, ObjectMapper mapper) { + try { + List results = new ArrayList<>(); + JsonNode root = mapper.readTree(json); + try { + JsonNode meta = root.get("meta"); + String key = meta.get("key").asText(); + JsonNode records = root.get(key); + for (final JsonNode record : records) { + results.add(mapper.readValue(record.toString(), recordType)); + } + + return buildPage(meta, results); + } catch (NullPointerException e) { + throw new ApiException("Key not found", e); + } + + } catch (final IOException e) { + throw new ApiConnectionException( + "Unable to deserialize response: " + e.getMessage() + "\nJSON: " + json, e + ); + } + } + + private static TokenPaginationPage buildPage(JsonNode meta, List results) { + try { + Builder builder = new Builder() + .key(meta.get("key").asText()); + + JsonNode nextTokenNode = meta.get("nextToken"); + if (nextTokenNode != null && !nextTokenNode.isNull()) { + builder.nextToken(nextTokenNode.asText()); + } + + JsonNode previousTokenNode = meta.get("previousToken"); + if (previousTokenNode != null && !previousTokenNode.isNull()) { + builder.previousToken(previousTokenNode.asText()); + } + + JsonNode pageSizeNode = meta.get("pageSize"); + builder.pageSize(pageSizeNode.asInt()); + + return builder.records(results).build(); + } catch (NullPointerException e) { + throw new ApiException("Key not found", e); + } + } + + protected static class Builder extends Page.Builder { + private String key; + private String nextToken; + private String previousToken; + + @Override + public Builder records(List records) { + super.records(records); + return this; + } + + @Override + public Builder pageSize(int pageSize) { + super.pageSize(pageSize); + return this; + } + + public Builder key(String key) { + this.key = key; + return this; + } + + public Builder nextToken(String nextToken) { + this.nextToken = nextToken; + return this; + } + + public Builder previousToken(String previousToken) { + this.previousToken = previousToken; + return this; + } + + public TokenPaginationPage build() { + return new TokenPaginationPage<>(this); + } + } +} diff --git a/src/test/java/com/twilio/base/TokenPaginationPageTest.java b/src/test/java/com/twilio/base/TokenPaginationPageTest.java new file mode 100644 index 0000000000..a05b753cbe --- /dev/null +++ b/src/test/java/com/twilio/base/TokenPaginationPageTest.java @@ -0,0 +1,495 @@ +package com.twilio.base; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.twilio.exception.ApiConnectionException; +import com.twilio.exception.ApiException; +import com.twilio.http.TwilioRestClient; +import lombok.Getter; +import lombok.Setter; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +/** + * Unit tests for TokenPaginationPage class. + * These tests focus on validating the TokenPaginationPage implementation which extends the Page class + * and provides token-based pagination functionality. The tests cover: + * 1. Construction and getters - Verifying object initialization and property access + * 2. queryString() methods - Testing URL query string generation for tokens + * 3. hasNextPage() behavior - Testing the availability of next pages + * 4. fromJson() parsing - Testing JSON deserialization with various structures + * 5. Builder pattern - Verifying the builder implementation works correctly + * 6. Inheritance - Confirming proper extension of Page class + * 7. Edge cases - Testing boundary conditions and error handling + */ +public class TokenPaginationPageTest { + + private ObjectMapper mapper; + private JsonNodeFactory factory; + + @Before + public void setUp() { + mapper = new ObjectMapper(); + factory = JsonNodeFactory.instance; + } + + /** + * Test constructor and getter methods + */ + @Test + public void testConstructorAndGetters() { + // Create a sample TokenPaginationPage using Builder + List records = Arrays.asList("Record1", "Record2"); + TokenPaginationPage page = createSamplePage( + records, "test_key", "next_token", "prev_token", 10); + + // Verify the fields are set correctly through getters + Assert.assertEquals(records, page.getRecords()); + Assert.assertEquals("test_key", page.getKey()); + Assert.assertEquals("next_token", page.getNextToken()); + Assert.assertEquals("prev_token", page.getPreviousToken()); + Assert.assertEquals(10, page.getPageSize()); + } + + /** + * Test nextQueryString() method + */ + @Test + public void testNextQueryString() { + TokenPaginationPage page = createSamplePage( + Collections.singletonList("Record"), "key", "next_token", "prev_token", 25); + + String query = page.nextQueryString(); + Assert.assertEquals("?pageSize=25&pageToken=next_token", query); + } + + /** + * Test previousQueryString() method + */ + @Test + public void testPreviousQueryString() { + TokenPaginationPage page = createSamplePage( + Collections.singletonList("Record"), "key", "next_token", "prev_token", 25); + + String query = page.previousQueryString(); + Assert.assertEquals("?pageSize=25&pageToken=prev_token", query); + } + + /** + * Test queryString() method with null page token + */ + @Test + public void testQueryStringWithNullToken() { + TokenPaginationPage page = createSamplePage( + Collections.singletonList("Record"), "key", null, "prev_token", 25); + + String query = page.nextQueryString(); + Assert.assertEquals("?pageSize=25", query); + } + + /** + * Test queryString() method with empty page token + */ + @Test + public void testQueryStringWithEmptyToken() { + TokenPaginationPage page = createSamplePage( + Collections.singletonList("Record"), "key", "", "prev_token", 25); + + String query = page.nextQueryString(); + Assert.assertEquals("?pageSize=25", query); + } + + /** + * Test queryString() method with zero page size + */ + @Test + public void testQueryStringWithZeroPageSize() { + TokenPaginationPage page = createSamplePage( + Collections.singletonList("Record"), "key", "next_token", "prev_token", 0); + + String query = page.nextQueryString(); + Assert.assertEquals("?pageToken=next_token", query); + } + + /** + * Test hasNextPage() method with non-null token + */ + @Test + public void testHasNextPageWithToken() { + TokenPaginationPage page = createSamplePage( + Collections.singletonList("Record"), "key", "next_token", "prev_token", 25); + + Assert.assertTrue(page.hasNextPage()); + } + + /** + * Test hasNextPage() method with null token + */ + @Test + public void testHasNextPageWithNullToken() { + TokenPaginationPage page = createSamplePage( + Collections.singletonList("Record"), "key", null, "prev_token", 25); + + Assert.assertFalse(page.hasNextPage()); + } + + /** + * Test hasNextPage() method with empty token + */ + @Test + public void testHasNextPageWithEmptyToken() { + TokenPaginationPage page = createSamplePage( + Collections.singletonList("Record"), "key", "", "prev_token", 25); + + Assert.assertFalse(page.hasNextPage()); + } + + /** + * Test fromJson() method with valid JSON with meta structure + */ + @Test + public void testFromJsonWithMetaStructure() throws Exception { + // Create mock JSON with meta structure + ObjectNode rootNode = factory.objectNode(); + + // Add meta information + ObjectNode metaNode = factory.objectNode(); + metaNode.put("key", "services"); + metaNode.put("nextToken", "next_token_123"); + metaNode.put("previousToken", "prev_token_456"); + metaNode.put("pageSize", 2); + rootNode.set("meta", metaNode); + + // Add sample records + ArrayNode recordsNode = factory.arrayNode(); + ObjectNode record1 = factory.objectNode(); + record1.put("id", "id1"); + record1.put("friendlyName", "Test Service 1"); + recordsNode.add(record1); + + ObjectNode record2 = factory.objectNode(); + record2.put("id", "id2"); + record2.put("friendlyName", "Test Service 2"); + recordsNode.add(record2); + + rootNode.set("services", recordsNode); + + String json = mapper.writeValueAsString(rootNode); + + // Parse JSON using fromJson + TokenPaginationPage page = TokenPaginationPage.fromJson( + "services", json, TestRecord.class, mapper); + + // Verify the page + Assert.assertEquals("services", page.getKey()); + Assert.assertEquals("next_token_123", page.getNextToken()); + Assert.assertEquals("prev_token_456", page.getPreviousToken()); + Assert.assertEquals(2, page.getPageSize()); + Assert.assertEquals(2, page.getRecords().size()); + Assert.assertEquals("id1", page.getRecords().get(0).getId()); + Assert.assertEquals("Test Service 1", page.getRecords().get(0).getFriendlyName()); + Assert.assertEquals("id2", page.getRecords().get(1).getId()); + Assert.assertEquals("Test Service 2", page.getRecords().get(1).getFriendlyName()); + } + + /** + * Test fromJson() method with invalid JSON + */ + @Test(expected = ApiConnectionException.class) + public void testFromJsonWithInvalidJson() { + String invalidJson = "{invalid json}"; + TokenPaginationPage.fromJson("records", invalidJson, TestRecord.class, mapper); + } + + /** + * Test fromJson() method with missing key field + */ + @Test(expected = ApiException.class) + public void testFromJsonWithMissingKey() throws Exception { + // Create mock JSON with missing key + ObjectNode rootNode = factory.objectNode(); + ObjectNode metaNode = factory.objectNode(); + // No key field + metaNode.put("nextToken", "next_token_123"); + metaNode.put("pageSize", 2); + rootNode.set("meta", metaNode); + + // Add sample records + ArrayNode recordsNode = factory.arrayNode(); + ObjectNode record1 = factory.objectNode(); + record1.put("id", "id1"); + recordsNode.add(record1); + rootNode.set("services", recordsNode); + + String json = mapper.writeValueAsString(rootNode); + + // Parse JSON using fromJson + TokenPaginationPage.fromJson( + "services", json, TestRecord.class, mapper); + } + + /** + * Test fromJson() method with null token values + */ + @Test + public void testFromJsonWithNullTokens() throws Exception { + // Create mock JSON with null tokens + ObjectNode rootNode = factory.objectNode(); + ObjectNode metaNode = factory.objectNode(); + metaNode.put("key", "services"); + metaNode.putNull("nextToken"); + metaNode.putNull("previousToken"); + metaNode.put("pageSize", 2); + rootNode.set("meta", metaNode); + + // Add sample records + ArrayNode recordsNode = factory.arrayNode(); + recordsNode.add(factory.objectNode().put("id", "id1").put("friendlyName", "Test Service 1")); + rootNode.set("services", recordsNode); + + String json = mapper.writeValueAsString(rootNode); + + // Parse JSON using fromJson + TokenPaginationPage page = TokenPaginationPage.fromJson( + "services", json, TestRecord.class, mapper); + + // Verify null tokens are handled properly + Assert.assertNull(page.getNextToken()); + Assert.assertNull(page.getPreviousToken()); + Assert.assertFalse(page.hasNextPage()); + } + + /** + * Test fromJson() method with missing pageSize + */ + @Test(expected = ApiException.class) + public void testFromJsonWithMissingPageSize() throws Exception { + // Create mock JSON without pageSize + ObjectNode rootNode = factory.objectNode(); + ObjectNode metaNode = factory.objectNode(); + metaNode.put("key", "services"); + metaNode.put("nextToken", "next_token_123"); + // No pageSize field + rootNode.set("meta", metaNode); + + // Add sample records + ArrayNode recordsNode = factory.arrayNode(); + recordsNode.add(factory.objectNode().put("id", "id1")); + recordsNode.add(factory.objectNode().put("id", "id2")); + rootNode.set("services", recordsNode); + + String json = mapper.writeValueAsString(rootNode); + + // Parse JSON using fromJson + TokenPaginationPage.fromJson( + "services", json, TestRecord.class, mapper); + } + + /** + * Test fromJson() method with empty records array + */ + @Test + public void testFromJsonWithEmptyRecords() throws Exception { + // Create mock JSON with empty records array + ObjectNode rootNode = factory.objectNode(); + ObjectNode metaNode = factory.objectNode(); + metaNode.put("key", "services"); + metaNode.put("nextToken", "next_token_123"); + metaNode.put("pageSize", 0); + rootNode.set("meta", metaNode); + + // Empty records array + ArrayNode recordsNode = factory.arrayNode(); + rootNode.set("services", recordsNode); + + String json = mapper.writeValueAsString(rootNode); + + // Parse JSON using fromJson + TokenPaginationPage page = TokenPaginationPage.fromJson( + "services", json, TestRecord.class, mapper); + + // Verify empty records are handled properly + Assert.assertNotNull(page.getRecords()); + Assert.assertEquals(0, page.getRecords().size()); + Assert.assertEquals(0, page.getPageSize()); + } + + /** + * Test handling of non-existent record key in JSON + */ + @Test(expected = ApiException.class) + public void testFromJsonWithNonExistentRecordKey() throws Exception { + // Create mock JSON + ObjectNode rootNode = factory.objectNode(); + ObjectNode metaNode = factory.objectNode(); + metaNode.put("key", "non_existent_key"); + metaNode.put("nextToken", "next_token"); + metaNode.put("pageSize", 5); + rootNode.set("meta", metaNode); + + // Records under a different key than what will be requested + ArrayNode recordsNode = factory.arrayNode(); + recordsNode.add(factory.objectNode().put("id", "id1")); + rootNode.set("services", recordsNode); + + String json = mapper.writeValueAsString(rootNode); + + // Parse JSON using fromJson with wrong record key + TokenPaginationPage page = TokenPaginationPage.fromJson( + "wrong_key", json, TestRecord.class, mapper); + } + + /** + * Test Builder pattern for TokenPaginationPage + */ + @Test + public void testBuilderPattern() { + List records = Arrays.asList("Record1", "Record2"); + + // Create page using builder pattern + TokenPaginationPage page = new TokenPaginationPage.Builder() + .records(records) + .key("test_key") + .nextToken("next_token") + .previousToken("prev_token") + .pageSize(10) + .build(); + + // Verify the fields + Assert.assertEquals(records, page.getRecords()); + Assert.assertEquals("test_key", page.getKey()); + Assert.assertEquals("next_token", page.getNextToken()); + Assert.assertEquals("prev_token", page.getPreviousToken()); + Assert.assertEquals(10, page.getPageSize()); + } + + /** + * Test inheritance relationship - TokenPaginationPage extends Page + */ + @Test + public void testInheritanceRelationship() { + List records = Arrays.asList("Record1", "Record2"); + TokenPaginationPage page = createSamplePage( + records, "test_key", "next_token", "prev_token", 10); + + // Verify that TokenPaginationPage is a Page + Assert.assertTrue(page instanceof Page); + + // Verify that a TokenPaginationPage can be used as a Page + Page basePage = page; + Assert.assertEquals(records, basePage.getRecords()); + Assert.assertEquals(10, basePage.getPageSize()); + } + + /** + * Test the overridden hasNextPage() method behaves correctly + */ + @Test + public void testOverriddenHasNextPageMethod() { + // Create a page with nextToken - hasNextPage should return true + TokenPaginationPage pageWithToken = createSamplePage( + Collections.singletonList("Record"), "key", "next_token", null, 10); + Assert.assertTrue(pageWithToken.hasNextPage()); + + TokenPaginationPage pageWithoutToken = createSamplePage( + Collections.singletonList("Record"), "key", null, "prev_token", 10); + Assert.assertFalse(pageWithoutToken.hasNextPage()); + } + + /** + * Test TokenPaginationPage with ResourceSet + */ + @Test + public void testTokenPaginationPageWithResourceSet() throws Exception { + // Create a mock reader that will return our TokenPaginationPage + Reader mockReader = new Reader() { + @Override + public ResourceSet read(TwilioRestClient client) { + return null; + } + + @Override + public Page firstPage(TwilioRestClient client) { + return null; + } + + @Override + public Page getPage(String targetUrl, TwilioRestClient client) { + return null; + } + + @Override + public Page nextPage(Page page, TwilioRestClient client) { + return null; + } + + @Override + public Page previousPage(Page page, TwilioRestClient client) { + return null; + } + }; + + // Create TokenPaginationPage + List records = Arrays.asList( + new TestRecord("id1", "test_name1"), + new TestRecord("id2", "test_name2") + ); + int pageSize = 2; + int limit = 5; + Page page = createSamplePage( + records, "test_key", "next_token", "prev_token", pageSize); + + mockReader.limit(limit); + mockReader.pageSize(pageSize); + // Create ResourceSet using TokenPaginationPage + ResourceSet resourceSet = new ResourceSet<>(mockReader, null, page); + + // Verify that ResourceSet works with TokenPaginationPage + Assert.assertNotNull(resourceSet); + Assert.assertEquals((long)(Math.ceil((double)limit/pageSize)), resourceSet.getPageLimit()); + } + + /** + * Helper method to create a sample TokenPaginationPage + */ + private TokenPaginationPage createSamplePage( + List records, String key, String nextToken, String prevToken, int pageSize) { + return new TokenPaginationPage.Builder() + .records(records) + .key(key) + .nextToken(nextToken) + .previousToken(prevToken) + .pageSize(pageSize) + .build(); + } + + /** + * Helper class for testing JSON deserialization. + * + * This simple POJO represents a record that can be deserialized from JSON, allowing + * us to test the TokenPaginationPage.fromJson() method with concrete types. + */ + public static class TestRecord extends Resource { + @Getter + @Setter + private String id; + @Getter + @Setter + private String friendlyName; + + public TestRecord() {} + + public TestRecord(String id, String friendlyName) { + this.id = id; + this.friendlyName = friendlyName; + } + } +}