diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 64676ce99c..663fe41e53 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ ktlint = "12.1.0" dokka = "2.0.0" kotlinx_datetime = "0.6.2" kotlinx_coroutines = "1.10.2" -pubnub_js = "9.8.1" +pubnub_js = "10.1.0" pubnub_swift = "9.3.2" [libraries] diff --git a/pubnub-gson/pubnub-gson-api/src/main/java/com/pubnub/api/java/endpoints/presence/HereNow.java b/pubnub-gson/pubnub-gson-api/src/main/java/com/pubnub/api/java/endpoints/presence/HereNow.java index b44f67387e..1fd02f2f50 100644 --- a/pubnub-gson/pubnub-gson-api/src/main/java/com/pubnub/api/java/endpoints/presence/HereNow.java +++ b/pubnub-gson/pubnub-gson-api/src/main/java/com/pubnub/api/java/endpoints/presence/HereNow.java @@ -11,4 +11,8 @@ public interface HereNow extends Endpoint { HereNow includeState(boolean includeState); HereNow includeUUIDs(boolean includeUUIDs); + + HereNow limit(int limit); + + HereNow offset(Integer offset); } diff --git a/pubnub-gson/pubnub-gson-docs/src/main/java/com/pubnub/docs/presence/HereNowApp.java b/pubnub-gson/pubnub-gson-docs/src/main/java/com/pubnub/docs/presence/HereNowApp.java index 13879ec6e8..0e80fe4cb6 100644 --- a/pubnub-gson/pubnub-gson-docs/src/main/java/com/pubnub/docs/presence/HereNowApp.java +++ b/pubnub-gson/pubnub-gson-docs/src/main/java/com/pubnub/docs/presence/HereNowApp.java @@ -4,6 +4,7 @@ import com.pubnub.api.PubNubException; import com.pubnub.api.UserId; +import com.pubnub.api.enums.PNLogVerbosity; import com.pubnub.api.java.PubNub; import com.pubnub.api.java.v2.PNConfiguration; import com.pubnub.api.models.consumer.presence.PNHereNowChannelData; @@ -17,6 +18,7 @@ public static void main(String[] args) throws PubNubException { // Configure PubNub instance PNConfiguration.Builder configBuilder = PNConfiguration.builder(new UserId("demoUserId"), "demo"); configBuilder.publishKey("demo"); + configBuilder.logVerbosity(PNLogVerbosity.BODY); configBuilder.secure(true); PubNub pubnub = PubNub.create(configBuilder.build()); @@ -25,6 +27,8 @@ public static void main(String[] args) throws PubNubException { pubnub.hereNow() .channels(Arrays.asList("coolChannel", "coolChannel2")) .includeUUIDs(true) + .limit(100) + .offset(10) .async(result -> { result.onSuccess((PNHereNowResult res) -> { diff --git a/pubnub-gson/pubnub-gson-impl/src/integrationTest/java/com/pubnub/api/integration/PresenceIntegrationTests.java b/pubnub-gson/pubnub-gson-impl/src/integrationTest/java/com/pubnub/api/integration/PresenceIntegrationTests.java index 66767d4623..b99367275b 100644 --- a/pubnub-gson/pubnub-gson-impl/src/integrationTest/java/com/pubnub/api/integration/PresenceIntegrationTests.java +++ b/pubnub-gson/pubnub-gson-impl/src/integrationTest/java/com/pubnub/api/integration/PresenceIntegrationTests.java @@ -1,6 +1,7 @@ package com.pubnub.api.integration; import com.google.gson.JsonObject; +import com.pubnub.api.PubNubException; import com.pubnub.api.enums.PNHeartbeatNotificationOptions; import com.pubnub.api.enums.PNStatusCategory; import com.pubnub.api.integration.util.BaseIntegrationTest; @@ -11,6 +12,7 @@ import com.pubnub.api.models.consumer.PNStatus; import com.pubnub.api.models.consumer.presence.PNHereNowChannelData; import com.pubnub.api.models.consumer.presence.PNHereNowOccupantData; +import com.pubnub.api.models.consumer.presence.PNHereNowResult; import com.pubnub.api.models.consumer.pubsub.PNPresenceEventResult; import org.awaitility.Awaitility; import org.awaitility.Durations; @@ -20,15 +22,20 @@ import org.junit.Test; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; public class PresenceIntegrationTests extends BaseIntegrationTest { @@ -382,4 +389,327 @@ public void status(@NotNull PubNub pubnub, @NotNull PNStatus status) { .until(() -> subscribeSuccess.get() && heartbeatCallsCount.get() > 2); } + @Test + public void testHereNowWithLimit() { + final AtomicBoolean success = new AtomicBoolean(); + final int testLimit = 3; + final int totalClientsCount = 6; + final String expectedChannel = RandomGenerator.get(); + + final List clients = new ArrayList() {{ + add(pubNub); + }}; + for (int i = 1; i < totalClientsCount; i++) { + clients.add(getPubNub()); + } + + for (PubNub client : clients) { + subscribeToChannel(client, expectedChannel); + } + + pause(TIMEOUT_MEDIUM); + + pubNub.hereNow() + .channels(Collections.singletonList(expectedChannel)) + .includeUUIDs(true) + .limit(testLimit) + .async((result) -> { + assertFalse(result.isFailure()); + result.onSuccess(pnHereNowResult -> { + assertEquals(1, pnHereNowResult.getTotalChannels()); + assertEquals(1, pnHereNowResult.getChannels().size()); + assertTrue(pnHereNowResult.getChannels().containsKey(expectedChannel)); + + PNHereNowChannelData channelData = pnHereNowResult.getChannels().get(expectedChannel); + assertNotNull(channelData); + assertEquals(totalClientsCount, channelData.getOccupancy()); + + // With limit=3, we should get only 3 occupants even though 6 are present + assertEquals(testLimit, channelData.getOccupants().size()); + + success.set(true); + }); + }); + + listen(success); + } + + @Test + public void testHereNowWithOffset() { + final AtomicBoolean success = new AtomicBoolean(); + final int offsetValue = 2; + final int totalClientsCount = 5; + final String expectedChannel = RandomGenerator.get(); + + final List clients = new ArrayList() {{ + add(pubNub); + }}; + for (int i = 1; i < totalClientsCount; i++) { + clients.add(getPubNub()); + } + + for (PubNub client : clients) { + subscribeToChannel(client, expectedChannel); + } + + pause(TIMEOUT_MEDIUM); + + pubNub.hereNow() + .channels(Collections.singletonList(expectedChannel)) + .includeUUIDs(true) + .offset(offsetValue) + .async((result) -> { + assertFalse(result.isFailure()); + result.onSuccess(pnHereNowResult -> { + assertEquals(1, pnHereNowResult.getTotalChannels()); + assertEquals(1, pnHereNowResult.getChannels().size()); + assertTrue(pnHereNowResult.getChannels().containsKey(expectedChannel)); + + PNHereNowChannelData channelData = pnHereNowResult.getChannels().get(expectedChannel); + assertNotNull(channelData); + assertEquals(totalClientsCount, channelData.getOccupancy()); + + // With offset=2, we should get remaining occupants (5 total - 2 skipped = 3 remaining) + assertEquals(totalClientsCount - offsetValue, channelData.getOccupants().size()); + + success.set(true); + }); + }); + + listen(success); + } + + @Test + public void testHereNowPaginationFlow() throws PubNubException { + // 8 users in channel01 + // 3 users in channel02 + final int pageSize = 3; + final int firstOffset = 0; + final int secondOffset = 3; + final int totalClientsCount = 11; + final int channel01TotalCount = 8; + final int channel02TotalCount = 3; + final String channel01 = RandomGenerator.get(); + final String channel02 = RandomGenerator.get(); + + final List clients = new ArrayList() {{ + add(pubNub); + }}; + for (int i = 1; i < channel01TotalCount; i++) { + clients.add(getPubNub()); + } + + for (PubNub client : clients) { + subscribeToChannel(client, channel01); + } + + for (int i = 0; i < channel02TotalCount; i++) { + subscribeToChannel(clients.get(i), channel02); + } + + pause(TIMEOUT_MEDIUM); + + final Set allOccupantsInChannel01 = new HashSet<>(); + + // First page + PNHereNowResult firstPage = pubNub.hereNow() + .channels(Arrays.asList(channel01, channel02)) + .includeUUIDs(true) + .limit(pageSize) + .sync(); + + assertNotNull(firstPage); + assertEquals(2, firstPage.getTotalChannels()); + + PNHereNowChannelData channel01DataPage01 = firstPage.getChannels().get(channel01); + assertNotNull(channel01DataPage01); + assertEquals(channel01TotalCount, channel01DataPage01.getOccupancy()); + assertEquals(totalClientsCount, firstPage.getTotalOccupancy()); // total occupancy across all channels + assertEquals(pageSize, channel01DataPage01.getOccupants().size()); + + PNHereNowChannelData channel02Data = firstPage.getChannels().get(channel02); + assertNotNull(channel02Data); + assertEquals(channel02TotalCount, channel02Data.getOccupancy()); + assertEquals(pageSize, channel02Data.getOccupants().size()); + + // Collect UUIDs from first page + for (PNHereNowOccupantData occupant : channel01DataPage01.getOccupants()) { + allOccupantsInChannel01.add(occupant.getUuid()); + } + + // Second page using pageSize + firstOffset + PNHereNowResult secondPage = pubNub.hereNow() + .channels(Collections.singletonList(channel01)) + .includeUUIDs(true) + .limit(pageSize) + .offset(pageSize + firstOffset) + .sync(); + + assertNotNull(secondPage); + PNHereNowChannelData channel01DataPage02 = secondPage.getChannels().get(channel01); + assertNotNull(channel01DataPage02); + assertEquals(channel01TotalCount, channel01DataPage02.getOccupancy()); + assertEquals(channel01TotalCount, secondPage.getTotalOccupancy()); // only channel01 in results + assertEquals(pageSize, channel01DataPage02.getOccupants().size()); + + + assertFalse(secondPage.getChannels().containsKey(channel02)); + + // Collect UUIDs from second page (should not overlap with first page) + for (PNHereNowOccupantData occupant : channel01DataPage02.getOccupants()) { + assertFalse("UUID " + occupant.getUuid() + " already found in first page", + allOccupantsInChannel01.contains(occupant.getUuid())); + allOccupantsInChannel01.add(occupant.getUuid()); + } + + // Third page using pageSize + secondOffset + PNHereNowResult thirdPage = pubNub.hereNow() + .channels(Collections.singletonList(channel01)) + .includeUUIDs(true) + .limit(pageSize) + .offset(pageSize + secondOffset) + .sync(); + + assertNotNull(thirdPage); + PNHereNowChannelData channel01DataPage03 = thirdPage.getChannels().get(channel01); + assertNotNull(channel01DataPage03); + assertEquals(channel01TotalCount, channel01DataPage03.getOccupancy()); + + // Should have remaining clients (8 - 3 - 3 = 2) + int expectedRemainingCount = channel01TotalCount - (pageSize * 2); + assertEquals(expectedRemainingCount, channel01DataPage03.getOccupants().size()); + + // Collect UUIDs from third page + for (PNHereNowOccupantData occupant : channel01DataPage03.getOccupants()) { + assertFalse("UUID " + occupant.getUuid() + " already found", + allOccupantsInChannel01.contains(occupant.getUuid())); + allOccupantsInChannel01.add(occupant.getUuid()); + } + + // Verify we got all unique clients + assertEquals(channel01TotalCount, allOccupantsInChannel01.size()); + } + + @Test + public void testHereNowPaginationWithEmptyChannels() { + final AtomicBoolean success = new AtomicBoolean(); + final String emptyChannel = RandomGenerator.get(); + final int pageSize = 10; + + // Don't subscribe any clients to the channel, leaving it empty + + pubNub.hereNow() + .channels(Collections.singletonList(emptyChannel)) + .includeUUIDs(true) + .limit(pageSize) + .async((result) -> { + assertFalse(result.isFailure()); + result.onSuccess(pnHereNowResult -> { + // Empty channels are still included in the response + assertEquals(1, pnHereNowResult.getTotalChannels()); + assertEquals(0, pnHereNowResult.getTotalOccupancy()); + assertEquals(1, pnHereNowResult.getChannels().size()); + + PNHereNowChannelData channelData = pnHereNowResult.getChannels().get(emptyChannel); + assertNotNull(channelData); + assertEquals(0, channelData.getOccupancy()); + assertTrue(channelData.getOccupants().isEmpty()); + + success.set(true); + }); + }); + + listen(success); + } + + @Test + public void testGlobalHereNowWithLimit() throws PubNubException { + final int testLimit = 3; + final int totalClientsCount = 6; + final String channel01 = RandomGenerator.get(); + final String channel02 = RandomGenerator.get(); + + final List clients = new ArrayList() {{ + add(pubNub); + }}; + for (int i = 1; i < totalClientsCount; i++) { + clients.add(getPubNub()); + } + + // Subscribe first 3 clients to channel01, all 6 to channel02 + for (int i = 0; i < 3; i++) { + subscribeToChannel(clients.get(i), channel01); + } + for (PubNub client : clients) { + subscribeToChannel(client, channel02); + } + + pause(TIMEOUT_MEDIUM); + + // Global hereNow (empty channels list) + PNHereNowResult result = pubNub.hereNow() + .channels(Collections.emptyList()) + .includeUUIDs(true) + .limit(testLimit) + .sync(); + + assertNotNull(result); + + // Should include at least our test channels + assertTrue(result.getTotalChannels() >= 2); + assertTrue(result.getChannels().containsKey(channel01)); + assertTrue(result.getChannels().containsKey(channel02)); + + PNHereNowChannelData channel01Data = result.getChannels().get(channel01); + PNHereNowChannelData channel02Data = result.getChannels().get(channel02); + + assertNotNull(channel01Data); + assertNotNull(channel02Data); + assertEquals(3, channel01Data.getOccupancy()); + assertEquals(6, channel02Data.getOccupancy()); + + // With limit=3, each channel should have at most 3 occupants returned + assertTrue(channel01Data.getOccupants().size() <= testLimit); + assertTrue(channel02Data.getOccupants().size() <= testLimit); + } + + @Test + public void testGlobalHereNowWithOffset() throws PubNubException { + final int offsetValue = 2; + final int totalClientsCount = 5; + final String expectedChannel = RandomGenerator.get(); + + final List clients = new ArrayList() {{ + add(pubNub); + }}; + for (int i = 1; i < totalClientsCount; i++) { + clients.add(getPubNub()); + } + + for (PubNub client : clients) { + subscribeToChannel(client, expectedChannel); + } + + pause(TIMEOUT_MEDIUM); + + // Global hereNow with offset + PNHereNowResult result = pubNub.hereNow() + .channels(Collections.emptyList()) + .includeUUIDs(true) + .offset(offsetValue) + .sync(); + + assertNotNull(result); + + // Should include at least our test channel + assertTrue(result.getTotalChannels() >= 1); + assertTrue(result.getChannels().containsKey(expectedChannel)); + + PNHereNowChannelData channelData = result.getChannels().get(expectedChannel); + assertNotNull(channelData); + assertEquals(totalClientsCount, channelData.getOccupancy()); + + // With offset=2, we should get remaining occupants + assertTrue(channelData.getOccupants().size() <= totalClientsCount - offsetValue); + } } diff --git a/pubnub-gson/pubnub-gson-impl/src/main/java/com/pubnub/internal/java/endpoints/presence/HereNowImpl.java b/pubnub-gson/pubnub-gson-impl/src/main/java/com/pubnub/internal/java/endpoints/presence/HereNowImpl.java index 2d6f54d462..2bdab9bd6c 100644 --- a/pubnub-gson/pubnub-gson-impl/src/main/java/com/pubnub/internal/java/endpoints/presence/HereNowImpl.java +++ b/pubnub-gson/pubnub-gson-impl/src/main/java/com/pubnub/internal/java/endpoints/presence/HereNowImpl.java @@ -16,10 +16,13 @@ @Setter @Accessors(chain = true, fluent = true) public class HereNowImpl extends PassthroughEndpoint implements HereNow { + public static final int MAX_CHANNEL_OCCUPANTS_LIMIT = 1000; private List channels = new ArrayList<>(); private List channelGroups = new ArrayList<>(); private boolean includeState = false; private boolean includeUUIDs = true; + private int limit = MAX_CHANNEL_OCCUPANTS_LIMIT; + private Integer offset = null; public HereNowImpl(PubNub pubnub) { super(pubnub); @@ -32,7 +35,9 @@ protected Endpoint createRemoteAction() { channels, channelGroups, includeState, - includeUUIDs + includeUUIDs, + limit, + offset ); } } diff --git a/pubnub-kotlin/pubnub-kotlin-api/src/appleMain/kotlin/com/pubnub/api/PubNubImpl.kt b/pubnub-kotlin/pubnub-kotlin-api/src/appleMain/kotlin/com/pubnub/api/PubNubImpl.kt index 43a2d06817..86d73e2a06 100644 --- a/pubnub-kotlin/pubnub-kotlin-api/src/appleMain/kotlin/com/pubnub/api/PubNubImpl.kt +++ b/pubnub-kotlin/pubnub-kotlin-api/src/appleMain/kotlin/com/pubnub/api/PubNubImpl.kt @@ -353,14 +353,18 @@ class PubNubImpl(private val pubNubObjC: KMPPubNub) : PubNub { channels: List, channelGroups: List, includeState: Boolean, - includeUUIDs: Boolean + includeUUIDs: Boolean, + limit: Int, + offset: Int? ): HereNow { return HereNowImpl( pubnub = pubNubObjC, channels = channels, channelGroups = channelGroups, includeState = includeState, - includeUUIDs = includeUUIDs + includeUUIDs = includeUUIDs, + limit = limit, + offset = offset ) } diff --git a/pubnub-kotlin/pubnub-kotlin-api/src/appleMain/kotlin/com/pubnub/api/endpoints/presence/HereNow.ios.kt b/pubnub-kotlin/pubnub-kotlin-api/src/appleMain/kotlin/com/pubnub/api/endpoints/presence/HereNow.ios.kt index e1043b2a3d..87ccd1334c 100644 --- a/pubnub-kotlin/pubnub-kotlin-api/src/appleMain/kotlin/com/pubnub/api/endpoints/presence/HereNow.ios.kt +++ b/pubnub-kotlin/pubnub-kotlin-api/src/appleMain/kotlin/com/pubnub/api/endpoints/presence/HereNow.ios.kt @@ -27,7 +27,9 @@ class HereNowImpl( private val channels: List, private val channelGroups: List, private val includeState: Boolean, - private val includeUUIDs: Boolean + private val includeUUIDs: Boolean, + private val limit: Int = 1000, + private val offset: Int? = null, ) : HereNow { override fun async(callback: Consumer>) { pubnub.hereNowWithChannels( @@ -35,6 +37,9 @@ class HereNowImpl( channelGroups = channelGroups, includeState = includeState, includeUUIDs = includeUUIDs, + // todo pass limit and offset once available + // limit = limit, + // offset = offset, onSuccess = callback.onSuccessHandler { PNHereNowResult( totalChannels = it?.totalChannels()?.toInt() ?: 0, diff --git a/pubnub-kotlin/pubnub-kotlin-api/src/commonMain/kotlin/com/pubnub/api/PubNub.kt b/pubnub-kotlin/pubnub-kotlin-api/src/commonMain/kotlin/com/pubnub/api/PubNub.kt index ee388d74ce..6086654a6f 100644 --- a/pubnub-kotlin/pubnub-kotlin-api/src/commonMain/kotlin/com/pubnub/api/PubNub.kt +++ b/pubnub-kotlin/pubnub-kotlin-api/src/commonMain/kotlin/com/pubnub/api/PubNub.kt @@ -171,6 +171,8 @@ expect interface PubNub { channelGroups: List = emptyList(), includeState: Boolean = false, includeUUIDs: Boolean = true, + limit: Int = 1000, + offset: Int? = null, ): HereNow fun whereNow(uuid: String = configuration.userId.value): WhereNow diff --git a/pubnub-kotlin/pubnub-kotlin-api/src/jsMain/kotlin/com/pubnub/api/PubNubImpl.kt b/pubnub-kotlin/pubnub-kotlin-api/src/jsMain/kotlin/com/pubnub/api/PubNubImpl.kt index 6fd8a1444b..e980f510bd 100644 --- a/pubnub-kotlin/pubnub-kotlin-api/src/jsMain/kotlin/com/pubnub/api/PubNubImpl.kt +++ b/pubnub-kotlin/pubnub-kotlin-api/src/jsMain/kotlin/com/pubnub/api/PubNubImpl.kt @@ -354,11 +354,14 @@ class PubNubImpl(val jsPubNub: PubNubJs) : PubNub { channels: List, channelGroups: List, includeState: Boolean, - includeUUIDs: Boolean + includeUUIDs: Boolean, + limit: Int, + offset: Int? ): HereNow { return HereNowImpl( jsPubNub, createJsObject { + // todo handle limit and offset this.channels = channels.toTypedArray() this.channelGroups = channelGroups.toTypedArray() this.includeState = includeState diff --git a/pubnub-kotlin/pubnub-kotlin-api/src/jvmMain/kotlin/com/pubnub/api/PubNub.kt b/pubnub-kotlin/pubnub-kotlin-api/src/jvmMain/kotlin/com/pubnub/api/PubNub.kt index e0fb0a88e9..d5f064ff87 100644 --- a/pubnub-kotlin/pubnub-kotlin-api/src/jvmMain/kotlin/com/pubnub/api/PubNub.kt +++ b/pubnub-kotlin/pubnub-kotlin-api/src/jvmMain/kotlin/com/pubnub/api/PubNub.kt @@ -761,19 +761,30 @@ actual interface PubNub : StatusEmitter, EventEmitter { * currently subscribed to the channel and the total occupancy count of the channel. * * @param channels The channels to get the 'here now' details of. - * Leave empty for a 'global her now'. + * Leave empty for a 'global here now'. * @param channelGroups The channel groups to get the 'here now' details of. - * Leave empty for a 'global her now'. + * Leave empty for a 'global here now'. * @param includeState Whether the response should include presence state information, if available. * Defaults to `false`. * @param includeUUIDs Whether the response should include UUIDs od connected clients. * Defaults to `true`. + * @param limit Maximum number of occupants to return per channel. Valid range: 0-1000. + * - Default: 1000 + * - Use 0 to get occupancy counts without user details + * - Values outside the valid range are automatically adjusted to 1000 with a warning log + * @param offset Zero-based starting index for pagination. Returns occupants starting from this position in the list. Must be >= 0. + * - Default: null (no offset) + * - Requires limit > 0 (throws [PubNubException] with [PubNubError.HERE_NOW_OFFSET_REQUIRES_LIMIT_HIGHER_THAN_0] if limit is 0) + * - Use with limit to paginate through large user lists + * - Throws [PubNubException] with [PubNubError.HERE_NOW_OFFSET_OUT_OF_RANGE] if negative */ actual fun hereNow( channels: List, channelGroups: List, includeState: Boolean, includeUUIDs: Boolean, + limit: Int, + offset: Int?, ): HereNow /** diff --git a/pubnub-kotlin/pubnub-kotlin-api/src/jvmMain/kotlin/com/pubnub/api/endpoints/presence/HereNow.kt b/pubnub-kotlin/pubnub-kotlin-api/src/jvmMain/kotlin/com/pubnub/api/endpoints/presence/HereNow.kt index 6594e1616c..d71c7fa14c 100644 --- a/pubnub-kotlin/pubnub-kotlin-api/src/jvmMain/kotlin/com/pubnub/api/endpoints/presence/HereNow.kt +++ b/pubnub-kotlin/pubnub-kotlin-api/src/jvmMain/kotlin/com/pubnub/api/endpoints/presence/HereNow.kt @@ -11,4 +11,6 @@ actual interface HereNow : Endpoint { val channelGroups: List val includeState: Boolean val includeUUIDs: Boolean + val limit: Int + val offset: Int? } diff --git a/pubnub-kotlin/pubnub-kotlin-api/src/nonJvm/kotlin/com/pubnub/api/PubNub.nonJvm.kt b/pubnub-kotlin/pubnub-kotlin-api/src/nonJvm/kotlin/com/pubnub/api/PubNub.nonJvm.kt index 20dc62ab1a..09fb611dda 100644 --- a/pubnub-kotlin/pubnub-kotlin-api/src/nonJvm/kotlin/com/pubnub/api/PubNub.nonJvm.kt +++ b/pubnub-kotlin/pubnub-kotlin-api/src/nonJvm/kotlin/com/pubnub/api/PubNub.nonJvm.kt @@ -158,7 +158,9 @@ actual interface PubNub { channels: List, channelGroups: List, includeState: Boolean, - includeUUIDs: Boolean + includeUUIDs: Boolean, + limit: Int, + offset: Int? ): HereNow actual fun whereNow(uuid: String): WhereNow diff --git a/pubnub-kotlin/pubnub-kotlin-core-api/src/commonMain/kotlin/com/pubnub/api/PubNubError.kt b/pubnub-kotlin/pubnub-kotlin-core-api/src/commonMain/kotlin/com/pubnub/api/PubNubError.kt index e855bd4fe3..9c5514b7ff 100644 --- a/pubnub-kotlin/pubnub-kotlin-core-api/src/commonMain/kotlin/com/pubnub/api/PubNubError.kt +++ b/pubnub-kotlin/pubnub-kotlin-core-api/src/commonMain/kotlin/com/pubnub/api/PubNubError.kt @@ -239,6 +239,17 @@ enum class PubNubError(private val code: Int, val message: String) { 181, "Channel and/or ChannelGroup contains empty string which is not allowed.", ), + + HERE_NOW_OFFSET_OUT_OF_RANGE( + 182, + "HereNow offset is out of range. Valid range is 0 to infinity.", + ), + + HERE_NOW_OFFSET_REQUIRES_LIMIT_HIGHER_THAN_0( + 183, + "offset requires limit > 0", + ) + ; override fun toString(): String { diff --git a/pubnub-kotlin/pubnub-kotlin-docs/src/main/kotlin/com/pubnub/docs/presence/HereNowMain.kt b/pubnub-kotlin/pubnub-kotlin-docs/src/main/kotlin/com/pubnub/docs/presence/HereNowMain.kt index 899ca616e9..512c99a01e 100644 --- a/pubnub-kotlin/pubnub-kotlin-docs/src/main/kotlin/com/pubnub/docs/presence/HereNowMain.kt +++ b/pubnub-kotlin/pubnub-kotlin-docs/src/main/kotlin/com/pubnub/docs/presence/HereNowMain.kt @@ -42,7 +42,9 @@ fun singleChannelHereNow(pubnub: PubNub, channel: String) { println("\n# Basic hereNow for single channel: $channel") pubnub.hereNow( - channels = listOf(channel) + channels = listOf(channel), + limit = 100, + offset = 10 ).async { result -> result.onSuccess { response -> println("SUCCESS: Retrieved presence information") diff --git a/pubnub-kotlin/pubnub-kotlin-impl/src/integrationTest/kotlin/com/pubnub/api/integration/PresenceIntegrationTests.kt b/pubnub-kotlin/pubnub-kotlin-impl/src/integrationTest/kotlin/com/pubnub/api/integration/PresenceIntegrationTests.kt index 3834dd491b..5bda93c815 100644 --- a/pubnub-kotlin/pubnub-kotlin-impl/src/integrationTest/kotlin/com/pubnub/api/integration/PresenceIntegrationTests.kt +++ b/pubnub-kotlin/pubnub-kotlin-impl/src/integrationTest/kotlin/com/pubnub/api/integration/PresenceIntegrationTests.kt @@ -12,6 +12,7 @@ import com.pubnub.test.CommonUtils.randomValue import com.pubnub.test.asyncRetry import com.pubnub.test.await import com.pubnub.test.listen +import com.pubnub.test.subscribeNonBlocking import com.pubnub.test.subscribeToBlocking import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull @@ -23,6 +24,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Timeout import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean @@ -284,4 +286,459 @@ class PresenceIntegrationTests : BaseIntegrationTest() { Assert.assertNotNull(interceptedUrl) assertTrue(interceptedUrl!!.queryParameterNames.contains("ee")) } + + @Test + fun testHereNowWithLimit() { + val testLimit = 3 + val totalClientsCount = 6 + val expectedChannel = randomChannel() + + val clients = + mutableListOf(pubnub).apply { + addAll(generateSequence { createPubNub {} }.take(totalClientsCount - 1).toList()) + } + + clients.forEach { + it.subscribeNonBlocking(expectedChannel) + } + Thread.sleep(2000) + + pubnub.hereNow( + channels = listOf(expectedChannel), + includeUUIDs = true, + limit = testLimit, + ).asyncRetry { result -> + assertFalse(result.isFailure) + result.onSuccess { + assertEquals(1, it.totalChannels) + assertEquals(1, it.channels.size) + assertTrue(it.channels.containsKey(expectedChannel)) + + val channelData = it.channels[expectedChannel]!! + assertEquals(totalClientsCount, channelData.occupancy) + + // With limit=3, we should get only 3 occupants even though 6 are present + assertEquals(testLimit, channelData.occupants.size) + } + } + } + + @Test + fun testHereNowWithStartFrom() { + val offsetValue = 2 + val totalClientsCount = 5 + val expectedChannel = randomChannel() + + val clients = + mutableListOf(pubnub).apply { + addAll(generateSequence { createPubNub {} }.take(totalClientsCount - 1).toList()) + } + + clients.forEach { + it.subscribeNonBlocking(expectedChannel) + } + Thread.sleep(2000) + + pubnub.hereNow( + channels = listOf(expectedChannel), + includeUUIDs = true, + offset = offsetValue, + ).asyncRetry { result -> + assertFalse(result.isFailure) + result.onSuccess { + assertEquals(1, it.totalChannels) + assertEquals(1, it.channels.size) + assertTrue(it.channels.containsKey(expectedChannel)) + + val channelData = it.channels[expectedChannel]!! + assertEquals(totalClientsCount, channelData.occupancy) + + // With offset=2, we should get remaining occupants (5 total - 2 skipped = 3 remaining) + assertEquals(totalClientsCount - offsetValue, channelData.occupants.size) + } + } + } + + @Test + fun testHereNowWithStartFromIncludeUUIDSisFalse() { + val offsetValue = 2 + val totalClientsCount = 5 + val expectedChannel = randomChannel() + + val clients = + mutableListOf(pubnub).apply { + addAll(generateSequence { createPubNub {} }.take(totalClientsCount - 1).toList()) + } + + clients.forEach { + it.subscribeNonBlocking(expectedChannel) + } + Thread.sleep(2000) + + pubnub.hereNow( + channels = listOf(expectedChannel), + includeUUIDs = false, + offset = offsetValue, + ).asyncRetry { result -> + assertFalse(result.isFailure) + result.onSuccess { + assertEquals(1, it.totalChannels) + assertEquals(1, it.channels.size) // Channel data is always present (consistent with multi-channel) + assertEquals(totalClientsCount, it.totalOccupancy) + + // Verify channel data is present with occupancy but no occupants list + val channelData = it.channels[expectedChannel]!! + assertEquals(totalClientsCount, channelData.occupancy) + assertEquals(0, channelData.occupants.size) // occupants list is empty when includeUUIDs = false + } + } + } + + @Test + fun testHereNowPaginationFlow() { + // 8 users in channel01 + // 3 users in channel02 + val pageSize = 3 + val firstPageOffset = 0 + val secondPageOffset = 3 + val totalClientsCount = 11 + val channel01TotalCount = 8 + val channel02TotalCount = 3 + val channel01 = randomChannel() + val channel02 = randomChannel() + + val clients = + mutableListOf(pubnub).apply { + addAll(generateSequence { createPubNub {} }.take(channel01TotalCount - 1).toList()) + } + + clients.forEach { + it.subscribeNonBlocking(channel01) + } + + clients.take(3).forEach { + it.subscribeNonBlocking(channel02) + } + + Thread.sleep(2000) + + val allOccupantsInChannel01 = mutableSetOf() + + // First page + val firstPage = pubnub.hereNow( + channels = listOf(channel01, channel02), + includeUUIDs = true, + limit = pageSize, + ).sync()!! + + assertEquals(2, firstPage.totalChannels) + val channel01DataPage01 = firstPage.channels[channel01]!! + assertEquals(channel01TotalCount, channel01DataPage01.occupancy) + assertEquals(totalClientsCount, firstPage.totalOccupancy) // this is totalOccupancy in all pages + assertEquals(pageSize, channel01DataPage01.occupants.size) + val channel02Data = firstPage.channels[channel02]!! + assertEquals(channel02TotalCount, channel02Data.occupancy) + assertEquals(pageSize, channel02Data.occupants.size) + + // Collect UUIDs from first page + channel01DataPage01.occupants.forEach { allOccupantsInChannel01.add(it.uuid) } + + // Second page using pageSize + firstPageOffset + val secondPage = pubnub.hereNow( + channels = listOf(channel01), + includeUUIDs = true, + limit = pageSize, + offset = pageSize + firstPageOffset, + ).sync()!! + + val channel01DataPage02 = secondPage.channels[channel01]!! + assertEquals(channel01TotalCount, channel01DataPage02.occupancy) + assertEquals( + channel01TotalCount, + secondPage.totalOccupancy + ) // we get result only from channel01 because there is no more result for channel02 + assertEquals(pageSize, channel01DataPage02.occupants.size) + + assertFalse(secondPage.channels.containsKey(channel02)) + + // Collect UUIDs from second page (should not overlap with first page) + channel01DataPage02.occupants.forEach { + assertFalse("UUID ${it.uuid} already found in first page", allOccupantsInChannel01.contains(it.uuid)) + allOccupantsInChannel01.add(it.uuid) + } + + // Third page using pageSize + secondPageOffset + val thirdPage = pubnub.hereNow( + channels = listOf(channel01), + includeUUIDs = true, + limit = pageSize, + offset = pageSize + secondPageOffset, + ).sync()!! + + val channel01DataPage03 = thirdPage.channels[channel01]!! + assertEquals(channel01TotalCount, channel01DataPage03.occupancy) + + // Should have remaining clients (8 - 3 - 3 = 2) + val expectedRemainingCount = channel01TotalCount - (pageSize * 2) + assertEquals(expectedRemainingCount, channel01DataPage03.occupants.size) + + // Collect UUIDs from third page + channel01DataPage03.occupants.forEach { + assertFalse("UUID ${it.uuid} already found", allOccupantsInChannel01.contains(it.uuid)) + allOccupantsInChannel01.add(it.uuid) + } + + // Verify we got all unique clients + assertEquals(channel01TotalCount, allOccupantsInChannel01.size) + } + + @Test + fun testHereNowPaginationFlowIncludeUUIDSisFalse() { + // 8 users in channel01 + // 3 users in channel02 + val pageSize = 3 + val totalClientsCount = 11 + val channel01TotalCount = 8 + val channel02TotalCount = 3 + val channel01 = randomChannel() + val channel02 = randomChannel() + + val clients = + mutableListOf(pubnub).apply { + addAll(generateSequence { createPubNub {} }.take(channel01TotalCount - 1).toList()) + } + + clients.forEach { + it.subscribeNonBlocking(channel01) + } + + clients.take(3).forEach { + it.subscribeNonBlocking(channel02) + } + + Thread.sleep(2000) + + // First page + val firstPage = pubnub.hereNow( + channels = listOf(channel01, channel02), + includeUUIDs = false, + limit = pageSize, + ).sync()!! + + assertEquals(2, firstPage.totalChannels) + val channel01Data = firstPage.channels[channel01]!! + assertEquals(channel01TotalCount, channel01Data.occupancy) + assertEquals(0, channel01Data.occupants.size) + assertEquals(totalClientsCount, firstPage.totalOccupancy) // this is totalOccupancy in all pages + val channel02Data = firstPage.channels[channel02]!! + assertEquals(channel02TotalCount, channel02Data.occupancy) + assertEquals(0, channel02Data.occupants.size) + } + + @Test + fun testHereNowWithLimit0() { + val limit = 0 + val totalClientsCount = 5 + val expectedChannel = randomChannel() + + // Subscribe multiple clients to the channel + val clients = + mutableListOf(pubnub).apply { + addAll(generateSequence { createPubNub {} }.take(totalClientsCount - 1).toList()) + } + + clients.forEach { + it.subscribeNonBlocking(expectedChannel) + } + Thread.sleep(2000) + + // Query with limit=0 to get occupancy without occupant details + pubnub.hereNow( + channels = listOf(expectedChannel), + includeUUIDs = true, + limit = limit, + ).asyncRetry { result -> + assertFalse(result.isFailure) + result.onSuccess { + assertEquals(1, it.totalChannels) + val channelData = it.channels[expectedChannel]!! + + // Occupancy should reflect actual client count + assertEquals(totalClientsCount, channelData.occupancy) + + // With limit=0, occupants list should be empty + assertEquals(0, channelData.occupants.size) + } + } + } + + @Test + fun testGlobalHereNowWithLimit() { + val testLimit = 3 + val totalClientsCount = 6 + val channel01 = randomChannel() + val channel02 = randomChannel() + + val clients = + mutableListOf(pubnub).apply { + addAll(generateSequence { createPubNub {} }.take(totalClientsCount - 1).toList()) + } + + // Subscribe first 3 clients to channel01, all 6 to channel02 + clients.take(3).forEach { + it.subscribeNonBlocking(channel01) + } + clients.forEach { + it.subscribeNonBlocking(channel02) + } + Thread.sleep(2000) + + // Global hereNow (no channels specified) + val result = pubnub.hereNow( + channels = emptyList(), + includeUUIDs = true, + limit = testLimit, + ).sync()!! + + // Should include at least our test channels + assertTrue(result.totalChannels >= 2) + assertTrue(result.channels.containsKey(channel01)) + assertTrue(result.channels.containsKey(channel02)) + + val channel01Data = result.channels[channel01]!! + val channel02Data = result.channels[channel02]!! + + assertEquals(3, channel01Data.occupancy) + assertEquals(6, channel02Data.occupancy) + + // With limit=3, each channel should have at most 3 occupants returned + assertTrue(channel01Data.occupants.size <= testLimit) + assertTrue(channel02Data.occupants.size <= testLimit) + } + + @Test + fun testGlobalHereNowWithOffset() { + val offsetValue = 2 + val totalClientsCount = 5 + val expectedChannel = randomChannel() + + val clients = + mutableListOf(pubnub).apply { + addAll(generateSequence { createPubNub {} }.take(totalClientsCount - 1).toList()) + } + + clients.forEach { + it.subscribeNonBlocking(expectedChannel) + } + Thread.sleep(2000) + + // Global hereNow with offset + val result = pubnub.hereNow( + channels = emptyList(), + includeUUIDs = true, + offset = offsetValue, + ).sync()!! + + // Should include at least our test channel + assertTrue(result.totalChannels >= 1) + assertTrue(result.channels.containsKey(expectedChannel)) + + val channelData = result.channels[expectedChannel]!! + assertEquals(totalClientsCount, channelData.occupancy) + + // With offset=2, we should get remaining occupants + assertTrue(channelData.occupants.size <= totalClientsCount - offsetValue) + } + + @Test + fun testGlobalHereNowWithNoActiveChannels() { + // Don't subscribe any clients, making it a truly empty global query + // Wait a bit to ensure no residual presence state from other tests + + val result = pubnub.hereNow( + channels = emptyList(), + includeUUIDs = true, + limit = 10, + ).sync()!! + + // Should have no channels + // Note: In a shared test environment, there might be residual presence state + assertTrue(result.totalOccupancy >= 0) + } + + @Test + fun testHereNowWithChannelGroupPagination() { + val testLimit = 3 + val totalClientsCount = 6 + val channelGroupName = randomValue() + val channel01 = randomChannel() + val channel02 = randomChannel() + + // Create channel group and add channels to it + pubnub.addChannelsToChannelGroup( + channels = listOf(channel01, channel02), + channelGroup = channelGroupName, + ).sync() + + Thread.sleep(1000) // Wait for channel group to be created + + // Subscribe clients to the channels + val clients = mutableListOf(pubnub).apply { + addAll(generateSequence { createPubNub {} }.take(totalClientsCount - 1).toList()) + } + + // Subscribe all clients to channel01, first 3 to channel02 + clients.forEach { + it.subscribeNonBlocking(channel01) + } + clients.take(3).forEach { + it.subscribeNonBlocking(channel02) + } + + Thread.sleep(2000) // Wait for presence to register + + // Query hereNow with channel group and limit + val result = pubnub.hereNow( + channelGroups = listOf(channelGroupName), + includeUUIDs = true, + limit = testLimit, + ).sync()!! + + // Verify results + assertEquals(2, result.totalChannels) + assertTrue(result.channels.containsKey(channel01)) + assertTrue(result.channels.containsKey(channel02)) + + val channel01Data = result.channels[channel01]!! + val channel02Data = result.channels[channel02]!! + + // Verify occupancy counts + assertEquals(totalClientsCount, channel01Data.occupancy) + assertEquals(3, channel02Data.occupancy) + + // Verify pagination: with limit=3, each channel should return at most 3 occupants + assertTrue(channel01Data.occupants.size <= testLimit) + assertTrue(channel02Data.occupants.size <= testLimit) + assertEquals(testLimit, channel01Data.occupants.size) // channel01 has 6 users, should return 3 + assertEquals(testLimit, channel02Data.occupants.size) // channel02 has 3 users, should return 3 + + // Test with offset + val resultWithOffset = pubnub.hereNow( + channels = emptyList(), + channelGroups = listOf(channelGroupName), + includeUUIDs = true, + limit = testLimit, + offset = 2, + ).sync()!! + + val channel01DataWithOffset = resultWithOffset.channels[channel01]!! + assertEquals(totalClientsCount, channel01DataWithOffset.occupancy) + // With offset=2 and limit=3, we should get 3 occupants (skipping first 2) + assertEquals(testLimit, channel01DataWithOffset.occupants.size) + + // Cleanup: remove channel group + pubnub.deleteChannelGroup( + channelGroup = channelGroupName, + ).sync() + } } diff --git a/pubnub-kotlin/pubnub-kotlin-impl/src/integrationTest/kotlin/com/pubnub/test/Extensions.kt b/pubnub-kotlin/pubnub-kotlin-impl/src/integrationTest/kotlin/com/pubnub/test/Extensions.kt index c6aab72a48..15d9ffc304 100644 --- a/pubnub-kotlin/pubnub-kotlin-impl/src/integrationTest/kotlin/com/pubnub/test/Extensions.kt +++ b/pubnub-kotlin/pubnub-kotlin-impl/src/integrationTest/kotlin/com/pubnub/test/Extensions.kt @@ -45,6 +45,7 @@ fun AtomicBoolean.listen(function: () -> Boolean): AtomicBoolean { fun RemoteAction.asyncRetry(function: (result: Result) -> Unit) { val hits = AtomicInteger(0) + var lastException: Throwable? = null val block = { hits.incrementAndGet() @@ -55,7 +56,15 @@ fun RemoteAction.asyncRetry(function: (result: Result) try { function.invoke(result) success.set(true) + } catch (e: AssertionError) { + // Test logic errors - fail immediately with clear context + throw AssertionError("Assertion failed on attempt ${hits.get()}: ${e.message}", e) + } catch (e: org.opentest4j.AssertionFailedError) { + // JUnit 5 assertion failures - fail immediately + throw e } catch (e: Throwable) { + // Environmental failures - save for potential retry + lastException = e success.set(false) } latch.countDown() @@ -127,6 +136,13 @@ fun PubNub.subscribeToBlocking(vararg channels: String) { Thread.sleep(2000) } +fun PubNub.subscribeNonBlocking(vararg channels: String) { + this.subscribe( + channels = listOf(*channels), + withPresence = true, + ) +} + fun PubNub.unsubscribeFromBlocking(vararg channels: String) { unsubscribe( channels = listOf(*channels), diff --git a/pubnub-kotlin/pubnub-kotlin-impl/src/integrationTest/resources/logback.xml b/pubnub-kotlin/pubnub-kotlin-impl/src/integrationTest/resources/logback.xml index 90b4f71928..a826781b3d 100644 --- a/pubnub-kotlin/pubnub-kotlin-impl/src/integrationTest/resources/logback.xml +++ b/pubnub-kotlin/pubnub-kotlin-impl/src/integrationTest/resources/logback.xml @@ -7,7 +7,7 @@ - + diff --git a/pubnub-kotlin/pubnub-kotlin-impl/src/main/kotlin/com/pubnub/internal/PubNubImpl.kt b/pubnub-kotlin/pubnub-kotlin-impl/src/main/kotlin/com/pubnub/internal/PubNubImpl.kt index b48fa00d7a..7bcf2e5c86 100644 --- a/pubnub-kotlin/pubnub-kotlin-impl/src/main/kotlin/com/pubnub/internal/PubNubImpl.kt +++ b/pubnub-kotlin/pubnub-kotlin-impl/src/main/kotlin/com/pubnub/internal/PubNubImpl.kt @@ -578,6 +578,8 @@ open class PubNubImpl( channelGroups: List, includeState: Boolean, includeUUIDs: Boolean, + limit: Int, + offset: Int?, ): HereNow { return HereNowEndpoint( pubnub = this, @@ -585,6 +587,8 @@ open class PubNubImpl( channelGroups = channelGroups, includeState = includeState, includeUUIDs = includeUUIDs, + limit = limit, + offset = offset, ) } diff --git a/pubnub-kotlin/pubnub-kotlin-impl/src/main/kotlin/com/pubnub/internal/endpoints/presence/HereNowEndpoint.kt b/pubnub-kotlin/pubnub-kotlin-impl/src/main/kotlin/com/pubnub/internal/endpoints/presence/HereNowEndpoint.kt index b23ce7d0b2..d396910725 100644 --- a/pubnub-kotlin/pubnub-kotlin-impl/src/main/kotlin/com/pubnub/internal/endpoints/presence/HereNowEndpoint.kt +++ b/pubnub-kotlin/pubnub-kotlin-impl/src/main/kotlin/com/pubnub/internal/endpoints/presence/HereNowEndpoint.kt @@ -1,6 +1,8 @@ package com.pubnub.internal.endpoints.presence import com.google.gson.JsonElement +import com.pubnub.api.PubNubError +import com.pubnub.api.PubNubException import com.pubnub.api.endpoints.presence.HereNow import com.pubnub.api.enums.PNOperationType import com.pubnub.api.logging.LogMessage @@ -18,6 +20,10 @@ import com.pubnub.internal.toCsv import retrofit2.Call import retrofit2.Response +private const val MAX_CHANNEL_OCCUPANTS_LIMIT = 1000 + +private const val MIN_CHANNEL_OCCUPANTS_LIMIT = 0 + /** * @see [PubNubImpl.hereNow] */ @@ -27,8 +33,23 @@ class HereNowEndpoint internal constructor( override val channelGroups: List = emptyList(), override val includeState: Boolean = false, override val includeUUIDs: Boolean = true, + override val limit: Int = MAX_CHANNEL_OCCUPANTS_LIMIT, + override val offset: Int? = null, ) : EndpointCore, PNHereNowResult>(pubnub), HereNow { private val log: PNLogger = LoggerManager.instance.getLogger(pubnub.logConfig, this::class.java) + internal val effectiveLimit: Int = if (limit in MIN_CHANNEL_OCCUPANTS_LIMIT..MAX_CHANNEL_OCCUPANTS_LIMIT) { + limit + } else { + log.warn( + LogMessage( + LogMessageContent.Text( + "Valid range is $MIN_CHANNEL_OCCUPANTS_LIMIT to $MAX_CHANNEL_OCCUPANTS_LIMIT. " + + "Shrinking limit to $MAX_CHANNEL_OCCUPANTS_LIMIT." + ) + ) + ) + MAX_CHANNEL_OCCUPANTS_LIMIT + } private fun isGlobalHereNow() = channels.isEmpty() && channelGroups.isEmpty() @@ -37,6 +58,13 @@ class HereNowEndpoint internal constructor( override fun getAffectedChannelGroups() = channelGroups override fun doWork(queryParams: HashMap): Call> { + if (offset != null && offset < 0) { + throw PubNubException(PubNubError.HERE_NOW_OFFSET_OUT_OF_RANGE) + } + if (offset != null && effectiveLimit == 0) { + throw PubNubException(PubNubError.HERE_NOW_OFFSET_REQUIRES_LIMIT_HIGHER_THAN_0) + } + log.debug( LogMessage( message = LogMessageContent.Object( @@ -45,7 +73,9 @@ class HereNowEndpoint internal constructor( "channelGroups" to channelGroups, "includeState" to includeState, "includeUUIDs" to includeUUIDs, - "isGlobalHereNow" to isGlobalHereNow() + "limit" to effectiveLimit, + "offset" to (offset?.toString() ?: "null"), + "isGlobalHereNow" to isGlobalHereNow(), ), operation = this::class.simpleName ), @@ -71,7 +101,7 @@ class HereNowEndpoint internal constructor( override fun createResponse(input: Response>): PNHereNowResult { return if (isGlobalHereNow() || (channels.size > 1 || channelGroups.isNotEmpty())) { - parseMultipleChannelResponse(input.body()?.payload!!) + parseMultipleChannelResponse(input.body()!!.payload!!) } else { parseSingleChannelResponse(input.body()!!) } @@ -82,48 +112,54 @@ class HereNowEndpoint internal constructor( override fun getEndpointGroupName(): RetryableEndpointGroup = RetryableEndpointGroup.PRESENCE private fun parseSingleChannelResponse(input: Envelope): PNHereNowResult { - val pnHereNowResult = - PNHereNowResult( - totalChannels = 1, - totalOccupancy = input.occupancy, - ) + val occupants = if (includeUUIDs) { + prepareOccupantData(input.uuids!!) + } else { + emptyList() + } - val pnHereNowChannelData = - PNHereNowChannelData( - channelName = channels[0], - occupancy = input.occupancy, - ) + val pnHereNowResult = PNHereNowResult( + totalChannels = 1, + totalOccupancy = input.occupancy, + ) - if (includeUUIDs) { - pnHereNowChannelData.occupants = prepareOccupantData(input.uuids!!) - pnHereNowResult.channels[channels[0]] = pnHereNowChannelData - } + // When includeUUIDs = false, occupants list will be empty but channel data is still present + val pnHereNowChannelData = PNHereNowChannelData( + channelName = channels[0], + occupancy = input.occupancy, + occupants = occupants + ) + pnHereNowResult.channels[channels[0]] = pnHereNowChannelData return pnHereNowResult } private fun parseMultipleChannelResponse(input: JsonElement): PNHereNowResult { - val pnHereNowResult = - PNHereNowResult( - totalChannels = pubnub.mapper.elementToInt(input, "total_channels"), - totalOccupancy = pubnub.mapper.elementToInt(input, "total_occupancy"), - ) - - val it = pubnub.mapper.getObjectIterator(input, "channels") - - while (it.hasNext()) { - val entry = it.next() - val pnHereNowChannelData = - PNHereNowChannelData( - channelName = entry.key, - occupancy = pubnub.mapper.elementToInt(entry.value, "occupancy"), - ) - if (includeUUIDs) { - pnHereNowChannelData.occupants = prepareOccupantData(pubnub.mapper.getField(entry.value, "uuids")!!) + val channels = pubnub.mapper.getObjectIterator(input, "channels") + + val channelsMap = mutableMapOf() + while (channels.hasNext()) { + val entry = channels.next() + val occupants = if (includeUUIDs) { + prepareOccupantData(pubnub.mapper.getField(entry.value, "uuids")!!) + } else { + emptyList() } - pnHereNowResult.channels[entry.key] = pnHereNowChannelData + + val pnHereNowChannelData = PNHereNowChannelData( + channelName = entry.key, + occupancy = pubnub.mapper.elementToInt(entry.value, "occupancy"), + occupants = occupants + ) + channelsMap[entry.key] = pnHereNowChannelData } + val pnHereNowResult = PNHereNowResult( + totalChannels = pubnub.mapper.elementToInt(input, "total_channels"), + totalOccupancy = pubnub.mapper.elementToInt(input, "total_occupancy"), + channels = channelsMap, + ) + return pnHereNowResult } @@ -160,5 +196,7 @@ class HereNowEndpoint internal constructor( if (channelGroups.isNotEmpty()) { queryParams["channel-group"] = channelGroups.toCsv() } + queryParams["limit"] = effectiveLimit.toString() + offset?.let { queryParams["offset"] = it.toString() } } } diff --git a/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/internal/endpoints/presence/HereNowEndpointTest.kt b/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/internal/endpoints/presence/HereNowEndpointTest.kt new file mode 100644 index 0000000000..8a4d9603d9 --- /dev/null +++ b/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/internal/endpoints/presence/HereNowEndpointTest.kt @@ -0,0 +1,120 @@ +package com.pubnub.internal.endpoints.presence + +import com.pubnub.api.legacy.BaseTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class HereNowEndpointTest : BaseTest() { + @Test + fun testPubNubHereNowWithPaginationParameters() { + // Test the public API with new pagination parameters + val hereNow = pubnub.hereNow( + channels = listOf("test-channel"), + channelGroups = emptyList(), + includeState = false, + includeUUIDs = true, + limit = 50, + offset = 100 + ) + + assertNotNull(hereNow) + assertEquals(50, (hereNow as HereNowEndpoint).limit) + assertEquals(100, hereNow.offset) + } + + @Test + fun testPubNubHereNowWithDefaultParameters() { + // Test that default parameters still work (backward compatibility) + val hereNow = pubnub.hereNow( + channels = listOf("test-channel"), + channelGroups = emptyList(), + includeState = false, + includeUUIDs = true + ) + + assertNotNull(hereNow) + assertEquals(1000, (hereNow as HereNowEndpoint).limit) // Default limit is 1000 + assertNull(hereNow.offset) + } + + @Test + fun testHereNowLimitMinimumBoundary() { + // Test minimum valid limit value + val hereNow = pubnub.hereNow( + channels = listOf("test-channel"), + includeUUIDs = true, + limit = 1 + ) + assertNotNull(hereNow) + assertEquals(1, (hereNow as HereNowEndpoint).limit) + } + + @Test + fun testHereNowLimitMaximumBoundary() { + // Test maximum valid limit value + val hereNow = pubnub.hereNow( + channels = listOf("test-channel"), + includeUUIDs = true, + limit = 1000 + ) + assertNotNull(hereNow) + assertEquals(1000, (hereNow as HereNowEndpoint).limit) + } + + @Test + fun testHereNowLimitAboveMaximumShrinks() { + // Test that limit > 1000 automatically shrinks to 1000 + val endpoint = HereNowEndpoint( + pubnub = pubnub, + channels = listOf("test-channel"), + includeUUIDs = true, + limit = 1500 + ) + assertNotNull(endpoint) + // When limit is out of range, endpoint stores the original value + assertEquals(1500, endpoint.limit) + assertEquals(1000, endpoint.effectiveLimit) + } + + @Test + fun testHereNowLimitNegativeUsesDefault() { + // Test that negative limit uses default of 1000 + val endpoint = HereNowEndpoint( + pubnub = pubnub, + channels = listOf("test-channel"), + includeUUIDs = true, + limit = -5 + ) + assertNotNull(endpoint) + // When limit is out of range, the original value is stored + assertEquals(-5, endpoint.limit) + assertEquals(1000, endpoint.effectiveLimit) + } + + @Test + fun testHereNowStartFromLargeValue() { + // Test large valid offset value + val hereNow = pubnub.hereNow( + channels = listOf("test-channel"), + includeUUIDs = true, + offset = 1000000 + ) + assertNotNull(hereNow) + assertEquals(1000000, (hereNow as HereNowEndpoint).offset) + } + + @Test + fun testHereNowStartFromLargeNegativeAccepted() { + // Test that large negative offset is accepted at creation time + // (validation happens during execution in doWork()) + val hereNow = pubnub.hereNow( + channels = listOf("test-channel"), + includeUUIDs = true, + offset = -100 + ) + assertNotNull(hereNow) + assertEquals(-100, (hereNow as HereNowEndpoint).offset) + } +} diff --git a/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/internal/suite/presence/HereNowPaginationTestSuite.kt b/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/internal/suite/presence/HereNowPaginationTestSuite.kt new file mode 100644 index 0000000000..13d35722af --- /dev/null +++ b/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/internal/suite/presence/HereNowPaginationTestSuite.kt @@ -0,0 +1,61 @@ +package com.pubnub.internal.suite.presence + +import com.github.tomakehurst.wiremock.client.WireMock.absent +import com.github.tomakehurst.wiremock.client.WireMock.equalTo +import com.github.tomakehurst.wiremock.client.WireMock.get +import com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo +import com.pubnub.api.endpoints.presence.HereNow +import com.pubnub.api.enums.PNOperationType +import com.pubnub.api.models.consumer.presence.PNHereNowResult +import org.junit.Assert.assertEquals + +class HereNowPaginationTestSuite : com.pubnub.internal.suite.CoreEndpointTestSuite() { + override fun pnOperation() = PNOperationType.PNHereNowOperation + + override fun requiredKeys() = com.pubnub.internal.suite.SUB + com.pubnub.internal.suite.AUTH + + override fun snippet(): HereNow = + pubnub.hereNow( + channels = listOf("ch1"), + limit = 100, + offset = 50 + ) + + override fun verifyResultExpectations(result: PNHereNowResult) { + assertEquals(1, result.totalChannels) + assertEquals(1, result.totalOccupancy) + assertEquals(1, result.channels.size) + assertEquals("user_1", result.channels["ch1"]!!.occupants[0].uuid) + } + + override fun successfulResponseBody() = + """ + { + "status": 200, + "message": "OK", + "occupancy": 1, + "uuids": [ + "user_1" + ], + "service": "Presence" + } + """.trimIndent() + + override fun unsuccessfulResponseBodyList() = + listOf( + """{"occupancy": 0, "uuids": null}""", + """{"payload": {"channels": null, "total_channels": 0, "total_occupancy": 0}}""", + """{"payload": {}}""", + """{"payload": null}""", + ) + + override fun mappingBuilder() = + get(urlPathEqualTo("/v2/presence/sub_key/mySubscribeKey/channel/ch1")) + .withQueryParam("state", absent()) + .withQueryParam("disable_uuids", absent()) + .withQueryParam("channel-group", absent()) + .withQueryParam("limit", equalTo("100")) + .withQueryParam("offset", equalTo("50")) + + override fun affectedChannelsAndGroups() = listOf("ch1") to emptyList() +}