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:
Trey T
2026-04-18 13:10:25 -05:00
parent 19471d780d
commit 224f6643bf
3 changed files with 374 additions and 0 deletions

View File

@@ -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"])
}
}