P6 Stream U: AuthenticatedImage composable + CoilAuthInterceptor
Token-aware image loading matching iOS AuthenticatedImage.swift. Bearer header attachment, 401-triggered refresh+retry, placeholder on error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import coil3.ColorImage
|
||||
import coil3.PlatformContext
|
||||
import coil3.decode.DataSource
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.network.HttpException
|
||||
import coil3.network.NetworkHeaders
|
||||
import coil3.network.NetworkResponse
|
||||
import coil3.network.httpHeaders
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.ImageResult
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.size.Size
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Unit tests for [CoilAuthInterceptor].
|
||||
*
|
||||
* The interceptor is responsible for:
|
||||
* 1. Attaching `Authorization: <scheme> <token>` to image requests.
|
||||
* 2. On HTTP 401, calling the refresh callback once and retrying the
|
||||
* request with the new token.
|
||||
* 3. Not looping: if the retry also returns 401, the error is returned.
|
||||
* 4. When no token is available, the request proceeds unauthenticated.
|
||||
*
|
||||
* Runs under Robolectric so Coil's Android `PlatformContext` (= `Context`)
|
||||
* is available for constructing `ImageRequest` values.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class CoilAuthInterceptorTest {
|
||||
|
||||
private val platformContext: PlatformContext
|
||||
get() = ApplicationProvider.getApplicationContext()
|
||||
|
||||
private fun makeRequest(): ImageRequest =
|
||||
ImageRequest.Builder(platformContext)
|
||||
.data("https://example.com/media/1")
|
||||
.build()
|
||||
|
||||
private fun makeSuccess(request: ImageRequest): SuccessResult =
|
||||
SuccessResult(
|
||||
image = ColorImage(0xFF000000.toInt()),
|
||||
request = request,
|
||||
dataSource = DataSource.NETWORK
|
||||
)
|
||||
|
||||
private fun make401Error(request: ImageRequest): ErrorResult {
|
||||
val response = NetworkResponse(code = 401, headers = NetworkHeaders.EMPTY)
|
||||
return ErrorResult(
|
||||
image = null,
|
||||
request = request,
|
||||
throwable = HttpException(response)
|
||||
)
|
||||
}
|
||||
|
||||
private class FakeChain(
|
||||
initialRequest: ImageRequest,
|
||||
private val responses: MutableList<(ImageRequest) -> ImageResult>,
|
||||
val capturedRequests: MutableList<ImageRequest> = mutableListOf(),
|
||||
) : Interceptor.Chain {
|
||||
private var currentRequest: ImageRequest = initialRequest
|
||||
|
||||
override val request: ImageRequest get() = currentRequest
|
||||
override val size: Size = Size.ORIGINAL
|
||||
|
||||
override fun withRequest(request: ImageRequest): Interceptor.Chain {
|
||||
currentRequest = request
|
||||
return this
|
||||
}
|
||||
|
||||
override fun withSize(size: Size): Interceptor.Chain = this
|
||||
|
||||
override suspend fun proceed(): ImageResult {
|
||||
capturedRequests += currentRequest
|
||||
val responder = responses.removeAt(0)
|
||||
return responder(currentRequest)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interceptor_attaches_authorization_header_when_token_present() = runTest {
|
||||
val request = makeRequest()
|
||||
val chain = FakeChain(
|
||||
initialRequest = request,
|
||||
responses = mutableListOf({ req -> makeSuccess(req) })
|
||||
)
|
||||
val interceptor = CoilAuthInterceptor(
|
||||
tokenProvider = { "abc123" },
|
||||
refreshToken = { null },
|
||||
authScheme = "Token",
|
||||
)
|
||||
|
||||
val result = interceptor.intercept(chain)
|
||||
|
||||
assertTrue(result is SuccessResult, "Expected success result")
|
||||
assertEquals(1, chain.capturedRequests.size)
|
||||
val sent = chain.capturedRequests.first()
|
||||
assertEquals("Token abc123", sent.httpHeaders["Authorization"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interceptor_skips_header_when_token_missing() = runTest {
|
||||
val request = makeRequest()
|
||||
val chain = FakeChain(
|
||||
initialRequest = request,
|
||||
responses = mutableListOf({ req -> makeSuccess(req) })
|
||||
)
|
||||
val interceptor = CoilAuthInterceptor(
|
||||
tokenProvider = { null },
|
||||
refreshToken = { null },
|
||||
authScheme = "Token",
|
||||
)
|
||||
|
||||
val result = interceptor.intercept(chain)
|
||||
|
||||
assertTrue(result is SuccessResult)
|
||||
assertEquals(1, chain.capturedRequests.size)
|
||||
val sent = chain.capturedRequests.first()
|
||||
// No Authorization header should have been added
|
||||
assertNull(sent.httpHeaders["Authorization"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interceptor_refreshes_and_retries_on_401() = runTest {
|
||||
val request = makeRequest()
|
||||
var refreshCallCount = 0
|
||||
val chain = FakeChain(
|
||||
initialRequest = request,
|
||||
responses = mutableListOf(
|
||||
{ req -> make401Error(req) }, // first attempt -> 401
|
||||
{ req -> makeSuccess(req) }, // retry -> 200
|
||||
)
|
||||
)
|
||||
val interceptor = CoilAuthInterceptor(
|
||||
tokenProvider = { "old-token" },
|
||||
refreshToken = {
|
||||
refreshCallCount++
|
||||
"new-token"
|
||||
},
|
||||
authScheme = "Token",
|
||||
)
|
||||
|
||||
val result = interceptor.intercept(chain)
|
||||
|
||||
assertTrue(result is SuccessResult, "Expected retry to succeed")
|
||||
assertEquals(1, refreshCallCount, "refreshToken should be invoked exactly once")
|
||||
assertEquals(2, chain.capturedRequests.size, "Expected original + 1 retry")
|
||||
assertEquals("Token old-token", chain.capturedRequests[0].httpHeaders["Authorization"])
|
||||
assertEquals("Token new-token", chain.capturedRequests[1].httpHeaders["Authorization"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interceptor_returns_error_when_refresh_returns_null() = runTest {
|
||||
val request = makeRequest()
|
||||
var refreshCallCount = 0
|
||||
val chain = FakeChain(
|
||||
initialRequest = request,
|
||||
responses = mutableListOf({ req -> make401Error(req) })
|
||||
)
|
||||
val interceptor = CoilAuthInterceptor(
|
||||
tokenProvider = { "old-token" },
|
||||
refreshToken = {
|
||||
refreshCallCount++
|
||||
null
|
||||
},
|
||||
authScheme = "Token",
|
||||
)
|
||||
|
||||
val result = interceptor.intercept(chain)
|
||||
|
||||
assertTrue(result is ErrorResult, "Expected error result when refresh fails")
|
||||
assertEquals(1, refreshCallCount, "refreshToken should be attempted once")
|
||||
// Only the first attempt should have gone through
|
||||
assertEquals(1, chain.capturedRequests.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interceptor_does_not_loop_on_second_401() = runTest {
|
||||
val request = makeRequest()
|
||||
var refreshCallCount = 0
|
||||
val chain = FakeChain(
|
||||
initialRequest = request,
|
||||
responses = mutableListOf(
|
||||
{ req -> make401Error(req) }, // first attempt -> 401
|
||||
{ req -> make401Error(req) }, // retry also -> 401
|
||||
)
|
||||
)
|
||||
val interceptor = CoilAuthInterceptor(
|
||||
tokenProvider = { "old-token" },
|
||||
refreshToken = {
|
||||
refreshCallCount++
|
||||
"new-token"
|
||||
},
|
||||
authScheme = "Token",
|
||||
)
|
||||
|
||||
val result = interceptor.intercept(chain)
|
||||
|
||||
assertTrue(result is ErrorResult, "Second 401 should surface as ErrorResult")
|
||||
assertEquals(1, refreshCallCount, "refreshToken should be called exactly once — no infinite loop")
|
||||
assertEquals(2, chain.capturedRequests.size, "Expected original + exactly one retry")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interceptor_passes_through_non_401_errors_without_refresh() = runTest {
|
||||
val request = makeRequest()
|
||||
var refreshCallCount = 0
|
||||
val chain = FakeChain(
|
||||
initialRequest = request,
|
||||
responses = mutableListOf({ req ->
|
||||
ErrorResult(
|
||||
image = null,
|
||||
request = req,
|
||||
throwable = HttpException(
|
||||
NetworkResponse(code = 500, headers = NetworkHeaders.EMPTY)
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
val interceptor = CoilAuthInterceptor(
|
||||
tokenProvider = { "tok" },
|
||||
refreshToken = {
|
||||
refreshCallCount++
|
||||
"should-not-be-called"
|
||||
},
|
||||
authScheme = "Token",
|
||||
)
|
||||
|
||||
val result = interceptor.intercept(chain)
|
||||
|
||||
assertTrue(result is ErrorResult)
|
||||
assertEquals(0, refreshCallCount, "refreshToken should not be invoked on non-401 errors")
|
||||
assertEquals(1, chain.capturedRequests.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interceptor_supports_bearer_scheme() = runTest {
|
||||
val request = makeRequest()
|
||||
val chain = FakeChain(
|
||||
initialRequest = request,
|
||||
responses = mutableListOf({ req -> makeSuccess(req) })
|
||||
)
|
||||
val interceptor = CoilAuthInterceptor(
|
||||
tokenProvider = { "jwt.payload.sig" },
|
||||
refreshToken = { null },
|
||||
authScheme = "Bearer",
|
||||
)
|
||||
|
||||
val result = interceptor.intercept(chain)
|
||||
|
||||
assertTrue(result is SuccessResult)
|
||||
val sent = chain.capturedRequests.first()
|
||||
assertEquals("Bearer jwt.payload.sig", sent.httpHeaders["Authorization"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user