From 53b8ffdd34bd2c413bea868c42ea2566a7906183 Mon Sep 17 00:00:00 2001 From: "marcin.cebo" Date: Wed, 17 Sep 2025 16:47:33 +0200 Subject: [PATCH 01/19] Test --- .../consumer/objects/channel/PNChannelMetadataArrayResult.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pubnub-kotlin/pubnub-kotlin-api/src/commonMain/kotlin/com/pubnub/api/models/consumer/objects/channel/PNChannelMetadataArrayResult.kt b/pubnub-kotlin/pubnub-kotlin-api/src/commonMain/kotlin/com/pubnub/api/models/consumer/objects/channel/PNChannelMetadataArrayResult.kt index 843381d926..62c729efcf 100644 --- a/pubnub-kotlin/pubnub-kotlin-api/src/commonMain/kotlin/com/pubnub/api/models/consumer/objects/channel/PNChannelMetadataArrayResult.kt +++ b/pubnub-kotlin/pubnub-kotlin-api/src/commonMain/kotlin/com/pubnub/api/models/consumer/objects/channel/PNChannelMetadataArrayResult.kt @@ -8,4 +8,6 @@ data class PNChannelMetadataArrayResult( val totalCount: Int?, val next: PNPage.PNNext?, val prev: PNPage.PNPrev?, -) +){ + +} From f5d4c6a65ea2afff07bb8d671f8cb9c9049d9280 Mon Sep 17 00:00:00 2001 From: "marcin.cebo" Date: Wed, 17 Sep 2025 16:55:16 +0200 Subject: [PATCH 02/19] Test --- .../consumer/objects/channel/PNChannelMetadataArrayResult.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pubnub-kotlin/pubnub-kotlin-api/src/commonMain/kotlin/com/pubnub/api/models/consumer/objects/channel/PNChannelMetadataArrayResult.kt b/pubnub-kotlin/pubnub-kotlin-api/src/commonMain/kotlin/com/pubnub/api/models/consumer/objects/channel/PNChannelMetadataArrayResult.kt index 62c729efcf..843381d926 100644 --- a/pubnub-kotlin/pubnub-kotlin-api/src/commonMain/kotlin/com/pubnub/api/models/consumer/objects/channel/PNChannelMetadataArrayResult.kt +++ b/pubnub-kotlin/pubnub-kotlin-api/src/commonMain/kotlin/com/pubnub/api/models/consumer/objects/channel/PNChannelMetadataArrayResult.kt @@ -8,6 +8,4 @@ data class PNChannelMetadataArrayResult( val totalCount: Int?, val next: PNPage.PNNext?, val prev: PNPage.PNPrev?, -){ - -} +) From 82bd6e6a208c8ca026171a674d9032a605c9a5b0 Mon Sep 17 00:00:00 2001 From: "marcin.cebo" Date: Thu, 25 Sep 2025 15:27:00 +0200 Subject: [PATCH 03/19] Initial changes --- .../api/java/endpoints/presence/HereNow.java | 4 + .../java/endpoints/presence/HereNowImpl.java | 6 +- .../kotlin/com/pubnub/api/PubNubImpl.kt | 8 +- .../api/endpoints/presence/HereNow.ios.kt | 9 +- .../kotlin/com/pubnub/api/PubNub.kt | 2 + .../jvmMain/kotlin/com/pubnub/api/PubNub.kt | 2 + .../pubnub/api/endpoints/presence/HereNow.kt | 2 + .../kotlin/com/pubnub/api/PubNub.nonJvm.kt | 4 +- .../kotlin/com/pubnub/api/PubNubError.kt | 10 ++ .../api/models/consumer/presence/PNHereNow.kt | 2 + .../com/pubnub/docs/presence/HereNowMain.kt | 1 + .../kotlin/com/pubnub/internal/PubNubImpl.kt | 4 + .../endpoints/presence/HereNowEndpoint.kt | 67 +++++++++++--- .../presence/HereNowPaginationTest.kt | 92 +++++++++++++++++++ .../presence/HereNowPaginationTestSuite.kt | 64 +++++++++++++ 15 files changed, 260 insertions(+), 17 deletions(-) create mode 100644 pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/internal/endpoints/presence/HereNowPaginationTest.kt create mode 100644 pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/internal/suite/presence/HereNowPaginationTestSuite.kt 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..245761cfa5 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 startFrom(Integer startFrom); } 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..df5cd52f25 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 @@ -20,6 +20,8 @@ public class HereNowImpl extends PassthroughEndpoint implements private List channelGroups = new ArrayList<>(); private boolean includeState = false; private boolean includeUUIDs = true; + private int limit = 1000; + private Integer startFrom = null; public HereNowImpl(PubNub pubnub) { super(pubnub); @@ -32,7 +34,9 @@ protected Endpoint createRemoteAction() { channels, channelGroups, includeState, - includeUUIDs + includeUUIDs, + limit, + startFrom ); } } 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..c8d3c4d98d 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, + startFrom: Int? ): HereNow { return HereNowImpl( pubnub = pubNubObjC, channels = channels, channelGroups = channelGroups, includeState = includeState, - includeUUIDs = includeUUIDs + includeUUIDs = includeUUIDs, + limit = limit, + startFrom = startFrom ) } 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..007e5ebfe1 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 startFrom: Int? = null, ) : HereNow { override fun async(callback: Consumer>) { pubnub.hereNowWithChannels( @@ -35,10 +37,14 @@ class HereNowImpl( channelGroups = channelGroups, includeState = includeState, includeUUIDs = includeUUIDs, + // todo pass limit and startFrom once available + // limit = limit, + // startFrom = startFrom, onSuccess = callback.onSuccessHandler { PNHereNowResult( totalChannels = it?.totalChannels()?.toInt() ?: 0, totalOccupancy = it?.totalOccupancy()?.toInt() ?: 0, + // nextStartFrom = it?.nextStartFrom()?.toInt(), // todo uncomment once available channels = (it?.channels()?.safeCast())?.mapValues { entry -> PNHereNowChannelData( channelName = entry.value.channelName(), @@ -51,6 +57,7 @@ class HereNowImpl( } ) }?.toMutableMap() ?: emptyMap().toMutableMap() + ) }, onFailure = callback.onFailureHandler() 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..d90b86be04 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, + startFrom: Int? = null, ): HereNow fun whereNow(uuid: String = configuration.userId.value): WhereNow 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..6c2b646673 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 @@ -774,6 +774,8 @@ actual interface PubNub : StatusEmitter, EventEmitter { channelGroups: List, includeState: Boolean, includeUUIDs: Boolean, + limit: Int, + startFrom: 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..e557e22dce 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 startFrom: 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..96a9c3d8f3 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, + startFrom: 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..5287204aeb 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,16 @@ enum class PubNubError(private val code: Int, val message: String) { 181, "Channel and/or ChannelGroup contains empty string which is not allowed.", ), + + HERE_NOW_LIMIT_OUT_OF_RANGE( + 182, + "HereNow limit is out of range. Valid range is 1 to 1000.", + ), + HERE_NOW_START_FROM_OUT_OF_RANGE( + 183, + "HereNow startFrom is out of range. Valid range is 0 to infinity.", + ) + ; override fun toString(): String { diff --git a/pubnub-kotlin/pubnub-kotlin-core-api/src/commonMain/kotlin/com/pubnub/api/models/consumer/presence/PNHereNow.kt b/pubnub-kotlin/pubnub-kotlin-core-api/src/commonMain/kotlin/com/pubnub/api/models/consumer/presence/PNHereNow.kt index 30e6a07cb1..ac0e87ad19 100644 --- a/pubnub-kotlin/pubnub-kotlin-core-api/src/commonMain/kotlin/com/pubnub/api/models/consumer/presence/PNHereNow.kt +++ b/pubnub-kotlin/pubnub-kotlin-core-api/src/commonMain/kotlin/com/pubnub/api/models/consumer/presence/PNHereNow.kt @@ -8,12 +8,14 @@ import com.pubnub.api.JsonElement * @property totalChannels Total number channels matching the associated HereNow call. * @property totalOccupancy Total occupancy matching the associated HereNow call. * @property channels A map with values of [PNHereNowChannelData] for each channel. + * @property nextStartFrom Starting position for next page of results. Null if no more pages available. */ class PNHereNowResult( val totalChannels: Int, val totalOccupancy: Int, // TODO this should be immutable val channels: MutableMap = mutableMapOf(), + val nextStartFrom: Int? = null, ) /** 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..4e06179ce7 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 @@ -41,6 +41,7 @@ fun main() { fun singleChannelHereNow(pubnub: PubNub, channel: String) { println("\n# Basic hereNow for single channel: $channel") + // todo consider adding limit and startFrom to docs pubnub.hereNow( channels = listOf(channel) ).async { result -> 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..9fc88ab216 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, + startFrom: Int?, ): HereNow { return HereNowEndpoint( pubnub = this, @@ -585,6 +587,8 @@ open class PubNubImpl( channelGroups = channelGroups, includeState = includeState, includeUUIDs = includeUUIDs, + limit = limit, + startFrom = startFrom, ) } 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..cb0e432efe 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,8 @@ import com.pubnub.internal.toCsv import retrofit2.Call import retrofit2.Response +private const val MAX_NUMBER_OF_RESULT_ON_ONE_PAGE = 1000 + /** * @see [PubNubImpl.hereNow] */ @@ -27,6 +31,8 @@ class HereNowEndpoint internal constructor( override val channelGroups: List = emptyList(), override val includeState: Boolean = false, override val includeUUIDs: Boolean = true, + override val limit: Int = MAX_NUMBER_OF_RESULT_ON_ONE_PAGE, + override val startFrom: Int? = null, ) : EndpointCore, PNHereNowResult>(pubnub), HereNow { private val log: PNLogger = LoggerManager.instance.getLogger(pubnub.logConfig, this::class.java) @@ -37,6 +43,14 @@ class HereNowEndpoint internal constructor( override fun getAffectedChannelGroups() = channelGroups override fun doWork(queryParams: HashMap): Call> { + if (limit !in 1..MAX_NUMBER_OF_RESULT_ON_ONE_PAGE) { + throw PubNubException(PubNubError.HERE_NOW_LIMIT_OUT_OF_RANGE) + } + if (startFrom != null && startFrom < 0) { + throw PubNubException(PubNubError.HERE_NOW_START_FROM_OUT_OF_RANGE) + + } + log.debug( LogMessage( message = LogMessageContent.Object( @@ -45,7 +59,9 @@ class HereNowEndpoint internal constructor( "channelGroups" to channelGroups, "includeState" to includeState, "includeUUIDs" to includeUUIDs, - "isGlobalHereNow" to isGlobalHereNow() + "limit" to limit, + "startFrom" to (startFrom?.toString() ?: "null"), + "isGlobalHereNow" to isGlobalHereNow(), ), operation = this::class.simpleName ), @@ -81,21 +97,37 @@ class HereNowEndpoint internal constructor( override fun getEndpointGroupName(): RetryableEndpointGroup = RetryableEndpointGroup.PRESENCE + internal fun calculateNextStartFrom(actualResultSize: Int): Int? { + return when { + actualResultSize < limit -> null + actualResultSize == limit -> (startFrom ?: 0) + limit + else -> null + } + } + private fun parseSingleChannelResponse(input: Envelope): PNHereNowResult { + val occupants = if (includeUUIDs) { + prepareOccupantData(input.uuids!!) + } else { + emptyList() + } + val actualResultSize = occupants.size + val pnHereNowResult = PNHereNowResult( totalChannels = 1, totalOccupancy = input.occupancy, + nextStartFrom = calculateNextStartFrom(actualResultSize), ) val pnHereNowChannelData = PNHereNowChannelData( channelName = channels[0], occupancy = input.occupancy, + occupants = occupants ) if (includeUUIDs) { - pnHereNowChannelData.occupants = prepareOccupantData(input.uuids!!) pnHereNowResult.channels[channels[0]] = pnHereNowChannelData } @@ -103,27 +135,36 @@ class HereNowEndpoint internal constructor( } 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") + var totalOccupantsReturned = 0 + val channelsMap = mutableMapOf() while (it.hasNext()) { val entry = it.next() + val occupants = if (includeUUIDs) { + prepareOccupantData(pubnub.mapper.getField(entry.value, "uuids")!!) + } else { + emptyList() + } + totalOccupantsReturned += occupants.size + val pnHereNowChannelData = PNHereNowChannelData( channelName = entry.key, occupancy = pubnub.mapper.elementToInt(entry.value, "occupancy"), + occupants = occupants ) - if (includeUUIDs) { - pnHereNowChannelData.occupants = prepareOccupantData(pubnub.mapper.getField(entry.value, "uuids")!!) - } - pnHereNowResult.channels[entry.key] = pnHereNowChannelData + channelsMap[entry.key] = pnHereNowChannelData } + val pnHereNowResult = + PNHereNowResult( + totalChannels = pubnub.mapper.elementToInt(input, "total_channels"), + totalOccupancy = pubnub.mapper.elementToInt(input, "total_occupancy"), + channels = channelsMap, + nextStartFrom = calculateNextStartFrom(totalOccupantsReturned), + ) + return pnHereNowResult } @@ -160,5 +201,7 @@ class HereNowEndpoint internal constructor( if (channelGroups.isNotEmpty()) { queryParams["channel-group"] = channelGroups.toCsv() } + queryParams["limit"] = limit.toString() + startFrom?.let { queryParams["offset"] = it.toString() } } } diff --git a/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/internal/endpoints/presence/HereNowPaginationTest.kt b/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/internal/endpoints/presence/HereNowPaginationTest.kt new file mode 100644 index 0000000000..322c783280 --- /dev/null +++ b/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/internal/endpoints/presence/HereNowPaginationTest.kt @@ -0,0 +1,92 @@ +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 HereNowPaginationTest : BaseTest() { + @Test + fun testNextStartFromCalculation_hasMoreResults() { + val endpoint = HereNowEndpoint( + pubnub = pubnub, + limit = 10, + startFrom = 20 + ) + + // When actual result size equals limit, there might be more results + val nextStartFrom = endpoint.calculateNextStartFrom(10) + assertEquals(30, nextStartFrom) // startFrom + limit + } + + @Test + fun testNextStartFromCalculation_noMoreResults() { + val endpoint = HereNowEndpoint( + pubnub = pubnub, + limit = 10, + startFrom = 20 + ) + + // When actual result size is less than limit, no more results + val nextStartFrom = endpoint.calculateNextStartFrom(5) + assertNull(nextStartFrom) + } + + @Test + fun testNextStartFromCalculation_withExplicitLimit() { + val endpoint = HereNowEndpoint( + pubnub = pubnub, + limit = 50, + startFrom = 20 + ) + + // When result equals limit, return next page + val nextStartFrom = endpoint.calculateNextStartFrom(50) + assertEquals(70, nextStartFrom) // 20 + 50 + } + + @Test + fun testNextStartFromCalculation_startFromDefaultsToZero() { + val endpoint = HereNowEndpoint( + pubnub = pubnub, + limit = 10, + startFrom = null // Should default to 0 in calculation + ) + + val nextStartFrom = endpoint.calculateNextStartFrom(10) + assertEquals(10, nextStartFrom) // 0 + 10 + } + + @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, + startFrom = 100 + ) + + assertNotNull(hereNow) + assertEquals(50, (hereNow as HereNowEndpoint).limit) + assertEquals(100, hereNow.startFrom) + } + + @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.startFrom) + } +} 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..a78cf79bc8 --- /dev/null +++ b/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/internal/suite/presence/HereNowPaginationTestSuite.kt @@ -0,0 +1,64 @@ +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 +import org.junit.Assert.assertNull + +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, + startFrom = 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) + // With only 1 occupant but limit 100, nextStartFrom should be null (no more results) + assertNull(result.nextStartFrom) + } + + 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() +} From da02d300b4dc9a1937d6cc32dc4c907c4f1471fa Mon Sep 17 00:00:00 2001 From: "marcin.cebo" Date: Fri, 3 Oct 2025 16:30:17 +0200 Subject: [PATCH 04/19] Added integration tests --- .../api/endpoints/presence/HereNow.ios.kt | 1 - .../kotlin/com/pubnub/api/PubNubError.kt | 6 +- .../integration/PresenceIntegrationTests.kt | 244 ++++++++++++++++++ .../kotlin/com/pubnub/test/Extensions.kt | 16 ++ .../src/integrationTest/resources/logback.xml | 2 +- .../endpoints/presence/HereNowEndpoint.kt | 19 +- .../endpoints/presence/HereNowEndpointTest.kt | 233 +++++++++++++++++ .../presence/HereNowPaginationTest.kt | 92 ------- 8 files changed, 505 insertions(+), 108 deletions(-) create mode 100644 pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/internal/endpoints/presence/HereNowEndpointTest.kt delete mode 100644 pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/internal/endpoints/presence/HereNowPaginationTest.kt 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 007e5ebfe1..710cff6d9e 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 @@ -57,7 +57,6 @@ class HereNowImpl( } ) }?.toMutableMap() ?: emptyMap().toMutableMap() - ) }, onFailure = callback.onFailureHandler() 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 5287204aeb..8cb4903e27 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 @@ -240,12 +240,8 @@ enum class PubNubError(private val code: Int, val message: String) { "Channel and/or ChannelGroup contains empty string which is not allowed.", ), - HERE_NOW_LIMIT_OUT_OF_RANGE( - 182, - "HereNow limit is out of range. Valid range is 1 to 1000.", - ), HERE_NOW_START_FROM_OUT_OF_RANGE( - 183, + 182, "HereNow startFrom is out of range. Valid range is 0 to infinity.", ) 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..e93d6bb6fa 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 @@ -5,6 +5,7 @@ import com.pubnub.api.callbacks.SubscribeCallback import com.pubnub.api.enums.PNHeartbeatNotificationOptions import com.pubnub.api.enums.PNStatusCategory import com.pubnub.api.models.consumer.PNStatus +import com.pubnub.api.models.consumer.presence.PNHereNowChannelData import com.pubnub.api.models.consumer.pubsub.PNPresenceEventResult import com.pubnub.test.CommonUtils.generatePayload import com.pubnub.test.CommonUtils.randomChannel @@ -12,6 +13,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 +25,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 +287,245 @@ 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) + + // nextStartFrom should be present since we limited results + assertEquals(3, it.nextStartFrom) + } + } + } + + @Test + fun testHereNowWithStartFrom() { + val startFromValue = 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, + startFrom = startFromValue, + ).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 startFrom=2, we should get remaining occupants (5 total - 2 skipped = 3 remaining) + assertEquals(totalClientsCount - startFromValue, channelData.occupants.size) + + // nextStartFrom should be null since we got all remaining results + assertNull(it.nextStartFrom) + } + } + } + + @Test + fun testHereNowPaginationFlow() { + // 8 users in expectedChannel + // 3 users in expectedChannel02 + 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()) + } + + println("-=channel: $channel01") + clients.forEach { + println("-= ${it.configuration.userId.value}") + it.subscribeNonBlocking(channel01) + } + + println("-=channel02: $channel01") + clients.take(3).forEach { + println("-= ${it.configuration.userId.value}") + it.subscribeNonBlocking(channel02) + } + + Thread.sleep(2000) + + + val allOccupantsInChannel01 = mutableSetOf() + + // First page + val firstPage = pubnub.hereNow( + channels = listOf(channel01, channel02), + includeUUIDs = true, + limit = pageSize, + ).sync()!! + + firstPage.channels.forEach { it: Map.Entry -> + println("-=Channel=${it.key} or ${it.value.channelName}") + val pnHereNowChannelData: PNHereNowChannelData = it.value + pnHereNowChannelData.occupants.forEach { occupant -> + println("-=uuid firstPage ${occupant.uuid}") + } + } + 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) + assertEquals(3, firstPage.nextStartFrom) + 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 nextStartFrom + val secondPage = pubnub.hereNow( + channels = listOf(channel01), + includeUUIDs = true, + limit = pageSize, + startFrom = firstPage.nextStartFrom!!, + ).sync()!! + + secondPage.channels.forEach { it: Map.Entry -> + val pnHereNowChannelData: PNHereNowChannelData = it.value + pnHereNowChannelData.occupants.forEach { occupant -> + println("-=uuid secondPage ${occupant.uuid}") + } + } + 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) + assertEquals(6, secondPage.nextStartFrom) + + 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 nextStartFrom from second page + val thirdPage = pubnub.hereNow( + channels = listOf(channel01), + includeUUIDs = true, + limit = pageSize, + startFrom = secondPage.nextStartFrom!!, + ).sync()!! + + thirdPage.channels.forEach { it: Map.Entry -> + val pnHereNowChannelData: PNHereNowChannelData = it.value + pnHereNowChannelData.occupants.forEach { occupant -> + println("-=uuid thirdPage ${occupant.uuid}") + } + } + + 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) + + // Should be null since no more pages + assertNull(thirdPage.nextStartFrom) + + // 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 testHereNowNextStartFromWhenMoreResults() { + val limitValue = 4 + val totalClientsCount = 10 + 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 = limitValue, + ).asyncRetry { result -> + assertFalse(result.isFailure) + result.onSuccess { + assertEquals(1, it.totalChannels) + val channelData = it.channels[expectedChannel]!! + assertEquals(totalClientsCount, channelData.occupancy) + assertEquals(limitValue, channelData.occupants.size) + + // Since returned count equals limit and there are more clients, + // nextStartFrom should be present + assertNotNull(it.nextStartFrom) + assertEquals(limitValue, it.nextStartFrom) + } + } + } } 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/endpoints/presence/HereNowEndpoint.kt b/pubnub-kotlin/pubnub-kotlin-impl/src/main/kotlin/com/pubnub/internal/endpoints/presence/HereNowEndpoint.kt index cb0e432efe..280e0eb2a4 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 @@ -35,6 +35,7 @@ class HereNowEndpoint internal constructor( override val startFrom: Int? = null, ) : EndpointCore, PNHereNowResult>(pubnub), HereNow { private val log: PNLogger = LoggerManager.instance.getLogger(pubnub.logConfig, this::class.java) + private val effectiveLimit: Int = if (limit in 1..MAX_NUMBER_OF_RESULT_ON_ONE_PAGE) limit else MAX_NUMBER_OF_RESULT_ON_ONE_PAGE private fun isGlobalHereNow() = channels.isEmpty() && channelGroups.isEmpty() @@ -43,12 +44,8 @@ class HereNowEndpoint internal constructor( override fun getAffectedChannelGroups() = channelGroups override fun doWork(queryParams: HashMap): Call> { - if (limit !in 1..MAX_NUMBER_OF_RESULT_ON_ONE_PAGE) { - throw PubNubException(PubNubError.HERE_NOW_LIMIT_OUT_OF_RANGE) - } if (startFrom != null && startFrom < 0) { throw PubNubException(PubNubError.HERE_NOW_START_FROM_OUT_OF_RANGE) - } log.debug( @@ -59,7 +56,7 @@ class HereNowEndpoint internal constructor( "channelGroups" to channelGroups, "includeState" to includeState, "includeUUIDs" to includeUUIDs, - "limit" to limit, + "limit" to effectiveLimit, "startFrom" to (startFrom?.toString() ?: "null"), "isGlobalHereNow" to isGlobalHereNow(), ), @@ -99,8 +96,8 @@ class HereNowEndpoint internal constructor( internal fun calculateNextStartFrom(actualResultSize: Int): Int? { return when { - actualResultSize < limit -> null - actualResultSize == limit -> (startFrom ?: 0) + limit + actualResultSize < effectiveLimit -> null + actualResultSize == effectiveLimit -> (startFrom ?: 0) + effectiveLimit else -> null } } @@ -146,7 +143,11 @@ class HereNowEndpoint internal constructor( } else { emptyList() } - totalOccupantsReturned += occupants.size + + // we want to know amount of occupants in channel that has the most occupants + if (occupants.size > totalOccupantsReturned){ + totalOccupantsReturned = occupants.size + } val pnHereNowChannelData = PNHereNowChannelData( @@ -201,7 +202,7 @@ class HereNowEndpoint internal constructor( if (channelGroups.isNotEmpty()) { queryParams["channel-group"] = channelGroups.toCsv() } - queryParams["limit"] = limit.toString() + queryParams["limit"] = effectiveLimit.toString() startFrom?.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..47e7a0803f --- /dev/null +++ b/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/internal/endpoints/presence/HereNowEndpointTest.kt @@ -0,0 +1,233 @@ +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 testNextStartFromCalculation_hasMoreResults() { + val endpoint = HereNowEndpoint( + pubnub = pubnub, + limit = 10, + startFrom = 20 + ) + + // When actual result size equals limit, there might be more results + val nextStartFrom = endpoint.calculateNextStartFrom(10) + assertEquals(30, nextStartFrom) // startFrom + limit + } + + @Test + fun testNextStartFromCalculation_noMoreResults() { + val endpoint = HereNowEndpoint( + pubnub = pubnub, + limit = 10, + startFrom = 20 + ) + + // When actual result size is less than limit, no more results + val nextStartFrom = endpoint.calculateNextStartFrom(5) + assertNull(nextStartFrom) + } + + @Test + fun testNextStartFromCalculation_withExplicitLimit() { + val endpoint = HereNowEndpoint( + pubnub = pubnub, + limit = 50, + startFrom = 20 + ) + + // When result equals limit, return next page + val nextStartFrom = endpoint.calculateNextStartFrom(50) + assertEquals(70, nextStartFrom) // 20 + 50 + } + + @Test + fun testNextStartFromCalculation_startFromDefaultsToZero() { + val endpoint = HereNowEndpoint( + pubnub = pubnub, + limit = 10, + startFrom = null // Should default to 0 in calculation + ) + + val nextStartFrom = endpoint.calculateNextStartFrom(10) + assertEquals(10, nextStartFrom) // 0 + 10 + } + + @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, + startFrom = 100 + ) + + assertNotNull(hereNow) + assertEquals(50, (hereNow as HereNowEndpoint).limit) + assertEquals(100, hereNow.startFrom) + } + + @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.startFrom) + } + + @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) + + // But effectiveLimit should be capped at 1000, verified by calculateNextStartFrom + // If we get 1000 results, nextStartFrom should be 1000 (not 1500) + val nextStartFrom = endpoint.calculateNextStartFrom(1000) + assertEquals(1000, nextStartFrom) // 0 + 1000 (capped limit) + } + + @Test + fun testHereNowLimitBelowMinimumUsesDefault() { + // Test that limit=0 uses default of 1000 + val endpoint = HereNowEndpoint( + pubnub = pubnub, + channels = listOf("test-channel"), + includeUUIDs = true, + limit = 0 + ) + assertNotNull(endpoint) + // When limit is out of range, the original value is stored + assertEquals(0, endpoint.limit) + + // But effectiveLimit should default to 1000, verified by calculateNextStartFrom + val nextStartFrom = endpoint.calculateNextStartFrom(1000) + assertEquals(1000, nextStartFrom) // 0 + 1000 (default limit) + } + + @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) + + // But effectiveLimit should default to 1000, verified by calculateNextStartFrom + val nextStartFrom = endpoint.calculateNextStartFrom(1000) + assertEquals(1000, nextStartFrom) // 0 + 1000 (default limit) + } + + @Test + fun testHereNowStartFromZeroBoundary() { + // Test minimum valid startFrom value + val hereNow = pubnub.hereNow( + channels = listOf("test-channel"), + includeUUIDs = true, + startFrom = 0 + ) + assertNotNull(hereNow) + assertEquals(0, (hereNow as HereNowEndpoint).startFrom) + } + + @Test + fun testHereNowStartFromLargeValue() { + // Test large valid startFrom value + val hereNow = pubnub.hereNow( + channels = listOf("test-channel"), + includeUUIDs = true, + startFrom = 1000000 + ) + assertNotNull(hereNow) + assertEquals(1000000, (hereNow as HereNowEndpoint).startFrom) + } + + @Test + fun testHereNowStartFromNullIsValid() { + // Test that null startFrom is valid (defaults to 0) + val hereNow = pubnub.hereNow( + channels = listOf("test-channel"), + includeUUIDs = true, + startFrom = null + ) + assertNotNull(hereNow) + assertNull((hereNow as HereNowEndpoint).startFrom) + } + + @Test + fun testHereNowStartFromNegativeAccepted() { + // Test that startFrom=-1 is accepted at creation time + // (validation happens during execution in doWork()) + val hereNow = pubnub.hereNow( + channels = listOf("test-channel"), + includeUUIDs = true, + startFrom = -1 + ) + assertNotNull(hereNow) + assertEquals(-1, (hereNow as HereNowEndpoint).startFrom) + } + + @Test + fun testHereNowStartFromLargeNegativeAccepted() { + // Test that large negative startFrom is accepted at creation time + // (validation happens during execution in doWork()) + val hereNow = pubnub.hereNow( + channels = listOf("test-channel"), + includeUUIDs = true, + startFrom = -100 + ) + assertNotNull(hereNow) + assertEquals(-100, (hereNow as HereNowEndpoint).startFrom) + } +} diff --git a/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/internal/endpoints/presence/HereNowPaginationTest.kt b/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/internal/endpoints/presence/HereNowPaginationTest.kt deleted file mode 100644 index 322c783280..0000000000 --- a/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/internal/endpoints/presence/HereNowPaginationTest.kt +++ /dev/null @@ -1,92 +0,0 @@ -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 HereNowPaginationTest : BaseTest() { - @Test - fun testNextStartFromCalculation_hasMoreResults() { - val endpoint = HereNowEndpoint( - pubnub = pubnub, - limit = 10, - startFrom = 20 - ) - - // When actual result size equals limit, there might be more results - val nextStartFrom = endpoint.calculateNextStartFrom(10) - assertEquals(30, nextStartFrom) // startFrom + limit - } - - @Test - fun testNextStartFromCalculation_noMoreResults() { - val endpoint = HereNowEndpoint( - pubnub = pubnub, - limit = 10, - startFrom = 20 - ) - - // When actual result size is less than limit, no more results - val nextStartFrom = endpoint.calculateNextStartFrom(5) - assertNull(nextStartFrom) - } - - @Test - fun testNextStartFromCalculation_withExplicitLimit() { - val endpoint = HereNowEndpoint( - pubnub = pubnub, - limit = 50, - startFrom = 20 - ) - - // When result equals limit, return next page - val nextStartFrom = endpoint.calculateNextStartFrom(50) - assertEquals(70, nextStartFrom) // 20 + 50 - } - - @Test - fun testNextStartFromCalculation_startFromDefaultsToZero() { - val endpoint = HereNowEndpoint( - pubnub = pubnub, - limit = 10, - startFrom = null // Should default to 0 in calculation - ) - - val nextStartFrom = endpoint.calculateNextStartFrom(10) - assertEquals(10, nextStartFrom) // 0 + 10 - } - - @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, - startFrom = 100 - ) - - assertNotNull(hereNow) - assertEquals(50, (hereNow as HereNowEndpoint).limit) - assertEquals(100, hereNow.startFrom) - } - - @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.startFrom) - } -} From ba34ae8daccb4839f691ce2a65bee3fcec9c3e8d Mon Sep 17 00:00:00 2001 From: "marcin.cebo" Date: Mon, 6 Oct 2025 09:06:36 +0200 Subject: [PATCH 05/19] Renamed nextStartFrom to nextOffset --- .../api/endpoints/presence/HereNow.ios.kt | 2 +- .../api/models/consumer/presence/PNHereNow.kt | 4 +-- .../integration/PresenceIntegrationTests.kt | 28 ++++++++--------- .../endpoints/presence/HereNowEndpoint.kt | 6 ++-- .../endpoints/presence/HereNowEndpointTest.kt | 30 +++++++++---------- .../presence/HereNowPaginationTestSuite.kt | 4 +-- 6 files changed, 37 insertions(+), 37 deletions(-) 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 710cff6d9e..69547cbffb 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 @@ -44,7 +44,7 @@ class HereNowImpl( PNHereNowResult( totalChannels = it?.totalChannels()?.toInt() ?: 0, totalOccupancy = it?.totalOccupancy()?.toInt() ?: 0, - // nextStartFrom = it?.nextStartFrom()?.toInt(), // todo uncomment once available + // nextOffset = it?.nextOffset()?.toInt(), // todo uncomment once available channels = (it?.channels()?.safeCast())?.mapValues { entry -> PNHereNowChannelData( channelName = entry.value.channelName(), diff --git a/pubnub-kotlin/pubnub-kotlin-core-api/src/commonMain/kotlin/com/pubnub/api/models/consumer/presence/PNHereNow.kt b/pubnub-kotlin/pubnub-kotlin-core-api/src/commonMain/kotlin/com/pubnub/api/models/consumer/presence/PNHereNow.kt index ac0e87ad19..f53eacb243 100644 --- a/pubnub-kotlin/pubnub-kotlin-core-api/src/commonMain/kotlin/com/pubnub/api/models/consumer/presence/PNHereNow.kt +++ b/pubnub-kotlin/pubnub-kotlin-core-api/src/commonMain/kotlin/com/pubnub/api/models/consumer/presence/PNHereNow.kt @@ -8,14 +8,14 @@ import com.pubnub.api.JsonElement * @property totalChannels Total number channels matching the associated HereNow call. * @property totalOccupancy Total occupancy matching the associated HereNow call. * @property channels A map with values of [PNHereNowChannelData] for each channel. - * @property nextStartFrom Starting position for next page of results. Null if no more pages available. + * @property nextOffset Starting position for next page of results. Null if no more pages available. */ class PNHereNowResult( val totalChannels: Int, val totalOccupancy: Int, // TODO this should be immutable val channels: MutableMap = mutableMapOf(), - val nextStartFrom: Int? = null, + val nextOffset: Int? = null, ) /** 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 e93d6bb6fa..f7ac87141a 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 @@ -321,8 +321,8 @@ class PresenceIntegrationTests : BaseIntegrationTest() { // With limit=3, we should get only 3 occupants even though 6 are present assertEquals(testLimit, channelData.occupants.size) - // nextStartFrom should be present since we limited results - assertEquals(3, it.nextStartFrom) + // nextOffset should be present since we limited results + assertEquals(3, it.nextOffset) } } } @@ -360,8 +360,8 @@ class PresenceIntegrationTests : BaseIntegrationTest() { // With startFrom=2, we should get remaining occupants (5 total - 2 skipped = 3 remaining) assertEquals(totalClientsCount - startFromValue, channelData.occupants.size) - // nextStartFrom should be null since we got all remaining results - assertNull(it.nextStartFrom) + // nextOffset should be null since we got all remaining results + assertNull(it.nextOffset) } } } @@ -418,7 +418,7 @@ class PresenceIntegrationTests : BaseIntegrationTest() { assertEquals(channel01TotalCount, channel01DataPage01.occupancy) assertEquals(totalClientsCount, firstPage.totalOccupancy) // this is totalOccupancy in all pages assertEquals(pageSize, channel01DataPage01.occupants.size) - assertEquals(3, firstPage.nextStartFrom) + assertEquals(3, firstPage.nextOffset) val channel02Data = firstPage.channels[channel02]!! assertEquals(channel02TotalCount, channel02Data.occupancy) assertEquals(pageSize, channel02Data.occupants.size) @@ -426,12 +426,12 @@ class PresenceIntegrationTests : BaseIntegrationTest() { // Collect UUIDs from first page channel01DataPage01.occupants.forEach { allOccupantsInChannel01.add(it.uuid) } - // Second page using nextStartFrom + // Second page using nextOffset val secondPage = pubnub.hereNow( channels = listOf(channel01), includeUUIDs = true, limit = pageSize, - startFrom = firstPage.nextStartFrom!!, + startFrom = firstPage.nextOffset!!, ).sync()!! secondPage.channels.forEach { it: Map.Entry -> @@ -447,7 +447,7 @@ class PresenceIntegrationTests : BaseIntegrationTest() { secondPage.totalOccupancy ) // we get result only from channel01 because there is no more result for channel02 assertEquals(pageSize, channel01DataPage02.occupants.size) - assertEquals(6, secondPage.nextStartFrom) + assertEquals(6, secondPage.nextOffset) assertFalse(secondPage.channels.containsKey(channel02)) @@ -457,12 +457,12 @@ class PresenceIntegrationTests : BaseIntegrationTest() { allOccupantsInChannel01.add(it.uuid) } - // Third page using nextStartFrom from second page + // Third page using nextOffset from second page val thirdPage = pubnub.hereNow( channels = listOf(channel01), includeUUIDs = true, limit = pageSize, - startFrom = secondPage.nextStartFrom!!, + startFrom = secondPage.nextOffset!!, ).sync()!! thirdPage.channels.forEach { it: Map.Entry -> @@ -480,7 +480,7 @@ class PresenceIntegrationTests : BaseIntegrationTest() { assertEquals(expectedRemainingCount, channel01DataPage03.occupants.size) // Should be null since no more pages - assertNull(thirdPage.nextStartFrom) + assertNull(thirdPage.nextOffset) // Collect UUIDs from third page channel01DataPage03.occupants.forEach { @@ -522,9 +522,9 @@ class PresenceIntegrationTests : BaseIntegrationTest() { assertEquals(limitValue, channelData.occupants.size) // Since returned count equals limit and there are more clients, - // nextStartFrom should be present - assertNotNull(it.nextStartFrom) - assertEquals(limitValue, it.nextStartFrom) + // nextOffset should be present + assertNotNull(it.nextOffset) + assertEquals(limitValue, it.nextOffset) } } } 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 280e0eb2a4..93006cbf79 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 @@ -94,7 +94,7 @@ class HereNowEndpoint internal constructor( override fun getEndpointGroupName(): RetryableEndpointGroup = RetryableEndpointGroup.PRESENCE - internal fun calculateNextStartFrom(actualResultSize: Int): Int? { + internal fun calculateNextOffset(actualResultSize: Int): Int? { return when { actualResultSize < effectiveLimit -> null actualResultSize == effectiveLimit -> (startFrom ?: 0) + effectiveLimit @@ -114,7 +114,7 @@ class HereNowEndpoint internal constructor( PNHereNowResult( totalChannels = 1, totalOccupancy = input.occupancy, - nextStartFrom = calculateNextStartFrom(actualResultSize), + nextOffset = calculateNextOffset(actualResultSize), ) val pnHereNowChannelData = @@ -163,7 +163,7 @@ class HereNowEndpoint internal constructor( totalChannels = pubnub.mapper.elementToInt(input, "total_channels"), totalOccupancy = pubnub.mapper.elementToInt(input, "total_occupancy"), channels = channelsMap, - nextStartFrom = calculateNextStartFrom(totalOccupantsReturned), + nextOffset = calculateNextOffset(totalOccupantsReturned), ) return pnHereNowResult 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 index 47e7a0803f..b553542cdf 100644 --- 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 @@ -16,8 +16,8 @@ class HereNowEndpointTest : BaseTest() { ) // When actual result size equals limit, there might be more results - val nextStartFrom = endpoint.calculateNextStartFrom(10) - assertEquals(30, nextStartFrom) // startFrom + limit + val nextOffset = endpoint.calculateNextOffset(10) + assertEquals(30, nextOffset) // startFrom + limit } @Test @@ -29,8 +29,8 @@ class HereNowEndpointTest : BaseTest() { ) // When actual result size is less than limit, no more results - val nextStartFrom = endpoint.calculateNextStartFrom(5) - assertNull(nextStartFrom) + val nextOffset = endpoint.calculateNextOffset(5) + assertNull(nextOffset) } @Test @@ -42,8 +42,8 @@ class HereNowEndpointTest : BaseTest() { ) // When result equals limit, return next page - val nextStartFrom = endpoint.calculateNextStartFrom(50) - assertEquals(70, nextStartFrom) // 20 + 50 + val nextOffset = endpoint.calculateNextOffset(50) + assertEquals(70, nextOffset) // 20 + 50 } @Test @@ -54,8 +54,8 @@ class HereNowEndpointTest : BaseTest() { startFrom = null // Should default to 0 in calculation ) - val nextStartFrom = endpoint.calculateNextStartFrom(10) - assertEquals(10, nextStartFrom) // 0 + 10 + val nextOffset = endpoint.calculateNextOffset(10) + assertEquals(10, nextOffset) // 0 + 10 } @Test @@ -128,9 +128,9 @@ class HereNowEndpointTest : BaseTest() { assertEquals(1500, endpoint.limit) // But effectiveLimit should be capped at 1000, verified by calculateNextStartFrom - // If we get 1000 results, nextStartFrom should be 1000 (not 1500) - val nextStartFrom = endpoint.calculateNextStartFrom(1000) - assertEquals(1000, nextStartFrom) // 0 + 1000 (capped limit) + // If we get 1000 results, nextOffset should be 1000 (not 1500) + val nextOffset = endpoint.calculateNextOffset(1000) + assertEquals(1000, nextOffset) // 0 + 1000 (capped limit) } @Test @@ -147,8 +147,8 @@ class HereNowEndpointTest : BaseTest() { assertEquals(0, endpoint.limit) // But effectiveLimit should default to 1000, verified by calculateNextStartFrom - val nextStartFrom = endpoint.calculateNextStartFrom(1000) - assertEquals(1000, nextStartFrom) // 0 + 1000 (default limit) + val nextOffset = endpoint.calculateNextOffset(1000) + assertEquals(1000, nextOffset) // 0 + 1000 (default limit) } @Test @@ -165,8 +165,8 @@ class HereNowEndpointTest : BaseTest() { assertEquals(-5, endpoint.limit) // But effectiveLimit should default to 1000, verified by calculateNextStartFrom - val nextStartFrom = endpoint.calculateNextStartFrom(1000) - assertEquals(1000, nextStartFrom) // 0 + 1000 (default limit) + val nextOffset = endpoint.calculateNextOffset(1000) + assertEquals(1000, nextOffset) // 0 + 1000 (default limit) } @Test 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 index a78cf79bc8..aaf483cd7c 100644 --- 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 @@ -27,8 +27,8 @@ class HereNowPaginationTestSuite : com.pubnub.internal.suite.CoreEndpointTestSui assertEquals(1, result.totalOccupancy) assertEquals(1, result.channels.size) assertEquals("user_1", result.channels["ch1"]!!.occupants[0].uuid) - // With only 1 occupant but limit 100, nextStartFrom should be null (no more results) - assertNull(result.nextStartFrom) + // With only 1 occupant but limit 100, nextOffset should be null (no more results) + assertNull(result.nextOffset) } override fun successfulResponseBody() = From 49864fc1abb4f4388fe71f4eacacf67e669abaf6 Mon Sep 17 00:00:00 2001 From: "marcin.cebo" Date: Mon, 6 Oct 2025 09:21:26 +0200 Subject: [PATCH 06/19] Renamed startFrom to offset --- .../api/java/endpoints/presence/HereNow.java | 2 +- .../java/endpoints/presence/HereNowImpl.java | 4 +- .../kotlin/com/pubnub/api/PubNubImpl.kt | 4 +- .../api/endpoints/presence/HereNow.ios.kt | 6 +-- .../kotlin/com/pubnub/api/PubNub.kt | 2 +- .../jvmMain/kotlin/com/pubnub/api/PubNub.kt | 2 +- .../pubnub/api/endpoints/presence/HereNow.kt | 2 +- .../kotlin/com/pubnub/api/PubNub.nonJvm.kt | 2 +- .../kotlin/com/pubnub/api/PubNubError.kt | 4 +- .../com/pubnub/docs/presence/HereNowMain.kt | 2 +- .../integration/PresenceIntegrationTests.kt | 14 +++--- .../kotlin/com/pubnub/internal/PubNubImpl.kt | 4 +- .../endpoints/presence/HereNowEndpoint.kt | 20 ++++---- .../endpoints/presence/HereNowEndpointTest.kt | 48 +++++++++---------- .../presence/HereNowPaginationTestSuite.kt | 2 +- 15 files changed, 60 insertions(+), 58 deletions(-) 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 245761cfa5..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 @@ -14,5 +14,5 @@ public interface HereNow extends Endpoint { HereNow limit(int limit); - HereNow startFrom(Integer startFrom); + HereNow offset(Integer offset); } 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 df5cd52f25..cdabbf229e 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 @@ -21,7 +21,7 @@ public class HereNowImpl extends PassthroughEndpoint implements private boolean includeState = false; private boolean includeUUIDs = true; private int limit = 1000; - private Integer startFrom = null; + private Integer offset = null; public HereNowImpl(PubNub pubnub) { super(pubnub); @@ -36,7 +36,7 @@ protected Endpoint createRemoteAction() { includeState, includeUUIDs, limit, - startFrom + 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 c8d3c4d98d..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 @@ -355,7 +355,7 @@ class PubNubImpl(private val pubNubObjC: KMPPubNub) : PubNub { includeState: Boolean, includeUUIDs: Boolean, limit: Int, - startFrom: Int? + offset: Int? ): HereNow { return HereNowImpl( pubnub = pubNubObjC, @@ -364,7 +364,7 @@ class PubNubImpl(private val pubNubObjC: KMPPubNub) : PubNub { includeState = includeState, includeUUIDs = includeUUIDs, limit = limit, - startFrom = startFrom + 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 69547cbffb..88d7bfcf69 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 @@ -29,7 +29,7 @@ class HereNowImpl( private val includeState: Boolean, private val includeUUIDs: Boolean, private val limit: Int = 1000, - private val startFrom: Int? = null, + private val offset: Int? = null, ) : HereNow { override fun async(callback: Consumer>) { pubnub.hereNowWithChannels( @@ -37,9 +37,9 @@ class HereNowImpl( channelGroups = channelGroups, includeState = includeState, includeUUIDs = includeUUIDs, - // todo pass limit and startFrom once available + // todo pass limit and offset once available // limit = limit, - // startFrom = startFrom, + // 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 d90b86be04..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 @@ -172,7 +172,7 @@ expect interface PubNub { includeState: Boolean = false, includeUUIDs: Boolean = true, limit: Int = 1000, - startFrom: Int? = null, + offset: Int? = null, ): HereNow fun whereNow(uuid: String = configuration.userId.value): WhereNow 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 6c2b646673..35a0575c3f 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 @@ -775,7 +775,7 @@ actual interface PubNub : StatusEmitter, EventEmitter { includeState: Boolean, includeUUIDs: Boolean, limit: Int, - startFrom: 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 e557e22dce..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 @@ -12,5 +12,5 @@ actual interface HereNow : Endpoint { val includeState: Boolean val includeUUIDs: Boolean val limit: Int - val startFrom: 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 96a9c3d8f3..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 @@ -160,7 +160,7 @@ actual interface PubNub { includeState: Boolean, includeUUIDs: Boolean, limit: Int, - startFrom: 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 8cb4903e27..31d148b2a3 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 @@ -240,9 +240,9 @@ enum class PubNubError(private val code: Int, val message: String) { "Channel and/or ChannelGroup contains empty string which is not allowed.", ), - HERE_NOW_START_FROM_OUT_OF_RANGE( + HERE_NOW_OFFSET_OUT_OF_RANGE( 182, - "HereNow startFrom is out of range. Valid range is 0 to infinity.", + "HereNow offset is out of range. Valid range is 0 to infinity.", ) ; 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 4e06179ce7..be4112bfb1 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 @@ -41,7 +41,7 @@ fun main() { fun singleChannelHereNow(pubnub: PubNub, channel: String) { println("\n# Basic hereNow for single channel: $channel") - // todo consider adding limit and startFrom to docs + // todo consider adding limit and offset to docs pubnub.hereNow( channels = listOf(channel) ).async { result -> 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 f7ac87141a..8c821f576f 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 @@ -329,7 +329,7 @@ class PresenceIntegrationTests : BaseIntegrationTest() { @Test fun testHereNowWithStartFrom() { - val startFromValue = 2 + val offsetValue = 2 val totalClientsCount = 5 val expectedChannel = randomChannel() @@ -346,7 +346,7 @@ class PresenceIntegrationTests : BaseIntegrationTest() { pubnub.hereNow( channels = listOf(expectedChannel), includeUUIDs = true, - startFrom = startFromValue, + offset = offsetValue, ).asyncRetry { result -> assertFalse(result.isFailure) result.onSuccess { @@ -357,8 +357,8 @@ class PresenceIntegrationTests : BaseIntegrationTest() { val channelData = it.channels[expectedChannel]!! assertEquals(totalClientsCount, channelData.occupancy) - // With startFrom=2, we should get remaining occupants (5 total - 2 skipped = 3 remaining) - assertEquals(totalClientsCount - startFromValue, channelData.occupants.size) + // With offset=2, we should get remaining occupants (5 total - 2 skipped = 3 remaining) + assertEquals(totalClientsCount - offsetValue, channelData.occupants.size) // nextOffset should be null since we got all remaining results assertNull(it.nextOffset) @@ -396,7 +396,6 @@ class PresenceIntegrationTests : BaseIntegrationTest() { Thread.sleep(2000) - val allOccupantsInChannel01 = mutableSetOf() // First page @@ -431,7 +430,7 @@ class PresenceIntegrationTests : BaseIntegrationTest() { channels = listOf(channel01), includeUUIDs = true, limit = pageSize, - startFrom = firstPage.nextOffset!!, + offset = firstPage.nextOffset!!, ).sync()!! secondPage.channels.forEach { it: Map.Entry -> @@ -462,7 +461,7 @@ class PresenceIntegrationTests : BaseIntegrationTest() { channels = listOf(channel01), includeUUIDs = true, limit = pageSize, - startFrom = secondPage.nextOffset!!, + offset = secondPage.nextOffset!!, ).sync()!! thirdPage.channels.forEach { it: Map.Entry -> @@ -492,7 +491,6 @@ class PresenceIntegrationTests : BaseIntegrationTest() { assertEquals(channel01TotalCount, allOccupantsInChannel01.size) } - @Test fun testHereNowNextStartFromWhenMoreResults() { val limitValue = 4 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 9fc88ab216..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 @@ -579,7 +579,7 @@ open class PubNubImpl( includeState: Boolean, includeUUIDs: Boolean, limit: Int, - startFrom: Int?, + offset: Int?, ): HereNow { return HereNowEndpoint( pubnub = this, @@ -588,7 +588,7 @@ open class PubNubImpl( includeState = includeState, includeUUIDs = includeUUIDs, limit = limit, - startFrom = startFrom, + 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 93006cbf79..af25a9fc91 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 @@ -32,10 +32,14 @@ class HereNowEndpoint internal constructor( override val includeState: Boolean = false, override val includeUUIDs: Boolean = true, override val limit: Int = MAX_NUMBER_OF_RESULT_ON_ONE_PAGE, - override val startFrom: Int? = null, + override val offset: Int? = null, ) : EndpointCore, PNHereNowResult>(pubnub), HereNow { private val log: PNLogger = LoggerManager.instance.getLogger(pubnub.logConfig, this::class.java) - private val effectiveLimit: Int = if (limit in 1..MAX_NUMBER_OF_RESULT_ON_ONE_PAGE) limit else MAX_NUMBER_OF_RESULT_ON_ONE_PAGE + private val effectiveLimit: Int = if (limit in 1..MAX_NUMBER_OF_RESULT_ON_ONE_PAGE) { + limit + } else { + MAX_NUMBER_OF_RESULT_ON_ONE_PAGE + } private fun isGlobalHereNow() = channels.isEmpty() && channelGroups.isEmpty() @@ -44,8 +48,8 @@ class HereNowEndpoint internal constructor( override fun getAffectedChannelGroups() = channelGroups override fun doWork(queryParams: HashMap): Call> { - if (startFrom != null && startFrom < 0) { - throw PubNubException(PubNubError.HERE_NOW_START_FROM_OUT_OF_RANGE) + if (offset != null && offset < 0) { + throw PubNubException(PubNubError.HERE_NOW_OFFSET_OUT_OF_RANGE) } log.debug( @@ -57,7 +61,7 @@ class HereNowEndpoint internal constructor( "includeState" to includeState, "includeUUIDs" to includeUUIDs, "limit" to effectiveLimit, - "startFrom" to (startFrom?.toString() ?: "null"), + "offset" to (offset?.toString() ?: "null"), "isGlobalHereNow" to isGlobalHereNow(), ), operation = this::class.simpleName @@ -97,7 +101,7 @@ class HereNowEndpoint internal constructor( internal fun calculateNextOffset(actualResultSize: Int): Int? { return when { actualResultSize < effectiveLimit -> null - actualResultSize == effectiveLimit -> (startFrom ?: 0) + effectiveLimit + actualResultSize == effectiveLimit -> (offset ?: 0) + effectiveLimit else -> null } } @@ -145,7 +149,7 @@ class HereNowEndpoint internal constructor( } // we want to know amount of occupants in channel that has the most occupants - if (occupants.size > totalOccupantsReturned){ + if (occupants.size > totalOccupantsReturned) { totalOccupantsReturned = occupants.size } @@ -203,6 +207,6 @@ class HereNowEndpoint internal constructor( queryParams["channel-group"] = channelGroups.toCsv() } queryParams["limit"] = effectiveLimit.toString() - startFrom?.let { queryParams["offset"] = it.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 index b553542cdf..e4ed9dc8aa 100644 --- 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 @@ -12,12 +12,12 @@ class HereNowEndpointTest : BaseTest() { val endpoint = HereNowEndpoint( pubnub = pubnub, limit = 10, - startFrom = 20 + offset = 20 ) // When actual result size equals limit, there might be more results val nextOffset = endpoint.calculateNextOffset(10) - assertEquals(30, nextOffset) // startFrom + limit + assertEquals(30, nextOffset) // offset + limit } @Test @@ -25,7 +25,7 @@ class HereNowEndpointTest : BaseTest() { val endpoint = HereNowEndpoint( pubnub = pubnub, limit = 10, - startFrom = 20 + offset = 20 ) // When actual result size is less than limit, no more results @@ -38,7 +38,7 @@ class HereNowEndpointTest : BaseTest() { val endpoint = HereNowEndpoint( pubnub = pubnub, limit = 50, - startFrom = 20 + offset = 20 ) // When result equals limit, return next page @@ -47,11 +47,11 @@ class HereNowEndpointTest : BaseTest() { } @Test - fun testNextStartFromCalculation_startFromDefaultsToZero() { + fun testNextStartFromCalculation_offsetDefaultsToZero() { val endpoint = HereNowEndpoint( pubnub = pubnub, limit = 10, - startFrom = null // Should default to 0 in calculation + offset = null // Should default to 0 in calculation ) val nextOffset = endpoint.calculateNextOffset(10) @@ -67,12 +67,12 @@ class HereNowEndpointTest : BaseTest() { includeState = false, includeUUIDs = true, limit = 50, - startFrom = 100 + offset = 100 ) assertNotNull(hereNow) assertEquals(50, (hereNow as HereNowEndpoint).limit) - assertEquals(100, hereNow.startFrom) + assertEquals(100, hereNow.offset) } @Test @@ -87,7 +87,7 @@ class HereNowEndpointTest : BaseTest() { assertNotNull(hereNow) assertEquals(1000, (hereNow as HereNowEndpoint).limit) // Default limit is 1000 - assertNull(hereNow.startFrom) + assertNull(hereNow.offset) } @Test @@ -171,63 +171,63 @@ class HereNowEndpointTest : BaseTest() { @Test fun testHereNowStartFromZeroBoundary() { - // Test minimum valid startFrom value + // Test minimum valid offset value val hereNow = pubnub.hereNow( channels = listOf("test-channel"), includeUUIDs = true, - startFrom = 0 + offset = 0 ) assertNotNull(hereNow) - assertEquals(0, (hereNow as HereNowEndpoint).startFrom) + assertEquals(0, (hereNow as HereNowEndpoint).offset) } @Test fun testHereNowStartFromLargeValue() { - // Test large valid startFrom value + // Test large valid offset value val hereNow = pubnub.hereNow( channels = listOf("test-channel"), includeUUIDs = true, - startFrom = 1000000 + offset = 1000000 ) assertNotNull(hereNow) - assertEquals(1000000, (hereNow as HereNowEndpoint).startFrom) + assertEquals(1000000, (hereNow as HereNowEndpoint).offset) } @Test fun testHereNowStartFromNullIsValid() { - // Test that null startFrom is valid (defaults to 0) + // Test that null offset is valid (defaults to 0) val hereNow = pubnub.hereNow( channels = listOf("test-channel"), includeUUIDs = true, - startFrom = null + offset = null ) assertNotNull(hereNow) - assertNull((hereNow as HereNowEndpoint).startFrom) + assertNull((hereNow as HereNowEndpoint).offset) } @Test fun testHereNowStartFromNegativeAccepted() { - // Test that startFrom=-1 is accepted at creation time + // Test that offset=-1 is accepted at creation time // (validation happens during execution in doWork()) val hereNow = pubnub.hereNow( channels = listOf("test-channel"), includeUUIDs = true, - startFrom = -1 + offset = -1 ) assertNotNull(hereNow) - assertEquals(-1, (hereNow as HereNowEndpoint).startFrom) + assertEquals(-1, (hereNow as HereNowEndpoint).offset) } @Test fun testHereNowStartFromLargeNegativeAccepted() { - // Test that large negative startFrom is accepted at creation time + // 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, - startFrom = -100 + offset = -100 ) assertNotNull(hereNow) - assertEquals(-100, (hereNow as HereNowEndpoint).startFrom) + 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 index aaf483cd7c..394b3bc388 100644 --- 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 @@ -19,7 +19,7 @@ class HereNowPaginationTestSuite : com.pubnub.internal.suite.CoreEndpointTestSui pubnub.hereNow( channels = listOf("ch1"), limit = 100, - startFrom = 50 + offset = 50 ) override fun verifyResultExpectations(result: PNHereNowResult) { From 0baebf7564e477f6acc1dd2b5ef5972786247d56 Mon Sep 17 00:00:00 2001 From: "marcin.cebo" Date: Mon, 6 Oct 2025 13:43:38 +0200 Subject: [PATCH 07/19] Updated snippets --- .../com/pubnub/docs/presence/HereNowApp.java | 58 +++++++++--- .../com/pubnub/docs/presence/HereNowMain.kt | 58 +++++++++--- .../integration/PresenceIntegrationTests.kt | 88 +++++++++++++------ 3 files changed, 152 insertions(+), 52 deletions(-) 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..6ddd095ed2 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 @@ -21,29 +21,61 @@ public static void main(String[] args) throws PubNubException { PubNub pubnub = PubNub.create(configBuilder.build()); - // Get presence information for specified channels + // Get presence information for specified channels with pagination support pubnub.hereNow() .channels(Arrays.asList("coolChannel", "coolChannel2")) .includeUUIDs(true) + .limit(100) .async(result -> { result.onSuccess((PNHereNowResult res) -> { + printHereNowResult(res); - System.out.println("Total Channels: " + res.getTotalChannels()); - System.out.println("Total Occupancy: " + res.getTotalOccupancy()); - - for (PNHereNowChannelData channelData : res.getChannels().values()) { - System.out.println("---"); - System.out.println("Channel: " + channelData.getChannelName()); - System.out.println("Occupancy: " + channelData.getOccupancy()); - System.out.println("Occupants:"); - for (PNHereNowOccupantData occupant : channelData.getOccupants()) { - System.out.println("UUID: " + occupant.getUuid() + " State: " + occupant.getState()); - } + // Check if more results are available + if (res.getNextOffset() != null && res.getNextOffset() != 0) { + System.out.println("\nMore results available. Fetching next page...\n"); + + // Fetch next page using the offset from previous response + pubnub.hereNow() + .channels(Arrays.asList("coolChannel", "coolChannel2")) + .includeUUIDs(true) + .limit(100) + .offset(res.getNextOffset()) + .async(result2 -> { + result2.onSuccess((PNHereNowResult res2) -> { + printHereNowResult(res2); + + // Continue pagination if needed by checking res2.getNextOffset() + }).onFailure((PubNubException exception) -> { + System.out.println("Error retrieving hereNow data: " + exception.getMessage()); + }); + }); } - }).onFailure( (PubNubException exception) -> { + }).onFailure((PubNubException exception) -> { System.out.println("Error retrieving hereNow data: " + exception.getMessage()); }); }); } + + /** + * Helper method to print HereNow presence information + */ + private static void printHereNowResult(PNHereNowResult result) { + System.out.println("Total Channels: " + result.getTotalChannels()); + System.out.println("Total Occupancy: " + result.getTotalOccupancy()); + + for (PNHereNowChannelData channelData : result.getChannels().values()) { + System.out.println("---"); + System.out.println("Channel: " + channelData.getChannelName()); + System.out.println("Occupancy: " + channelData.getOccupancy()); + System.out.println("Occupants:"); + for (PNHereNowOccupantData occupant : channelData.getOccupants()) { + System.out.println("UUID: " + occupant.getUuid() + " State: " + occupant.getState()); + } + } + + if (result.getNextOffset() != null && result.getNextOffset() != 0) { + System.out.println("Next Offset: " + result.getNextOffset()); + } + } } // snippet.end 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 be4112bfb1..5cb9651099 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 @@ -4,6 +4,7 @@ package com.pubnub.docs.presence import com.pubnub.api.PubNub import com.pubnub.api.UserId +import com.pubnub.api.models.consumer.presence.PNHereNowResult import com.pubnub.api.v2.PNConfiguration fun main() { @@ -36,27 +37,39 @@ fun main() { } /** - * Demonstrates basic usage of hereNow for a single channel + * Demonstrates basic usage of hereNow for a single channel with pagination support */ fun singleChannelHereNow(pubnub: PubNub, channel: String) { println("\n# Basic hereNow for single channel: $channel") - // todo consider adding limit and offset to docs pubnub.hereNow( - channels = listOf(channel) + channels = listOf(channel), + limit = 100, ).async { result -> result.onSuccess { response -> println("SUCCESS: Retrieved presence information") - - // Get information for our specific channel - val channelData = response.channels[channel] - - if (channelData != null) { - println("Channel: $channel") - println("Occupancy: ${channelData.occupancy}") - println("UUIDs: ${channelData.occupants.map { it.uuid }}") - } else { - println("No presence data for channel: $channel") + printChannelData(channel, response) + + // Check if more results are available + if (response.nextOffset != null && response.nextOffset != 0) { + println("\nMore occupants available. Fetching next page...") + + // Fetch next page using the offset from previous response + pubnub.hereNow( + channels = listOf(channel), + limit = 100, + offset = response.nextOffset + ).async { nextResult -> + nextResult.onSuccess { nextResponse -> + println("\nNext Page Results:") + printChannelData(channel, nextResponse) + + // Continue pagination if needed by checking nextResponse.nextOffset + }.onFailure { exception -> + println("ERROR: Failed to get next page of presence information") + println("Error details: ${exception.message}") + } + } } }.onFailure { exception -> println("ERROR: Failed to get presence information") @@ -148,4 +161,23 @@ fun advancedHereNow(pubnub: PubNub, channel: String) { // Wait for the operation to complete Thread.sleep(2000) } + +/** + * Helper function to print channel presence data + */ +private fun printChannelData(channel: String, response: PNHereNowResult) { + val channelData = response.channels[channel] + + if (channelData != null) { + println("Channel: $channel") + println("Occupancy: ${channelData.occupancy}") + println("UUIDs: ${channelData.occupants.map { it.uuid }}") + + if (response.nextOffset != null && response.nextOffset != 0) { + println("Next Offset: ${response.nextOffset}") + } + } else { + println("No presence data for channel: $channel") + } +} // snippet.end 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 8c821f576f..a23c60cf97 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 @@ -368,8 +368,8 @@ class PresenceIntegrationTests : BaseIntegrationTest() { @Test fun testHereNowPaginationFlow() { - // 8 users in expectedChannel - // 3 users in expectedChannel02 + // 8 users in channel01 + // 3 users in channel02 val pageSize = 3 val totalClientsCount = 11 val channel01TotalCount = 8 @@ -382,15 +382,11 @@ class PresenceIntegrationTests : BaseIntegrationTest() { addAll(generateSequence { createPubNub {} }.take(channel01TotalCount - 1).toList()) } - println("-=channel: $channel01") clients.forEach { - println("-= ${it.configuration.userId.value}") it.subscribeNonBlocking(channel01) } - println("-=channel02: $channel01") clients.take(3).forEach { - println("-= ${it.configuration.userId.value}") it.subscribeNonBlocking(channel02) } @@ -405,13 +401,6 @@ class PresenceIntegrationTests : BaseIntegrationTest() { limit = pageSize, ).sync()!! - firstPage.channels.forEach { it: Map.Entry -> - println("-=Channel=${it.key} or ${it.value.channelName}") - val pnHereNowChannelData: PNHereNowChannelData = it.value - pnHereNowChannelData.occupants.forEach { occupant -> - println("-=uuid firstPage ${occupant.uuid}") - } - } assertEquals(2, firstPage.totalChannels) val channel01DataPage01 = firstPage.channels[channel01]!! assertEquals(channel01TotalCount, channel01DataPage01.occupancy) @@ -433,12 +422,6 @@ class PresenceIntegrationTests : BaseIntegrationTest() { offset = firstPage.nextOffset!!, ).sync()!! - secondPage.channels.forEach { it: Map.Entry -> - val pnHereNowChannelData: PNHereNowChannelData = it.value - pnHereNowChannelData.occupants.forEach { occupant -> - println("-=uuid secondPage ${occupant.uuid}") - } - } val channel01DataPage02 = secondPage.channels[channel01]!! assertEquals(channel01TotalCount, channel01DataPage02.occupancy) assertEquals( @@ -464,13 +447,6 @@ class PresenceIntegrationTests : BaseIntegrationTest() { offset = secondPage.nextOffset!!, ).sync()!! - thirdPage.channels.forEach { it: Map.Entry -> - val pnHereNowChannelData: PNHereNowChannelData = it.value - pnHereNowChannelData.occupants.forEach { occupant -> - println("-=uuid thirdPage ${occupant.uuid}") - } - } - val channel01DataPage03 = thirdPage.channels[channel01]!! assertEquals(channel01TotalCount, channel01DataPage03.occupancy) @@ -526,4 +502,64 @@ class PresenceIntegrationTests : BaseIntegrationTest() { } } } + + @Test + fun testHereNowPaginationWithEmptyChannels() { + val emptyChannel = randomChannel() + val pageSize = 10 + + // Don't subscribe any clients to the channel, leaving it empty + + pubnub.hereNow( + channels = listOf(emptyChannel), + includeUUIDs = true, + limit = pageSize, + ).asyncRetry { result -> + assertFalse(result.isFailure) + result.onSuccess { + // Empty channels are still included in the response + assertEquals(1, it.totalChannels) + assertEquals(0, it.totalOccupancy) + assertEquals(1, it.channels.size) + + val channelData = it.channels[emptyChannel]!! + assertEquals(0, channelData.occupancy) + assertTrue(channelData.occupants.isEmpty()) + + // No pagination needed for empty results + assertNull(it.nextOffset) + } + } + } + + @Test + fun testHereNowPaginationWithEmptyChannelsAndOffset() { + val emptyChannel = randomChannel() + val pageSize = 10 + val offset = 5 + + // Don't subscribe any clients to the channel, leaving it empty + + pubnub.hereNow( + channels = listOf(emptyChannel), + includeUUIDs = true, + limit = pageSize, + offset = offset, + ).asyncRetry { result -> + assertFalse(result.isFailure) + result.onSuccess { + // Empty channels are still included in the response even with offset + assertEquals(1, it.totalChannels) + assertEquals(0, it.totalOccupancy) + assertEquals(1, it.channels.size) + + val channelData = it.channels[emptyChannel]!! + assertEquals(0, channelData.occupancy) + assertTrue(channelData.occupants.isEmpty()) + + // No pagination needed for empty results + assertNull(it.nextOffset) + } + } + } } From dc78bd82d5308d13d15cb6645db1d62f2b149476 Mon Sep 17 00:00:00 2001 From: "marcin.cebo" Date: Mon, 6 Oct 2025 14:14:14 +0200 Subject: [PATCH 08/19] Added log statement for case when limit is out of allowed range. --- .../endpoints/presence/HereNowEndpoint.kt | 63 ++++++++++--------- 1 file changed, 32 insertions(+), 31 deletions(-) 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 af25a9fc91..70dd9fd1a0 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 @@ -20,7 +20,7 @@ import com.pubnub.internal.toCsv import retrofit2.Call import retrofit2.Response -private const val MAX_NUMBER_OF_RESULT_ON_ONE_PAGE = 1000 +private const val MAX_NUMBER_OF_RESULT_ON_ONE_PAGE_PER_CHANNEL = 1000 /** * @see [PubNubImpl.hereNow] @@ -31,14 +31,22 @@ class HereNowEndpoint internal constructor( override val channelGroups: List = emptyList(), override val includeState: Boolean = false, override val includeUUIDs: Boolean = true, - override val limit: Int = MAX_NUMBER_OF_RESULT_ON_ONE_PAGE, + override val limit: Int = MAX_NUMBER_OF_RESULT_ON_ONE_PAGE_PER_CHANNEL, override val offset: Int? = null, ) : EndpointCore, PNHereNowResult>(pubnub), HereNow { private val log: PNLogger = LoggerManager.instance.getLogger(pubnub.logConfig, this::class.java) - private val effectiveLimit: Int = if (limit in 1..MAX_NUMBER_OF_RESULT_ON_ONE_PAGE) { + private val effectiveLimit: Int = if (limit in 1..MAX_NUMBER_OF_RESULT_ON_ONE_PAGE_PER_CHANNEL) { limit } else { - MAX_NUMBER_OF_RESULT_ON_ONE_PAGE + log.warn( + LogMessage( + LogMessageContent.Text( + "Valid range is 1 to $MAX_NUMBER_OF_RESULT_ON_ONE_PAGE_PER_CHANNEL. " + + "Shrinking limit to $MAX_NUMBER_OF_RESULT_ON_ONE_PAGE_PER_CHANNEL." + ) + ) + ) + MAX_NUMBER_OF_RESULT_ON_ONE_PAGE_PER_CHANNEL } private fun isGlobalHereNow() = channels.isEmpty() && channelGroups.isEmpty() @@ -63,8 +71,7 @@ class HereNowEndpoint internal constructor( "limit" to effectiveLimit, "offset" to (offset?.toString() ?: "null"), "isGlobalHereNow" to isGlobalHereNow(), - ), - operation = this::class.simpleName + ), operation = this::class.simpleName ), details = "HereNow API call", ) @@ -114,19 +121,15 @@ class HereNowEndpoint internal constructor( } val actualResultSize = occupants.size - val pnHereNowResult = - PNHereNowResult( - totalChannels = 1, - totalOccupancy = input.occupancy, - nextOffset = calculateNextOffset(actualResultSize), - ) + val pnHereNowResult = PNHereNowResult( + totalChannels = 1, + totalOccupancy = input.occupancy, + nextOffset = calculateNextOffset(actualResultSize), + ) - val pnHereNowChannelData = - PNHereNowChannelData( - channelName = channels[0], - occupancy = input.occupancy, - occupants = occupants - ) + val pnHereNowChannelData = PNHereNowChannelData( + channelName = channels[0], occupancy = input.occupancy, occupants = occupants + ) if (includeUUIDs) { pnHereNowResult.channels[channels[0]] = pnHereNowChannelData @@ -153,22 +156,20 @@ class HereNowEndpoint internal constructor( totalOccupantsReturned = occupants.size } - val pnHereNowChannelData = - PNHereNowChannelData( - channelName = entry.key, - occupancy = pubnub.mapper.elementToInt(entry.value, "occupancy"), - occupants = occupants - ) + 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, - nextOffset = calculateNextOffset(totalOccupantsReturned), - ) + val pnHereNowResult = PNHereNowResult( + totalChannels = pubnub.mapper.elementToInt(input, "total_channels"), + totalOccupancy = pubnub.mapper.elementToInt(input, "total_occupancy"), + channels = channelsMap, + nextOffset = calculateNextOffset(totalOccupantsReturned), + ) return pnHereNowResult } From 49c6b3db463e468436329c2ed2c12da12034092c Mon Sep 17 00:00:00 2001 From: "marcin.cebo" Date: Mon, 6 Oct 2025 17:21:21 +0200 Subject: [PATCH 09/19] Added test for Global HereNow --- .../com/pubnub/docs/presence/HereNowApp.java | 2 +- .../com/pubnub/docs/presence/HereNowMain.kt | 2 +- .../integration/PresenceIntegrationTests.kt | 179 ++++++++++++++++++ 3 files changed, 181 insertions(+), 2 deletions(-) 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 6ddd095ed2..653f10dd85 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 @@ -31,7 +31,7 @@ public static void main(String[] args) throws PubNubException { printHereNowResult(res); // Check if more results are available - if (res.getNextOffset() != null && res.getNextOffset() != 0) { + if (res.getNextOffset() != null) { System.out.println("\nMore results available. Fetching next page...\n"); // Fetch next page using the offset from previous response 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 5cb9651099..749a82649b 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 @@ -51,7 +51,7 @@ fun singleChannelHereNow(pubnub: PubNub, channel: String) { printChannelData(channel, response) // Check if more results are available - if (response.nextOffset != null && response.nextOffset != 0) { + if (response.nextOffset != null) { println("\nMore occupants available. Fetching next page...") // Fetch next page using the offset from previous response 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 a23c60cf97..1eaa50d5a9 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 @@ -562,4 +562,183 @@ class PresenceIntegrationTests : BaseIntegrationTest() { } } } + + @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) + + // nextOffset should be present since we limited results and channel02 has 6 occupants + assertNotNull(result.nextOffset) + } + + @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 testGlobalHereNowPaginationFlow() { + val pageSize = 3 + val totalClientsInChannel01 = 8 + val totalClientsInChannel02 = 3 + val channel01 = randomChannel() + val channel02 = randomChannel() + + val clients = + mutableListOf(pubnub).apply { + addAll(generateSequence { createPubNub {} }.take(totalClientsInChannel01 - 1).toList()) + } + + // Subscribe 8 clients to channel01 + clients.forEach { + it.subscribeNonBlocking(channel01) + } + + // Subscribe first 3 clients to channel02 as well + clients.take(totalClientsInChannel02).forEach { + it.subscribeNonBlocking(channel02) + } + + Thread.sleep(2000) + + val allOccupantsInChannel01 = mutableSetOf() + + // First page - global hereNow with no channels specified + val firstPage = pubnub.hereNow( + channels = emptyList(), + includeUUIDs = true, + limit = pageSize, + ).sync()!! + + // Should include at least our test channels + assertTrue(firstPage.totalChannels >= 2) + assertTrue(firstPage.channels.containsKey(channel01)) + assertTrue(firstPage.channels.containsKey(channel02)) + + val channel01DataPage01 = firstPage.channels[channel01]!! + val channel02DataPage01 = firstPage.channels[channel02]!! + + assertEquals(totalClientsInChannel01, channel01DataPage01.occupancy) + assertEquals(totalClientsInChannel02, channel02DataPage01.occupancy) + + // With limit, should get limited results + assertTrue(channel01DataPage01.occupants.size <= pageSize) + assertTrue(channel02DataPage01.occupants.size <= pageSize) + + // Collect UUIDs from first page + channel01DataPage01.occupants.forEach { allOccupantsInChannel01.add(it.uuid) } + + // Should have nextOffset since we have more results + assertNotNull(firstPage.nextOffset) + + // Second page using nextOffset + val secondPage = pubnub.hereNow( + channels = emptyList(), + includeUUIDs = true, + limit = pageSize, + offset = firstPage.nextOffset!!, + ).sync()!! + + // May have more or fewer channels than first page + assertTrue(secondPage.totalChannels >= 1) + + if (secondPage.channels.containsKey(channel01)) { + val channel01DataPage02 = secondPage.channels[channel01]!! + assertEquals(totalClientsInChannel01, channel01DataPage02.occupancy) + + // 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) + } + } + } + + @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 + Thread.sleep(1000) + + val result = pubnub.hereNow( + channels = emptyList(), + includeUUIDs = true, + limit = 10, + ).sync()!! + + // Should have no channels or very few residual ones + // Note: In a shared test environment, there might be residual presence state + assertTrue(result.totalOccupancy >= 0) + + // No pagination needed when no active subscriptions for this client + // Note: Result may vary based on test isolation + } } From e3d916d4da271c78a072a3b2266d563c11117523 Mon Sep 17 00:00:00 2001 From: "marcin.cebo" Date: Mon, 6 Oct 2025 17:54:00 +0200 Subject: [PATCH 10/19] Added integration tests for java --- .../integration/PresenceIntegrationTests.java | 542 ++++++++++++++++++ .../integration/PresenceIntegrationTests.kt | 1 - .../endpoints/presence/HereNowEndpoint.kt | 9 +- 3 files changed, 548 insertions(+), 4 deletions(-) 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..278f896ad2 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,539 @@ 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()); + + // nextOffset should be present since we limited results + assertNotNull(pnHereNowResult.getNextOffset()); + assertEquals(Integer.valueOf(3), pnHereNowResult.getNextOffset()); + + 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()); + + // nextOffset should be null since we got all remaining results + assertNull(pnHereNowResult.getNextOffset()); + + 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 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()); + assertNotNull(firstPage.getNextOffset()); + assertEquals(Integer.valueOf(3), firstPage.getNextOffset()); + + 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 nextOffset + PNHereNowResult secondPage = pubNub.hereNow() + .channels(Collections.singletonList(channel01)) + .includeUUIDs(true) + .limit(pageSize) + .offset(firstPage.getNextOffset()) + .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()); + assertNotNull(secondPage.getNextOffset()); + assertEquals(Integer.valueOf(6), secondPage.getNextOffset()); + + 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 nextOffset from second page + PNHereNowResult thirdPage = pubNub.hereNow() + .channels(Collections.singletonList(channel01)) + .includeUUIDs(true) + .limit(pageSize) + .offset(secondPage.getNextOffset()) + .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()); + + // Should be null since no more pages + assertNull(thirdPage.getNextOffset()); + + // 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 testHereNowNextOffsetWhenMoreResults() { + final AtomicBoolean success = new AtomicBoolean(); + final int limitValue = 4; + final int totalClientsCount = 10; + 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(limitValue) + .async((result) -> { + assertFalse(result.isFailure()); + result.onSuccess(pnHereNowResult -> { + assertEquals(1, pnHereNowResult.getTotalChannels()); + + PNHereNowChannelData channelData = pnHereNowResult.getChannels().get(expectedChannel); + assertNotNull(channelData); + assertEquals(totalClientsCount, channelData.getOccupancy()); + assertEquals(limitValue, channelData.getOccupants().size()); + + // Since returned count equals limit and there are more clients, + // nextOffset should be present + assertNotNull(pnHereNowResult.getNextOffset()); + assertEquals(Integer.valueOf(limitValue), pnHereNowResult.getNextOffset()); + + success.set(true); + }); + }); + + listen(success); + } + + @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()); + + // No pagination needed for empty results + assertNull(pnHereNowResult.getNextOffset()); + + success.set(true); + }); + }); + + listen(success); + } + + @Test + public void testHereNowPaginationWithEmptyChannelsAndOffset() { + final AtomicBoolean success = new AtomicBoolean(); + final String emptyChannel = RandomGenerator.get(); + final int pageSize = 10; + final int offset = 5; + + // Don't subscribe any clients to the channel, leaving it empty + + pubNub.hereNow() + .channels(Collections.singletonList(emptyChannel)) + .includeUUIDs(true) + .limit(pageSize) + .offset(offset) + .async((result) -> { + assertFalse(result.isFailure()); + result.onSuccess(pnHereNowResult -> { + // Empty channels are still included in the response even with offset + 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()); + + // No pagination needed for empty results + assertNull(pnHereNowResult.getNextOffset()); + + 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); + + // nextOffset should be present since we limited results and channel02 has 6 occupants + assertNotNull(result.getNextOffset()); + } + + @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); + } + + @Test + public void testGlobalHereNowPaginationFlow() throws PubNubException { + final int pageSize = 3; + final int totalClientsInChannel01 = 8; + final int totalClientsInChannel02 = 3; + final String channel01 = RandomGenerator.get(); + final String channel02 = RandomGenerator.get(); + + final List clients = new ArrayList() {{ + add(pubNub); + }}; + for (int i = 1; i < totalClientsInChannel01; i++) { + clients.add(getPubNub()); + } + + // Subscribe 8 clients to channel01 + for (PubNub client : clients) { + subscribeToChannel(client, channel01); + } + + // Subscribe first 3 clients to channel02 as well + for (int i = 0; i < totalClientsInChannel02; i++) { + subscribeToChannel(clients.get(i), channel02); + } + + pause(TIMEOUT_MEDIUM); + + final Set allOccupantsInChannel01 = new HashSet<>(); + + // First page - global hereNow with no channels specified + PNHereNowResult firstPage = pubNub.hereNow() + .channels(Collections.emptyList()) + .includeUUIDs(true) + .limit(pageSize) + .sync(); + + assertNotNull(firstPage); + + // Should include at least our test channels + assertTrue(firstPage.getTotalChannels() >= 2); + assertTrue(firstPage.getChannels().containsKey(channel01)); + assertTrue(firstPage.getChannels().containsKey(channel02)); + + PNHereNowChannelData channel01DataPage01 = firstPage.getChannels().get(channel01); + PNHereNowChannelData channel02DataPage01 = firstPage.getChannels().get(channel02); + + assertNotNull(channel01DataPage01); + assertNotNull(channel02DataPage01); + assertEquals(totalClientsInChannel01, channel01DataPage01.getOccupancy()); + assertEquals(totalClientsInChannel02, channel02DataPage01.getOccupancy()); + + // With limit, should get limited results + assertTrue(channel01DataPage01.getOccupants().size() <= pageSize); + assertTrue(channel02DataPage01.getOccupants().size() <= pageSize); + + // Collect UUIDs from first page + for (PNHereNowOccupantData occupant : channel01DataPage01.getOccupants()) { + allOccupantsInChannel01.add(occupant.getUuid()); + } + + // Should have nextOffset since we have more results + assertNotNull(firstPage.getNextOffset()); + + // Second page using nextOffset + PNHereNowResult secondPage = pubNub.hereNow() + .channels(Collections.emptyList()) + .includeUUIDs(true) + .limit(pageSize) + .offset(firstPage.getNextOffset()) + .sync(); + + assertNotNull(secondPage); + + // May have more or fewer channels than first page + assertTrue(secondPage.getTotalChannels() >= 1); + + if (secondPage.getChannels().containsKey(channel01)) { + PNHereNowChannelData channel01DataPage02 = secondPage.getChannels().get(channel01); + assertNotNull(channel01DataPage02); + assertEquals(totalClientsInChannel01, channel01DataPage02.getOccupancy()); + + // 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()); + } + } + } + + @Test + public void testGlobalHereNowWithNoActiveChannels() throws PubNubException { + // Don't subscribe any clients, making it a truly empty global query + // Wait a bit to ensure no residual presence state from other tests + pause(1); + + PNHereNowResult result = pubNub.hereNow() + .channels(Collections.emptyList()) + .includeUUIDs(true) + .limit(10) + .sync(); + + assertNotNull(result); + + // Should have no channels or very few residual ones + // Note: In a shared test environment, there might be residual presence state + assertTrue(result.getTotalOccupancy() >= 0); + + // No pagination needed when no active subscriptions for this client + // Note: Result may vary based on test isolation + } } 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 1eaa50d5a9..51abe970e0 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 @@ -5,7 +5,6 @@ import com.pubnub.api.callbacks.SubscribeCallback import com.pubnub.api.enums.PNHeartbeatNotificationOptions import com.pubnub.api.enums.PNStatusCategory import com.pubnub.api.models.consumer.PNStatus -import com.pubnub.api.models.consumer.presence.PNHereNowChannelData import com.pubnub.api.models.consumer.pubsub.PNPresenceEventResult import com.pubnub.test.CommonUtils.generatePayload import com.pubnub.test.CommonUtils.randomChannel 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 70dd9fd1a0..3bff362535 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 @@ -42,7 +42,7 @@ class HereNowEndpoint internal constructor( LogMessage( LogMessageContent.Text( "Valid range is 1 to $MAX_NUMBER_OF_RESULT_ON_ONE_PAGE_PER_CHANNEL. " + - "Shrinking limit to $MAX_NUMBER_OF_RESULT_ON_ONE_PAGE_PER_CHANNEL." + "Shrinking limit to $MAX_NUMBER_OF_RESULT_ON_ONE_PAGE_PER_CHANNEL." ) ) ) @@ -71,7 +71,8 @@ class HereNowEndpoint internal constructor( "limit" to effectiveLimit, "offset" to (offset?.toString() ?: "null"), "isGlobalHereNow" to isGlobalHereNow(), - ), operation = this::class.simpleName + ), + operation = this::class.simpleName ), details = "HereNow API call", ) @@ -128,7 +129,9 @@ class HereNowEndpoint internal constructor( ) val pnHereNowChannelData = PNHereNowChannelData( - channelName = channels[0], occupancy = input.occupancy, occupants = occupants + channelName = channels[0], + occupancy = input.occupancy, + occupants = occupants ) if (includeUUIDs) { From 4994a7ee11df61cf862f9f05a5174976b6c5311a Mon Sep 17 00:00:00 2001 From: "marcin.cebo" Date: Mon, 6 Oct 2025 18:15:51 +0200 Subject: [PATCH 11/19] Rename variables --- .../endpoints/presence/HereNowEndpoint.kt | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) 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 3bff362535..1679cf418b 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 @@ -20,7 +20,7 @@ import com.pubnub.internal.toCsv import retrofit2.Call import retrofit2.Response -private const val MAX_NUMBER_OF_RESULT_ON_ONE_PAGE_PER_CHANNEL = 1000 +private const val MAX_CHANNEL_OCCUPANTS_LIMIT = 1000 /** * @see [PubNubImpl.hereNow] @@ -31,22 +31,22 @@ class HereNowEndpoint internal constructor( override val channelGroups: List = emptyList(), override val includeState: Boolean = false, override val includeUUIDs: Boolean = true, - override val limit: Int = MAX_NUMBER_OF_RESULT_ON_ONE_PAGE_PER_CHANNEL, + 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) - private val effectiveLimit: Int = if (limit in 1..MAX_NUMBER_OF_RESULT_ON_ONE_PAGE_PER_CHANNEL) { + private val effectiveLimit: Int = if (limit in 1..MAX_CHANNEL_OCCUPANTS_LIMIT) { limit } else { log.warn( LogMessage( LogMessageContent.Text( - "Valid range is 1 to $MAX_NUMBER_OF_RESULT_ON_ONE_PAGE_PER_CHANNEL. " + - "Shrinking limit to $MAX_NUMBER_OF_RESULT_ON_ONE_PAGE_PER_CHANNEL." + "Valid range is 1 to $MAX_CHANNEL_OCCUPANTS_LIMIT. " + + "Shrinking limit to $MAX_CHANNEL_OCCUPANTS_LIMIT." ) ) ) - MAX_NUMBER_OF_RESULT_ON_ONE_PAGE_PER_CHANNEL + MAX_CHANNEL_OCCUPANTS_LIMIT } private fun isGlobalHereNow() = channels.isEmpty() && channelGroups.isEmpty() @@ -106,10 +106,10 @@ class HereNowEndpoint internal constructor( override fun getEndpointGroupName(): RetryableEndpointGroup = RetryableEndpointGroup.PRESENCE - internal fun calculateNextOffset(actualResultSize: Int): Int? { + internal fun calculateNextOffset(occupantsCount: Int): Int? { return when { - actualResultSize < effectiveLimit -> null - actualResultSize == effectiveLimit -> (offset ?: 0) + effectiveLimit + occupantsCount < effectiveLimit -> null + occupantsCount == effectiveLimit -> (offset ?: 0) + effectiveLimit else -> null } } @@ -120,12 +120,12 @@ class HereNowEndpoint internal constructor( } else { emptyList() } - val actualResultSize = occupants.size + val occupantsCount = occupants.size val pnHereNowResult = PNHereNowResult( totalChannels = 1, totalOccupancy = input.occupancy, - nextOffset = calculateNextOffset(actualResultSize), + nextOffset = calculateNextOffset(occupantsCount), ) val pnHereNowChannelData = PNHereNowChannelData( @@ -143,7 +143,7 @@ class HereNowEndpoint internal constructor( private fun parseMultipleChannelResponse(input: JsonElement): PNHereNowResult { val it = pubnub.mapper.getObjectIterator(input, "channels") - var totalOccupantsReturned = 0 + var maxOccupantsReturned = 0 val channelsMap = mutableMapOf() while (it.hasNext()) { @@ -155,8 +155,8 @@ class HereNowEndpoint internal constructor( } // we want to know amount of occupants in channel that has the most occupants - if (occupants.size > totalOccupantsReturned) { - totalOccupantsReturned = occupants.size + if (occupants.size > maxOccupantsReturned) { + maxOccupantsReturned = occupants.size } val pnHereNowChannelData = PNHereNowChannelData( @@ -171,7 +171,7 @@ class HereNowEndpoint internal constructor( totalChannels = pubnub.mapper.elementToInt(input, "total_channels"), totalOccupancy = pubnub.mapper.elementToInt(input, "total_occupancy"), channels = channelsMap, - nextOffset = calculateNextOffset(totalOccupantsReturned), + nextOffset = calculateNextOffset(maxOccupantsReturned), ) return pnHereNowResult From 99a1f7720ac9615577f3ce65181c570cd47e6a96 Mon Sep 17 00:00:00 2001 From: "marcin.cebo" Date: Tue, 7 Oct 2025 10:29:24 +0200 Subject: [PATCH 12/19] Fix lint --- .../pubnub/internal/java/endpoints/presence/HereNowImpl.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 cdabbf229e..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,11 +16,12 @@ @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 = 1000; + private int limit = MAX_CHANNEL_OCCUPANTS_LIMIT; private Integer offset = null; public HereNowImpl(PubNub pubnub) { From 38a97a409cdd865d5c65a45c5c1133372772e84f Mon Sep 17 00:00:00 2001 From: "marcin.cebo" Date: Tue, 7 Oct 2025 13:00:57 +0200 Subject: [PATCH 13/19] Fix JS --- .../src/jsMain/kotlin/com/pubnub/api/PubNubImpl.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 From 83a5dc379843e83de788153fcc56be930404c852 Mon Sep 17 00:00:00 2001 From: "marcin.cebo" Date: Tue, 7 Oct 2025 20:30:33 +0200 Subject: [PATCH 14/19] Improved paging in snippets. --- .../com/pubnub/docs/presence/HereNowApp.java | 64 ++++++++++--------- .../com/pubnub/docs/presence/HereNowMain.kt | 46 ++++++------- 2 files changed, 58 insertions(+), 52 deletions(-) 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 653f10dd85..a697dd7d82 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 @@ -5,12 +5,14 @@ import com.pubnub.api.PubNubException; import com.pubnub.api.UserId; import com.pubnub.api.java.PubNub; +import com.pubnub.api.java.endpoints.presence.HereNow; import com.pubnub.api.java.v2.PNConfiguration; 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 java.util.Arrays; +import java.util.List; public class HereNowApp { public static void main(String[] args) throws PubNubException { @@ -21,39 +23,43 @@ public static void main(String[] args) throws PubNubException { PubNub pubnub = PubNub.create(configBuilder.build()); - // Get presence information for specified channels with pagination support - pubnub.hereNow() - .channels(Arrays.asList("coolChannel", "coolChannel2")) + // Get presence information for specified channels with automatic pagination + List channels = Arrays.asList("coolChannel", "coolChannel2"); + fetchHereNowWithPagination(pubnub, channels, null); + } + + /** + * Fetches hereNow data with automatic pagination handling. + * This method recursively fetches all pages of results. + * + * @param pubnub PubNub instance + * @param channels List of channels to query + * @param offset Pagination offset (null for first page) + */ + private static void fetchHereNowWithPagination(PubNub pubnub, List channels, Integer offset) { + HereNow builder = pubnub.hereNow() + .channels(channels) .includeUUIDs(true) - .limit(100) - .async(result -> { - result.onSuccess((PNHereNowResult res) -> { - printHereNowResult(res); + .limit(100); - // Check if more results are available - if (res.getNextOffset() != null) { - System.out.println("\nMore results available. Fetching next page...\n"); + // Apply offset if provided (for subsequent pages) + if (offset != null) { + builder.offset(offset); + System.out.println("\nFetching next page with offset: " + offset + "\n"); + } - // Fetch next page using the offset from previous response - pubnub.hereNow() - .channels(Arrays.asList("coolChannel", "coolChannel2")) - .includeUUIDs(true) - .limit(100) - .offset(res.getNextOffset()) - .async(result2 -> { - result2.onSuccess((PNHereNowResult res2) -> { - printHereNowResult(res2); + builder.async(result -> { + result.onSuccess((PNHereNowResult res) -> { + printHereNowResult(res); - // Continue pagination if needed by checking res2.getNextOffset() - }).onFailure((PubNubException exception) -> { - System.out.println("Error retrieving hereNow data: " + exception.getMessage()); - }); - }); - } - }).onFailure((PubNubException exception) -> { - System.out.println("Error retrieving hereNow data: " + exception.getMessage()); - }); - }); + // Recursively fetch next page if available + if (res.getNextOffset() != null) { + fetchHereNowWithPagination(pubnub, channels, res.getNextOffset()); + } + }).onFailure((PubNubException exception) -> { + System.out.println("Error retrieving hereNow data: " + exception.getMessage()); + }); + }); } /** 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 749a82649b..af13b7a569 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 @@ -37,48 +37,48 @@ fun main() { } /** - * Demonstrates basic usage of hereNow for a single channel with pagination support + * Demonstrates basic usage of hereNow for a single channel with automatic pagination support */ fun singleChannelHereNow(pubnub: PubNub, channel: String) { println("\n# Basic hereNow for single channel: $channel") + fetchHereNowWithPagination(pubnub, channel, null) + + // Wait for the operation to complete + Thread.sleep(2000) +} + +/** + * Fetches hereNow data with automatic pagination handling. + * This function recursively fetches all pages of results. + * + * @param pubnub PubNub instance + * @param channel Channel to query + * @param offset Pagination offset (null for first page) + */ +private fun fetchHereNowWithPagination(pubnub: PubNub, channel: String, offset: Int?) { + if (offset != null) { + println("\nFetching next page with offset: $offset") + } + pubnub.hereNow( channels = listOf(channel), limit = 100, + offset = offset ).async { result -> result.onSuccess { response -> println("SUCCESS: Retrieved presence information") printChannelData(channel, response) - // Check if more results are available + // Recursively fetch next page if available if (response.nextOffset != null) { - println("\nMore occupants available. Fetching next page...") - - // Fetch next page using the offset from previous response - pubnub.hereNow( - channels = listOf(channel), - limit = 100, - offset = response.nextOffset - ).async { nextResult -> - nextResult.onSuccess { nextResponse -> - println("\nNext Page Results:") - printChannelData(channel, nextResponse) - - // Continue pagination if needed by checking nextResponse.nextOffset - }.onFailure { exception -> - println("ERROR: Failed to get next page of presence information") - println("Error details: ${exception.message}") - } - } + fetchHereNowWithPagination(pubnub, channel, response.nextOffset) } }.onFailure { exception -> println("ERROR: Failed to get presence information") println("Error details: ${exception.message}") } } - - // Wait for the operation to complete - Thread.sleep(2000) } /** From eb4d1b7c1c2d0ef37e10315069240390142d4d14 Mon Sep 17 00:00:00 2001 From: "marcin.cebo" Date: Wed, 8 Oct 2025 18:28:32 +0200 Subject: [PATCH 15/19] Allowed limit to be 0 --- gradle/libs.versions.toml | 2 +- .../integration/PresenceIntegrationTests.kt | 37 +++++++++++++++++++ .../endpoints/presence/HereNowEndpoint.kt | 21 ++++++----- 3 files changed, 49 insertions(+), 11 deletions(-) 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-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 51abe970e0..8db3718140 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 @@ -502,6 +502,43 @@ class PresenceIntegrationTests : BaseIntegrationTest() { } } + @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 testHereNowPaginationWithEmptyChannels() { val emptyChannel = randomChannel() 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 1679cf418b..bc033a58c1 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 @@ -22,6 +22,8 @@ import retrofit2.Response private const val MAX_CHANNEL_OCCUPANTS_LIMIT = 1000 +private const val MIN_CHANNEL_OCCUPANTS_LIMIT = 0 + /** * @see [PubNubImpl.hereNow] */ @@ -35,13 +37,13 @@ class HereNowEndpoint internal constructor( override val offset: Int? = null, ) : EndpointCore, PNHereNowResult>(pubnub), HereNow { private val log: PNLogger = LoggerManager.instance.getLogger(pubnub.logConfig, this::class.java) - private val effectiveLimit: Int = if (limit in 1..MAX_CHANNEL_OCCUPANTS_LIMIT) { + private val effectiveLimit: Int = if (limit in MIN_CHANNEL_OCCUPANTS_LIMIT..MAX_CHANNEL_OCCUPANTS_LIMIT) { limit } else { log.warn( LogMessage( LogMessageContent.Text( - "Valid range is 1 to $MAX_CHANNEL_OCCUPANTS_LIMIT. " + + "Valid range is $MIN_CHANNEL_OCCUPANTS_LIMIT to $MAX_CHANNEL_OCCUPANTS_LIMIT. " + "Shrinking limit to $MAX_CHANNEL_OCCUPANTS_LIMIT." ) ) @@ -115,8 +117,8 @@ class HereNowEndpoint internal constructor( } private fun parseSingleChannelResponse(input: Envelope): PNHereNowResult { - val occupants = if (includeUUIDs) { - prepareOccupantData(input.uuids!!) + val occupants = if (includeUUIDs && input.uuids != null) { + prepareOccupantData(input.uuids) } else { emptyList() } @@ -128,13 +130,12 @@ class HereNowEndpoint internal constructor( nextOffset = calculateNextOffset(occupantsCount), ) - val pnHereNowChannelData = PNHereNowChannelData( - channelName = channels[0], - occupancy = input.occupancy, - occupants = occupants - ) - if (includeUUIDs) { + val pnHereNowChannelData = PNHereNowChannelData( + channelName = channels[0], + occupancy = input.occupancy, + occupants = occupants + ) pnHereNowResult.channels[channels[0]] = pnHereNowChannelData } From 00be4a9ae554bddaa7c7480f90728c036fc3053e Mon Sep 17 00:00:00 2001 From: "marcin.cebo" Date: Sat, 11 Oct 2025 19:37:22 +0200 Subject: [PATCH 16/19] Channel data are returned for one channel when includeUUIDs=false to make behaviour consistent with multi-channel calls. --- .../kotlin/com/pubnub/api/PubNubError.kt | 5 ++ .../integration/PresenceIntegrationTests.kt | 83 +++++++++++++++++++ .../endpoints/presence/HereNowEndpoint.kt | 27 +++--- 3 files changed, 103 insertions(+), 12 deletions(-) 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 31d148b2a3..d9f01c9b3c 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 @@ -243,6 +243,11 @@ enum class PubNubError(private val code: Int, val message: String) { 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", ) ; 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 8db3718140..9441bdbf62 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 @@ -365,6 +365,45 @@ class PresenceIntegrationTests : BaseIntegrationTest() { } } + @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 + + // nextOffset should be null since includeUUIDs = false + assertNull(it.nextOffset) + } + } + } + + @Test fun testHereNowPaginationFlow() { // 8 users in channel01 @@ -466,6 +505,50 @@ class PresenceIntegrationTests : BaseIntegrationTest() { 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 + assertNull(firstPage.nextOffset) + val channel02Data = firstPage.channels[channel02]!! + assertEquals(channel02TotalCount, channel02Data.occupancy) + assertEquals(0, channel02Data.occupants.size) + } + @Test fun testHereNowNextStartFromWhenMoreResults() { val limitValue = 4 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 bc033a58c1..2ac698a08a 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 @@ -61,6 +61,9 @@ class HereNowEndpoint internal constructor( 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( @@ -110,6 +113,7 @@ class HereNowEndpoint internal constructor( internal fun calculateNextOffset(occupantsCount: Int): Int? { return when { + !includeUUIDs -> null occupantsCount < effectiveLimit -> null occupantsCount == effectiveLimit -> (offset ?: 0) + effectiveLimit else -> null @@ -130,32 +134,31 @@ class HereNowEndpoint internal constructor( nextOffset = calculateNextOffset(occupantsCount), ) - if (includeUUIDs) { - val pnHereNowChannelData = PNHereNowChannelData( - channelName = channels[0], - occupancy = input.occupancy, - occupants = occupants - ) - 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 it = pubnub.mapper.getObjectIterator(input, "channels") + val channels = pubnub.mapper.getObjectIterator(input, "channels") var maxOccupantsReturned = 0 val channelsMap = mutableMapOf() - while (it.hasNext()) { - val entry = it.next() + while (channels.hasNext()) { + val entry = channels.next() val occupants = if (includeUUIDs) { prepareOccupantData(pubnub.mapper.getField(entry.value, "uuids")!!) } else { emptyList() } - // we want to know amount of occupants in channel that has the most occupants + // we want to know number of occupants in channel that has the most occupants if (occupants.size > maxOccupantsReturned) { maxOccupantsReturned = occupants.size } From 4454939a56831eec00a67c5e0afea5ff243d0f73 Mon Sep 17 00:00:00 2001 From: "marcin.cebo" Date: Tue, 14 Oct 2025 16:22:53 +0200 Subject: [PATCH 17/19] Removed nextOffset --- .../com/pubnub/docs/presence/HereNowApp.java | 86 +++---- .../integration/PresenceIntegrationTests.java | 206 +---------------- .../api/endpoints/presence/HereNow.ios.kt | 1 - .../kotlin/com/pubnub/api/PubNubError.kt | 4 +- .../api/models/consumer/presence/PNHereNow.kt | 2 - .../com/pubnub/docs/presence/HereNowMain.kt | 59 ++--- .../integration/PresenceIntegrationTests.kt | 209 +----------------- .../endpoints/presence/HereNowEndpoint.kt | 20 +- .../endpoints/presence/HereNowEndpointTest.kt | 117 +--------- .../presence/HereNowPaginationTestSuite.kt | 3 - 10 files changed, 60 insertions(+), 647 deletions(-) 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 a697dd7d82..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,84 +4,50 @@ 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.endpoints.presence.HereNow; import com.pubnub.api.java.v2.PNConfiguration; 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 java.util.Arrays; -import java.util.List; public class HereNowApp { 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()); - // Get presence information for specified channels with automatic pagination - List channels = Arrays.asList("coolChannel", "coolChannel2"); - fetchHereNowWithPagination(pubnub, channels, null); - } - - /** - * Fetches hereNow data with automatic pagination handling. - * This method recursively fetches all pages of results. - * - * @param pubnub PubNub instance - * @param channels List of channels to query - * @param offset Pagination offset (null for first page) - */ - private static void fetchHereNowWithPagination(PubNub pubnub, List channels, Integer offset) { - HereNow builder = pubnub.hereNow() - .channels(channels) + // Get presence information for specified channels + pubnub.hereNow() + .channels(Arrays.asList("coolChannel", "coolChannel2")) .includeUUIDs(true) - .limit(100); - - // Apply offset if provided (for subsequent pages) - if (offset != null) { - builder.offset(offset); - System.out.println("\nFetching next page with offset: " + offset + "\n"); - } - - builder.async(result -> { - result.onSuccess((PNHereNowResult res) -> { - printHereNowResult(res); - - // Recursively fetch next page if available - if (res.getNextOffset() != null) { - fetchHereNowWithPagination(pubnub, channels, res.getNextOffset()); - } - }).onFailure((PubNubException exception) -> { - System.out.println("Error retrieving hereNow data: " + exception.getMessage()); - }); - }); - } - - /** - * Helper method to print HereNow presence information - */ - private static void printHereNowResult(PNHereNowResult result) { - System.out.println("Total Channels: " + result.getTotalChannels()); - System.out.println("Total Occupancy: " + result.getTotalOccupancy()); - - for (PNHereNowChannelData channelData : result.getChannels().values()) { - System.out.println("---"); - System.out.println("Channel: " + channelData.getChannelName()); - System.out.println("Occupancy: " + channelData.getOccupancy()); - System.out.println("Occupants:"); - for (PNHereNowOccupantData occupant : channelData.getOccupants()) { - System.out.println("UUID: " + occupant.getUuid() + " State: " + occupant.getState()); - } - } - - if (result.getNextOffset() != null && result.getNextOffset() != 0) { - System.out.println("Next Offset: " + result.getNextOffset()); - } + .limit(100) + .offset(10) + .async(result -> { + result.onSuccess((PNHereNowResult res) -> { + + System.out.println("Total Channels: " + res.getTotalChannels()); + System.out.println("Total Occupancy: " + res.getTotalOccupancy()); + + for (PNHereNowChannelData channelData : res.getChannels().values()) { + System.out.println("---"); + System.out.println("Channel: " + channelData.getChannelName()); + System.out.println("Occupancy: " + channelData.getOccupancy()); + System.out.println("Occupants:"); + for (PNHereNowOccupantData occupant : channelData.getOccupants()) { + System.out.println("UUID: " + occupant.getUuid() + " State: " + occupant.getState()); + } + } + }).onFailure( (PubNubException exception) -> { + System.out.println("Error retrieving hereNow data: " + exception.getMessage()); + }); + }); } } // snippet.end 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 278f896ad2..3c174ebb88 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 @@ -427,10 +427,6 @@ public void testHereNowWithLimit() { // With limit=3, we should get only 3 occupants even though 6 are present assertEquals(testLimit, channelData.getOccupants().size()); - // nextOffset should be present since we limited results - assertNotNull(pnHereNowResult.getNextOffset()); - assertEquals(Integer.valueOf(3), pnHereNowResult.getNextOffset()); - success.set(true); }); }); @@ -476,9 +472,6 @@ public void testHereNowWithOffset() { // With offset=2, we should get remaining occupants (5 total - 2 skipped = 3 remaining) assertEquals(totalClientsCount - offsetValue, channelData.getOccupants().size()); - // nextOffset should be null since we got all remaining results - assertNull(pnHereNowResult.getNextOffset()); - success.set(true); }); }); @@ -491,6 +484,8 @@ public void testHereNowPaginationFlow() throws PubNubException { // 8 users in channel01 // 3 users in channel02 final int pageSize = 3; + final int firstOffset = 0; + final int secondOffset = 0; final int totalClientsCount = 11; final int channel01TotalCount = 8; final int channel02TotalCount = 3; @@ -531,8 +526,6 @@ public void testHereNowPaginationFlow() throws PubNubException { assertEquals(channel01TotalCount, channel01DataPage01.getOccupancy()); assertEquals(totalClientsCount, firstPage.getTotalOccupancy()); // total occupancy across all channels assertEquals(pageSize, channel01DataPage01.getOccupants().size()); - assertNotNull(firstPage.getNextOffset()); - assertEquals(Integer.valueOf(3), firstPage.getNextOffset()); PNHereNowChannelData channel02Data = firstPage.getChannels().get(channel02); assertNotNull(channel02Data); @@ -544,12 +537,12 @@ public void testHereNowPaginationFlow() throws PubNubException { allOccupantsInChannel01.add(occupant.getUuid()); } - // Second page using nextOffset + // Second page using pageSize + firstOffset PNHereNowResult secondPage = pubNub.hereNow() .channels(Collections.singletonList(channel01)) .includeUUIDs(true) .limit(pageSize) - .offset(firstPage.getNextOffset()) + .offset(pageSize + firstOffset) .sync(); assertNotNull(secondPage); @@ -558,8 +551,7 @@ public void testHereNowPaginationFlow() throws PubNubException { assertEquals(channel01TotalCount, channel01DataPage02.getOccupancy()); assertEquals(channel01TotalCount, secondPage.getTotalOccupancy()); // only channel01 in results assertEquals(pageSize, channel01DataPage02.getOccupants().size()); - assertNotNull(secondPage.getNextOffset()); - assertEquals(Integer.valueOf(6), secondPage.getNextOffset()); + assertFalse(secondPage.getChannels().containsKey(channel02)); @@ -570,12 +562,12 @@ public void testHereNowPaginationFlow() throws PubNubException { allOccupantsInChannel01.add(occupant.getUuid()); } - // Third page using nextOffset from second page + // Third page using pageSize + secondOffset PNHereNowResult thirdPage = pubNub.hereNow() .channels(Collections.singletonList(channel01)) .includeUUIDs(true) .limit(pageSize) - .offset(secondPage.getNextOffset()) + .offset(pageSize + secondOffset) .sync(); assertNotNull(thirdPage); @@ -587,9 +579,6 @@ public void testHereNowPaginationFlow() throws PubNubException { int expectedRemainingCount = channel01TotalCount - (pageSize * 2); assertEquals(expectedRemainingCount, channel01DataPage03.getOccupants().size()); - // Should be null since no more pages - assertNull(thirdPage.getNextOffset()); - // Collect UUIDs from third page for (PNHereNowOccupantData occupant : channel01DataPage03.getOccupants()) { assertFalse("UUID " + occupant.getUuid() + " already found", @@ -601,52 +590,6 @@ public void testHereNowPaginationFlow() throws PubNubException { assertEquals(channel01TotalCount, allOccupantsInChannel01.size()); } - @Test - public void testHereNowNextOffsetWhenMoreResults() { - final AtomicBoolean success = new AtomicBoolean(); - final int limitValue = 4; - final int totalClientsCount = 10; - 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(limitValue) - .async((result) -> { - assertFalse(result.isFailure()); - result.onSuccess(pnHereNowResult -> { - assertEquals(1, pnHereNowResult.getTotalChannels()); - - PNHereNowChannelData channelData = pnHereNowResult.getChannels().get(expectedChannel); - assertNotNull(channelData); - assertEquals(totalClientsCount, channelData.getOccupancy()); - assertEquals(limitValue, channelData.getOccupants().size()); - - // Since returned count equals limit and there are more clients, - // nextOffset should be present - assertNotNull(pnHereNowResult.getNextOffset()); - assertEquals(Integer.valueOf(limitValue), pnHereNowResult.getNextOffset()); - - success.set(true); - }); - }); - - listen(success); - } - @Test public void testHereNowPaginationWithEmptyChannels() { final AtomicBoolean success = new AtomicBoolean(); @@ -672,46 +615,6 @@ public void testHereNowPaginationWithEmptyChannels() { assertEquals(0, channelData.getOccupancy()); assertTrue(channelData.getOccupants().isEmpty()); - // No pagination needed for empty results - assertNull(pnHereNowResult.getNextOffset()); - - success.set(true); - }); - }); - - listen(success); - } - - @Test - public void testHereNowPaginationWithEmptyChannelsAndOffset() { - final AtomicBoolean success = new AtomicBoolean(); - final String emptyChannel = RandomGenerator.get(); - final int pageSize = 10; - final int offset = 5; - - // Don't subscribe any clients to the channel, leaving it empty - - pubNub.hereNow() - .channels(Collections.singletonList(emptyChannel)) - .includeUUIDs(true) - .limit(pageSize) - .offset(offset) - .async((result) -> { - assertFalse(result.isFailure()); - result.onSuccess(pnHereNowResult -> { - // Empty channels are still included in the response even with offset - 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()); - - // No pagination needed for empty results - assertNull(pnHereNowResult.getNextOffset()); - success.set(true); }); }); @@ -768,9 +671,6 @@ public void testGlobalHereNowWithLimit() throws PubNubException { // With limit=3, each channel should have at most 3 occupants returned assertTrue(channel01Data.getOccupants().size() <= testLimit); assertTrue(channel02Data.getOccupants().size() <= testLimit); - - // nextOffset should be present since we limited results and channel02 has 6 occupants - assertNotNull(result.getNextOffset()); } @Test @@ -813,96 +713,6 @@ public void testGlobalHereNowWithOffset() throws PubNubException { assertTrue(channelData.getOccupants().size() <= totalClientsCount - offsetValue); } - @Test - public void testGlobalHereNowPaginationFlow() throws PubNubException { - final int pageSize = 3; - final int totalClientsInChannel01 = 8; - final int totalClientsInChannel02 = 3; - final String channel01 = RandomGenerator.get(); - final String channel02 = RandomGenerator.get(); - - final List clients = new ArrayList() {{ - add(pubNub); - }}; - for (int i = 1; i < totalClientsInChannel01; i++) { - clients.add(getPubNub()); - } - - // Subscribe 8 clients to channel01 - for (PubNub client : clients) { - subscribeToChannel(client, channel01); - } - - // Subscribe first 3 clients to channel02 as well - for (int i = 0; i < totalClientsInChannel02; i++) { - subscribeToChannel(clients.get(i), channel02); - } - - pause(TIMEOUT_MEDIUM); - - final Set allOccupantsInChannel01 = new HashSet<>(); - - // First page - global hereNow with no channels specified - PNHereNowResult firstPage = pubNub.hereNow() - .channels(Collections.emptyList()) - .includeUUIDs(true) - .limit(pageSize) - .sync(); - - assertNotNull(firstPage); - - // Should include at least our test channels - assertTrue(firstPage.getTotalChannels() >= 2); - assertTrue(firstPage.getChannels().containsKey(channel01)); - assertTrue(firstPage.getChannels().containsKey(channel02)); - - PNHereNowChannelData channel01DataPage01 = firstPage.getChannels().get(channel01); - PNHereNowChannelData channel02DataPage01 = firstPage.getChannels().get(channel02); - - assertNotNull(channel01DataPage01); - assertNotNull(channel02DataPage01); - assertEquals(totalClientsInChannel01, channel01DataPage01.getOccupancy()); - assertEquals(totalClientsInChannel02, channel02DataPage01.getOccupancy()); - - // With limit, should get limited results - assertTrue(channel01DataPage01.getOccupants().size() <= pageSize); - assertTrue(channel02DataPage01.getOccupants().size() <= pageSize); - - // Collect UUIDs from first page - for (PNHereNowOccupantData occupant : channel01DataPage01.getOccupants()) { - allOccupantsInChannel01.add(occupant.getUuid()); - } - - // Should have nextOffset since we have more results - assertNotNull(firstPage.getNextOffset()); - - // Second page using nextOffset - PNHereNowResult secondPage = pubNub.hereNow() - .channels(Collections.emptyList()) - .includeUUIDs(true) - .limit(pageSize) - .offset(firstPage.getNextOffset()) - .sync(); - - assertNotNull(secondPage); - - // May have more or fewer channels than first page - assertTrue(secondPage.getTotalChannels() >= 1); - - if (secondPage.getChannels().containsKey(channel01)) { - PNHereNowChannelData channel01DataPage02 = secondPage.getChannels().get(channel01); - assertNotNull(channel01DataPage02); - assertEquals(totalClientsInChannel01, channel01DataPage02.getOccupancy()); - - // 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()); - } - } - } - @Test public void testGlobalHereNowWithNoActiveChannels() throws PubNubException { // Don't subscribe any clients, making it a truly empty global query @@ -919,7 +729,7 @@ public void testGlobalHereNowWithNoActiveChannels() throws PubNubException { // Should have no channels or very few residual ones // Note: In a shared test environment, there might be residual presence state - assertTrue(result.getTotalOccupancy() >= 0); + assertTrue(result.getTotalOccupancy() == 0); // No pagination needed when no active subscriptions for this client // Note: Result may vary based on test isolation 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 88d7bfcf69..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 @@ -44,7 +44,6 @@ class HereNowImpl( PNHereNowResult( totalChannels = it?.totalChannels()?.toInt() ?: 0, totalOccupancy = it?.totalOccupancy()?.toInt() ?: 0, - // nextOffset = it?.nextOffset()?.toInt(), // todo uncomment once available channels = (it?.channels()?.safeCast())?.mapValues { entry -> PNHereNowChannelData( channelName = entry.value.channelName(), 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 d9f01c9b3c..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 @@ -246,8 +246,8 @@ enum class PubNubError(private val code: Int, val message: String) { ), HERE_NOW_OFFSET_REQUIRES_LIMIT_HIGHER_THAN_0( - 183, - "offset requires limit > 0", + 183, + "offset requires limit > 0", ) ; diff --git a/pubnub-kotlin/pubnub-kotlin-core-api/src/commonMain/kotlin/com/pubnub/api/models/consumer/presence/PNHereNow.kt b/pubnub-kotlin/pubnub-kotlin-core-api/src/commonMain/kotlin/com/pubnub/api/models/consumer/presence/PNHereNow.kt index f53eacb243..30e6a07cb1 100644 --- a/pubnub-kotlin/pubnub-kotlin-core-api/src/commonMain/kotlin/com/pubnub/api/models/consumer/presence/PNHereNow.kt +++ b/pubnub-kotlin/pubnub-kotlin-core-api/src/commonMain/kotlin/com/pubnub/api/models/consumer/presence/PNHereNow.kt @@ -8,14 +8,12 @@ import com.pubnub.api.JsonElement * @property totalChannels Total number channels matching the associated HereNow call. * @property totalOccupancy Total occupancy matching the associated HereNow call. * @property channels A map with values of [PNHereNowChannelData] for each channel. - * @property nextOffset Starting position for next page of results. Null if no more pages available. */ class PNHereNowResult( val totalChannels: Int, val totalOccupancy: Int, // TODO this should be immutable val channels: MutableMap = mutableMapOf(), - val nextOffset: Int? = null, ) /** 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 af13b7a569..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 @@ -4,7 +4,6 @@ package com.pubnub.docs.presence import com.pubnub.api.PubNub import com.pubnub.api.UserId -import com.pubnub.api.models.consumer.presence.PNHereNowResult import com.pubnub.api.v2.PNConfiguration fun main() { @@ -37,48 +36,37 @@ fun main() { } /** - * Demonstrates basic usage of hereNow for a single channel with automatic pagination support + * Demonstrates basic usage of hereNow for a single channel */ fun singleChannelHereNow(pubnub: PubNub, channel: String) { println("\n# Basic hereNow for single channel: $channel") - fetchHereNowWithPagination(pubnub, channel, null) - - // Wait for the operation to complete - Thread.sleep(2000) -} - -/** - * Fetches hereNow data with automatic pagination handling. - * This function recursively fetches all pages of results. - * - * @param pubnub PubNub instance - * @param channel Channel to query - * @param offset Pagination offset (null for first page) - */ -private fun fetchHereNowWithPagination(pubnub: PubNub, channel: String, offset: Int?) { - if (offset != null) { - println("\nFetching next page with offset: $offset") - } - pubnub.hereNow( channels = listOf(channel), limit = 100, - offset = offset + offset = 10 ).async { result -> result.onSuccess { response -> println("SUCCESS: Retrieved presence information") - printChannelData(channel, response) - // Recursively fetch next page if available - if (response.nextOffset != null) { - fetchHereNowWithPagination(pubnub, channel, response.nextOffset) + // Get information for our specific channel + val channelData = response.channels[channel] + + if (channelData != null) { + println("Channel: $channel") + println("Occupancy: ${channelData.occupancy}") + println("UUIDs: ${channelData.occupants.map { it.uuid }}") + } else { + println("No presence data for channel: $channel") } }.onFailure { exception -> println("ERROR: Failed to get presence information") println("Error details: ${exception.message}") } } + + // Wait for the operation to complete + Thread.sleep(2000) } /** @@ -161,23 +149,4 @@ fun advancedHereNow(pubnub: PubNub, channel: String) { // Wait for the operation to complete Thread.sleep(2000) } - -/** - * Helper function to print channel presence data - */ -private fun printChannelData(channel: String, response: PNHereNowResult) { - val channelData = response.channels[channel] - - if (channelData != null) { - println("Channel: $channel") - println("Occupancy: ${channelData.occupancy}") - println("UUIDs: ${channelData.occupants.map { it.uuid }}") - - if (response.nextOffset != null && response.nextOffset != 0) { - println("Next Offset: ${response.nextOffset}") - } - } else { - println("No presence data for channel: $channel") - } -} // snippet.end 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 9441bdbf62..61d46e2875 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 @@ -319,9 +319,6 @@ class PresenceIntegrationTests : BaseIntegrationTest() { // With limit=3, we should get only 3 occupants even though 6 are present assertEquals(testLimit, channelData.occupants.size) - - // nextOffset should be present since we limited results - assertEquals(3, it.nextOffset) } } } @@ -358,9 +355,6 @@ class PresenceIntegrationTests : BaseIntegrationTest() { // With offset=2, we should get remaining occupants (5 total - 2 skipped = 3 remaining) assertEquals(totalClientsCount - offsetValue, channelData.occupants.size) - - // nextOffset should be null since we got all remaining results - assertNull(it.nextOffset) } } } @@ -396,19 +390,17 @@ class PresenceIntegrationTests : BaseIntegrationTest() { val channelData = it.channels[expectedChannel]!! assertEquals(totalClientsCount, channelData.occupancy) assertEquals(0, channelData.occupants.size) // occupants list is empty when includeUUIDs = false - - // nextOffset should be null since includeUUIDs = false - assertNull(it.nextOffset) } } } - @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 @@ -444,7 +436,6 @@ class PresenceIntegrationTests : BaseIntegrationTest() { assertEquals(channel01TotalCount, channel01DataPage01.occupancy) assertEquals(totalClientsCount, firstPage.totalOccupancy) // this is totalOccupancy in all pages assertEquals(pageSize, channel01DataPage01.occupants.size) - assertEquals(3, firstPage.nextOffset) val channel02Data = firstPage.channels[channel02]!! assertEquals(channel02TotalCount, channel02Data.occupancy) assertEquals(pageSize, channel02Data.occupants.size) @@ -452,12 +443,12 @@ class PresenceIntegrationTests : BaseIntegrationTest() { // Collect UUIDs from first page channel01DataPage01.occupants.forEach { allOccupantsInChannel01.add(it.uuid) } - // Second page using nextOffset + // Second page using pageSize + firstPageOffset val secondPage = pubnub.hereNow( channels = listOf(channel01), includeUUIDs = true, limit = pageSize, - offset = firstPage.nextOffset!!, + offset = pageSize + firstPageOffset, ).sync()!! val channel01DataPage02 = secondPage.channels[channel01]!! @@ -467,7 +458,6 @@ class PresenceIntegrationTests : BaseIntegrationTest() { secondPage.totalOccupancy ) // we get result only from channel01 because there is no more result for channel02 assertEquals(pageSize, channel01DataPage02.occupants.size) - assertEquals(6, secondPage.nextOffset) assertFalse(secondPage.channels.containsKey(channel02)) @@ -477,12 +467,12 @@ class PresenceIntegrationTests : BaseIntegrationTest() { allOccupantsInChannel01.add(it.uuid) } - // Third page using nextOffset from second page + // Third page using pageSize + secondPageOffset val thirdPage = pubnub.hereNow( channels = listOf(channel01), includeUUIDs = true, limit = pageSize, - offset = secondPage.nextOffset!!, + offset = pageSize + secondPageOffset, ).sync()!! val channel01DataPage03 = thirdPage.channels[channel01]!! @@ -492,9 +482,6 @@ class PresenceIntegrationTests : BaseIntegrationTest() { val expectedRemainingCount = channel01TotalCount - (pageSize * 2) assertEquals(expectedRemainingCount, channel01DataPage03.occupants.size) - // Should be null since no more pages - assertNull(thirdPage.nextOffset) - // Collect UUIDs from third page channel01DataPage03.occupants.forEach { assertFalse("UUID ${it.uuid} already found", allOccupantsInChannel01.contains(it.uuid)) @@ -543,48 +530,11 @@ class PresenceIntegrationTests : BaseIntegrationTest() { assertEquals(channel01TotalCount, channel01Data.occupancy) assertEquals(0, channel01Data.occupants.size) assertEquals(totalClientsCount, firstPage.totalOccupancy) // this is totalOccupancy in all pages - assertNull(firstPage.nextOffset) val channel02Data = firstPage.channels[channel02]!! assertEquals(channel02TotalCount, channel02Data.occupancy) assertEquals(0, channel02Data.occupants.size) } - @Test - fun testHereNowNextStartFromWhenMoreResults() { - val limitValue = 4 - val totalClientsCount = 10 - 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 = limitValue, - ).asyncRetry { result -> - assertFalse(result.isFailure) - result.onSuccess { - assertEquals(1, it.totalChannels) - val channelData = it.channels[expectedChannel]!! - assertEquals(totalClientsCount, channelData.occupancy) - assertEquals(limitValue, channelData.occupants.size) - - // Since returned count equals limit and there are more clients, - // nextOffset should be present - assertNotNull(it.nextOffset) - assertEquals(limitValue, it.nextOffset) - } - } - } - @Test fun testHereNowWithLimit0() { val limit = 0 @@ -622,66 +572,6 @@ class PresenceIntegrationTests : BaseIntegrationTest() { } } - @Test - fun testHereNowPaginationWithEmptyChannels() { - val emptyChannel = randomChannel() - val pageSize = 10 - - // Don't subscribe any clients to the channel, leaving it empty - - pubnub.hereNow( - channels = listOf(emptyChannel), - includeUUIDs = true, - limit = pageSize, - ).asyncRetry { result -> - assertFalse(result.isFailure) - result.onSuccess { - // Empty channels are still included in the response - assertEquals(1, it.totalChannels) - assertEquals(0, it.totalOccupancy) - assertEquals(1, it.channels.size) - - val channelData = it.channels[emptyChannel]!! - assertEquals(0, channelData.occupancy) - assertTrue(channelData.occupants.isEmpty()) - - // No pagination needed for empty results - assertNull(it.nextOffset) - } - } - } - - @Test - fun testHereNowPaginationWithEmptyChannelsAndOffset() { - val emptyChannel = randomChannel() - val pageSize = 10 - val offset = 5 - - // Don't subscribe any clients to the channel, leaving it empty - - pubnub.hereNow( - channels = listOf(emptyChannel), - includeUUIDs = true, - limit = pageSize, - offset = offset, - ).asyncRetry { result -> - assertFalse(result.isFailure) - result.onSuccess { - // Empty channels are still included in the response even with offset - assertEquals(1, it.totalChannels) - assertEquals(0, it.totalOccupancy) - assertEquals(1, it.channels.size) - - val channelData = it.channels[emptyChannel]!! - assertEquals(0, channelData.occupancy) - assertTrue(channelData.occupants.isEmpty()) - - // No pagination needed for empty results - assertNull(it.nextOffset) - } - } - } - @Test fun testGlobalHereNowWithLimit() { val testLimit = 3 @@ -724,9 +614,6 @@ class PresenceIntegrationTests : BaseIntegrationTest() { // With limit=3, each channel should have at most 3 occupants returned assertTrue(channel01Data.occupants.size <= testLimit) assertTrue(channel02Data.occupants.size <= testLimit) - - // nextOffset should be present since we limited results and channel02 has 6 occupants - assertNotNull(result.nextOffset) } @Test @@ -763,89 +650,10 @@ class PresenceIntegrationTests : BaseIntegrationTest() { assertTrue(channelData.occupants.size <= totalClientsCount - offsetValue) } - @Test - fun testGlobalHereNowPaginationFlow() { - val pageSize = 3 - val totalClientsInChannel01 = 8 - val totalClientsInChannel02 = 3 - val channel01 = randomChannel() - val channel02 = randomChannel() - - val clients = - mutableListOf(pubnub).apply { - addAll(generateSequence { createPubNub {} }.take(totalClientsInChannel01 - 1).toList()) - } - - // Subscribe 8 clients to channel01 - clients.forEach { - it.subscribeNonBlocking(channel01) - } - - // Subscribe first 3 clients to channel02 as well - clients.take(totalClientsInChannel02).forEach { - it.subscribeNonBlocking(channel02) - } - - Thread.sleep(2000) - - val allOccupantsInChannel01 = mutableSetOf() - - // First page - global hereNow with no channels specified - val firstPage = pubnub.hereNow( - channels = emptyList(), - includeUUIDs = true, - limit = pageSize, - ).sync()!! - - // Should include at least our test channels - assertTrue(firstPage.totalChannels >= 2) - assertTrue(firstPage.channels.containsKey(channel01)) - assertTrue(firstPage.channels.containsKey(channel02)) - - val channel01DataPage01 = firstPage.channels[channel01]!! - val channel02DataPage01 = firstPage.channels[channel02]!! - - assertEquals(totalClientsInChannel01, channel01DataPage01.occupancy) - assertEquals(totalClientsInChannel02, channel02DataPage01.occupancy) - - // With limit, should get limited results - assertTrue(channel01DataPage01.occupants.size <= pageSize) - assertTrue(channel02DataPage01.occupants.size <= pageSize) - - // Collect UUIDs from first page - channel01DataPage01.occupants.forEach { allOccupantsInChannel01.add(it.uuid) } - - // Should have nextOffset since we have more results - assertNotNull(firstPage.nextOffset) - - // Second page using nextOffset - val secondPage = pubnub.hereNow( - channels = emptyList(), - includeUUIDs = true, - limit = pageSize, - offset = firstPage.nextOffset!!, - ).sync()!! - - // May have more or fewer channels than first page - assertTrue(secondPage.totalChannels >= 1) - - if (secondPage.channels.containsKey(channel01)) { - val channel01DataPage02 = secondPage.channels[channel01]!! - assertEquals(totalClientsInChannel01, channel01DataPage02.occupancy) - - // 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) - } - } - } - @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 - Thread.sleep(1000) val result = pubnub.hereNow( channels = emptyList(), @@ -853,11 +661,8 @@ class PresenceIntegrationTests : BaseIntegrationTest() { limit = 10, ).sync()!! - // Should have no channels or very few residual ones + // Should have no channels // Note: In a shared test environment, there might be residual presence state assertTrue(result.totalOccupancy >= 0) - - // No pagination needed when no active subscriptions for this client - // Note: Result may vary based on test isolation } } 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 2ac698a08a..d858b3764f 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 @@ -37,7 +37,7 @@ class HereNowEndpoint internal constructor( override val offset: Int? = null, ) : EndpointCore, PNHereNowResult>(pubnub), HereNow { private val log: PNLogger = LoggerManager.instance.getLogger(pubnub.logConfig, this::class.java) - private val effectiveLimit: Int = if (limit in MIN_CHANNEL_OCCUPANTS_LIMIT..MAX_CHANNEL_OCCUPANTS_LIMIT) { + internal val effectiveLimit: Int = if (limit in MIN_CHANNEL_OCCUPANTS_LIMIT..MAX_CHANNEL_OCCUPANTS_LIMIT) { limit } else { log.warn( @@ -111,27 +111,16 @@ class HereNowEndpoint internal constructor( override fun getEndpointGroupName(): RetryableEndpointGroup = RetryableEndpointGroup.PRESENCE - internal fun calculateNextOffset(occupantsCount: Int): Int? { - return when { - !includeUUIDs -> null - occupantsCount < effectiveLimit -> null - occupantsCount == effectiveLimit -> (offset ?: 0) + effectiveLimit - else -> null - } - } - private fun parseSingleChannelResponse(input: Envelope): PNHereNowResult { val occupants = if (includeUUIDs && input.uuids != null) { prepareOccupantData(input.uuids) } else { emptyList() } - val occupantsCount = occupants.size val pnHereNowResult = PNHereNowResult( totalChannels = 1, totalOccupancy = input.occupancy, - nextOffset = calculateNextOffset(occupantsCount), ) // When includeUUIDs = false, occupants list will be empty but channel data is still present @@ -147,7 +136,6 @@ class HereNowEndpoint internal constructor( private fun parseMultipleChannelResponse(input: JsonElement): PNHereNowResult { val channels = pubnub.mapper.getObjectIterator(input, "channels") - var maxOccupantsReturned = 0 val channelsMap = mutableMapOf() while (channels.hasNext()) { @@ -158,11 +146,6 @@ class HereNowEndpoint internal constructor( emptyList() } - // we want to know number of occupants in channel that has the most occupants - if (occupants.size > maxOccupantsReturned) { - maxOccupantsReturned = occupants.size - } - val pnHereNowChannelData = PNHereNowChannelData( channelName = entry.key, occupancy = pubnub.mapper.elementToInt(entry.value, "occupancy"), @@ -175,7 +158,6 @@ class HereNowEndpoint internal constructor( totalChannels = pubnub.mapper.elementToInt(input, "total_channels"), totalOccupancy = pubnub.mapper.elementToInt(input, "total_occupancy"), channels = channelsMap, - nextOffset = calculateNextOffset(maxOccupantsReturned), ) return pnHereNowResult 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 index e4ed9dc8aa..8a4d9603d9 100644 --- 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 @@ -7,57 +7,6 @@ import org.junit.Assert.assertNull import org.junit.Test class HereNowEndpointTest : BaseTest() { - @Test - fun testNextStartFromCalculation_hasMoreResults() { - val endpoint = HereNowEndpoint( - pubnub = pubnub, - limit = 10, - offset = 20 - ) - - // When actual result size equals limit, there might be more results - val nextOffset = endpoint.calculateNextOffset(10) - assertEquals(30, nextOffset) // offset + limit - } - - @Test - fun testNextStartFromCalculation_noMoreResults() { - val endpoint = HereNowEndpoint( - pubnub = pubnub, - limit = 10, - offset = 20 - ) - - // When actual result size is less than limit, no more results - val nextOffset = endpoint.calculateNextOffset(5) - assertNull(nextOffset) - } - - @Test - fun testNextStartFromCalculation_withExplicitLimit() { - val endpoint = HereNowEndpoint( - pubnub = pubnub, - limit = 50, - offset = 20 - ) - - // When result equals limit, return next page - val nextOffset = endpoint.calculateNextOffset(50) - assertEquals(70, nextOffset) // 20 + 50 - } - - @Test - fun testNextStartFromCalculation_offsetDefaultsToZero() { - val endpoint = HereNowEndpoint( - pubnub = pubnub, - limit = 10, - offset = null // Should default to 0 in calculation - ) - - val nextOffset = endpoint.calculateNextOffset(10) - assertEquals(10, nextOffset) // 0 + 10 - } - @Test fun testPubNubHereNowWithPaginationParameters() { // Test the public API with new pagination parameters @@ -126,29 +75,7 @@ class HereNowEndpointTest : BaseTest() { assertNotNull(endpoint) // When limit is out of range, endpoint stores the original value assertEquals(1500, endpoint.limit) - - // But effectiveLimit should be capped at 1000, verified by calculateNextStartFrom - // If we get 1000 results, nextOffset should be 1000 (not 1500) - val nextOffset = endpoint.calculateNextOffset(1000) - assertEquals(1000, nextOffset) // 0 + 1000 (capped limit) - } - - @Test - fun testHereNowLimitBelowMinimumUsesDefault() { - // Test that limit=0 uses default of 1000 - val endpoint = HereNowEndpoint( - pubnub = pubnub, - channels = listOf("test-channel"), - includeUUIDs = true, - limit = 0 - ) - assertNotNull(endpoint) - // When limit is out of range, the original value is stored - assertEquals(0, endpoint.limit) - - // But effectiveLimit should default to 1000, verified by calculateNextStartFrom - val nextOffset = endpoint.calculateNextOffset(1000) - assertEquals(1000, nextOffset) // 0 + 1000 (default limit) + assertEquals(1000, endpoint.effectiveLimit) } @Test @@ -163,22 +90,7 @@ class HereNowEndpointTest : BaseTest() { assertNotNull(endpoint) // When limit is out of range, the original value is stored assertEquals(-5, endpoint.limit) - - // But effectiveLimit should default to 1000, verified by calculateNextStartFrom - val nextOffset = endpoint.calculateNextOffset(1000) - assertEquals(1000, nextOffset) // 0 + 1000 (default limit) - } - - @Test - fun testHereNowStartFromZeroBoundary() { - // Test minimum valid offset value - val hereNow = pubnub.hereNow( - channels = listOf("test-channel"), - includeUUIDs = true, - offset = 0 - ) - assertNotNull(hereNow) - assertEquals(0, (hereNow as HereNowEndpoint).offset) + assertEquals(1000, endpoint.effectiveLimit) } @Test @@ -193,31 +105,6 @@ class HereNowEndpointTest : BaseTest() { assertEquals(1000000, (hereNow as HereNowEndpoint).offset) } - @Test - fun testHereNowStartFromNullIsValid() { - // Test that null offset is valid (defaults to 0) - val hereNow = pubnub.hereNow( - channels = listOf("test-channel"), - includeUUIDs = true, - offset = null - ) - assertNotNull(hereNow) - assertNull((hereNow as HereNowEndpoint).offset) - } - - @Test - fun testHereNowStartFromNegativeAccepted() { - // Test that offset=-1 is accepted at creation time - // (validation happens during execution in doWork()) - val hereNow = pubnub.hereNow( - channels = listOf("test-channel"), - includeUUIDs = true, - offset = -1 - ) - assertNotNull(hereNow) - assertEquals(-1, (hereNow as HereNowEndpoint).offset) - } - @Test fun testHereNowStartFromLargeNegativeAccepted() { // Test that large negative offset is accepted at creation time 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 index 394b3bc388..13d35722af 100644 --- 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 @@ -8,7 +8,6 @@ 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 -import org.junit.Assert.assertNull class HereNowPaginationTestSuite : com.pubnub.internal.suite.CoreEndpointTestSuite() { override fun pnOperation() = PNOperationType.PNHereNowOperation @@ -27,8 +26,6 @@ class HereNowPaginationTestSuite : com.pubnub.internal.suite.CoreEndpointTestSui assertEquals(1, result.totalOccupancy) assertEquals(1, result.channels.size) assertEquals("user_1", result.channels["ch1"]!!.occupants[0].uuid) - // With only 1 occupant but limit 100, nextOffset should be null (no more results) - assertNull(result.nextOffset) } override fun successfulResponseBody() = From 8d3c6c36616095eb27bdf091e2089a5d3a459330 Mon Sep 17 00:00:00 2001 From: "marcin.cebo" Date: Tue, 14 Oct 2025 16:56:51 +0200 Subject: [PATCH 18/19] Updated kDoc --- .../integration/PresenceIntegrationTests.java | 24 +----- .../jvmMain/kotlin/com/pubnub/api/PubNub.kt | 13 +++- .../integration/PresenceIntegrationTests.kt | 76 +++++++++++++++++++ 3 files changed, 88 insertions(+), 25 deletions(-) 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 3c174ebb88..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 @@ -485,7 +485,7 @@ public void testHereNowPaginationFlow() throws PubNubException { // 3 users in channel02 final int pageSize = 3; final int firstOffset = 0; - final int secondOffset = 0; + final int secondOffset = 3; final int totalClientsCount = 11; final int channel01TotalCount = 8; final int channel02TotalCount = 3; @@ -712,26 +712,4 @@ public void testGlobalHereNowWithOffset() throws PubNubException { // With offset=2, we should get remaining occupants assertTrue(channelData.getOccupants().size() <= totalClientsCount - offsetValue); } - - @Test - public void testGlobalHereNowWithNoActiveChannels() throws PubNubException { - // Don't subscribe any clients, making it a truly empty global query - // Wait a bit to ensure no residual presence state from other tests - pause(1); - - PNHereNowResult result = pubNub.hereNow() - .channels(Collections.emptyList()) - .includeUUIDs(true) - .limit(10) - .sync(); - - assertNotNull(result); - - // Should have no channels or very few residual ones - // Note: In a shared test environment, there might be residual presence state - assertTrue(result.getTotalOccupancy() == 0); - - // No pagination needed when no active subscriptions for this client - // Note: Result may vary based on test isolation - } } 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 35a0575c3f..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,13 +761,22 @@ 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, 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 61d46e2875..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 @@ -665,4 +665,80 @@ class PresenceIntegrationTests : BaseIntegrationTest() { // 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() + } } From 9d3384267319aeda4b4b4d56069d2126cb584dff Mon Sep 17 00:00:00 2001 From: "marcin.cebo" Date: Wed, 15 Oct 2025 11:25:15 +0200 Subject: [PATCH 19/19] Fix --- .../pubnub/internal/endpoints/presence/HereNowEndpoint.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 d858b3764f..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 @@ -101,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()!!) } @@ -112,8 +112,8 @@ class HereNowEndpoint internal constructor( override fun getEndpointGroupName(): RetryableEndpointGroup = RetryableEndpointGroup.PRESENCE private fun parseSingleChannelResponse(input: Envelope): PNHereNowResult { - val occupants = if (includeUUIDs && input.uuids != null) { - prepareOccupantData(input.uuids) + val occupants = if (includeUUIDs) { + prepareOccupantData(input.uuids!!) } else { emptyList() }