From a22023a3f12d0151e0590edf60ac76d74ea5625d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20G=C3=B3mez?= Date: Sun, 29 Mar 2026 12:28:03 +0200 Subject: [PATCH 1/5] add more interceptors to network module --- .../commons/network/ext/ResponseExtension.kt | 9 +- .../interceptor/AuthTokenInterceptor.kt | 24 +++ .../interceptor/CacheLoggerInterceptor.kt | 6 +- .../interceptor/CacheOfflineInterceptor.kt | 8 +- .../interceptor/ErrorResponseInterceptor.kt | 36 ++++ .../network/interceptor/HeadersInterceptor.kt | 31 ++++ .../interceptor/NetworkMonitorInterceptor.kt | 19 +++ .../interceptor/RequestTimingInterceptor.kt | 24 +++ .../network/interceptor/RetryInterceptor.kt | 48 ++++++ .../interceptor/AuthTokenInterceptorTest.kt | 82 ++++++++++ .../interceptor/CacheLoggerInterceptorTest.kt | 89 ++++++++++ .../CacheNetworkInterceptorTest.kt | 114 +++++++++++++ .../CacheOfflineInterceptorTest.kt | 154 ++++++++++++++++++ .../ErrorResponseInterceptorTest.kt | 145 +++++++++++++++++ .../interceptor/HeadersInterceptorTest.kt | 110 +++++++++++++ .../NetworkMonitorInterceptorTest.kt | 72 ++++++++ .../RequestTimingInterceptorTest.kt | 92 +++++++++++ .../interceptor/RetryInterceptorTest.kt | 146 +++++++++++++++++ 18 files changed, 1200 insertions(+), 9 deletions(-) create mode 100644 libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/AuthTokenInterceptor.kt create mode 100644 libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/ErrorResponseInterceptor.kt create mode 100644 libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/HeadersInterceptor.kt create mode 100644 libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/NetworkMonitorInterceptor.kt create mode 100644 libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/RequestTimingInterceptor.kt create mode 100644 libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/RetryInterceptor.kt create mode 100644 libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/AuthTokenInterceptorTest.kt create mode 100644 libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/CacheLoggerInterceptorTest.kt create mode 100644 libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/CacheNetworkInterceptorTest.kt create mode 100644 libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/CacheOfflineInterceptorTest.kt create mode 100644 libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/ErrorResponseInterceptorTest.kt create mode 100644 libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/HeadersInterceptorTest.kt create mode 100644 libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/NetworkMonitorInterceptorTest.kt create mode 100644 libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/RequestTimingInterceptorTest.kt create mode 100644 libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/RetryInterceptorTest.kt diff --git a/libraries/network/src/main/java/com/raxdenstudios/commons/network/ext/ResponseExtension.kt b/libraries/network/src/main/java/com/raxdenstudios/commons/network/ext/ResponseExtension.kt index cc320545..2828c70e 100644 --- a/libraries/network/src/main/java/com/raxdenstudios/commons/network/ext/ResponseExtension.kt +++ b/libraries/network/src/main/java/com/raxdenstudios/commons/network/ext/ResponseExtension.kt @@ -32,7 +32,14 @@ internal fun Int.toAnswer( bodyError: E? = null, message: String, ): Answer> = when (this) { - in (200..399) -> Answer.Success(body!!) + in (200..399) -> body?.let { Answer.Success(it) } + ?: Answer.Failure( + NetworkError.Unknown( + code = this, + body = bodyError, + message = "Success response with null body" + ) + ) in (400..499) -> Answer.Failure( NetworkError.Client( code = this, diff --git a/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/AuthTokenInterceptor.kt b/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/AuthTokenInterceptor.kt new file mode 100644 index 00000000..e9172aec --- /dev/null +++ b/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/AuthTokenInterceptor.kt @@ -0,0 +1,24 @@ +package com.raxdenstudios.commons.network.interceptor + +import okhttp3.Interceptor +import okhttp3.Response + +class AuthTokenInterceptor( + private val tokenProvider: () -> String? +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + val token = tokenProvider() + + val request = if (token != null) { + originalRequest.newBuilder() + .header("Authorization", "Bearer $token") + .build() + } else { + originalRequest + } + + return chain.proceed(request) + } +} diff --git a/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/CacheLoggerInterceptor.kt b/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/CacheLoggerInterceptor.kt index 068e1d6f..149e045d 100644 --- a/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/CacheLoggerInterceptor.kt +++ b/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/CacheLoggerInterceptor.kt @@ -9,10 +9,8 @@ class CacheLoggerInterceptor( override fun intercept(chain: Interceptor.Chain): Response { val response = chain.proceed(chain.request()) - if (response.networkResponse != null) - printMessage("Response from network") - if (response.cacheResponse != null) - printMessage("(HIT) Response from cache") + response.networkResponse?.let { printMessage("Response from network") } + response.cacheResponse?.let { printMessage("(HIT) Response from cache") } return response } } diff --git a/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/CacheOfflineInterceptor.kt b/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/CacheOfflineInterceptor.kt index 6e405dc8..d0be16bd 100644 --- a/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/CacheOfflineInterceptor.kt +++ b/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/CacheOfflineInterceptor.kt @@ -38,14 +38,14 @@ class CacheOfflineInterceptor( @Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response { - var request = chain.request() - - if (!isNetworkAvailable()) { - request = chain.request().newBuilder() + val request = if (!isNetworkAvailable()) { + chain.request().newBuilder() .removeHeader(HEADER_PRAGMA) .removeHeader(HEADER_CACHE_CONTROL) .cacheControl(cacheControl) .build() + } else { + chain.request() } return chain.proceed(request) } diff --git a/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/ErrorResponseInterceptor.kt b/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/ErrorResponseInterceptor.kt new file mode 100644 index 00000000..e082dbca --- /dev/null +++ b/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/ErrorResponseInterceptor.kt @@ -0,0 +1,36 @@ +package com.raxdenstudios.commons.network.interceptor + +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException + +class ErrorResponseInterceptor : Interceptor { + + @Suppress("MagicNumber") + override fun intercept(chain: Interceptor.Chain): Response { + val response = chain.proceed(chain.request()) + + if (!response.isSuccessful) { + val errorBody = response.body?.string() + throw when (response.code) { + 401 -> UnauthorizedException(errorBody) + 403 -> ForbiddenException(errorBody) + 404 -> NotFoundException(errorBody) + 500 -> ServerException(errorBody) + else -> HttpException(response.code, errorBody) + } + } + + return response + } +} + +open class HttpException( + val code: Int, + val errorBody: String? +) : IOException("HTTP $code: ${errorBody ?: "Unknown error"}") + +class UnauthorizedException(errorBody: String?) : HttpException(401, errorBody) +class ForbiddenException(errorBody: String?) : HttpException(403, errorBody) +class NotFoundException(errorBody: String?) : HttpException(404, errorBody) +class ServerException(errorBody: String?) : HttpException(500, errorBody) diff --git a/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/HeadersInterceptor.kt b/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/HeadersInterceptor.kt new file mode 100644 index 00000000..f416eccb --- /dev/null +++ b/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/HeadersInterceptor.kt @@ -0,0 +1,31 @@ +package com.raxdenstudios.commons.network.interceptor + +import okhttp3.Interceptor +import okhttp3.Response + +class HeadersInterceptor( + private val headers: Map +) : Interceptor { + + companion object { + fun withUserAgent(userAgent: String) = HeadersInterceptor( + mapOf("User-Agent" to userAgent) + ) + + fun withApiVersion(version: String) = HeadersInterceptor( + mapOf("X-API-Version" to version) + ) + + fun withHeaders(vararg pairs: Pair) = HeadersInterceptor( + mapOf(*pairs) + ) + } + + override fun intercept(chain: Interceptor.Chain): Response { + val requestBuilder = chain.request().newBuilder() + headers.forEach { (key, value) -> + requestBuilder.header(key, value) + } + return chain.proceed(requestBuilder.build()) + } +} diff --git a/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/NetworkMonitorInterceptor.kt b/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/NetworkMonitorInterceptor.kt new file mode 100644 index 00000000..0b07b7e0 --- /dev/null +++ b/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/NetworkMonitorInterceptor.kt @@ -0,0 +1,19 @@ +package com.raxdenstudios.commons.network.interceptor + +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException + +class NetworkMonitorInterceptor( + private val isNetworkAvailable: () -> Boolean +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + if (!isNetworkAvailable()) { + throw NoNetworkException("No network connection available") + } + return chain.proceed(chain.request()) + } +} + +class NoNetworkException(message: String) : IOException(message) diff --git a/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/RequestTimingInterceptor.kt b/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/RequestTimingInterceptor.kt new file mode 100644 index 00000000..0ce6cb67 --- /dev/null +++ b/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/RequestTimingInterceptor.kt @@ -0,0 +1,24 @@ +package com.raxdenstudios.commons.network.interceptor + +import okhttp3.Interceptor +import okhttp3.Response + +class RequestTimingInterceptor( + private val onRequestCompleted: (url: String, durationMs: Long) -> Unit +) : Interceptor { + + @Suppress("TooGenericExceptionCaught") + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val startTime = System.currentTimeMillis() + + val response = try { + chain.proceed(request) + } finally { + val duration = System.currentTimeMillis() - startTime + onRequestCompleted(request.url.toString(), duration) + } + + return response + } +} diff --git a/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/RetryInterceptor.kt b/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/RetryInterceptor.kt new file mode 100644 index 00000000..a7c2340c --- /dev/null +++ b/libraries/network/src/main/java/com/raxdenstudios/commons/network/interceptor/RetryInterceptor.kt @@ -0,0 +1,48 @@ +package com.raxdenstudios.commons.network.interceptor + +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException + +class RetryInterceptor( + private val maxRetries: Int = 3, + private val initialDelayMs: Long = 1000, + private val shouldRetry: (Response) -> Boolean = { !it.isSuccessful } +) : Interceptor { + + companion object { + val default = RetryInterceptor() + + fun withMaxRetries(retries: Int) = RetryInterceptor(maxRetries = retries) + + fun withDelay(delayMs: Long) = RetryInterceptor(initialDelayMs = delayMs) + } + + @Suppress("TooGenericExceptionCaught") + override fun intercept(chain: Interceptor.Chain): Response { + var attempt = 0 + var response: Response? = null + var exception: IOException? = null + + while (attempt < maxRetries) { + try { + response = chain.proceed(chain.request()) + + if (!shouldRetry(response)) { + return response + } + + response.close() + } catch (e: IOException) { + exception = e + } + + attempt++ + if (attempt < maxRetries) { + Thread.sleep(initialDelayMs * (1 shl (attempt - 1))) + } + } + + throw exception ?: IOException("Max retries exceeded") + } +} diff --git a/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/AuthTokenInterceptorTest.kt b/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/AuthTokenInterceptorTest.kt new file mode 100644 index 00000000..4c40a7d2 --- /dev/null +++ b/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/AuthTokenInterceptorTest.kt @@ -0,0 +1,82 @@ +package com.raxdenstudios.commons.network.interceptor + +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import org.junit.Test + +internal class AuthTokenInterceptorTest { + + @Test + fun `intercept should add Authorization header when token is available`() { + val token = "test-token-123" + val tokenProvider = { token } + val interceptor = AuthTokenInterceptor(tokenProvider) + + val chain = mockk() + val request = mockk() + val requestBuilder = mockk() + val authorizedRequest = mockk() + val response = mockk() + + every { chain.request() } returns request + every { request.newBuilder() } returns requestBuilder + every { requestBuilder.header("Authorization", "Bearer $token") } returns requestBuilder + every { requestBuilder.build() } returns authorizedRequest + every { chain.proceed(authorizedRequest) } returns response + + val result = interceptor.intercept(chain) + + assertThat(result).isEqualTo(response) + verify { requestBuilder.header("Authorization", "Bearer $token") } + verify { chain.proceed(authorizedRequest) } + } + + @Test + fun `intercept should not add Authorization header when token is null`() { + val tokenProvider = { null } + val interceptor = AuthTokenInterceptor(tokenProvider) + + val chain = mockk() + val request = mockk() + val response = mockk() + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + + val result = interceptor.intercept(chain) + + assertThat(result).isEqualTo(response) + verify { chain.proceed(request) } + } + + @Test + fun `intercept should use latest token from provider`() { + var token: String? = "initial-token" + val tokenProvider = { token } + val interceptor = AuthTokenInterceptor(tokenProvider) + + val chain = mockk() + val request = mockk() + val requestBuilder = mockk() + val authorizedRequest = mockk() + val response = mockk() + + every { chain.request() } returns request + every { request.newBuilder() } returns requestBuilder + every { requestBuilder.header("Authorization", any()) } returns requestBuilder + every { requestBuilder.build() } returns authorizedRequest + every { chain.proceed(authorizedRequest) } returns response + + interceptor.intercept(chain) + verify { requestBuilder.header("Authorization", "Bearer initial-token") } + + token = "updated-token" + interceptor.intercept(chain) + verify { requestBuilder.header("Authorization", "Bearer updated-token") } + } +} diff --git a/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/CacheLoggerInterceptorTest.kt b/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/CacheLoggerInterceptorTest.kt new file mode 100644 index 00000000..398c0187 --- /dev/null +++ b/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/CacheLoggerInterceptorTest.kt @@ -0,0 +1,89 @@ +package com.raxdenstudios.commons.network.interceptor + +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import org.junit.Test + +internal class CacheLoggerInterceptorTest { + + private val printMessage: (String) -> Unit = mockk(relaxed = true) + private val interceptor = CacheLoggerInterceptor(printMessage) + + @Test + fun `intercept should log network response when response is from network`() { + val chain = mockk() + val request = mockk() + val response = mockk { + every { networkResponse } returns mockk() + every { cacheResponse } returns null + } + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + + val result = interceptor.intercept(chain) + + assertThat(result).isEqualTo(response) + verify { printMessage("Response from network") } + } + + @Test + fun `intercept should log cache hit when response is from cache`() { + val chain = mockk() + val request = mockk() + val response = mockk { + every { networkResponse } returns null + every { cacheResponse } returns mockk() + } + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + + val result = interceptor.intercept(chain) + + assertThat(result).isEqualTo(response) + verify { printMessage("(HIT) Response from cache") } + } + + @Test + fun `intercept should log both when response is from network and cache`() { + val chain = mockk() + val request = mockk() + val response = mockk { + every { networkResponse } returns mockk() + every { cacheResponse } returns mockk() + } + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + + val result = interceptor.intercept(chain) + + assertThat(result).isEqualTo(response) + verify { printMessage("Response from network") } + verify { printMessage("(HIT) Response from cache") } + } + + @Test + fun `intercept should not log when response is neither from network nor cache`() { + val chain = mockk() + val request = mockk() + val response = mockk { + every { networkResponse } returns null + every { cacheResponse } returns null + } + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + + val result = interceptor.intercept(chain) + + assertThat(result).isEqualTo(response) + verify(exactly = 0) { printMessage(any()) } + } +} diff --git a/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/CacheNetworkInterceptorTest.kt b/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/CacheNetworkInterceptorTest.kt new file mode 100644 index 00000000..93b7d794 --- /dev/null +++ b/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/CacheNetworkInterceptorTest.kt @@ -0,0 +1,114 @@ +package com.raxdenstudios.commons.network.interceptor + +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import okhttp3.CacheControl +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import org.junit.Test +import java.util.concurrent.TimeUnit + +internal class CacheNetworkInterceptorTest { + + @Test + fun `default interceptor should use 5 seconds max age`() { + val chain = mockk() + val request = mockk() + val response = mockk() + val responseBuilder = mockk() + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + every { response.newBuilder() } returns responseBuilder + every { responseBuilder.removeHeader("Pragma") } returns responseBuilder + every { responseBuilder.removeHeader("Cache-Control") } returns responseBuilder + every { responseBuilder.header("Cache-Control", any()) } returns responseBuilder + every { responseBuilder.build() } returns response + + val interceptor = CacheNetworkInterceptor.default + + val result = interceptor.intercept(chain) + + assertThat(result).isEqualTo(response) + verify { responseBuilder.removeHeader("Pragma") } + verify { responseBuilder.removeHeader("Cache-Control") } + verify { responseBuilder.header("Cache-Control", any()) } + } + + @Test + fun `withMaxAge should create interceptor with custom max age`() { + val chain = mockk() + val request = mockk() + val response = mockk() + val responseBuilder = mockk() + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + every { response.newBuilder() } returns responseBuilder + every { responseBuilder.removeHeader("Pragma") } returns responseBuilder + every { responseBuilder.removeHeader("Cache-Control") } returns responseBuilder + every { responseBuilder.header("Cache-Control", any()) } returns responseBuilder + every { responseBuilder.build() } returns response + + val interceptor = CacheNetworkInterceptor.withMaxAge(10) + + val result = interceptor.intercept(chain) + + assertThat(result).isEqualTo(response) + verify { responseBuilder.header("Cache-Control", match { it.contains("max-age=10") }) } + } + + @Test + fun `intercept should remove Pragma and Cache-Control headers`() { + val chain = mockk() + val request = mockk() + val response = mockk() + val responseBuilder = mockk() + val cacheControl = CacheControl.Builder() + .maxAge(5, TimeUnit.SECONDS) + .build() + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + every { response.newBuilder() } returns responseBuilder + every { responseBuilder.removeHeader("Pragma") } returns responseBuilder + every { responseBuilder.removeHeader("Cache-Control") } returns responseBuilder + every { responseBuilder.header("Cache-Control", any()) } returns responseBuilder + every { responseBuilder.build() } returns response + + val interceptor = CacheNetworkInterceptor(cacheControl) + + interceptor.intercept(chain) + + verify(exactly = 1) { responseBuilder.removeHeader("Pragma") } + verify(exactly = 1) { responseBuilder.removeHeader("Cache-Control") } + } + + @Test + fun `intercept should add Cache-Control header with correct value`() { + val chain = mockk() + val request = mockk() + val response = mockk() + val responseBuilder = mockk() + val cacheControl = CacheControl.Builder() + .maxAge(30, TimeUnit.SECONDS) + .build() + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + every { response.newBuilder() } returns responseBuilder + every { responseBuilder.removeHeader(any()) } returns responseBuilder + every { responseBuilder.header("Cache-Control", cacheControl.toString()) } returns responseBuilder + every { responseBuilder.build() } returns response + + val interceptor = CacheNetworkInterceptor(cacheControl) + + val result = interceptor.intercept(chain) + + assertThat(result).isEqualTo(response) + verify { responseBuilder.header("Cache-Control", cacheControl.toString()) } + } +} diff --git a/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/CacheOfflineInterceptorTest.kt b/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/CacheOfflineInterceptorTest.kt new file mode 100644 index 00000000..97abd2e9 --- /dev/null +++ b/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/CacheOfflineInterceptorTest.kt @@ -0,0 +1,154 @@ +package com.raxdenstudios.commons.network.interceptor + +import com.google.common.truth.Truth.assertThat +import com.raxdenstudios.commons.network.interceptor.CacheOfflineInterceptor +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import okhttp3.CacheControl +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import org.junit.Test +import java.util.concurrent.TimeUnit + +internal class CacheOfflineInterceptorTest { + + @Test + fun `default interceptor should use 7 days max stale`() { + val chain = mockk() + val request = mockk() + val response = mockk() + val isNetworkAvailable = { true } + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + + val interceptor = CacheOfflineInterceptor.default(isNetworkAvailable) + + val result = interceptor.intercept(chain) + + assertThat(result).isEqualTo(response) + verify { chain.proceed(request) } + } + + @Test + fun `withMaxStale should create interceptor with custom max stale`() { + val chain = mockk() + val request = mockk() + val response = mockk() + val isNetworkAvailable = { true } + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + + val interceptor = CacheOfflineInterceptor.withMaxStale(14, isNetworkAvailable) + + val result = interceptor.intercept(chain) + + assertThat(result).isEqualTo(response) + } + + @Test + fun `intercept should not modify request when network is available`() { + val chain = mockk() + val request = mockk() + val response = mockk() + val isNetworkAvailable = { true } + val cacheControl = CacheControl.Builder() + .maxStale(7, TimeUnit.DAYS) + .build() + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + + val interceptor = CacheOfflineInterceptor(cacheControl, isNetworkAvailable) + + val result = interceptor.intercept(chain) + + assertThat(result).isEqualTo(response) + verify(exactly = 1) { chain.proceed(request) } + } + + @Test + fun `intercept should modify request when network is not available`() { + val chain = mockk() + val request = mockk() + val requestBuilder = mockk() + val modifiedRequest = mockk() + val response = mockk() + val isNetworkAvailable = { false } + val cacheControl = CacheControl.Builder() + .maxStale(7, TimeUnit.DAYS) + .build() + + every { chain.request() } returns request + every { request.newBuilder() } returns requestBuilder + every { requestBuilder.removeHeader("Pragma") } returns requestBuilder + every { requestBuilder.removeHeader("Cache-Control") } returns requestBuilder + every { requestBuilder.cacheControl(cacheControl) } returns requestBuilder + every { requestBuilder.build() } returns modifiedRequest + every { chain.proceed(modifiedRequest) } returns response + + val interceptor = CacheOfflineInterceptor(cacheControl, isNetworkAvailable) + + val result = interceptor.intercept(chain) + + assertThat(result).isEqualTo(response) + verify { requestBuilder.removeHeader("Pragma") } + verify { requestBuilder.removeHeader("Cache-Control") } + verify { requestBuilder.cacheControl(cacheControl) } + verify { chain.proceed(modifiedRequest) } + } + + @Test + fun `intercept should use cache control when offline`() { + val chain = mockk() + val request = mockk() + val requestBuilder = mockk() + val modifiedRequest = mockk() + val response = mockk() + val isNetworkAvailable = { false } + val cacheControl = CacheControl.Builder() + .maxStale(14, TimeUnit.DAYS) + .build() + + every { chain.request() } returns request + every { request.newBuilder() } returns requestBuilder + every { requestBuilder.removeHeader(any()) } returns requestBuilder + every { requestBuilder.cacheControl(cacheControl) } returns requestBuilder + every { requestBuilder.build() } returns modifiedRequest + every { chain.proceed(modifiedRequest) } returns response + + val interceptor = CacheOfflineInterceptor(cacheControl, isNetworkAvailable) + + val result = interceptor.intercept(chain) + + assertThat(result).isEqualTo(response) + verify { requestBuilder.cacheControl(cacheControl) } + } + + @Test + fun `intercept should check network availability before modifying request`() { + val chain = mockk() + val request = mockk() + val response = mockk() + var networkCheckCount = 0 + val isNetworkAvailable = { + networkCheckCount++ + true + } + val cacheControl = CacheControl.Builder() + .maxStale(7, TimeUnit.DAYS) + .build() + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + + val interceptor = CacheOfflineInterceptor(cacheControl, isNetworkAvailable) + + interceptor.intercept(chain) + + assertThat(networkCheckCount).isEqualTo(1) + } +} diff --git a/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/ErrorResponseInterceptorTest.kt b/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/ErrorResponseInterceptorTest.kt new file mode 100644 index 00000000..03f1563c --- /dev/null +++ b/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/ErrorResponseInterceptorTest.kt @@ -0,0 +1,145 @@ +package com.raxdenstudios.commons.network.interceptor + +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody +import org.junit.Test + +internal class ErrorResponseInterceptorTest { + + private val interceptor = ErrorResponseInterceptor() + + @Test + fun `intercept should return response when request is successful`() { + val chain = mockk() + val request = mockk() + val response = mockk() + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + every { response.isSuccessful } returns true + + val result = interceptor.intercept(chain) + + assertThat(result).isEqualTo(response) + } + + @Test + fun `intercept should throw UnauthorizedException for 401 status`() { + val chain = mockk() + val request = mockk() + val response = mockk() + val responseBody = mockk() + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + every { response.isSuccessful } returns false + every { response.code } returns 401 + every { response.body } returns responseBody + every { responseBody.string() } returns "Unauthorized" + + try { + interceptor.intercept(chain) + throw AssertionError("Expected UnauthorizedException to be thrown") + } catch (e: UnauthorizedException) { + assertThat(e.code).isEqualTo(401) + assertThat(e.errorBody).isEqualTo("Unauthorized") + } + } + + @Test + fun `intercept should throw ForbiddenException for 403 status`() { + val chain = mockk() + val request = mockk() + val response = mockk() + val responseBody = mockk() + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + every { response.isSuccessful } returns false + every { response.code } returns 403 + every { response.body } returns responseBody + every { responseBody.string() } returns "Forbidden" + + try { + interceptor.intercept(chain) + throw AssertionError("Expected ForbiddenException to be thrown") + } catch (e: ForbiddenException) { + assertThat(e.code).isEqualTo(403) + assertThat(e.errorBody).isEqualTo("Forbidden") + } + } + + @Test + fun `intercept should throw NotFoundException for 404 status`() { + val chain = mockk() + val request = mockk() + val response = mockk() + val responseBody = mockk() + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + every { response.isSuccessful } returns false + every { response.code } returns 404 + every { response.body } returns responseBody + every { responseBody.string() } returns "Not Found" + + try { + interceptor.intercept(chain) + throw AssertionError("Expected NotFoundException to be thrown") + } catch (e: NotFoundException) { + assertThat(e.code).isEqualTo(404) + assertThat(e.errorBody).isEqualTo("Not Found") + } + } + + @Test + fun `intercept should throw ServerException for 500 status`() { + val chain = mockk() + val request = mockk() + val response = mockk() + val responseBody = mockk() + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + every { response.isSuccessful } returns false + every { response.code } returns 500 + every { response.body } returns responseBody + every { responseBody.string() } returns "Internal Server Error" + + try { + interceptor.intercept(chain) + throw AssertionError("Expected ServerException to be thrown") + } catch (e: ServerException) { + assertThat(e.code).isEqualTo(500) + assertThat(e.errorBody).isEqualTo("Internal Server Error") + } + } + + @Test + fun `intercept should throw HttpException for other error codes`() { + val chain = mockk() + val request = mockk() + val response = mockk() + val responseBody = mockk() + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + every { response.isSuccessful } returns false + every { response.code } returns 418 + every { response.body } returns responseBody + every { responseBody.string() } returns "I'm a teapot" + + try { + interceptor.intercept(chain) + throw AssertionError("Expected HttpException to be thrown") + } catch (e: HttpException) { + assertThat(e.code).isEqualTo(418) + assertThat(e.errorBody).isEqualTo("I'm a teapot") + } + } +} diff --git a/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/HeadersInterceptorTest.kt b/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/HeadersInterceptorTest.kt new file mode 100644 index 00000000..403d4217 --- /dev/null +++ b/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/HeadersInterceptorTest.kt @@ -0,0 +1,110 @@ +package com.raxdenstudios.commons.network.interceptor + +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import org.junit.Test + +internal class HeadersInterceptorTest { + + @Test + fun `intercept should add all headers to request`() { + val headers = mapOf( + "X-Custom-Header" to "custom-value", + "X-Another-Header" to "another-value" + ) + val interceptor = HeadersInterceptor(headers) + + val chain = mockk() + val request = mockk() + val requestBuilder = mockk() + val modifiedRequest = mockk() + val response = mockk() + + every { chain.request() } returns request + every { request.newBuilder() } returns requestBuilder + every { requestBuilder.header(any(), any()) } returns requestBuilder + every { requestBuilder.build() } returns modifiedRequest + every { chain.proceed(modifiedRequest) } returns response + + val result = interceptor.intercept(chain) + + assertThat(result).isEqualTo(response) + verify { requestBuilder.header("X-Custom-Header", "custom-value") } + verify { requestBuilder.header("X-Another-Header", "another-value") } + verify { chain.proceed(modifiedRequest) } + } + + @Test + fun `withUserAgent should create interceptor with User-Agent header`() { + val userAgent = "MyApp/1.0" + val interceptor = HeadersInterceptor.withUserAgent(userAgent) + + val chain = mockk() + val request = mockk() + val requestBuilder = mockk() + val modifiedRequest = mockk() + val response = mockk() + + every { chain.request() } returns request + every { request.newBuilder() } returns requestBuilder + every { requestBuilder.header("User-Agent", userAgent) } returns requestBuilder + every { requestBuilder.build() } returns modifiedRequest + every { chain.proceed(modifiedRequest) } returns response + + interceptor.intercept(chain) + + verify { requestBuilder.header("User-Agent", userAgent) } + } + + @Test + fun `withApiVersion should create interceptor with X-API-Version header`() { + val version = "v2" + val interceptor = HeadersInterceptor.withApiVersion(version) + + val chain = mockk() + val request = mockk() + val requestBuilder = mockk() + val modifiedRequest = mockk() + val response = mockk() + + every { chain.request() } returns request + every { request.newBuilder() } returns requestBuilder + every { requestBuilder.header("X-API-Version", version) } returns requestBuilder + every { requestBuilder.build() } returns modifiedRequest + every { chain.proceed(modifiedRequest) } returns response + + interceptor.intercept(chain) + + verify { requestBuilder.header("X-API-Version", version) } + } + + @Test + fun `withHeaders should create interceptor with multiple headers`() { + val interceptor = HeadersInterceptor.withHeaders( + "Header1" to "Value1", + "Header2" to "Value2" + ) + + val chain = mockk() + val request = mockk() + val requestBuilder = mockk() + val modifiedRequest = mockk() + val response = mockk() + + every { chain.request() } returns request + every { request.newBuilder() } returns requestBuilder + every { requestBuilder.header(any(), any()) } returns requestBuilder + every { requestBuilder.build() } returns modifiedRequest + every { chain.proceed(modifiedRequest) } returns response + + interceptor.intercept(chain) + + verify { requestBuilder.header("Header1", "Value1") } + verify { requestBuilder.header("Header2", "Value2") } + } +} diff --git a/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/NetworkMonitorInterceptorTest.kt b/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/NetworkMonitorInterceptorTest.kt new file mode 100644 index 00000000..8bda67d5 --- /dev/null +++ b/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/NetworkMonitorInterceptorTest.kt @@ -0,0 +1,72 @@ +package com.raxdenstudios.commons.network.interceptor + +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import org.junit.Test + +internal class NetworkMonitorInterceptorTest { + + @Test + fun `intercept should proceed when network is available`() { + val isNetworkAvailable = { true } + val interceptor = NetworkMonitorInterceptor(isNetworkAvailable) + + val chain = mockk() + val request = mockk() + val response = mockk() + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + + val result = interceptor.intercept(chain) + + assertThat(result).isEqualTo(response) + verify { chain.proceed(request) } + } + + @Test + fun `intercept should throw NoNetworkException when network is not available`() { + val isNetworkAvailable = { false } + val interceptor = NetworkMonitorInterceptor(isNetworkAvailable) + + val chain = mockk() + val request = mockk() + + every { chain.request() } returns request + + try { + interceptor.intercept(chain) + throw AssertionError("Expected NoNetworkException to be thrown") + } catch (e: NoNetworkException) { + assertThat(e.message).isEqualTo("No network connection available") + } + + verify(exactly = 0) { chain.proceed(any()) } + } + + @Test + fun `intercept should check network availability before proceeding`() { + var networkCheckCount = 0 + val isNetworkAvailable = { + networkCheckCount++ + true + } + val interceptor = NetworkMonitorInterceptor(isNetworkAvailable) + + val chain = mockk() + val request = mockk() + val response = mockk() + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + + interceptor.intercept(chain) + + assertThat(networkCheckCount).isEqualTo(1) + } +} diff --git a/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/RequestTimingInterceptorTest.kt b/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/RequestTimingInterceptorTest.kt new file mode 100644 index 00000000..94d575df --- /dev/null +++ b/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/RequestTimingInterceptorTest.kt @@ -0,0 +1,92 @@ +package com.raxdenstudios.commons.network.interceptor + +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import org.junit.Test + +internal class RequestTimingInterceptorTest { + + @Test + fun `intercept should call onRequestCompleted with url and duration`() { + var capturedUrl: String? = null + var capturedDuration: Long? = null + val onRequestCompleted: (String, Long) -> Unit = { url, duration -> + capturedUrl = url + capturedDuration = duration + } + val interceptor = RequestTimingInterceptor(onRequestCompleted) + + val chain = mockk() + val request = mockk() + val response = mockk() + val url = "https://api.example.com/test".toHttpUrl() + + every { chain.request() } returns request + every { request.url } returns url + every { chain.proceed(request) } returns response + + val result = interceptor.intercept(chain) + + assertThat(result).isEqualTo(response) + assertThat(capturedUrl).isEqualTo("https://api.example.com/test") + assertThat(capturedDuration).isNotNull() + assertThat(capturedDuration).isAtLeast(0) + } + + @Test + fun `intercept should call onRequestCompleted even when request fails`() { + var callbackInvoked = false + val onRequestCompleted: (String, Long) -> Unit = { _, _ -> + callbackInvoked = true + } + val interceptor = RequestTimingInterceptor(onRequestCompleted) + + val chain = mockk() + val request = mockk() + val url = "https://api.example.com/test".toHttpUrl() + + every { chain.request() } returns request + every { request.url } returns url + every { chain.proceed(request) } throws RuntimeException("Network error") + + try { + interceptor.intercept(chain) + } catch (e: RuntimeException) { + // Expected + } + + assertThat(callbackInvoked).isTrue() + } + + @Test + fun `intercept should measure actual request duration`() { + var capturedDuration: Long? = null + val onRequestCompleted: (String, Long) -> Unit = { _, duration -> + capturedDuration = duration + } + val interceptor = RequestTimingInterceptor(onRequestCompleted) + + val chain = mockk() + val request = mockk() + val response = mockk() + val url = "https://api.example.com/test".toHttpUrl() + + every { chain.request() } returns request + every { request.url } returns url + every { chain.proceed(request) } answers { + Thread.sleep(100) + response + } + + interceptor.intercept(chain) + + assertThat(capturedDuration).isNotNull() + assertThat(capturedDuration).isAtLeast(100) + } +} diff --git a/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/RetryInterceptorTest.kt b/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/RetryInterceptorTest.kt new file mode 100644 index 00000000..4c6a939e --- /dev/null +++ b/libraries/network/src/test/java/com/raxdenstudios/commons/network/interceptor/RetryInterceptorTest.kt @@ -0,0 +1,146 @@ +package com.raxdenstudios.commons.network.interceptor + +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import org.junit.Test +import java.io.IOException + +internal class RetryInterceptorTest { + + @Test + fun `intercept should return response on first successful attempt`() { + val interceptor = RetryInterceptor.default + val chain = mockk() + val request = mockk() + val response = mockk() + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + every { response.isSuccessful } returns true + + val result = interceptor.intercept(chain) + + assertThat(result).isEqualTo(response) + verify(exactly = 1) { chain.proceed(request) } + } + + @Test + fun `intercept should retry on unsuccessful response`() { + val interceptor = RetryInterceptor(maxRetries = 2, initialDelayMs = 10) + val chain = mockk() + val request = mockk() + val failedResponse = mockk() + val successResponse = mockk() + + every { chain.request() } returns request + every { chain.proceed(request) } returnsMany listOf(failedResponse, successResponse) + every { failedResponse.isSuccessful } returns false + every { failedResponse.close() } returns Unit + every { successResponse.isSuccessful } returns true + + val result = interceptor.intercept(chain) + + assertThat(result).isEqualTo(successResponse) + verify(exactly = 2) { chain.proceed(request) } + verify { failedResponse.close() } + } + + @Test + fun `intercept should throw IOException when max retries exceeded`() { + val interceptor = RetryInterceptor(maxRetries = 2, initialDelayMs = 10) + val chain = mockk() + val request = mockk() + val response = mockk() + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + every { response.isSuccessful } returns false + every { response.close() } returns Unit + + try { + interceptor.intercept(chain) + throw AssertionError("Expected IOException to be thrown") + } catch (e: IOException) { + assertThat(e.message).isEqualTo("Max retries exceeded") + } + + verify(exactly = 2) { chain.proceed(request) } + } + + @Test + fun `intercept should retry on IOException`() { + val interceptor = RetryInterceptor(maxRetries = 2, initialDelayMs = 10) + val chain = mockk() + val request = mockk() + val response = mockk() + + every { chain.request() } returns request + every { chain.proceed(request) } throwsMany listOf( + IOException("Network error"), + IOException("Network error") + ) andThen response + every { response.isSuccessful } returns true + + try { + interceptor.intercept(chain) + throw AssertionError("Expected IOException to be thrown") + } catch (e: IOException) { + assertThat(e.message).isEqualTo("Network error") + } + + verify(exactly = 2) { chain.proceed(request) } + } + + @Test + fun `withMaxRetries should create interceptor with custom max retries`() { + val interceptor = RetryInterceptor.withMaxRetries(5) + val chain = mockk() + val request = mockk() + val response = mockk() + + every { chain.request() } returns request + every { chain.proceed(request) } returns response + every { response.isSuccessful } returns false + every { response.close() } returns Unit + + try { + interceptor.intercept(chain) + } catch (e: IOException) { + // Expected + } + + verify(exactly = 5) { chain.proceed(request) } + } + + @Test + fun `intercept should use custom shouldRetry predicate`() { + val shouldRetry: (Response) -> Boolean = { response -> + response.code == 503 + } + val interceptor = RetryInterceptor( + maxRetries = 2, + initialDelayMs = 10, + shouldRetry = shouldRetry + ) + val chain = mockk() + val request = mockk() + val response503 = mockk() + val response200 = mockk() + + every { chain.request() } returns request + every { chain.proceed(request) } returnsMany listOf(response503, response200) + every { response503.code } returns 503 + every { response503.close() } returns Unit + every { response200.code } returns 200 + + val result = interceptor.intercept(chain) + + assertThat(result).isEqualTo(response200) + verify(exactly = 2) { chain.proceed(request) } + } +} From 7fafd87941372571e9b00ca2e6ca99f2e76d2a6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20G=C3=B3mez?= Date: Sun, 29 Mar 2026 12:37:53 +0200 Subject: [PATCH 2/5] cover pagination with more tests --- .../commons/pagination/CoPagination.kt | 13 +- .../CoPaginationCancellationTest.kt | 88 +++++++++++++ libraries/pagination/build.gradle.kts | 3 + .../commons/pagination/Pagination.kt | 15 ++- .../pagination/ext/PaginationExtension.kt | 18 ++- .../pagination/ext/PaginationExtensionTest.kt | 121 ++++++++++++++++++ .../commons/pagination/model/PageListTest.kt | 65 ++++++++++ 7 files changed, 308 insertions(+), 15 deletions(-) create mode 100644 libraries/pagination-co/src/test/java/com/raxdenstudios/commons/pagination/CoPaginationCancellationTest.kt create mode 100644 libraries/pagination/src/test/java/com/raxdenstudios/commons/pagination/ext/PaginationExtensionTest.kt create mode 100644 libraries/pagination/src/test/java/com/raxdenstudios/commons/pagination/model/PageListTest.kt diff --git a/libraries/pagination-co/src/main/java/com/raxdenstudios/commons/pagination/CoPagination.kt b/libraries/pagination-co/src/main/java/com/raxdenstudios/commons/pagination/CoPagination.kt index c24074cd..7a562812 100644 --- a/libraries/pagination-co/src/main/java/com/raxdenstudios/commons/pagination/CoPagination.kt +++ b/libraries/pagination-co/src/main/java/com/raxdenstudios/commons/pagination/CoPagination.kt @@ -9,13 +9,16 @@ import com.raxdenstudios.commons.pagination.model.PageList import com.raxdenstudios.commons.pagination.model.PageResult import com.raxdenstudios.commons.pagination.model.PageSize import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job class CoPagination( override val config: Config = Config.default, - override val logger: (message: String) -> Unit = {}, + override val logger: (message: () -> String) -> Unit = {}, private val coroutineScope: CoroutineScope, ) : Pagination() { + private var currentJob: Job? = null + fun requestFirstPage( pageRequest: suspend (page: Page, pageSize: PageSize) -> PageList, pageResponse: (pageResult: PageResult) -> Unit, @@ -48,7 +51,8 @@ class CoPagination( pageRequest: suspend (page: Page, pageSize: PageSize) -> PageList, pageResponse: (pageResult: PageResult) -> Unit, ) { - coroutineScope.launch( + currentJob?.cancel() + currentJob = coroutineScope.launch( onError = { error -> processRequestError(error, pageResponse) } ) { processRequestStart(pageResponse) @@ -58,4 +62,9 @@ class CoPagination( ) } } + + fun cancelCurrentRequest() { + currentJob?.cancel() + currentJob = null + } } diff --git a/libraries/pagination-co/src/test/java/com/raxdenstudios/commons/pagination/CoPaginationCancellationTest.kt b/libraries/pagination-co/src/test/java/com/raxdenstudios/commons/pagination/CoPaginationCancellationTest.kt new file mode 100644 index 00000000..347a9c1d --- /dev/null +++ b/libraries/pagination-co/src/test/java/com/raxdenstudios/commons/pagination/CoPaginationCancellationTest.kt @@ -0,0 +1,88 @@ +package com.raxdenstudios.commons.pagination + +import android.util.Log +import com.raxdenstudios.commons.coroutines.test.rules.MainDispatcherRule +import com.raxdenstudios.commons.pagination.model.Page +import com.raxdenstudios.commons.pagination.model.PageIndex +import com.raxdenstudios.commons.pagination.model.PageList +import com.raxdenstudios.commons.pagination.model.PageResult +import com.raxdenstudios.commons.pagination.model.PageSize +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +internal class CoPaginationCancellationTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val pageRequest: suspend (Page, PageSize) -> PageList = mockk() + private val pageResponse: (PageResult) -> Unit = mockk(relaxed = true) + private val pagination: CoPagination by lazy { + CoPagination( + config = Pagination.Config.default, + coroutineScope = CoroutineScope(mainDispatcherRule.testDispatcher) + ) + } + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.e(any(), any(), any()) } returns 0 + } + + @After + fun after() { + unmockkStatic(Log::class) + } + + @Test + fun `cancelCurrentRequest should cancel ongoing request`() { + val items = listOf("item1", "item2", "item3") + var requestStarted = false + + coEvery { + pageRequest.invoke(any(), any()) + } coAnswers { + requestStarted = true + delay(1000) // Simulate long request + PageList(items, Page(0)) + } + + pagination.requestFirstPage( + pageRequest = { page, pageSize -> pageRequest(page, pageSize) }, + pageResponse = { pageResult -> pageResponse(pageResult) } + ) + + // Give time for the request to start + mainDispatcherRule.testDispatcher.scheduler.advanceTimeBy(100) + + pagination.cancelCurrentRequest() + + // Advance time to complete the cancelled request + mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() + + // Verify that Loading was called but Content was not (because it was cancelled) + coVerify(exactly = 1) { pageResponse.invoke(PageResult.Loading) } + coVerify(exactly = 0) { pageResponse.invoke(any>()) } + } + + @Test + fun `cancelCurrentRequest method exists and can be called`() { + // Verify that the cancelCurrentRequest method exists and can be called + pagination.cancelCurrentRequest() + + // No exception should be thrown + } +} diff --git a/libraries/pagination/build.gradle.kts b/libraries/pagination/build.gradle.kts index ad9e41bc..0d83cca6 100644 --- a/libraries/pagination/build.gradle.kts +++ b/libraries/pagination/build.gradle.kts @@ -31,4 +31,7 @@ dependencies { implementation(libs.android.material) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.material3) + + testImplementation(libs.truth) + testImplementation(libs.mockk.android) } diff --git a/libraries/pagination/src/main/java/com/raxdenstudios/commons/pagination/Pagination.kt b/libraries/pagination/src/main/java/com/raxdenstudios/commons/pagination/Pagination.kt index efb9b6c4..cb8f637d 100644 --- a/libraries/pagination/src/main/java/com/raxdenstudios/commons/pagination/Pagination.kt +++ b/libraries/pagination/src/main/java/com/raxdenstudios/commons/pagination/Pagination.kt @@ -5,14 +5,15 @@ import com.raxdenstudios.commons.pagination.model.PageIndex import com.raxdenstudios.commons.pagination.model.PageList import com.raxdenstudios.commons.pagination.model.PageResult import com.raxdenstudios.commons.pagination.model.PageSize +import java.util.concurrent.ConcurrentHashMap @Suppress("TooManyFunctions") abstract class Pagination { abstract val config: Config - abstract val logger: (message: String) -> Unit + abstract val logger: (message: () -> String) -> Unit - private val history: MutableMap> = mutableMapOf() + private val history: MutableMap> = ConcurrentHashMap() private var itemsLoaded: Int = 0 private var status: Status = Status.Empty private var currentPage: Page? = null @@ -98,15 +99,15 @@ abstract class Pagination { private fun indexItsEndOfTheList(pageIndex: PageIndex, itemsLoaded: Int): Boolean { val endOfTheList = pageIndex.value >= (itemsLoaded - config.prefetchDistance) - logger("$endOfTheList <- ${pageIndex.value} >= ($itemsLoaded - ${config.prefetchDistance})") + logger { "$endOfTheList <- ${pageIndex.value} >= ($itemsLoaded - ${config.prefetchDistance})" } return endOfTheList } protected sealed class Status { - object Empty : Status() - object NotEmpty : Status() - object Loading : Status() - object NoMoreResults : Status() + data object Empty : Status() + data object NotEmpty : Status() + data object Loading : Status() + data object NoMoreResults : Status() } data class Config( diff --git a/libraries/pagination/src/main/java/com/raxdenstudios/commons/pagination/ext/PaginationExtension.kt b/libraries/pagination/src/main/java/com/raxdenstudios/commons/pagination/ext/PaginationExtension.kt index e05d3603..3963f182 100644 --- a/libraries/pagination/src/main/java/com/raxdenstudios/commons/pagination/ext/PaginationExtension.kt +++ b/libraries/pagination/src/main/java/com/raxdenstudios/commons/pagination/ext/PaginationExtension.kt @@ -5,11 +5,17 @@ import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.recyclerview.widget.LinearLayoutManager import com.raxdenstudios.commons.pagination.model.PageIndex -fun LinearLayoutManager.toPageIndex(): PageIndex = - PageIndex(childCount + findFirstVisibleItemPosition()) +fun LinearLayoutManager.toPageIndex(): PageIndex { + val lastVisiblePosition = findFirstVisibleItemPosition() + childCount - 1 + return PageIndex(lastVisiblePosition.coerceAtLeast(0)) +} -fun LazyGridState.toPageIndex(): PageIndex = - PageIndex(layoutInfo.visibleItemsInfo.size + firstVisibleItemIndex) +fun LazyGridState.toPageIndex(): PageIndex { + val lastVisibleIndex = firstVisibleItemIndex + layoutInfo.visibleItemsInfo.size - 1 + return PageIndex(lastVisibleIndex.coerceAtLeast(0)) +} -fun LazyListState.toPageIndex(): PageIndex = - PageIndex(layoutInfo.visibleItemsInfo.size + firstVisibleItemIndex) +fun LazyListState.toPageIndex(): PageIndex { + val lastVisibleIndex = firstVisibleItemIndex + layoutInfo.visibleItemsInfo.size - 1 + return PageIndex(lastVisibleIndex.coerceAtLeast(0)) +} diff --git a/libraries/pagination/src/test/java/com/raxdenstudios/commons/pagination/ext/PaginationExtensionTest.kt b/libraries/pagination/src/test/java/com/raxdenstudios/commons/pagination/ext/PaginationExtensionTest.kt new file mode 100644 index 00000000..83caae45 --- /dev/null +++ b/libraries/pagination/src/test/java/com/raxdenstudios/commons/pagination/ext/PaginationExtensionTest.kt @@ -0,0 +1,121 @@ +package com.raxdenstudios.commons.pagination.ext + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyGridLayoutInfo +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.common.truth.Truth.assertThat +import com.raxdenstudios.commons.pagination.model.PageIndex +import io.mockk.every +import io.mockk.mockk +import org.junit.Test + +internal class PaginationExtensionTest { + + @Test + fun `LinearLayoutManager toPageIndex should return correct last visible position`() { + val layoutManager = mockk() + every { layoutManager.findFirstVisibleItemPosition() } returns 5 + every { layoutManager.childCount } returns 10 + + val result = layoutManager.toPageIndex() + + assertThat(result.value).isEqualTo(14) // 5 + 10 - 1 + } + + @Test + fun `LinearLayoutManager toPageIndex should return 0 when no items visible`() { + val layoutManager = mockk() + every { layoutManager.findFirstVisibleItemPosition() } returns -1 + every { layoutManager.childCount } returns 0 + + val result = layoutManager.toPageIndex() + + assertThat(result.value).isEqualTo(0) // coerceAtLeast(0) + } + + @Test + fun `LinearLayoutManager toPageIndex should handle first item visible`() { + val layoutManager = mockk() + every { layoutManager.findFirstVisibleItemPosition() } returns 0 + every { layoutManager.childCount } returns 5 + + val result = layoutManager.toPageIndex() + + assertThat(result.value).isEqualTo(4) // 0 + 5 - 1 + } + + @Test + fun `LazyGridState toPageIndex should return correct last visible index`() { + val gridState = mockk() + val layoutInfo = mockk() + val visibleItems = List(8) { mockk() } + + every { gridState.firstVisibleItemIndex } returns 10 + every { gridState.layoutInfo } returns layoutInfo + every { layoutInfo.visibleItemsInfo } returns visibleItems + + val result = gridState.toPageIndex() + + assertThat(result.value).isEqualTo(17) // 10 + 8 - 1 + } + + @Test + fun `LazyGridState toPageIndex should return 0 when no items visible`() { + val gridState = mockk() + val layoutInfo = mockk() + + every { gridState.firstVisibleItemIndex } returns 0 + every { gridState.layoutInfo } returns layoutInfo + every { layoutInfo.visibleItemsInfo } returns emptyList() + + val result = gridState.toPageIndex() + + assertThat(result.value).isEqualTo(0) // 0 + 0 - 1 = -1, coerceAtLeast(0) + } + + @Test + fun `LazyListState toPageIndex should return correct last visible index`() { + val listState = mockk() + val layoutInfo = mockk() + val visibleItems = List(12) { mockk() } + + every { listState.firstVisibleItemIndex } returns 20 + every { listState.layoutInfo } returns layoutInfo + every { layoutInfo.visibleItemsInfo } returns visibleItems + + val result = listState.toPageIndex() + + assertThat(result.value).isEqualTo(31) // 20 + 12 - 1 + } + + @Test + fun `LazyListState toPageIndex should return 0 when no items visible`() { + val listState = mockk() + val layoutInfo = mockk() + + every { listState.firstVisibleItemIndex } returns 0 + every { listState.layoutInfo } returns layoutInfo + every { layoutInfo.visibleItemsInfo } returns emptyList() + + val result = listState.toPageIndex() + + assertThat(result.value).isEqualTo(0) // 0 + 0 - 1 = -1, coerceAtLeast(0) + } + + @Test + fun `LazyListState toPageIndex should handle single item visible`() { + val listState = mockk() + val layoutInfo = mockk() + val visibleItems = List(1) { mockk() } + + every { listState.firstVisibleItemIndex } returns 5 + every { listState.layoutInfo } returns layoutInfo + every { layoutInfo.visibleItemsInfo } returns visibleItems + + val result = listState.toPageIndex() + + assertThat(result.value).isEqualTo(5) // 5 + 1 - 1 + } +} diff --git a/libraries/pagination/src/test/java/com/raxdenstudios/commons/pagination/model/PageListTest.kt b/libraries/pagination/src/test/java/com/raxdenstudios/commons/pagination/model/PageListTest.kt new file mode 100644 index 00000000..877956a5 --- /dev/null +++ b/libraries/pagination/src/test/java/com/raxdenstudios/commons/pagination/model/PageListTest.kt @@ -0,0 +1,65 @@ +package com.raxdenstudios.commons.pagination.model + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class PageListTest { + + @Test + fun `totalItems should return correct size`() { + val items = listOf("item1", "item2", "item3") + val pageList = PageList(items, Page(0)) + + assertThat(pageList.totalItems).isEqualTo(3) + } + + @Test + fun `totalItems should return 0 for empty list`() { + val pageList = PageList(emptyList(), Page(0)) + + assertThat(pageList.totalItems).isEqualTo(0) + } + + @Test + fun `map should transform items correctly`() { + val items = listOf(1, 2, 3) + val pageList = PageList(items, Page(0)) + + val result = pageList.map { it.map { num -> num * 2 } } + + assertThat(result.items).isEqualTo(listOf(2, 4, 6)) + assertThat(result.page).isEqualTo(Page(0)) + } + + @Test + fun `map should preserve page information`() { + val items = listOf("a", "b", "c") + val page = Page(5) + val pageList = PageList(items, page) + + val result = pageList.map { it.map { str -> str.uppercase() } } + + assertThat(result.items).isEqualTo(listOf("A", "B", "C")) + assertThat(result.page).isEqualTo(page) + } + + @Test + fun `map should handle empty list`() { + val pageList = PageList(emptyList(), Page(0)) + + val result = pageList.map { it.map { num -> num * 2 } } + + assertThat(result.items).isEmpty() + assertThat(result.totalItems).isEqualTo(0) + } + + @Test + fun `map should change type correctly`() { + val items = listOf(1, 2, 3) + val pageList = PageList(items, Page(0)) + + val result = pageList.map { it.map { num -> "Number: $num" } } + + assertThat(result.items).isEqualTo(listOf("Number: 1", "Number: 2", "Number: 3")) + } +} From a3ba9c256624c580a9aa69dc4825a5d2a9350c6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20G=C3=B3mez?= Date: Sun, 29 Mar 2026 12:43:39 +0200 Subject: [PATCH 3/5] cover permissions with more tests --- libraries/permissions/build.gradle.kts | 3 + .../permissions/PermissionsManagerImpl.kt | 12 +-- .../commons/permissions/model/Permission.kt | 35 +++++- .../permissions/PermissionsManagerImplTest.kt | 84 +++++++++++++++ .../permissions/model/PermissionTest.kt | 101 ++++++++++++++++++ 5 files changed, 224 insertions(+), 11 deletions(-) create mode 100644 libraries/permissions/src/test/java/com/raxdenstudios/commons/permissions/PermissionsManagerImplTest.kt create mode 100644 libraries/permissions/src/test/java/com/raxdenstudios/commons/permissions/model/PermissionTest.kt diff --git a/libraries/permissions/build.gradle.kts b/libraries/permissions/build.gradle.kts index 107c4277..4b0291dd 100644 --- a/libraries/permissions/build.gradle.kts +++ b/libraries/permissions/build.gradle.kts @@ -33,4 +33,7 @@ dependencies { api(projects.libraries.android) implementation(libs.android.material) + + testImplementation(libs.truth) + testImplementation(libs.mockk.android) } diff --git a/libraries/permissions/src/main/java/com/raxdenstudios/commons/permissions/PermissionsManagerImpl.kt b/libraries/permissions/src/main/java/com/raxdenstudios/commons/permissions/PermissionsManagerImpl.kt index 3ac924fc..31851a38 100644 --- a/libraries/permissions/src/main/java/com/raxdenstudios/commons/permissions/PermissionsManagerImpl.kt +++ b/libraries/permissions/src/main/java/com/raxdenstudios/commons/permissions/PermissionsManagerImpl.kt @@ -13,8 +13,8 @@ import com.raxdenstudios.commons.permissions.model.Permission class PermissionsManagerImpl : PermissionsManager { private val activityHolder: ActivityHolder = ActivityHolder() - private lateinit var _permissionResultLauncher: ActivityResultLauncher> - private lateinit var _permissionsCallbacks: PermissionsManager.Callbacks + private var _permissionResultLauncher: ActivityResultLauncher>? = null + private var _permissionsCallbacks: PermissionsManager.Callbacks = PermissionsManager.Callbacks() override fun attach(activity: ComponentActivity) { activityHolder.attach(activity) @@ -45,7 +45,7 @@ class PermissionsManagerImpl : PermissionsManager { private fun handleRegisterPermissionsResult( result: Map ) { - result.entries.map { entryPermissions -> + result.entries.forEach { entryPermissions -> val isGranted = entryPermissions.value val permission = Permission.fromValue(entryPermissions.key) when { @@ -81,10 +81,10 @@ class PermissionsManagerImpl : PermissionsManager { } private fun performRequestPermission(permissions: List) { + if (permissions.isEmpty()) return + val arrayPermissions = permissions.map { it.value }.toTypedArray() - if (this::_permissionResultLauncher.isInitialized) { - _permissionResultLauncher.launch(arrayPermissions) - } + _permissionResultLauncher?.launch(arrayPermissions) } private fun shouldShowRequestPermissionRationale(permission: Permission) = diff --git a/libraries/permissions/src/main/java/com/raxdenstudios/commons/permissions/model/Permission.kt b/libraries/permissions/src/main/java/com/raxdenstudios/commons/permissions/model/Permission.kt index 44677754..22afee85 100644 --- a/libraries/permissions/src/main/java/com/raxdenstudios/commons/permissions/model/Permission.kt +++ b/libraries/permissions/src/main/java/com/raxdenstudios/commons/permissions/model/Permission.kt @@ -13,15 +13,34 @@ sealed class Permission( value = Manifest.permission.ACCESS_FINE_LOCATION ) - /** - * Read contacts - * - * @constructor Create empty Read contacts - */ + data object AccessCoarseLocation : Permission( + value = Manifest.permission.ACCESS_COARSE_LOCATION + ) + data object ReadContacts : Permission( value = Manifest.permission.READ_CONTACTS, ) + data object WriteContacts : Permission( + value = Manifest.permission.WRITE_CONTACTS, + ) + + data object RecordAudio : Permission( + value = Manifest.permission.RECORD_AUDIO, + ) + + data object CallPhone : Permission( + value = Manifest.permission.CALL_PHONE, + ) + + data object ReadExternalStorage : Permission( + value = Manifest.permission.READ_EXTERNAL_STORAGE, + ) + + data object WriteExternalStorage : Permission( + value = Manifest.permission.WRITE_EXTERNAL_STORAGE, + ) + data class Other( val permission: String ) : Permission( @@ -39,7 +58,13 @@ sealed class Permission( fun fromValue(value: String): Permission = when (value) { Manifest.permission.CAMERA -> Camera Manifest.permission.ACCESS_FINE_LOCATION -> AccessFineLocation + Manifest.permission.ACCESS_COARSE_LOCATION -> AccessCoarseLocation Manifest.permission.READ_CONTACTS -> ReadContacts + Manifest.permission.WRITE_CONTACTS -> WriteContacts + Manifest.permission.RECORD_AUDIO -> RecordAudio + Manifest.permission.CALL_PHONE -> CallPhone + Manifest.permission.READ_EXTERNAL_STORAGE -> ReadExternalStorage + Manifest.permission.WRITE_EXTERNAL_STORAGE -> WriteExternalStorage else -> Other(value) } } diff --git a/libraries/permissions/src/test/java/com/raxdenstudios/commons/permissions/PermissionsManagerImplTest.kt b/libraries/permissions/src/test/java/com/raxdenstudios/commons/permissions/PermissionsManagerImplTest.kt new file mode 100644 index 00000000..bee209c1 --- /dev/null +++ b/libraries/permissions/src/test/java/com/raxdenstudios/commons/permissions/PermissionsManagerImplTest.kt @@ -0,0 +1,84 @@ +package com.raxdenstudios.commons.permissions + +import androidx.activity.ComponentActivity +import androidx.lifecycle.LifecycleRegistry +import com.google.common.truth.Truth.assertThat +import com.raxdenstudios.commons.permissions.model.Permission +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Test + +internal class PermissionsManagerImplTest { + + private lateinit var permissionsManager: PermissionsManagerImpl + private val activity: ComponentActivity = mockk(relaxed = true) + private val lifecycle: LifecycleRegistry = mockk(relaxed = true) + + @Before + fun setUp() { + permissionsManager = PermissionsManagerImpl() + every { activity.lifecycle } returns lifecycle + } + + @Test + fun `attach should add lifecycle observer`() { + permissionsManager.attach(activity) + + verify { lifecycle.addObserver(permissionsManager) } + } + + @Test + fun `Callbacks should have default empty implementations`() { + val callbacks = PermissionsManager.Callbacks() + + // Should not throw exceptions + callbacks.onGranted(Permission.Camera) + callbacks.onRationale(Permission.Camera) + callbacks.onDenied(Permission.Camera) + } + + @Test + fun `Callbacks should execute custom implementations`() { + var grantedCalled = false + var rationaleCalled = false + var deniedCalled = false + + val callbacks = PermissionsManager.Callbacks( + onGranted = { grantedCalled = true }, + onRationale = { rationaleCalled = true }, + onDenied = { deniedCalled = true } + ) + + callbacks.onGranted(Permission.Camera) + callbacks.onRationale(Permission.Camera) + callbacks.onDenied(Permission.Camera) + + assertThat(grantedCalled).isTrue() + assertThat(rationaleCalled).isTrue() + assertThat(deniedCalled).isTrue() + } + + @Test + fun `PermissionsManagerImpl should be instantiable`() { + val manager = PermissionsManagerImpl() + + assertThat(manager).isNotNull() + } + + @Test + fun `Callbacks with partial implementations should work`() { + var grantedCalled = false + + val callbacks = PermissionsManager.Callbacks( + onGranted = { grantedCalled = true } + ) + + callbacks.onGranted(Permission.Camera) + callbacks.onRationale(Permission.Camera) // Should not throw + callbacks.onDenied(Permission.Camera) // Should not throw + + assertThat(grantedCalled).isTrue() + } +} diff --git a/libraries/permissions/src/test/java/com/raxdenstudios/commons/permissions/model/PermissionTest.kt b/libraries/permissions/src/test/java/com/raxdenstudios/commons/permissions/model/PermissionTest.kt new file mode 100644 index 00000000..63fe998c --- /dev/null +++ b/libraries/permissions/src/test/java/com/raxdenstudios/commons/permissions/model/PermissionTest.kt @@ -0,0 +1,101 @@ +package com.raxdenstudios.commons.permissions.model + +import android.Manifest +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class PermissionTest { + + @Test + fun `fromValue should return Camera for camera permission`() { + val result = Permission.fromValue(Manifest.permission.CAMERA) + + assertThat(result).isEqualTo(Permission.Camera) + } + + @Test + fun `fromValue should return AccessFineLocation for fine location permission`() { + val result = Permission.fromValue(Manifest.permission.ACCESS_FINE_LOCATION) + + assertThat(result).isEqualTo(Permission.AccessFineLocation) + } + + @Test + fun `fromValue should return AccessCoarseLocation for coarse location permission`() { + val result = Permission.fromValue(Manifest.permission.ACCESS_COARSE_LOCATION) + + assertThat(result).isEqualTo(Permission.AccessCoarseLocation) + } + + @Test + fun `fromValue should return ReadContacts for read contacts permission`() { + val result = Permission.fromValue(Manifest.permission.READ_CONTACTS) + + assertThat(result).isEqualTo(Permission.ReadContacts) + } + + @Test + fun `fromValue should return WriteContacts for write contacts permission`() { + val result = Permission.fromValue(Manifest.permission.WRITE_CONTACTS) + + assertThat(result).isEqualTo(Permission.WriteContacts) + } + + @Test + fun `fromValue should return RecordAudio for record audio permission`() { + val result = Permission.fromValue(Manifest.permission.RECORD_AUDIO) + + assertThat(result).isEqualTo(Permission.RecordAudio) + } + + @Test + fun `fromValue should return CallPhone for call phone permission`() { + val result = Permission.fromValue(Manifest.permission.CALL_PHONE) + + assertThat(result).isEqualTo(Permission.CallPhone) + } + + @Test + fun `fromValue should return ReadExternalStorage for read external storage permission`() { + val result = Permission.fromValue(Manifest.permission.READ_EXTERNAL_STORAGE) + + assertThat(result).isEqualTo(Permission.ReadExternalStorage) + } + + @Test + fun `fromValue should return WriteExternalStorage for write external storage permission`() { + val result = Permission.fromValue(Manifest.permission.WRITE_EXTERNAL_STORAGE) + + assertThat(result).isEqualTo(Permission.WriteExternalStorage) + } + + @Test + fun `fromValue should return Other for unknown permission`() { + val unknownPermission = "android.permission.UNKNOWN" + val result = Permission.fromValue(unknownPermission) + + assertThat(result).isInstanceOf(Permission.Other::class.java) + assertThat((result as Permission.Other).permission).isEqualTo(unknownPermission) + } + + @Test + fun `Permission value should match manifest constant`() { + assertThat(Permission.Camera.value).isEqualTo(Manifest.permission.CAMERA) + assertThat(Permission.AccessFineLocation.value).isEqualTo(Manifest.permission.ACCESS_FINE_LOCATION) + assertThat(Permission.AccessCoarseLocation.value).isEqualTo(Manifest.permission.ACCESS_COARSE_LOCATION) + assertThat(Permission.ReadContacts.value).isEqualTo(Manifest.permission.READ_CONTACTS) + assertThat(Permission.WriteContacts.value).isEqualTo(Manifest.permission.WRITE_CONTACTS) + assertThat(Permission.RecordAudio.value).isEqualTo(Manifest.permission.RECORD_AUDIO) + assertThat(Permission.CallPhone.value).isEqualTo(Manifest.permission.CALL_PHONE) + assertThat(Permission.ReadExternalStorage.value).isEqualTo(Manifest.permission.READ_EXTERNAL_STORAGE) + assertThat(Permission.WriteExternalStorage.value).isEqualTo(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + + @Test + fun `Other permission should have correct value`() { + val customPermission = "com.example.CUSTOM_PERMISSION" + val permission = Permission.Other(customPermission) + + assertThat(permission.value).isEqualTo(customPermission) + } +} From 8ade41d917d175260b541ce83132fc0ef68e4e44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20G=C3=B3mez?= Date: Sun, 29 Mar 2026 12:47:05 +0200 Subject: [PATCH 4/5] cover preferences with more tests --- .../preferences/AdvancedPreferences.kt | 24 +++- .../preferences/AdvancedPreferencesTest.kt | 22 ++-- .../preferences/PreferencesExtensionTest.kt | 111 ++++++++++++++++++ 3 files changed, 138 insertions(+), 19 deletions(-) create mode 100644 libraries/preferences/src/test/java/com/raxdenstudios/commons/preferences/PreferencesExtensionTest.kt diff --git a/libraries/preferences/src/main/java/com/raxdenstudios/commons/preferences/AdvancedPreferences.kt b/libraries/preferences/src/main/java/com/raxdenstudios/commons/preferences/AdvancedPreferences.kt index ce34c1c1..ec358850 100644 --- a/libraries/preferences/src/main/java/com/raxdenstudios/commons/preferences/AdvancedPreferences.kt +++ b/libraries/preferences/src/main/java/com/raxdenstudios/commons/preferences/AdvancedPreferences.kt @@ -51,7 +51,7 @@ sealed class AdvancedPreferences( is Set<*> -> sharedPreferencesEditor.putStringSet(key, value as Set) is JSONObject -> sharedPreferencesEditor.putString(key, value.toString()) is JSONArray -> sharedPreferencesEditor.putString(key, value.toString()) - else -> sharedPreferencesEditor.putString(key, gson.toJson(value).toString()) + else -> sharedPreferencesEditor.putString(key, gson.toJson(value)) }.let { this } } @@ -64,15 +64,27 @@ sealed class AdvancedPreferences( @Suppress("UNCHECKED_CAST") fun get(key: String, defaultValue: Any): Any = when (defaultValue) { is Int -> sharedPreferences.getInt(key, defaultValue) - is String -> sharedPreferences.getString(key, defaultValue) as String + is String -> sharedPreferences.getString(key, defaultValue) ?: defaultValue is Boolean -> sharedPreferences.getBoolean(key, defaultValue) is Float -> sharedPreferences.getFloat(key, defaultValue) is Long -> sharedPreferences.getLong(key, defaultValue) - is Set<*> -> sharedPreferences.getStringSet(key, defaultValue as Set) as Set - is JSONObject -> JSONObject(sharedPreferences.getString(key, defaultValue.toString()) ?: "") - is JSONArray -> JSONArray(sharedPreferences.getString(key, defaultValue.toString())) + is Set<*> -> sharedPreferences.getStringSet(key, defaultValue as? Set) ?: defaultValue + is JSONObject -> try { + JSONObject(sharedPreferences.getString(key, null) ?: defaultValue.toString()) + } catch (e: Exception) { + defaultValue + } + is JSONArray -> try { + JSONArray(sharedPreferences.getString(key, null) ?: defaultValue.toString()) + } catch (e: Exception) { + defaultValue + } else -> sharedPreferences.getString(key, null)?.let { - gson.fromJson(it, defaultValue::class.java) + try { + gson.fromJson(it, defaultValue::class.java) + } catch (e: Exception) { + defaultValue + } } ?: defaultValue } } diff --git a/libraries/preferences/src/test/java/com/raxdenstudios/commons/preferences/AdvancedPreferencesTest.kt b/libraries/preferences/src/test/java/com/raxdenstudios/commons/preferences/AdvancedPreferencesTest.kt index e50a39ce..58b1f50c 100644 --- a/libraries/preferences/src/test/java/com/raxdenstudios/commons/preferences/AdvancedPreferencesTest.kt +++ b/libraries/preferences/src/test/java/com/raxdenstudios/commons/preferences/AdvancedPreferencesTest.kt @@ -200,21 +200,17 @@ class AdvancedPreferencesTest { @Test fun `persist a random values, get all preferences and verify that preferences exists`() { defaultPreferences.edit { - put("key", "string") - put("key", 23) - put("key", 12f) - put("key", 42L) + put("key_string", "string") + put("key_int", 23) + put("key_float", 12f) + put("key_long", 42L) } - assertEquals( - mapOf( - "key" to "string", - "key" to 23, - "key" to 12f, - "key" to 42L - ), - defaultPreferences.getAll() - ) + val allPrefs = defaultPreferences.getAll() + assertEquals("string", allPrefs["key_string"]) + assertEquals(23, allPrefs["key_int"]) + assertEquals(12f, allPrefs["key_float"]) + assertEquals(42L, allPrefs["key_long"]) } data class TestObject(val key: String, val value: String) : Comparable { diff --git a/libraries/preferences/src/test/java/com/raxdenstudios/commons/preferences/PreferencesExtensionTest.kt b/libraries/preferences/src/test/java/com/raxdenstudios/commons/preferences/PreferencesExtensionTest.kt new file mode 100644 index 00000000..f13b4879 --- /dev/null +++ b/libraries/preferences/src/test/java/com/raxdenstudios/commons/preferences/PreferencesExtensionTest.kt @@ -0,0 +1,111 @@ +package com.raxdenstudios.commons.preferences + +import android.content.Context +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P]) +class PreferencesExtensionTest { + + private lateinit var preferences: AdvancedPreferences + + @Before + fun setUp() { + val context: Context = ApplicationProvider.getApplicationContext() + preferences = AdvancedPreferences.Default(context) + // Clear preferences before each test + preferences.edit { clear() } + } + + @Test + fun `edit extension with apply should persist value`() { + preferences.edit { + put("test_key", "test_value") + } + + assertEquals("test_value", preferences.get("test_key", "default")) + } + + @Test + fun `edit extension with commit should persist value`() { + preferences.edit(commit = true) { + put("test_key", "test_value") + } + + assertEquals("test_value", preferences.get("test_key", "default")) + } + + @Test + fun `edit extension should allow multiple operations`() { + preferences.edit { + put("key1", "value1") + put("key2", 42) + put("key3", true) + } + + assertEquals("value1", preferences.get("key1", "")) + assertEquals(42, preferences.get("key2", 0)) + assertEquals(true, preferences.get("key3", false)) + } + + @Test + fun `edit extension with commit true should use commit`() { + // This test verifies that commit = true works + val result = preferences.edit(commit = true) { + put("key", "value") + } + + // Verify the value was persisted + assertEquals("value", preferences.get("key", "")) + } + + @Test + fun `edit extension with commit false should use apply`() { + // This test verifies that commit = false (default) works + preferences.edit(commit = false) { + put("key", "value") + } + + // Verify the value was persisted + assertEquals("value", preferences.get("key", "")) + } + + @Test + fun `edit extension should support remove operation`() { + preferences.edit { + put("key1", "value1") + put("key2", "value2") + } + + preferences.edit { + remove("key1") + } + + assertEquals("default", preferences.get("key1", "default")) + assertEquals("value2", preferences.get("key2", "default")) + } + + @Test + fun `edit extension should support clear operation`() { + preferences.edit { + put("key1", "value1") + put("key2", "value2") + put("key3", "value3") + } + + preferences.edit { + clear() + } + + assertEquals(false, preferences.contains("key1")) + assertEquals(false, preferences.contains("key2")) + assertEquals(false, preferences.contains("key3")) + } +} From 42902f7aa659ec9b77f1ce50cdf2bf1badd621a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20G=C3=B3mez?= Date: Sun, 29 Mar 2026 12:50:06 +0200 Subject: [PATCH 5/5] use sdk 35 --- gradle/libraries.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libraries.versions.toml b/gradle/libraries.versions.toml index 59f4e97f..550c7285 100644 --- a/gradle/libraries.versions.toml +++ b/gradle/libraries.versions.toml @@ -5,8 +5,8 @@ # ------------------------------------------------------------------- jdk = "17" minSdk = "24" -targetSdk = "34" -compileSdk = "36" +targetSdk = "35" +compileSdk = "35" sourceCompatibility = "17" targetCompatibility = "17"