diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/ApiFactoryTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/ApiFactoryTest.kt new file mode 100644 index 0000000..9dc25ed --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/ApiFactoryTest.kt @@ -0,0 +1,280 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(StreamInternalApi::class) + +package io.getstream.android.core.api + +import io.getstream.android.core.annotations.StreamInternalApi +import io.getstream.android.core.api.authentication.StreamTokenManager as StreamTokenManagerFactory +import io.getstream.android.core.api.authentication.StreamTokenProvider +import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.model.value.StreamUserId +import io.getstream.android.core.api.processing.StreamBatcher as StreamBatcherFactory +import io.getstream.android.core.api.processing.StreamRetryProcessor as StreamRetryProcessorFactory +import io.getstream.android.core.api.processing.StreamSerialProcessingQueue as StreamSerialProcessingQueueFactory +import io.getstream.android.core.api.processing.StreamSingleFlightProcessor as StreamSingleFlightProcessorFactory +import io.getstream.android.core.api.serialization.StreamEventSerialization as StreamEventSerializationFactory +import io.getstream.android.core.api.serialization.StreamJsonSerialization +import io.getstream.android.core.api.socket.StreamConnectionIdHolder as StreamConnectionIdHolderFactory +import io.getstream.android.core.api.socket.StreamWebSocket as StreamWebSocketFactoryMethod +import io.getstream.android.core.api.socket.StreamWebSocketFactory as StreamWebSocketFactoryFactory +import io.getstream.android.core.api.socket.StreamWebSocketFactory +import io.getstream.android.core.api.socket.listeners.StreamWebSocketListener +import io.getstream.android.core.api.socket.monitor.StreamHealthMonitor as StreamHealthMonitorFactory +import io.getstream.android.core.api.subscribe.StreamSubscription +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager as StreamSubscriptionManagerFactory +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import io.getstream.android.core.internal.authentication.StreamTokenManagerImpl +import io.getstream.android.core.internal.processing.StreamBatcherImpl +import io.getstream.android.core.internal.processing.StreamRetryProcessorImpl +import io.getstream.android.core.internal.processing.StreamSerialProcessingQueueImpl +import io.getstream.android.core.internal.processing.StreamSingleFlightProcessorImpl +import io.getstream.android.core.internal.serialization.StreamClientEventSerializationImpl +import io.getstream.android.core.internal.socket.StreamWebSocketImpl +import io.getstream.android.core.internal.socket.connection.StreamConnectionIdHolderImpl +import io.getstream.android.core.internal.socket.factory.StreamWebSocketFactoryImpl +import io.getstream.android.core.internal.socket.monitor.StreamHealthMonitorImpl +import io.getstream.android.core.internal.subscribe.StreamSubscriptionManagerImpl +import io.getstream.android.core.testutil.assertFieldEquals +import io.getstream.android.core.testutil.readPrivateField +import io.mockk.mockk +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import okhttp3.OkHttpClient +import org.junit.Test + +internal class ApiFactoryTest { + + private val dispatcher = StandardTestDispatcher() + private val testScope = TestScope(dispatcher) + + @Test + fun `StreamSerialProcessingQueue factory wires implementation`() { + // Given + val logger = mockk(relaxed = true) + val autoStart = false + val startMode = CoroutineStart.LAZY + val capacity = 7 + + // When + val queue = + StreamSerialProcessingQueueFactory( + logger = logger, + scope = testScope, + autoStart = autoStart, + startMode = startMode, + capacity = capacity, + ) + + // Then + assertTrue(queue is StreamSerialProcessingQueueImpl) + queue.assertFieldEquals("logger", logger) + queue.assertFieldEquals("scope", testScope) + queue.assertFieldEquals("autoStart", autoStart) + queue.assertFieldEquals("startMode", startMode) + val inbox = queue.readPrivateField("inbox")!! + val capacityValue = inbox.readPrivateField("capacity") + assertEquals(capacity, capacityValue) + } + + @Test + fun `StreamSingleFlightProcessor factory uses implementation and scope`() { + // Given + val scope = TestScope(dispatcher) + + // When + val processor = StreamSingleFlightProcessorFactory(scope = scope) + + // Then + assertTrue(processor is StreamSingleFlightProcessorImpl) + processor.assertFieldEquals("scope", scope) + } + + @Test + fun `StreamBatcher factory wires configuration`() { + // Given + val scope = TestScope(dispatcher) + val batchSize = 5 + val initialDelay = 50L + val maxDelay = 500L + val autoStart = false + val capacity = 3 + + // When + val batcher = + StreamBatcherFactory( + scope = scope, + batchSize = batchSize, + initialDelayMs = initialDelay, + maxDelayMs = maxDelay, + autoStart = autoStart, + channelCapacity = capacity, + ) + + // Then + assertTrue(batcher is StreamBatcherImpl) + batcher.assertFieldEquals("scope", scope) + batcher.assertFieldEquals("batchSize", batchSize) + batcher.assertFieldEquals("initialDelayMs", initialDelay) + batcher.assertFieldEquals("maxDelayMs", maxDelay) + batcher.assertFieldEquals("autoStart", autoStart) + val inbox = batcher.readPrivateField("inbox")!! + val capacityValue = inbox.readPrivateField("capacity") + assertEquals(capacity, capacityValue) + } + + @Test + fun `StreamRetryProcessor factory passes logger`() { + // Given + val logger = mockk(relaxed = true) + + // When + val retryProcessor = StreamRetryProcessorFactory(logger) + + // Then + assertTrue(retryProcessor is StreamRetryProcessorImpl) + retryProcessor.assertFieldEquals("logger", logger) + } + + @Test + fun `StreamEventSerialization factory passes json parser`() { + // Given + val jsonParser = mockk(relaxed = true) + + // When + val serialization = StreamEventSerializationFactory(jsonParser) + + // Then + assertTrue(serialization is StreamClientEventSerializationImpl) + serialization.assertFieldEquals("jsonParser", jsonParser) + } + + @Test + fun `StreamWebSocket factory wires dependencies`() { + // Given + val logger = mockk(relaxed = true) + val socketFactory = mockk(relaxed = true) + val subscriptionManager = + mockk>(relaxed = true) + + // When + val webSocket = + StreamWebSocketFactoryMethod( + logger = logger, + socketFactory = socketFactory, + subscriptionManager = subscriptionManager, + ) + + // Then + assertTrue(webSocket is StreamWebSocketImpl<*>) + webSocket.assertFieldEquals("logger", logger) + webSocket.assertFieldEquals("socketFactory", socketFactory) + webSocket.assertFieldEquals("subscriptionManager", subscriptionManager) + } + + @Test + fun `StreamWebSocketFactory factory wires okHttp and logger`() { + // Given + val okHttpClient = OkHttpClient() + val logger = mockk(relaxed = true) + + // When + val factory = StreamWebSocketFactoryFactory(okHttpClient = okHttpClient, logger = logger) + + // Then + assertTrue(factory is StreamWebSocketFactoryImpl) + factory.assertFieldEquals("okHttpClient", okHttpClient) + factory.assertFieldEquals("logger", logger) + } + + @Test + fun `StreamHealthMonitor factory applies configuration`() { + // Given + val logger = mockk(relaxed = true) + val scope = TestScope(dispatcher) + val interval = 1_000L + val liveness = 2_000L + + // When + val monitor = + StreamHealthMonitorFactory( + logger = logger, + scope = scope, + interval = interval, + livenessThreshold = liveness, + ) + + // Then + assertTrue(monitor is StreamHealthMonitorImpl) + monitor.assertFieldEquals("logger", logger) + monitor.assertFieldEquals("scope", scope) + monitor.assertFieldEquals("interval", interval) + monitor.assertFieldEquals("livenessThreshold", liveness) + } + + @Test + fun `StreamSubscriptionManager factory propagates limits`() { + // Given + val logger = mockk(relaxed = true) + val maxStrong = 3 + val maxWeak = 4 + + // When + val manager = + StreamSubscriptionManagerFactory( + logger = logger, + maxStrongSubscriptions = maxStrong, + maxWeakSubscriptions = maxWeak, + ) + + // Then + assertTrue(manager is StreamSubscriptionManagerImpl) + manager.assertFieldEquals("logger", logger) + manager.assertFieldEquals("maxStrongSubscriptions", maxStrong) + manager.assertFieldEquals("maxWeakSubscriptions", maxWeak) + } + + @Test + fun `StreamConnectionIdHolder factory returns implementation`() { + // Given + + // When + val holder = StreamConnectionIdHolderFactory() + + // Then + assertTrue(holder is StreamConnectionIdHolderImpl) + } + + @Test + fun `StreamTokenManager factory wires dependencies`() { + // Given + val userId = StreamUserId.fromString("user-123") + val tokenProvider = mockk(relaxed = true) + val singleFlight = StreamSingleFlightProcessorFactory(scope = testScope) + + // When + val manager = StreamTokenManagerFactory(userId, tokenProvider, singleFlight) + + // Then + assertTrue(manager is StreamTokenManagerImpl) + manager.assertFieldEquals("userId", userId.rawValue) + manager.assertFieldEquals("tokenProvider", tokenProvider) + manager.assertFieldEquals("singleFlight", singleFlight) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt new file mode 100644 index 0000000..e1aabeb --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt @@ -0,0 +1,277 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(StreamInternalApi::class) + +package io.getstream.android.core.api + +import io.getstream.android.core.annotations.StreamInternalApi +import io.getstream.android.core.api.authentication.StreamTokenManager +import io.getstream.android.core.api.authentication.StreamTokenProvider +import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.log.StreamLoggerProvider +import io.getstream.android.core.api.model.config.StreamClientSerializationConfig +import io.getstream.android.core.api.model.config.StreamHttpConfig +import io.getstream.android.core.api.model.config.StreamSocketConfig +import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.android.core.api.model.value.StreamApiKey +import io.getstream.android.core.api.model.value.StreamHttpClientInfoHeader +import io.getstream.android.core.api.model.value.StreamUserId +import io.getstream.android.core.api.model.value.StreamWsUrl +import io.getstream.android.core.api.processing.StreamBatcher +import io.getstream.android.core.api.processing.StreamRetryProcessor +import io.getstream.android.core.api.processing.StreamSerialProcessingQueue +import io.getstream.android.core.api.processing.StreamSingleFlightProcessor +import io.getstream.android.core.api.serialization.StreamEventSerialization +import io.getstream.android.core.api.socket.StreamConnectionIdHolder +import io.getstream.android.core.api.socket.StreamWebSocketFactory +import io.getstream.android.core.api.socket.listeners.StreamClientListener +import io.getstream.android.core.api.socket.monitor.StreamHealthMonitor +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import io.getstream.android.core.internal.client.StreamClientImpl +import io.getstream.android.core.internal.http.interceptor.StreamApiKeyInterceptor +import io.getstream.android.core.internal.http.interceptor.StreamAuthInterceptor +import io.getstream.android.core.internal.http.interceptor.StreamClientInfoInterceptor +import io.getstream.android.core.internal.http.interceptor.StreamConnectionIdInterceptor +import io.getstream.android.core.internal.http.interceptor.StreamEndpointErrorInterceptor +import io.getstream.android.core.internal.serialization.StreamCompositeEventSerializationImpl +import io.getstream.android.core.internal.socket.StreamSocketSession +import io.getstream.android.core.internal.socket.StreamWebSocketImpl +import io.getstream.android.core.testutil.assertFieldEquals +import io.getstream.android.core.testutil.readPrivateField +import io.mockk.mockk +import kotlin.test.assertEquals +import kotlin.test.assertNotSame +import kotlin.test.assertTrue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import org.junit.Test + +internal class StreamClientFactoryTest { + + private val dispatcher = StandardTestDispatcher() + private val testScope = TestScope(dispatcher) + private val productSerializer = mockk>(relaxed = true) + private val serializationConfig = StreamClientSerializationConfig.default(productSerializer) + private val logProvider = + object : StreamLoggerProvider { + override fun taggedLogger(tag: String): StreamLogger = + object : StreamLogger { + override fun log( + level: StreamLogger.LogLevel, + throwable: Throwable?, + message: () -> String, + ) { + // no-op for tests + } + } + } + + private data class Dependencies( + val apiKey: StreamApiKey, + val userId: StreamUserId, + val wsUrl: StreamWsUrl, + val clientInfo: StreamHttpClientInfoHeader, + val clientSubscriptionManager: StreamSubscriptionManager, + val tokenProvider: StreamTokenProvider, + val tokenManager: StreamTokenManager, + val singleFlight: StreamSingleFlightProcessor, + val serialQueue: StreamSerialProcessingQueue, + val retryProcessor: StreamRetryProcessor, + val connectionIdHolder: StreamConnectionIdHolder, + val socketFactory: StreamWebSocketFactory, + val healthMonitor: StreamHealthMonitor, + val batcher: StreamBatcher, + ) + + private fun createClient( + httpConfig: StreamHttpConfig? = null + ): Pair { + val deps = + Dependencies( + apiKey = StreamApiKey.fromString("key123"), + userId = StreamUserId.fromString("user-123"), + wsUrl = StreamWsUrl.fromString("wss://test.stream/video"), + clientInfo = + StreamHttpClientInfoHeader.create( + product = "android", + productVersion = "1.0", + os = "android", + apiLevel = 33, + deviceModel = "Pixel", + app = "test-app", + appVersion = "1.0.0", + ), + clientSubscriptionManager = mockk(relaxed = true), + tokenProvider = mockk(relaxed = true), + tokenManager = mockk(relaxed = true), + singleFlight = mockk(relaxed = true), + serialQueue = mockk(relaxed = true), + retryProcessor = mockk(relaxed = true), + connectionIdHolder = mockk(relaxed = true), + socketFactory = mockk(relaxed = true), + healthMonitor = mockk(relaxed = true), + batcher = mockk(relaxed = true), + ) + + val client = + StreamClient( + apiKey = deps.apiKey, + userId = deps.userId, + wsUrl = deps.wsUrl, + products = listOf("feeds"), + clientInfoHeader = deps.clientInfo, + clientSubscriptionManager = deps.clientSubscriptionManager, + tokenProvider = deps.tokenProvider, + tokenManager = deps.tokenManager, + singleFlight = deps.singleFlight, + serialQueue = deps.serialQueue, + retryProcessor = deps.retryProcessor, + scope = testScope, + connectionIdHolder = deps.connectionIdHolder, + socketFactory = deps.socketFactory, + healthMonitor = deps.healthMonitor, + batcher = deps.batcher, + httpConfig = httpConfig, + serializationConfig = serializationConfig, + logProvider = logProvider, + ) + + return client to deps + } + + @Test + fun `StreamClient factory wires core dependencies`() { + // Given + + // When + val (client, deps) = createClient() + + // Then + assertTrue(client is StreamClientImpl<*>) + assertTrue(client.connectionState.value is StreamConnectionState.Idle) + + // Verify client level wiring + client.assertFieldEquals("userId", deps.userId.rawValue) + client.assertFieldEquals("tokenManager", deps.tokenManager) + client.assertFieldEquals("singleFlight", deps.singleFlight) + client.assertFieldEquals("serialQueue", deps.serialQueue) + client.assertFieldEquals("connectionIdHolder", deps.connectionIdHolder) + client.assertFieldEquals("subscriptionManager", deps.clientSubscriptionManager) + val scope = client.readPrivateField("scope") as CoroutineScope + + assertNotSame(testScope, scope) + + // socket session wiring + val socketSession = client.readPrivateField("socketSession") as StreamSocketSession<*> + val expectedConfig = + StreamSocketConfig.jwt( + url = deps.wsUrl.rawValue, + apiKey = deps.apiKey, + clientInfoHeader = deps.clientInfo, + ) + socketSession.assertFieldEquals("config", expectedConfig) + socketSession.assertFieldEquals("healthMonitor", deps.healthMonitor) + socketSession.assertFieldEquals("batcher", deps.batcher) + socketSession.assertFieldEquals("products", listOf("feeds")) + + val internalSocket = socketSession.readPrivateField("internalSocket") + assertTrue(internalSocket is StreamWebSocketImpl<*>) + + val eventParser = socketSession.readPrivateField("eventParser") + assertTrue(eventParser is StreamCompositeEventSerializationImpl<*>) + + val sessionSubscriptionManager = socketSession.readPrivateField("subscriptionManager") + assertTrue(sessionSubscriptionManager is StreamSubscriptionManager<*>) + } + + @Test + fun `StreamClient factory attaches automatic and custom http interceptors`() { + // Given + val builder = OkHttpClient.Builder() + val customInterceptor = Interceptor { chain -> chain.proceed(chain.request()) } + val httpConfig = + StreamHttpConfig( + httpBuilder = builder, + automaticInterceptors = true, + configuredInterceptors = setOf(customInterceptor), + ) + + // When + val (client, deps) = createClient(httpConfig) + val interceptors = builder.interceptors() + + // Then + assertEquals(6, interceptors.size) + assertTrue(interceptors[0] is StreamClientInfoInterceptor) + assertTrue(interceptors[1] is StreamApiKeyInterceptor) + assertTrue(interceptors[2] is StreamConnectionIdInterceptor) + assertTrue(interceptors[3] is StreamAuthInterceptor) + assertTrue(interceptors[4] is StreamEndpointErrorInterceptor) + assertEquals(customInterceptor, interceptors[5]) + + val clientInfoInterceptor = interceptors[0] as StreamClientInfoInterceptor + val storedClientInfo = clientInfoInterceptor.readPrivateField("clientInfo") + when (storedClientInfo) { + is String -> assertEquals(deps.clientInfo.rawValue, storedClientInfo) + else -> assertEquals(deps.clientInfo, storedClientInfo) + } + + val apiKeyInterceptor = interceptors[1] as StreamApiKeyInterceptor + val storedApiKey = apiKeyInterceptor.readPrivateField("apiKey") + when (storedApiKey) { + is String -> assertEquals(deps.apiKey.rawValue, storedApiKey) + else -> assertEquals(deps.apiKey, storedApiKey) + } + + val connectionInterceptor = interceptors[2] as StreamConnectionIdInterceptor + connectionInterceptor.assertFieldEquals("connectionIdHolder", deps.connectionIdHolder) + + val session = + (client as StreamClientImpl<*>).readPrivateField("socketSession") + as StreamSocketSession<*> + val jsonSerialization = session.readPrivateField("jsonSerialization") + + val authInterceptor = interceptors[3] as StreamAuthInterceptor + authInterceptor.assertFieldEquals("tokenManager", deps.tokenManager) + authInterceptor.assertFieldEquals("authType", "jwt") + assertEquals(jsonSerialization, authInterceptor.readPrivateField("jsonParser")) + + val errorInterceptor = interceptors[4] as StreamEndpointErrorInterceptor + assertEquals(jsonSerialization, errorInterceptor.readPrivateField("jsonParser")) + } + + @Test + fun `StreamClient factory respects disabled automatic interceptors`() { + // Given + val builder = OkHttpClient.Builder() + val customInterceptor = Interceptor { chain -> chain.proceed(chain.request()) } + val httpConfig = + StreamHttpConfig( + httpBuilder = builder, + automaticInterceptors = false, + configuredInterceptors = setOf(customInterceptor), + ) + + // When + createClient(httpConfig) + + // Then + assertEquals(listOf(customInterceptor), builder.interceptors()) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/http/StreamOkHttpInterceptorsTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/http/StreamOkHttpInterceptorsTest.kt new file mode 100644 index 0000000..a3d6863 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/http/StreamOkHttpInterceptorsTest.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(StreamInternalApi::class) + +package io.getstream.android.core.api.http + +import io.getstream.android.core.annotations.StreamInternalApi +import io.getstream.android.core.api.authentication.StreamTokenManager +import io.getstream.android.core.api.model.value.StreamApiKey +import io.getstream.android.core.api.model.value.StreamHttpClientInfoHeader +import io.getstream.android.core.api.serialization.StreamJsonSerialization +import io.getstream.android.core.api.socket.StreamConnectionIdHolder +import io.getstream.android.core.internal.http.interceptor.StreamApiKeyInterceptor +import io.getstream.android.core.internal.http.interceptor.StreamAuthInterceptor +import io.getstream.android.core.internal.http.interceptor.StreamClientInfoInterceptor +import io.getstream.android.core.internal.http.interceptor.StreamConnectionIdInterceptor +import io.getstream.android.core.internal.http.interceptor.StreamEndpointErrorInterceptor +import io.getstream.android.core.testutil.assertFieldEquals +import io.getstream.android.core.testutil.readPrivateField +import io.mockk.mockk +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.Test + +internal class StreamOkHttpInterceptorsTest { + + @Test + fun `auth factory wires type token manager and parser`() { + // Given + val authType = "jwt" + val tokenManager = mockk(relaxed = true) + val jsonParser = mockk(relaxed = true) + + // When + val interceptor = StreamOkHttpInterceptors.auth(authType, tokenManager, jsonParser) + + // Then + assertTrue(interceptor is StreamAuthInterceptor) + interceptor.assertFieldEquals("authType", authType) + interceptor.assertFieldEquals("tokenManager", tokenManager) + interceptor.assertFieldEquals("jsonParser", jsonParser) + } + + @Test + fun `connectionId factory wires holder`() { + // Given + val holder = mockk(relaxed = true) + + // When + val interceptor = StreamOkHttpInterceptors.connectionId(holder) + + // Then + assertTrue(interceptor is StreamConnectionIdInterceptor) + interceptor.assertFieldEquals("connectionIdHolder", holder) + } + + @Test + fun `clientInfo factory wires header`() { + // Given + val header = + StreamHttpClientInfoHeader.create( + product = "android", + productVersion = "1.0", + os = "Android", + apiLevel = 33, + deviceModel = "Pixel", + app = "test-app", + appVersion = "1.2.3", + ) + + // When + val interceptor = StreamOkHttpInterceptors.clientInfo(header) + + // Then + assertTrue(interceptor is StreamClientInfoInterceptor) + val stored = interceptor.readPrivateField("clientInfo") + when (stored) { + is String -> assertEquals(header.rawValue, stored) + else -> assertEquals(header, stored) + } + } + + @Test + fun `apiKey factory wires key`() { + // Given + val apiKey = StreamApiKey.fromString("key123") + + // When + val interceptor = StreamOkHttpInterceptors.apiKey(apiKey) + + // Then + assertTrue(interceptor is StreamApiKeyInterceptor) + val storedKey = interceptor.readPrivateField("apiKey") + when (storedKey) { + is String -> assertEquals(apiKey.rawValue, storedKey) + else -> assertEquals(apiKey, storedKey) + } + } + + @Test + fun `error factory wires json parser`() { + // Given + val jsonParser = mockk(relaxed = true) + + // When + val interceptor = StreamOkHttpInterceptors.error(jsonParser) + + // Then + assertTrue(interceptor is StreamEndpointErrorInterceptor) + interceptor.assertFieldEquals("jsonParser", jsonParser) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/log/StreamLoggerProviderTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/log/StreamLoggerProviderTest.kt new file mode 100644 index 0000000..593b2cb --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/log/StreamLoggerProviderTest.kt @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(StreamInternalApi::class) + +package io.getstream.android.core.api.log + +import android.util.Log +import io.getstream.android.core.annotations.StreamInternalApi +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import org.junit.After +import org.junit.Before +import org.junit.Test + +internal class StreamLoggerProviderTest { + + private val tag = "TestTag" + + @Before + fun setUp() { + mockkStatic(Log::class) + } + + @After + fun tearDown() { + unmockkAll() + } + + private fun stubLogDefaults() { + every { Log.isLoggable(any(), any()) } returns true + every { Log.getStackTraceString(any()) } answers + { + "stack:${firstArg().message}" + } + every { Log.println(any(), any(), any()) } returns 0 + } + + @Test + fun `default logger filters messages below min level`() { + // Given + stubLogDefaults() + val provider = + StreamLoggerProvider.defaultAndroidLogger(minLevel = StreamLogger.LogLevel.Info) + val logger = provider.taggedLogger(tag) + + // When + logger.d { "debug" } + + // Then + verify(exactly = 0) { Log.println(any(), any(), any()) } + } + + @Test + fun `default logger honors isLoggable gate`() { + // Given + stubLogDefaults() + every { Log.isLoggable(tag, Log.DEBUG) } returns false + val provider = StreamLoggerProvider.defaultAndroidLogger(honorAndroidIsLoggable = true) + val logger = provider.taggedLogger(tag) + + // When + logger.d { "blocked" } + + // Then + verify(exactly = 0) { Log.println(any(), any(), any()) } + } + + @Test + fun `default logger splits long messages into chunks`() { + // Given + val messages = mutableListOf() + every { Log.isLoggable(any(), any()) } returns true + every { Log.getStackTraceString(any()) } answers { "" } + every { Log.println(any(), any(), any()) } answers + { + messages += thirdArg() + 0 + } + val provider = StreamLoggerProvider.defaultAndroidLogger() + val logger = provider.taggedLogger(tag) + val longMessage = buildString(9005) { repeat(9005) { append('x') } } + + // When + logger.i { longMessage } + + // Then + assertEquals(3, messages.size) + assertEquals(4000, messages[0].length) + assertEquals(4000, messages[1].length) + assertEquals(1005, messages[2].length) + } + + @Test + fun `default logger appends stack trace when throwable provided`() { + // Given + val captured = mutableListOf() + stubLogDefaults() + every { Log.println(any(), any(), any()) } answers + { + captured += thirdArg() + 0 + } + val provider = StreamLoggerProvider.defaultAndroidLogger() + val logger = provider.taggedLogger(tag) + val throwable = IllegalStateException("boom") + + // When + logger.e(throwable, message = { "failure" }) + + // Then + val logged = captured.single() + assertTrue(logged.contains("failure")) + assertTrue(logged.contains("stack:boom")) + } + + @Test + fun `default logger recovers from message supplier exception`() { + // Given + val captured = mutableListOf() + stubLogDefaults() + every { Log.println(any(), any(), any()) } answers + { + captured += thirdArg() + 0 + } + val provider = StreamLoggerProvider.defaultAndroidLogger() + val logger = provider.taggedLogger(tag) + + // When + logger.w { throw IllegalArgumentException("bad supplier") } + + // Then + assertEquals("Log message supplier threw: bad supplier", captured.single()) + } + + @Test + fun `default logger rethrows cancellation`() { + // Given + stubLogDefaults() + val provider = StreamLoggerProvider.defaultAndroidLogger() + val logger = provider.taggedLogger(tag) + + // When & Then + assertFailsWith { + logger.log(StreamLogger.LogLevel.Debug, null) { throw CancellationException("cancel") } + } + } + + @Test + fun `stream logger convenience methods delegate to log`() { + // Given + val recorded = mutableListOf>() + val logger = + object : StreamLogger { + override fun log( + level: StreamLogger.LogLevel, + throwable: Throwable?, + message: () -> String, + ) { + recorded += level to message() + } + } + val throwable = IllegalArgumentException("fail") + + // When + logger.v { "verbose" } + logger.d { "debug" } + logger.i { "info" } + logger.w { "warn" } + logger.e { "error" } + logger.e(throwable, message = { "failure" }) + + // Then + val levels = recorded.map { it.first } + assertEquals( + listOf( + StreamLogger.LogLevel.Verbose, + StreamLogger.LogLevel.Debug, + StreamLogger.LogLevel.Info, + StreamLogger.LogLevel.Warning, + StreamLogger.LogLevel.Error, + StreamLogger.LogLevel.Error, + ), + levels, + ) + assertEquals("failure", recorded.last().second) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/socket/listeners/StreamListenersDefaultImplsTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/socket/listeners/StreamListenersDefaultImplsTest.kt new file mode 100644 index 0000000..5b96b56 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/socket/listeners/StreamListenersDefaultImplsTest.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(StreamInternalApi::class) + +package io.getstream.android.core.api.socket.listeners + +import io.getstream.android.core.annotations.StreamInternalApi +import io.getstream.android.core.api.model.connection.StreamConnectionState +import kotlin.test.assertEquals +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okio.ByteString.Companion.encodeUtf8 +import org.junit.Test + +internal class StreamListenersDefaultImplsTest { + + @Test + fun `StreamClientListener default methods are no-ops`() { + // Given + val listener = object : StreamClientListener {} + + // When + listener.onState(StreamConnectionState.Idle) + listener.onEvent("ignored") + listener.onError(IllegalStateException("boom")) + + // Then: no exceptions thrown + } + + @Test + fun `StreamClientListener overrides get invoked`() = runTest { + // Given + val stateChannel = Channel(capacity = 1) + val eventChannel = Channel(capacity = 1) + val errorChannel = Channel(capacity = 1) + + val listener = + object : StreamClientListener { + override fun onState(state: StreamConnectionState) { + stateChannel.trySend(state) + } + + override fun onEvent(event: Any) { + eventChannel.trySend(event) + } + + override fun onError(err: Throwable) { + errorChannel.trySend(err) + } + } + + val state = StreamConnectionState.Connecting.Opening("user") + val event = "event" + val error = IllegalArgumentException("fail") + + listener.onState(state) + listener.onEvent(event) + listener.onError(error) + + assertEquals(state, stateChannel.receive()) + assertEquals(event, eventChannel.receive()) + assertEquals(error, errorChannel.receive()) + } + + @Test + fun `StreamWebSocketListener default methods are no-ops`() { + // Given + val listener = object : StreamWebSocketListener {} + val response = + Response.Builder() + .request(Request.Builder().url("https://example.com").build()) + .protocol(Protocol.HTTP_1_1) + .code(101) + .message("Switching Protocols") + .build() + + // When + listener.onOpen(response) + listener.onMessage("message") + listener.onMessage("bytes".encodeUtf8()) + listener.onFailure(IllegalStateException("boom"), response) + listener.onClosed(1000, "closed") + listener.onClosing(1001, "closing") + + // Then: no exceptions thrown + } + + @Test + fun `StreamWebSocketListener overrides get invoked`() = runTest { + // Given + val events = Channel(capacity = 6) + val response = + Response.Builder() + .request(Request.Builder().url("https://example.com").build()) + .protocol(Protocol.HTTP_1_1) + .code(101) + .message("Switching Protocols") + .build() + val error = IllegalArgumentException("fail") + + val listener = + object : StreamWebSocketListener { + override fun onOpen(response: Response) { + launch { events.send("open:${response.code}") } + } + + override fun onMessage(bytes: okio.ByteString) { + launch { events.send("bytes:${bytes.utf8()}") } + } + + override fun onMessage(text: String) { + launch { events.send("text:$text") } + } + + override fun onFailure(t: Throwable, response: Response?) { + launch { events.send("failure:${t.message}") } + } + + override fun onClosed(code: Int, reason: String) { + launch { events.send("closed:$code:$reason") } + } + + override fun onClosing(code: Int, reason: String) { + launch { events.send("closing:$code:$reason") } + } + } + + // When + listener.onOpen(response) + listener.onMessage("text") + listener.onMessage("bytes".encodeUtf8()) + listener.onFailure(error, response) + listener.onClosed(1000, "bye") + listener.onClosing(1001, "closing") + + // Then + assertEquals("open:101", events.receive()) + assertEquals("text:text", events.receive()) + assertEquals("bytes:bytes", events.receive()) + assertEquals("failure:fail", events.receive()) + assertEquals("closed:1000:bye", events.receive()) + assertEquals("closing:1001:closing", events.receive()) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/sort/SortTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/sort/SortTest.kt new file mode 100644 index 0000000..db2ae38 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/sort/SortTest.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.api.sort + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlin.collections.sortedWith +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.Test + +internal class SortTest { + + private data class TestModel(val score: Int, val name: String) + + @Test + fun `sort toDto exposes remote field and direction`() { + // Given + val field = SortField.create(remote = "score") { it.score } + val sort = Sort(field = field, direction = SortDirection.REVERSE) + + // When + val dto = sort.toDto() + + // Then + assertEquals(mapOf("field" to "score", "direction" to -1), dto) + } + + @Test + fun `sort compare delegates to comparator`() { + // Given + val comparator = mockk>() + val first = TestModel(score = 10, name = "b") + val second = TestModel(score = 7, name = "a") + every { comparator.compare(first, second, SortDirection.REVERSE) } returns 42 + val sortField = + object : SortField { + override val comparator: AnySortComparator = comparator + override val remote: String = "score" + } + val sort = Sort(field = sortField, direction = SortDirection.REVERSE) + + // When + val result = sort.compare(first, second) + + // Then + assertEquals(42, result) + verify(exactly = 1) { comparator.compare(first, second, SortDirection.REVERSE) } + } + + @Test + fun `sort comparator pushes null objects to the end in forward order`() { + // Given + val comparator = SortComparator { it.score } + + // When + val comparison = + comparator.compare( + lhs = null, + rhs = TestModel(score = 5, name = "a"), + direction = SortDirection.FORWARD, + ) + + // Then + assertEquals(-1, comparison) + } + + @Test + fun `sort comparator in reverse order flips the comparison result`() { + // Given + val comparator = SortComparator { it.score } + val lhs = TestModel(score = 3, name = "a") + val rhs = TestModel(score = 7, name = "b") + + // When + val forwardResult = comparator.compare(lhs, rhs, SortDirection.FORWARD) + val reverseResult = comparator.compare(lhs, rhs, SortDirection.REVERSE) + + // Then + assertTrue(forwardResult < 0) + assertEquals(-forwardResult, reverseResult) + } + + @Test + fun `sortedWith applies multiple sorts in order`() { + // Given + val models = + listOf( + TestModel(score = 1, name = "c"), + TestModel(score = 2, name = "b"), + TestModel(score = 1, name = "a"), + ) + val scoreSort = + Sort(SortField.create("score") { it.score }, SortDirection.FORWARD) + val nameSort = + Sort(SortField.create("name") { it.name }, SortDirection.FORWARD) + + // When + val sorted = models.sortedWith(listOf(scoreSort, nameSort)) + + // Then + assertEquals( + listOf( + TestModel(score = 1, name = "a"), + TestModel(score = 1, name = "c"), + TestModel(score = 2, name = "b"), + ), + sorted, + ) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/testutil/ReflectionAssertions.kt b/stream-android-core/src/test/java/io/getstream/android/core/testutil/ReflectionAssertions.kt new file mode 100644 index 0000000..55728a5 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/testutil/ReflectionAssertions.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.testutil + +import kotlin.test.assertEquals +import kotlin.test.assertSame + +/** Reads a private field named [fieldName] from [instance] (walking the class hierarchy). */ +private fun readPrivateFieldInternal(instance: Any?, fieldName: String): Any? { + val target = requireNotNull(instance) { "Cannot read '$fieldName' on null instance" } + var current: Class<*>? = target::class.java + while (current != null) { + runCatching { + val field = current.getDeclaredField(fieldName) + field.isAccessible = true + return field.get(target) + } + current = current.superclass + } + throw NoSuchFieldException(fieldName) +} + +/** Extension wrapper over [readPrivateField] for ergonomic usage. */ +public fun Any?.readPrivateField(fieldName: String): Any? = + readPrivateFieldInternal(this, fieldName) + +/** + * Asserts that the private field [fieldName] equals [expected]. Value classes, primitives, numbers, + * booleans, and strings are compared with [assertEquals]; all other references are compared with + * [assertSame]. + */ +public fun Any.assertFieldEquals(fieldName: String, expected: Any) { + val value = readPrivateFieldInternal(this, fieldName) + if (shouldUseEquals(expected)) { + assertEquals(expected, value) + } else { + assertSame(expected, value) + } +} + +private fun shouldUseEquals(expected: Any): Boolean = + expected::class.isValue || + expected::class.isData || + expected::class.javaPrimitiveType != null || + expected is String || + expected is Number || + expected is Boolean || + expected is List<*>