fix: adapt Blossom interceptor test to OkHttp 5 Interceptor.Chain

OkHttp 5.4.0 expanded Interceptor.Chain from ~10 to ~40 members, breaking
the test's hand-rolled `object : Interceptor.Chain` fake. Replace it with a
java.lang.reflect.Proxy that implements only request()/proceed() and throws
for the rest, so it survives future interface growth without dozens of empty
stubs. A real OkHttpClient can't be used here because Platform init fails
outside Robolectric (android.util.Log.isLoggable is unavailable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-06-13 18:17:42 -04:00
co-authored by Claude Opus 4.8
parent ece7194456
commit a75b60135a
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.service.okhttp
import okhttp3.Connection
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Protocol
@@ -29,6 +28,7 @@ import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Test
import java.lang.reflect.Proxy
class LocalBlossomCacheRedirectInterceptorTest {
private val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
@@ -37,7 +37,7 @@ class LocalBlossomCacheRedirectInterceptorTest {
fun bridgeOffPassesThrough() {
val interceptor = LocalBlossomCacheRedirectInterceptor { false }
val captured = mutableListOf<String>()
val response = interceptor.intercept(fakeChain("https://blossom.example.com/$sha.jpg", captured))
val response = run(interceptor, "https://blossom.example.com/$sha.jpg", captured)
assertEquals("https://blossom.example.com/$sha.jpg", captured.single())
response.close()
}
@@ -46,7 +46,7 @@ class LocalBlossomCacheRedirectInterceptorTest {
fun bridgeOnRewritesShaInPath() {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val response = interceptor.intercept(fakeChain("https://blossom.example.com/$sha.jpg", captured))
val response = run(interceptor, "https://blossom.example.com/$sha.jpg", captured)
assertEquals(
"http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fblossom.example.com",
captured.single(),
@@ -58,7 +58,7 @@ class LocalBlossomCacheRedirectInterceptorTest {
fun bridgeOnPreservesNonHexUrls() {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val response = interceptor.intercept(fakeChain("https://example.com/avatar.jpg", captured))
val response = run(interceptor, "https://example.com/avatar.jpg", captured)
assertEquals("https://example.com/avatar.jpg", captured.single())
response.close()
}
@@ -67,7 +67,7 @@ class LocalBlossomCacheRedirectInterceptorTest {
fun bridgeOnSkipsLocalhost() {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val response = interceptor.intercept(fakeChain("http://127.0.0.1:24242/$sha.jpg", captured))
val response = run(interceptor, "http://127.0.0.1:24242/$sha.jpg", captured)
assertEquals("http://127.0.0.1:24242/$sha.jpg", captured.single())
response.close()
}
@@ -76,7 +76,7 @@ class LocalBlossomCacheRedirectInterceptorTest {
fun bridgeOnHandlesNonStandardPort() {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val response = interceptor.intercept(fakeChain("https://blossom.example.com:8443/$sha.png", captured))
val response = run(interceptor, "https://blossom.example.com:8443/$sha.png", captured)
assertEquals(
"http://127.0.0.1:24242/$sha.png?xs=https%3A%2F%2Fblossom.example.com%3A8443",
captured.single(),
@@ -88,7 +88,7 @@ class LocalBlossomCacheRedirectInterceptorTest {
fun bridgeOnHandlesUppercaseSha() {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val response = interceptor.intercept(fakeChain("https://blossom.example.com/${sha.uppercase()}.jpg", captured))
val response = run(interceptor, "https://blossom.example.com/${sha.uppercase()}.jpg", captured)
assertEquals(
"http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fblossom.example.com",
captured.single(),
@@ -100,7 +100,7 @@ class LocalBlossomCacheRedirectInterceptorTest {
fun bridgeOnFallsBackToBinExtension() {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val response = interceptor.intercept(fakeChain("https://blossom.example.com/$sha", captured))
val response = run(interceptor, "https://blossom.example.com/$sha", captured)
assertEquals(
"http://127.0.0.1:24242/$sha.bin?xs=https%3A%2F%2Fblossom.example.com",
captured.single(),
@@ -112,7 +112,7 @@ class LocalBlossomCacheRedirectInterceptorTest {
fun bridgeOnHandlesShaInDeeperPathSegment() {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val response = interceptor.intercept(fakeChain("https://nostr.build/i/cache/$sha.webp", captured))
val response = run(interceptor, "https://nostr.build/i/cache/$sha.webp", captured)
assertEquals(
"http://127.0.0.1:24242/$sha.webp?xs=https%3A%2F%2Fnostr.build%2Fi%2Fcache",
captured.single(),
@@ -124,7 +124,7 @@ class LocalBlossomCacheRedirectInterceptorTest {
fun bridgeOnPreservesNostrBuildPathPrefix() {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val response = interceptor.intercept(fakeChain("https://cdn.nostr.build/i/$sha.jpg", captured))
val response = run(interceptor, "https://cdn.nostr.build/i/$sha.jpg", captured)
assertEquals(
"http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fcdn.nostr.build%2Fi",
captured.single(),
@@ -138,11 +138,10 @@ class LocalBlossomCacheRedirectInterceptorTest {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val response =
interceptor.intercept(
fakeChain(
"https://share.yabu.me/84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp",
captured,
),
run(
interceptor,
"https://share.yabu.me/84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp",
captured,
)
assertEquals(
"http://127.0.0.1:24242/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp?xs=https%3A%2F%2Fshare.yabu.me%2F84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5",
@@ -158,21 +157,19 @@ class LocalBlossomCacheRedirectInterceptorTest {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val url = "https://example.com/$sha/avatar.jpg"
val response = interceptor.intercept(fakeChain(url, captured))
val response = run(interceptor, url, captured)
assertEquals(url, captured.single())
response.close()
}
@Test
fun bridgeOnSkipsWhenLastSegmentHasNonHexPrefixBeforeSha() {
// nostr.build /i/ layout: <prefix>_<sha>.<ext>. The hex inside the
// filename isn't a Blossom blob — rewriting to the local cache and
fun bridgeOnSkipsNostrBuildResizedVariants() {
// sending xs=https://nostr.build/i would 404 because the real blob
// is at /i/nostr.build_<sha>.jpg, not /i/<sha>.
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val url = "https://nostr.build/i/nostr.build_$sha.jpg"
val response = interceptor.intercept(fakeChain(url, captured))
val response = run(interceptor, url, captured)
assertEquals(url, captured.single())
response.close()
}
@@ -183,55 +180,52 @@ class LocalBlossomCacheRedirectInterceptorTest {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val url = "https://example.com/${sha}_thumb.jpg"
val response = interceptor.intercept(fakeChain(url, captured))
val response = run(interceptor, url, captured)
assertEquals(url, captured.single())
response.close()
}
/**
* Runs [interceptor] against [url] and returns its response. The chain is a
* dynamic proxy that only implements [Interceptor.Chain.request] and
* [Interceptor.Chain.proceed]; `proceed` records the URL it is asked to
* fetch — i.e. the URL after any rewrite [interceptor] applied — and returns
* a synthetic 200 without touching the network. A proxy is used instead of
* an `object :` literal because OkHttp's `Interceptor.Chain` declares ~40
* members (client-config getters and `withX` reconfigurers) that this test
* never exercises and that would otherwise need empty stubs.
*/
private fun run(
interceptor: Interceptor,
url: String,
captured: MutableList<String>,
): Response = interceptor.intercept(fakeChain(url, captured))
private fun fakeChain(
url: String,
captured: MutableList<String>,
): Interceptor.Chain =
object : Interceptor.Chain {
private val request = Request.Builder().url(url.toHttpUrl()).build()
override fun request(): Request = request
override fun proceed(request: Request): Response {
captured.add(request.url.toString())
return Response
.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body("".toResponseBody(null))
.build()
): Interceptor.Chain {
val request = Request.Builder().url(url.toHttpUrl()).build()
return Proxy.newProxyInstance(
Interceptor.Chain::class.java.classLoader,
arrayOf(Interceptor.Chain::class.java),
) { _, method, args ->
when (method.name) {
"request" -> request
"proceed" -> {
val proceeded = args[0] as Request
captured.add(proceeded.url.toString())
Response
.Builder()
.request(proceeded)
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body("".toResponseBody(null))
.build()
}
else -> throw UnsupportedOperationException(method.name)
}
override fun connection(): Connection? = null
override fun call() = throw UnsupportedOperationException()
override fun connectTimeoutMillis(): Int = 0
override fun readTimeoutMillis(): Int = 0
override fun writeTimeoutMillis(): Int = 0
override fun withConnectTimeout(
timeout: Int,
unit: java.util.concurrent.TimeUnit,
): Interceptor.Chain = this
override fun withReadTimeout(
timeout: Int,
unit: java.util.concurrent.TimeUnit,
): Interceptor.Chain = this
override fun withWriteTimeout(
timeout: Int,
unit: java.util.concurrent.TimeUnit,
): Interceptor.Chain = this
}
} as Interceptor.Chain
}
}