From ce6362957efda04e5b62f950f6cedaf4e1b4851d Mon Sep 17 00:00:00 2001
From: Guian Gumpac <guian.gumpac@improving.com>
Date: Sat, 29 Jun 2024 00:49:10 +0000
Subject: [PATCH] Java: Add `ZSCAN` command (#397)

---------

Co-authored-by: James Duong <james.duong@improving.com>
---
 glide-core/src/protobuf/redis_request.proto   |   1 +
 glide-core/src/request_type.rs                |   3 +
 .../src/main/java/glide/api/BaseClient.java   |  15 ++
 .../api/commands/SortedSetBaseCommands.java   |  70 +++++++
 .../glide/api/models/BaseTransaction.java     |  41 ++++
 .../models/commands/scan/ZScanOptions.java    |  13 ++
 .../test/java/glide/api/RedisClientTest.java  |  54 +++++
 .../glide/api/models/TransactionTests.java    |   8 +
 .../test/java/glide/SharedCommandTests.java   | 186 +++++++++++++++++-
 .../java/glide/TransactionTestUtilities.java  |   7 +
 10 files changed, 395 insertions(+), 3 deletions(-)
 create mode 100644 java/client/src/main/java/glide/api/models/commands/scan/ZScanOptions.java

diff --git a/glide-core/src/protobuf/redis_request.proto b/glide-core/src/protobuf/redis_request.proto
index e2a1fef74e..1394e95721 100644
--- a/glide-core/src/protobuf/redis_request.proto
+++ b/glide-core/src/protobuf/redis_request.proto
@@ -239,6 +239,7 @@ enum RequestType {
     XPending = 198;
     XGroupSetId = 199;
     SScan = 200;
+    ZScan = 201;
 }
 
 message Command {
diff --git a/glide-core/src/request_type.rs b/glide-core/src/request_type.rs
index 0e46f7800a..d73c6576f8 100644
--- a/glide-core/src/request_type.rs
+++ b/glide-core/src/request_type.rs
@@ -209,6 +209,7 @@ pub enum RequestType {
     XPending = 198,
     XGroupSetId = 199,
     SScan = 200,
+    ZScan = 201,
 }
 
 fn get_two_word_command(first: &str, second: &str) -> Cmd {
@@ -421,6 +422,7 @@ impl From<::protobuf::EnumOrUnknown<ProtobufRequestType>> for RequestType {
             ProtobufRequestType::XPending => RequestType::XPending,
             ProtobufRequestType::XGroupSetId => RequestType::XGroupSetId,
             ProtobufRequestType::SScan => RequestType::SScan,
+            ProtobufRequestType::ZScan => RequestType::ZScan,
         }
     }
 }
@@ -631,6 +633,7 @@ impl RequestType {
             RequestType::XPending => Some(cmd("XPENDING")),
             RequestType::XGroupSetId => Some(get_two_word_command("XGROUP", "SETID")),
             RequestType::SScan => Some(cmd("SSCAN")),
+            RequestType::ZScan => Some(cmd("ZSCAN")),
         }
     }
 }
diff --git a/java/client/src/main/java/glide/api/BaseClient.java b/java/client/src/main/java/glide/api/BaseClient.java
index eeaeab79a1..41a947b872 100644
--- a/java/client/src/main/java/glide/api/BaseClient.java
+++ b/java/client/src/main/java/glide/api/BaseClient.java
@@ -167,6 +167,7 @@
 import static redis_request.RedisRequestOuterClass.RequestType.ZRemRangeByRank;
 import static redis_request.RedisRequestOuterClass.RequestType.ZRemRangeByScore;
 import static redis_request.RedisRequestOuterClass.RequestType.ZRevRank;
+import static redis_request.RedisRequestOuterClass.RequestType.ZScan;
 import static redis_request.RedisRequestOuterClass.RequestType.ZScore;
 import static redis_request.RedisRequestOuterClass.RequestType.ZUnion;
 import static redis_request.RedisRequestOuterClass.RequestType.ZUnionStore;
@@ -209,6 +210,7 @@
 import glide.api.models.commands.geospatial.GeoUnit;
 import glide.api.models.commands.geospatial.GeospatialData;
 import glide.api.models.commands.scan.SScanOptions;
+import glide.api.models.commands.scan.ZScanOptions;
 import glide.api.models.commands.stream.StreamAddOptions;
 import glide.api.models.commands.stream.StreamGroupOptions;
 import glide.api.models.commands.stream.StreamPendingOptions;
@@ -2799,4 +2801,17 @@ public CompletableFuture<Object[]> sscan(
         String[] arguments = concatenateArrays(new String[] {key, cursor}, sScanOptions.toArgs());
         return commandManager.submitNewCommand(SScan, arguments, this::handleArrayResponse);
     }
+
+    @Override
+    public CompletableFuture<Object[]> zscan(@NonNull String key, @NonNull String cursor) {
+        String[] arguments = new String[] {key, cursor};
+        return commandManager.submitNewCommand(ZScan, arguments, this::handleArrayResponse);
+    }
+
+    @Override
+    public CompletableFuture<Object[]> zscan(
+            @NonNull String key, @NonNull String cursor, @NonNull ZScanOptions zScanOptions) {
+        String[] arguments = concatenateArrays(new String[] {key, cursor}, zScanOptions.toArgs());
+        return commandManager.submitNewCommand(ZScan, arguments, this::handleArrayResponse);
+    }
 }
diff --git a/java/client/src/main/java/glide/api/commands/SortedSetBaseCommands.java b/java/client/src/main/java/glide/api/commands/SortedSetBaseCommands.java
index 0da26317d4..7f45405c10 100644
--- a/java/client/src/main/java/glide/api/commands/SortedSetBaseCommands.java
+++ b/java/client/src/main/java/glide/api/commands/SortedSetBaseCommands.java
@@ -19,6 +19,7 @@
 import glide.api.models.commands.WeightAggregateOptions.KeysOrWeightedKeys;
 import glide.api.models.commands.WeightAggregateOptions.WeightedKeys;
 import glide.api.models.commands.ZAddOptions;
+import glide.api.models.commands.scan.ZScanOptions;
 import java.util.Map;
 import java.util.concurrent.CompletableFuture;
 
@@ -1577,4 +1578,73 @@ CompletableFuture<Map<String, Double>> zinterWithScores(
      * }</pre>
      */
     CompletableFuture<Long> zintercard(GlideString[] keys, long limit);
+
+    /**
+     * Iterates incrementally over a sorted set.
+     *
+     * @see <a href="https://valkey.io/commands/zscan">valkey.io</a> for details.
+     * @param key The key of the set.
+     * @param cursor The cursor that points to the next iteration of results.
+     * @return An <code>Array</code> of <code>Objects</code>. The first element is always the <code>
+     *     cursor</code> for the next iteration of results. <code>0</code> will be the <code>cursor
+     *     </code> returned on the last iteration of the set. The second element is always an <code>
+     *     Array</code> of the subset of the set held in <code>key</code>. The array in the second
+     *     element is always a flattened series of String pairs, where the value is at even indices
+     *     and the score is at odd indices.
+     * @example
+     *     <pre>{@code
+     * // Assume key contains a set with 200 member-score pairs
+     * String cursor = "0";
+     * Object[] result;
+     * do {
+     *   result = client.zscan(key1, cursor).get();
+     *   cursor = result[0].toString();
+     *   Object[] stringResults = (Object[]) result[1];
+     *
+     *   System.out.println("\nZSCAN iteration:");
+     *   for (int i = 0; i < stringResults.length; i += 2) {
+     *     System.out.printf("{%s=%s}", stringResults[i], stringResults[i + 1]);
+     *     if (i + 2 < stringResults.length) {
+     *       System.out.print(", ");
+     *     }
+     *   }
+     * } while (!cursor.equals("0"));
+     * }</pre>
+     */
+    CompletableFuture<Object[]> zscan(String key, String cursor);
+
+    /**
+     * Iterates incrementally over a sorted set.
+     *
+     * @see <a href="https://valkey.io/commands/zscan">valkey.io</a> for details.
+     * @param key The key of the set.
+     * @param cursor The cursor that points to the next iteration of results.
+     * @param zScanOptions The {@link ZScanOptions}.
+     * @return An <code>Array</code> of <code>Objects</code>. The first element is always the <code>
+     *     cursor</code> for the next iteration of results. <code>0</code> will be the <code>cursor
+     *     </code> returned on the last iteration of the set. The second element is always an <code>
+     *     Array</code> of the subset of the set held in <code>key</code>. The array in the second
+     *     element is always a flattened series of String pairs, where the value is at even indices
+     *     and the score is at odd indices.
+     * @example
+     *     <pre>{@code
+     * // Assume key contains a set with 200 member-score pairs
+     * String cursor = "0";
+     * Object[] result;
+     * do {
+     *   result = client.zscan(key1, cursor, ZScanOptions.builder().matchPattern("*").count(20L).build()).get();
+     *   cursor = result[0].toString();
+     *   Object[] stringResults = (Object[]) result[1];
+     *
+     *   System.out.println("\nZSCAN iteration:");
+     *   for (int i = 0; i < stringResults.length; i += 2) {
+     *     System.out.printf("{%s=%s}", stringResults[i], stringResults[i + 1]);
+     *     if (i + 2 < stringResults.length) {
+     *       System.out.print(", ");
+     *     }
+     *   }
+     * } while (!cursor.equals("0"));
+     * }</pre>
+     */
+    CompletableFuture<Object[]> zscan(String key, String cursor, ZScanOptions zScanOptions);
 }
diff --git a/java/client/src/main/java/glide/api/models/BaseTransaction.java b/java/client/src/main/java/glide/api/models/BaseTransaction.java
index 0d8974c217..994f42ee94 100644
--- a/java/client/src/main/java/glide/api/models/BaseTransaction.java
+++ b/java/client/src/main/java/glide/api/models/BaseTransaction.java
@@ -193,6 +193,7 @@
 import static redis_request.RedisRequestOuterClass.RequestType.ZRemRangeByRank;
 import static redis_request.RedisRequestOuterClass.RequestType.ZRemRangeByScore;
 import static redis_request.RedisRequestOuterClass.RequestType.ZRevRank;
+import static redis_request.RedisRequestOuterClass.RequestType.ZScan;
 import static redis_request.RedisRequestOuterClass.RequestType.ZScore;
 import static redis_request.RedisRequestOuterClass.RequestType.ZUnion;
 import static redis_request.RedisRequestOuterClass.RequestType.ZUnionStore;
@@ -242,6 +243,7 @@
 import glide.api.models.commands.geospatial.GeoUnit;
 import glide.api.models.commands.geospatial.GeospatialData;
 import glide.api.models.commands.scan.SScanOptions;
+import glide.api.models.commands.scan.ZScanOptions;
 import glide.api.models.commands.stream.StreamAddOptions;
 import glide.api.models.commands.stream.StreamAddOptions.StreamAddOptionsBuilder;
 import glide.api.models.commands.stream.StreamGroupOptions;
@@ -5163,6 +5165,45 @@ public T sscan(@NonNull String key, @NonNull String cursor, @NonNull SScanOption
         return getThis();
     }
 
+    /**
+     * Iterates incrementally over a sorted set.
+     *
+     * @see <a href="https://valkey.io/commands/zscan">valkey.io</a> for details.
+     * @param key The key of the set.
+     * @param cursor The cursor that points to the next iteration of results.
+     * @return Command Response - An <code>Array</code> of <code>Objects</code>. The first element is
+     *     always the <code>cursor</code> for the next iteration of results. <code>0</code> will be
+     *     the <code>cursor</code> returned on the last iteration of the set. The second element is
+     *     always an <code>Array</code> of the subset of the set held in <code>key</code>. The array
+     *     in the second element is always a flattened series of String pairs, where the value is at
+     *     even indices and the score is at odd indices.
+     */
+    public T zscan(@NonNull String key, @NonNull String cursor) {
+        protobufTransaction.addCommands(buildCommand(ZScan, buildArgs(key, cursor)));
+        return getThis();
+    }
+
+    /**
+     * Iterates incrementally over a sorted set.
+     *
+     * @see <a href="https://valkey.io/commands/zscan">valkey.io</a> for details.
+     * @param key The key of the set.
+     * @param cursor The cursor that points to the next iteration of results.
+     * @param zScanOptions The {@link ZScanOptions}.
+     * @return Command Response - An <code>Array</code> of <code>Objects</code>. The first element is
+     *     always the <code>cursor</code> for the next iteration of results. <code>0</code> will be
+     *     the <code>cursor</code> returned on the last iteration of the set. The second element is
+     *     always an <code>Array</code> of the subset of the set held in <code>key</code>. The array
+     *     in the second element is always a flattened series of String pairs, where the value is at
+     *     even indices and the score is at odd indices.
+     */
+    public T zscan(@NonNull String key, @NonNull String cursor, @NonNull ZScanOptions zScanOptions) {
+        ArgsArray commandArgs =
+                buildArgs(concatenateArrays(new String[] {key, cursor}, zScanOptions.toArgs()));
+        protobufTransaction.addCommands(buildCommand(ZScan, commandArgs));
+        return getThis();
+    }
+
     /** Build protobuf {@link Command} object for given command and arguments. */
     protected Command buildCommand(RequestType requestType) {
         return buildCommand(requestType, buildArgs());
diff --git a/java/client/src/main/java/glide/api/models/commands/scan/ZScanOptions.java b/java/client/src/main/java/glide/api/models/commands/scan/ZScanOptions.java
new file mode 100644
index 0000000000..8d4bd0b078
--- /dev/null
+++ b/java/client/src/main/java/glide/api/models/commands/scan/ZScanOptions.java
@@ -0,0 +1,13 @@
+/** Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 */
+package glide.api.models.commands.scan;
+
+import glide.api.commands.SortedSetBaseCommands;
+import lombok.experimental.SuperBuilder;
+
+/**
+ * Optional arguments for {@link SortedSetBaseCommands#zscan(String, String, ZScanOptions)}.
+ *
+ * @see <a href="https://valkey.io/commands/zscan/">valkey.io</a>
+ */
+@SuperBuilder
+public class ZScanOptions extends ScanOptions {}
diff --git a/java/client/src/test/java/glide/api/RedisClientTest.java b/java/client/src/test/java/glide/api/RedisClientTest.java
index 8a0c18917b..0d4dba1e53 100644
--- a/java/client/src/test/java/glide/api/RedisClientTest.java
+++ b/java/client/src/test/java/glide/api/RedisClientTest.java
@@ -248,6 +248,7 @@
 import static redis_request.RedisRequestOuterClass.RequestType.ZRemRangeByRank;
 import static redis_request.RedisRequestOuterClass.RequestType.ZRemRangeByScore;
 import static redis_request.RedisRequestOuterClass.RequestType.ZRevRank;
+import static redis_request.RedisRequestOuterClass.RequestType.ZScan;
 import static redis_request.RedisRequestOuterClass.RequestType.ZScore;
 import static redis_request.RedisRequestOuterClass.RequestType.ZUnion;
 import static redis_request.RedisRequestOuterClass.RequestType.ZUnionStore;
@@ -298,6 +299,7 @@
 import glide.api.models.commands.geospatial.GeoUnit;
 import glide.api.models.commands.geospatial.GeospatialData;
 import glide.api.models.commands.scan.SScanOptions;
+import glide.api.models.commands.scan.ZScanOptions;
 import glide.api.models.commands.stream.StreamAddOptions;
 import glide.api.models.commands.stream.StreamGroupOptions;
 import glide.api.models.commands.stream.StreamPendingOptions;
@@ -9013,4 +9015,56 @@ public void sscan_with_options_returns_success() {
         assertEquals(testResponse, response);
         assertEquals(value, payload);
     }
+
+    @SneakyThrows
+    @Test
+    public void zscan_returns_success() {
+        // setup
+        String key = "testKey";
+        String cursor = "0";
+        String[] arguments = new String[] {key, cursor};
+        Object[] value = new Object[] {0L, new String[] {"hello", "world"}};
+
+        CompletableFuture<Object[]> testResponse = new CompletableFuture<>();
+        testResponse.complete(value);
+
+        // match on protobuf request
+        when(commandManager.<Object[]>submitNewCommand(eq(ZScan), eq(arguments), any()))
+                .thenReturn(testResponse);
+
+        // exercise
+        CompletableFuture<Object[]> response = service.zscan(key, cursor);
+        Object[] payload = response.get();
+
+        // verify
+        assertEquals(testResponse, response);
+        assertEquals(value, payload);
+    }
+
+    @SneakyThrows
+    @Test
+    public void zscan_with_options_returns_success() {
+        // setup
+        String key = "testKey";
+        String cursor = "0";
+        String[] arguments =
+                new String[] {key, cursor, MATCH_OPTION_STRING, "*", COUNT_OPTION_STRING, "1"};
+        Object[] value = new Object[] {0L, new String[] {"hello", "world"}};
+
+        CompletableFuture<Object[]> testResponse = new CompletableFuture<>();
+        testResponse.complete(value);
+
+        // match on protobuf request
+        when(commandManager.<Object[]>submitNewCommand(eq(ZScan), eq(arguments), any()))
+                .thenReturn(testResponse);
+
+        // exercise
+        CompletableFuture<Object[]> response =
+                service.zscan(key, cursor, ZScanOptions.builder().matchPattern("*").count(1L).build());
+        Object[] payload = response.get();
+
+        // verify
+        assertEquals(testResponse, response);
+        assertEquals(value, payload);
+    }
 }
diff --git a/java/client/src/test/java/glide/api/models/TransactionTests.java b/java/client/src/test/java/glide/api/models/TransactionTests.java
index 62e17215bf..dec4038c85 100644
--- a/java/client/src/test/java/glide/api/models/TransactionTests.java
+++ b/java/client/src/test/java/glide/api/models/TransactionTests.java
@@ -211,6 +211,7 @@
 import static redis_request.RedisRequestOuterClass.RequestType.ZRemRangeByRank;
 import static redis_request.RedisRequestOuterClass.RequestType.ZRemRangeByScore;
 import static redis_request.RedisRequestOuterClass.RequestType.ZRevRank;
+import static redis_request.RedisRequestOuterClass.RequestType.ZScan;
 import static redis_request.RedisRequestOuterClass.RequestType.ZScore;
 import static redis_request.RedisRequestOuterClass.RequestType.ZUnion;
 import static redis_request.RedisRequestOuterClass.RequestType.ZUnionStore;
@@ -249,6 +250,7 @@
 import glide.api.models.commands.geospatial.GeoUnit;
 import glide.api.models.commands.geospatial.GeospatialData;
 import glide.api.models.commands.scan.SScanOptions;
+import glide.api.models.commands.scan.ZScanOptions;
 import glide.api.models.commands.stream.StreamAddOptions;
 import glide.api.models.commands.stream.StreamGroupOptions;
 import glide.api.models.commands.stream.StreamPendingOptions;
@@ -1176,6 +1178,12 @@ InfScoreBound.NEGATIVE_INFINITY, new ScoreBoundary(3, false), new Limit(1, 2)),
         transaction.sscan("key1", "0", SScanOptions.builder().matchPattern("*").count(10L).build());
         results.add(Pair.of(SScan, buildArgs("key1", "0", "MATCH", "*", "COUNT", "10")));
 
+        transaction.zscan("key1", "0");
+        results.add(Pair.of(ZScan, buildArgs("key1", "0")));
+
+        transaction.zscan("key1", "0", ZScanOptions.builder().matchPattern("*").count(10L).build());
+        results.add(Pair.of(ZScan, buildArgs("key1", "0", "MATCH", "*", "COUNT", "10")));
+
         var protobufTransaction = transaction.getProtobufTransaction().build();
 
         for (int idx = 0; idx < protobufTransaction.getCommandsCount(); idx++) {
diff --git a/java/integTest/src/test/java/glide/SharedCommandTests.java b/java/integTest/src/test/java/glide/SharedCommandTests.java
index 6f7c738833..9930a74c7f 100644
--- a/java/integTest/src/test/java/glide/SharedCommandTests.java
+++ b/java/integTest/src/test/java/glide/SharedCommandTests.java
@@ -71,6 +71,7 @@
 import glide.api.models.commands.geospatial.GeoUnit;
 import glide.api.models.commands.geospatial.GeospatialData;
 import glide.api.models.commands.scan.SScanOptions;
+import glide.api.models.commands.scan.ZScanOptions;
 import glide.api.models.commands.stream.StreamAddOptions;
 import glide.api.models.commands.stream.StreamGroupOptions;
 import glide.api.models.commands.stream.StreamPendingOptions;
@@ -7041,6 +7042,11 @@ public void sscan(BaseClient client) {
         assertEquals(initialCursor, result[resultCursorIndex]);
         assertDeepEquals(new String[] {}, result[resultCollectionIndex]);
 
+        // Negative cursor
+        result = client.sscan(key1, "-1").get();
+        assertEquals(initialCursor, result[resultCursorIndex]);
+        assertDeepEquals(new String[] {}, result[resultCollectionIndex]);
+
         // Result contains the whole set
         assertEquals(charMembers.length, client.sadd(key1, charMembers).get());
         result = client.sscan(key1, initialCursor).get();
@@ -7085,6 +7091,12 @@ public void sscan(BaseClient client) {
                             .collect(Collectors.toSet()));
         } while (!resultCursor.equals("0")); // 0 is returned for the cursor of the last iteration.
 
+        assertTrue(
+                secondResultValues.containsAll(numberMembersSet),
+                String.format(
+                        "secondResultValues: {%s}, numberMembersSet: {%s}",
+                        secondResultValues, numberMembersSet));
+
         assertTrue(
                 secondResultValues.containsAll(numberMembersSet),
                 String.format(
@@ -7094,12 +7106,12 @@ public void sscan(BaseClient client) {
         // Test match pattern
         result =
                 client.sscan(key1, initialCursor, SScanOptions.builder().matchPattern("*").build()).get();
-        assertTrue(Long.parseLong(result[resultCursorIndex].toString()) > 0);
+        assertTrue(Long.parseLong(result[resultCursorIndex].toString()) >= 0);
         assertTrue(ArrayUtils.getLength(result[resultCollectionIndex]) >= defaultCount);
 
         // Test count
         result = client.sscan(key1, initialCursor, SScanOptions.builder().count(20L).build()).get();
-        assertTrue(Long.parseLong(result[resultCursorIndex].toString()) > 0);
+        assertTrue(Long.parseLong(result[resultCursorIndex].toString()) >= 0);
         assertTrue(ArrayUtils.getLength(result[resultCollectionIndex]) >= 20);
 
         // Test count with match returns a non-empty list
@@ -7108,7 +7120,7 @@ public void sscan(BaseClient client) {
                         .sscan(
                                 key1, initialCursor, SScanOptions.builder().matchPattern("1*").count(20L).build())
                         .get();
-        assertTrue(Long.parseLong(result[resultCursorIndex].toString()) > 0);
+        assertTrue(Long.parseLong(result[resultCursorIndex].toString()) >= 0);
         assertTrue(ArrayUtils.getLength(result[resultCollectionIndex]) > 0);
 
         // Exceptions
@@ -7137,4 +7149,172 @@ public void sscan(BaseClient client) {
                         () -> client.sscan(key1, "-1", SScanOptions.builder().count(-1L).build()).get());
         assertInstanceOf(RequestException.class, executionException.getCause());
     }
+
+    @SneakyThrows
+    @ParameterizedTest(autoCloseArguments = false)
+    @MethodSource("getClients")
+    public void zscan(BaseClient client) {
+        String key1 = "{key}-1" + UUID.randomUUID();
+        String key2 = "{key}-2" + UUID.randomUUID();
+        String initialCursor = "0";
+        long defaultCount = 20;
+        int resultCursorIndex = 0;
+        int resultCollectionIndex = 1;
+
+        // Setup test data
+        Map<String, Double> numberMap = new HashMap<>();
+        for (Double i = 0.0; i < 125; i++) {
+            numberMap.put(String.valueOf(i), i);
+        }
+        String[] charMembers = new String[] {"a", "b", "c", "d", "e"};
+        Map<String, Double> charMap = new HashMap<>();
+        for (double i = 0.0; i < 5; i++) {
+            charMap.put(charMembers[(int) i], i);
+        }
+
+        // Empty set
+        Object[] result = client.zscan(key1, initialCursor).get();
+        assertEquals(initialCursor, result[resultCursorIndex]);
+        assertDeepEquals(new String[] {}, result[resultCollectionIndex]);
+
+        // Negative cursor
+        result = client.zscan(key1, "-1").get();
+        assertEquals(initialCursor, result[resultCursorIndex]);
+        assertDeepEquals(new String[] {}, result[resultCollectionIndex]);
+
+        // Result contains the whole set
+        assertEquals(charMembers.length, client.zadd(key1, charMap).get());
+        result = client.zscan(key1, initialCursor).get();
+        assertEquals(initialCursor, result[resultCursorIndex]);
+        assertEquals(
+                charMap.size() * 2,
+                ((Object[]) result[resultCollectionIndex])
+                        .length); // Length includes the score which is twice the map size
+        final Object[] resultArray = (Object[]) result[resultCollectionIndex];
+
+        final Set<Object> resultKeys = new HashSet<>();
+        final Set<Object> resultValues = new HashSet<>();
+        for (int i = 0; i < resultArray.length; i += 2) {
+            resultKeys.add(resultArray[i]);
+            resultValues.add(resultArray[i + 1]);
+        }
+        assertTrue(
+                resultKeys.containsAll(charMap.keySet()),
+                String.format("resultKeys: {%s} charMap.keySet(): {%s}", resultKeys, charMap.keySet()));
+
+        // The score comes back as an integer converted to a String when the fraction is zero.
+        final Set<String> expectedScoresAsStrings =
+                charMap.values().stream()
+                        .map(v -> String.valueOf(v.intValue()))
+                        .collect(Collectors.toSet());
+
+        assertTrue(
+                resultValues.containsAll(expectedScoresAsStrings),
+                String.format(
+                        "resultValues: {%s} expectedScoresAsStrings: {%s}",
+                        resultValues, expectedScoresAsStrings));
+
+        result =
+                client.zscan(key1, initialCursor, ZScanOptions.builder().matchPattern("a").build()).get();
+        assertEquals(initialCursor, result[resultCursorIndex]);
+        assertDeepEquals(new String[] {"a", "0"}, result[resultCollectionIndex]);
+
+        // Result contains a subset of the key
+        assertEquals(numberMap.size(), client.zadd(key1, numberMap).get());
+        String resultCursor = "0";
+        final Set<Object> secondResultAllKeys = new HashSet<>();
+        final Set<Object> secondResultAllValues = new HashSet<>();
+        do {
+            result = client.zscan(key1, resultCursor).get();
+            resultCursor = result[resultCursorIndex].toString();
+            Object[] resultEntry = (Object[]) result[resultCollectionIndex];
+            for (int i = 0; i < resultEntry.length; i += 2) {
+                secondResultAllKeys.add(resultEntry[i]);
+                secondResultAllValues.add(resultEntry[i + 1]);
+            }
+
+            if (resultCursor.equals("0")) {
+                break;
+            }
+
+            // Scan with result cursor has a different set
+            Object[] secondResult = client.zscan(key1, resultCursor).get();
+            String newResultCursor = secondResult[resultCursorIndex].toString();
+            assertNotEquals(resultCursor, newResultCursor);
+            resultCursor = newResultCursor;
+            Object[] secondResultEntry = (Object[]) secondResult[resultCollectionIndex];
+            assertFalse(
+                    Arrays.deepEquals(
+                            ArrayUtils.toArray(result[resultCollectionIndex]),
+                            ArrayUtils.toArray(secondResult[resultCollectionIndex])));
+
+            for (int i = 0; i < secondResultEntry.length; i += 2) {
+                secondResultAllKeys.add(secondResultEntry[i]);
+                secondResultAllValues.add(secondResultEntry[i + 1]);
+            }
+        } while (!resultCursor.equals("0")); // 0 is returned for the cursor of the last iteration.
+
+        assertTrue(
+                secondResultAllKeys.containsAll(numberMap.keySet()),
+                String.format(
+                        "secondResultAllKeys: {%s} numberMap.keySet: {%s}",
+                        secondResultAllKeys, numberMap.keySet()));
+
+        final Set<String> numberMapValuesAsStrings =
+                numberMap.values().stream()
+                        .map(d -> String.valueOf(d.intValue()))
+                        .collect(Collectors.toSet());
+
+        assertTrue(
+                secondResultAllValues.containsAll(numberMapValuesAsStrings),
+                String.format(
+                        "secondResultAllValues: {%s} numberMapValuesAsStrings: {%s}",
+                        secondResultAllValues, numberMapValuesAsStrings));
+
+        // Test match pattern
+        result =
+                client.zscan(key1, initialCursor, ZScanOptions.builder().matchPattern("*").build()).get();
+        assertTrue(Long.parseLong(result[resultCursorIndex].toString()) >= 0);
+        assertTrue(ArrayUtils.getLength(result[resultCollectionIndex]) >= defaultCount);
+
+        // Test count
+        result = client.zscan(key1, initialCursor, ZScanOptions.builder().count(20L).build()).get();
+        assertTrue(Long.parseLong(result[resultCursorIndex].toString()) >= 0);
+        assertTrue(ArrayUtils.getLength(result[resultCollectionIndex]) >= 20);
+
+        // Test count with match returns a non-empty list
+        result =
+                client
+                        .zscan(
+                                key1, initialCursor, ZScanOptions.builder().matchPattern("1*").count(20L).build())
+                        .get();
+        assertTrue(Long.parseLong(result[resultCursorIndex].toString()) >= 0);
+        assertTrue(ArrayUtils.getLength(result[resultCollectionIndex]) > 0);
+
+        // Exceptions
+        // Non-set key
+        assertEquals(OK, client.set(key2, "test").get());
+        ExecutionException executionException =
+                assertThrows(ExecutionException.class, () -> client.zscan(key2, initialCursor).get());
+        assertInstanceOf(RequestException.class, executionException.getCause());
+
+        executionException =
+                assertThrows(
+                        ExecutionException.class,
+                        () ->
+                                client
+                                        .zscan(
+                                                key2,
+                                                initialCursor,
+                                                ZScanOptions.builder().matchPattern("test").count(1L).build())
+                                        .get());
+        assertInstanceOf(RequestException.class, executionException.getCause());
+
+        // Negative count
+        executionException =
+                assertThrows(
+                        ExecutionException.class,
+                        () -> client.zscan(key1, "-1", ZScanOptions.builder().count(-1L).build()).get());
+        assertInstanceOf(RequestException.class, executionException.getCause());
+    }
 }
diff --git a/java/integTest/src/test/java/glide/TransactionTestUtilities.java b/java/integTest/src/test/java/glide/TransactionTestUtilities.java
index 8e4813562b..15ec558a7e 100644
--- a/java/integTest/src/test/java/glide/TransactionTestUtilities.java
+++ b/java/integTest/src/test/java/glide/TransactionTestUtilities.java
@@ -37,6 +37,7 @@
 import glide.api.models.commands.geospatial.GeoUnit;
 import glide.api.models.commands.geospatial.GeospatialData;
 import glide.api.models.commands.scan.SScanOptions;
+import glide.api.models.commands.scan.ZScanOptions;
 import glide.api.models.commands.stream.StreamAddOptions;
 import glide.api.models.commands.stream.StreamGroupOptions;
 import glide.api.models.commands.stream.StreamRange;
@@ -624,6 +625,8 @@ private static Object[] sortedSetCommands(BaseTransaction<?> transaction) {
                 .zrandmember(zSetKey2)
                 .zrandmemberWithCount(zSetKey2, 1)
                 .zrandmemberWithCountWithScores(zSetKey2, 1)
+                .zscan(zSetKey2, "0")
+                .zscan(zSetKey2, "0", ZScanOptions.builder().count(20L).build())
                 .bzpopmin(new String[] {zSetKey2}, .1);
         // zSetKey2 is now empty
 
@@ -683,6 +686,10 @@ private static Object[] sortedSetCommands(BaseTransaction<?> transaction) {
                     "one", // zrandmember(zSetKey2)
                     new String[] {"one"}, // .zrandmemberWithCount(zSetKey2, 1)
                     new Object[][] {{"one", 1.0}}, // .zrandmemberWithCountWithScores(zSetKey2, 1);
+                    new Object[] {"0", new String[] {"one", "1"}}, // zscan(zSetKey2, 0)
+                    new Object[] {
+                        "0", new String[] {"one", "1"}
+                    }, // zscan(zSetKey2, 0, ZScanOptions.builder().count(20L).build())
                     new Object[] {zSetKey2, "one", 1.0}, // bzpopmin(new String[] { zsetKey2 }, .1)
                 };