Repository: andregasser/bigbone (kotlin)

Issue:
Keep an Instance reference in MastodonClient
Summarizing relevant parts from #94:

Currently, instance information for the currently configured instance can be retrieved via a respective `MastodonClient` instance like: `client.instances.getInstances()` (Kotlin) or `client.instances().getInstances()` (Java). This is currently the only Mastodon API call available via `InstanceMethods`.

Most of this information is constant for the lifetime of the `MastodonClient` object and sometimes necessary for our client to function, for example the version, configuration or streaming API URL. Other information can at least be expected to not change too regularly, for example server policies.

As such, it might be sensible to build our client with a fixed Instance reference that is retrieved while building it, and make that member available directly, like `client.instance` / `client.getInstance()`.

Potential issues that were previously brought up:
- bootstrapping: accessing "api/vX/instance" endpoint without yet knowing server version that is contained in the return value for that endpoint
- getting back either a V1 or V2 instance depending on server version
- library users might still want to access the endpoint repeatedly to get updated information that _does_ change, like usage statistics

Pull request:
# Description

This PR adds a reference of `Instance` to the `MastodonClient` _instance_ (🥁🐍). We previously already called the endpoint but manually got from the JSON only what we needed for the streaming API URL.

With this PR, the `Instance` retrieval is a necessary part of the `MastodonClient.Builder#build` call. Other pre-existing properties like `instanceName` and `instanceVersion` as well as `streamingUrl` which previously made its own call to the instance endpoint now reuse the `instance` gotten during `MastodonClient` instantiation.

Closes #114 

# Type of Change

- Improvement of existing feature
- Addition of new property to `MastodonClient`

# Breaking Changes

- `MastodonClient.Builder#build` may now break in more ways than before. Previously, we gracefully failed if the endpoint to get an `Instance` didn’t work out for us to get a streaming API URL. Now, the whole `build` call fails with an `InstanceRetrievalException` that may wrap other exceptions that might have occurred during retrieval—JSON parsing exceptions, network call failures, …
- `MastodonClient#getInstanceName` and `MastodonClient#getInstanceVersion` are deprecated and will throw an error if used. A drop-in replacement is available and offered by your IDE: `getInstance().domain` and `getInstance().version`

# How Has This Been Tested?

<!-- Please describe the tests that you ran to verify your changes. -->

# Mandatory Checklist

- [x] I ran `gradle check` and there were no errors reported
- [x] I have performed a self-review of my code
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] All tests pass locally with my changes
- [x] I have added KDoc documentation to all public methods

Patch:
diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
index fba624dcd..d5ffd1dc4 100644
--- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
+++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
@@ -1,8 +1,6 @@
 package social.bigbone
 
-import kotlinx.serialization.json.JsonPrimitive
-import kotlinx.serialization.json.contentOrNull
-import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.SerializationException
 import okhttp3.HttpUrl
 import okhttp3.HttpUrl.Companion.toHttpUrl
 import okhttp3.Interceptor
@@ -17,6 +15,7 @@ import okhttp3.WebSocket
 import okhttp3.WebSocketListener
 import okio.ByteString
 import social.bigbone.api.Pageable
+import social.bigbone.api.entity.Instance
 import social.bigbone.api.entity.streaming.MastodonApiEvent.GenericMessage
 import social.bigbone.api.entity.streaming.MastodonApiEvent.StreamEvent
 import social.bigbone.api.entity.streaming.ParsedStreamEvent.Companion.toStreamEvent
@@ -28,7 +27,8 @@ import social.bigbone.api.entity.streaming.TechnicalEvent.Open
 import social.bigbone.api.entity.streaming.WebSocketCallback
 import social.bigbone.api.exception.BigBoneClientInstantiationException
 import social.bigbone.api.exception.BigBoneRequestException
-import social.bigbone.api.exception.InstanceVersionRetrievalException
+import social.bigbone.api.exception.InstanceRetrievalException
+import social.bigbone.api.exception.UnsupportedServerException
 import social.bigbone.api.method.AccountMethods
 import social.bigbone.api.method.AnnouncementMethods
 import social.bigbone.api.method.AppMethods
@@ -93,16 +93,25 @@ import javax.net.ssl.X509TrustManager
  * towards the Mastodon instance specified. Request/response data is serialized/deserialized accordingly.
  */
 class MastodonClient private constructor(
-    private val instanceName: String,
-    internal val streamingUrl: String,
+    private val instance: Instance,
     private val client: OkHttpClient,
     private val accessToken: String? = null,
     private val debug: Boolean = false,
-    private val instanceVersion: String? = null,
     private val scheme: String = "https",
     private val port: Int = 443
 ) {
 
+    internal val streamingUrl: HttpUrl by lazy {
+        val instanceStreamingUrl = instance.configuration.urls.streaming
+            .takeIf { it.isNotBlank() }
+            // okhttp’s HttpUrl which is used later to parse this result only allows http(s)
+            // so we need to replace ws(s) first
+            ?.replace("ws:", "http:")
+            ?.replace("wss:", "https:")
+
+        instanceStreamingUrl?.toHttpUrl() ?: HttpUrl.Builder().scheme(scheme).host(getInstance().domain).build()
+    }
+
     //region API methods
 
     /**
@@ -441,7 +450,6 @@ class MastodonClient private constructor(
     @Suppress("unused") // public API
     @get:JvmName("trends")
     val trends: TrendMethods by lazy { TrendMethods(this) }
-
     //endregion API methods
 
     /**
@@ -455,9 +463,29 @@ class MastodonClient private constructor(
         PUT
     }
 
-    fun getInstanceName() = instanceName
+    @Deprecated(
+        message = "This method is deprecated and will be removed in future versions.",
+        replaceWith = ReplaceWith("getInstance().domain"),
+        level = DeprecationLevel.ERROR
+    )
+    fun getInstanceName() = getInstance().domain
+
+    @Deprecated(
+        message = "This method is deprecated and will be removed in future versions.",
+        replaceWith = ReplaceWith("getInstance().version"),
+        level = DeprecationLevel.ERROR
+    )
+    fun getInstanceVersion() = getInstance().version
 
-    fun getInstanceVersion() = instanceVersion
+    /**
+     * Gets the [Instance] retrieved during instantiation of the client through [Builder.build].
+     * The [Instance] kept here is stored in memory for the lifetime of the client instance and is NOT updated again.
+     *
+     * If you need up-to-date information about the instance, consider using the [InstanceMethods.getInstance]
+     * method to fetch the latest data. [InstanceMethods] are available via [instances] in this client.
+     * Calling [InstanceMethods.getInstance] will NOT automatically update the instance kept here.
+     */
+    fun getInstance(): Instance = instance
 
     fun getScheme() = scheme
 
@@ -572,7 +600,7 @@ class MastodonClient private constructor(
      */
     fun delete(path: String, body: Parameters?): Response {
         try {
-            val url = fullUrl(scheme, instanceName, port, path)
+            val url = fullUrl(scheme, getInstance().domain, port, path)
             debugPrintUrl(url)
             val call = client.newCall(
                 Request.Builder()
@@ -593,7 +621,7 @@ class MastodonClient private constructor(
      */
     fun get(path: String, query: Parameters? = null): Response {
         try {
-            val url = fullUrl(scheme, instanceName, port, path, query)
+            val url = fullUrl(scheme, getInstance().domain, port, path, query)
             debugPrintUrl(url)
             val call = client.newCall(
                 Request.Builder()
@@ -623,7 +651,7 @@ class MastodonClient private constructor(
                 .header("Authorization", "Bearer $accessToken")
                 .url(
                     fullUrl(
-                        existingUrl = streamingUrl.toHttpUrl(),
+                        existingUrl = streamingUrl,
                         path = "api/v1/streaming",
                         queryParameters = parameters
                     )
@@ -698,7 +726,7 @@ class MastodonClient private constructor(
      */
     fun patch(path: String, body: Parameters?): Response {
         try {
-            val url = fullUrl(scheme, instanceName, port, path)
+            val url = fullUrl(scheme, getInstance().domain, port, path)
             debugPrintUrl(url)
             val call = client.newCall(
                 Request.Builder()
@@ -738,7 +766,7 @@ class MastodonClient private constructor(
      */
     fun postRequestBody(path: String, body: RequestBody, idempotencyKey: String? = null): Response {
         try {
-            val url = fullUrl(scheme, instanceName, port, path)
+            val url = fullUrl(scheme, getInstance().domain, port, path)
             debugPrintUrl(url)
             val call = client.newCall(
                 Request.Builder().apply {
@@ -774,7 +802,7 @@ class MastodonClient private constructor(
      */
     fun putRequestBody(path: String, body: RequestBody): Response {
         try {
-            val url = fullUrl(scheme, instanceName, port, path)
+            val url = fullUrl(scheme, getInstance().domain, port, path)
             debugPrintUrl(url)
             val call = client.newCall(
                 Request.Builder()
@@ -939,43 +967,20 @@ class MastodonClient private constructor(
             this.debug = true
         }
 
-        private fun getStreamingApiUrl(fallbackUrl: () -> String): String {
-            executeInstanceRequest().use { response: Response ->
-                if (!response.isSuccessful) return fallbackUrl()
-
-                val streamingUrl: String? = response.body.string().let { responseBody: String ->
-                    val rawJsonObject = JSON_SERIALIZER
-                        .parseToJsonElement(responseBody)
-                        .jsonObject
-
-                    (rawJsonObject["configuration"]?.jsonObject?.get("urls")?.jsonObject?.get("streaming") as? JsonPrimitive)
-                        ?.contentOrNull
-                        // okhttp’s HttpUrl which is used later to parse this result only allows http(s)
-                        // so we need to replace ws(s) first
-                        ?.replace("ws:", "http:")
-                        ?.replace("wss:", "https:")
-                }
-
-                return streamingUrl ?: fallbackUrl()
-            }
-        }
-
         /**
-         * Get the version string for this Mastodon instance.
-         * @return a string corresponding to the version of this Mastodon instance
-         * @throws BigBoneClientInstantiationException if instance version cannot be retrieved
+         * Runs a [NodeInfoClient.retrieveServerInfo] request to ensure the server runs Mastodon.
+         *
+         * @throws BigBoneClientInstantiationException if server doesn't appear to run Mastodon
          */
         @Throws(BigBoneClientInstantiationException::class)
-        private fun getInstanceVersion(): String {
-            val serverVersion: String? = NodeInfoClient
+        private fun requireServerRunsMastodon() {
+            val serverRunsMastodon: Boolean = NodeInfoClient
                 .retrieveServerInfo(host = instanceName, scheme = scheme, port = port)
                 ?.software
-                ?.takeIf { it.name == "mastodon" }
-                ?.version
-
-            if (serverVersion == null) throw InstanceVersionRetrievalException(message = "Server $instanceName doesn't appear to run Mastodon")
+                ?.name == "mastodon"
+            if (serverRunsMastodon) return
 
-            return serverVersion
+            throw UnsupportedServerException(message = "Server $instanceName doesn't appear to run Mastodon")
         }
 
         private fun configureForTrustAll(clientBuilder: OkHttpClient.Builder) {
@@ -995,12 +1000,8 @@ class MastodonClient private constructor(
          * via [ResponseBody.string], or by calling [Response.close].
          * @return Server response as [Response] for this request
          */
-        internal fun executeInstanceRequest(): Response {
-            val clientBuilder = OkHttpClient.Builder()
-            if (trustAllCerts) configureForTrustAll(clientBuilder)
-
-            return clientBuilder
-                .build()
+        internal fun executeInstanceRequest(httpClient: OkHttpClient): Response {
+            return httpClient
                 .newCall(
                     Request.Builder()
                         .url(
@@ -1017,32 +1018,66 @@ class MastodonClient private constructor(
                 .execute()
         }
 
+        /**
+         * Tries to get an [Instance] from the instance API endpoint.
+         *
+         * @param httpClient the HTTP client to use for the request
+         *
+         * @return the [Instance] retrieved from the server
+         * @throws InstanceRetrievalException if Instance could not be parsed from the server response
+         */
+        private fun getInstance(httpClient: OkHttpClient): Instance {
+            executeInstanceRequest(httpClient).use { response: Response ->
+                if (response.isSuccessful) {
+                    val body: String = response.body.string()
+                    try {
+                        val instance: Instance = JSON_SERIALIZER.decodeFromString(body)
+                        return instance
+                    } catch (e: SerializationException) {
+                        throw InstanceRetrievalException(
+                            "Could not decode response from server into Instance: $body",
+                            e
+                        )
+                    } catch (e: IllegalArgumentException) {
+                        throw InstanceRetrievalException(
+                            "Response from server does not appear to be a valid Instance: $body",
+                            e
+                        )
+                    }
+                } else {
+                    throw InstanceRetrievalException(
+                        "Instance request failed with code ${response.code} and message \"${response.message}\"."
+                    )
+                }
+            }
+        }
+
         /**
          * Builds this MastodonClient.
          *
          * @throws BigBoneClientInstantiationException if the client could not be instantiated, likely due to an issue
-         * when getting the instance version of the server in [instanceName]. Other exceptions, e.g. due to no Internet
+         * when getting the [Instance] of the server. Other exceptions, e.g. due to no Internet
          * connection are _not_ caught by this library.
          */
         fun build(): MastodonClient {
+            requireServerRunsMastodon()
+
+            val httpClient = okHttpClientBuilder
+                .addNetworkInterceptor(AuthorizationInterceptor(accessToken))
+                .readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
+                .writeTimeout(writeTimeoutSeconds, TimeUnit.SECONDS)
+                .connectTimeout(connectTimeoutSeconds, TimeUnit.SECONDS)
+                .build()
+
+            val instance = getInstance(httpClient)
+
             return MastodonClient(
-                instanceName = instanceName,
-                client = okHttpClientBuilder
-                    .addNetworkInterceptor(AuthorizationInterceptor(accessToken))
-                    .readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
-                    .writeTimeout(writeTimeoutSeconds, TimeUnit.SECONDS)
-                    .connectTimeout(connectTimeoutSeconds, TimeUnit.SECONDS)
-                    .build(),
+                instance = instance,
+                client = httpClient,
                 accessToken = accessToken,
                 debug = debug,
-                instanceVersion = getInstanceVersion(),
                 scheme = scheme,
-                port = port,
-                streamingUrl = getStreamingApiUrl(
-                    fallbackUrl = {
-                        HttpUrl.Builder().scheme(scheme).host(instanceName).toString()
-                    }
-                )
+                port = port
             )
         }
     }
diff --git a/bigbone/src/main/kotlin/social/bigbone/api/exception/BigBoneClientInstantiationException.kt b/bigbone/src/main/kotlin/social/bigbone/api/exception/BigBoneClientInstantiationException.kt
index aed460b94..8537a1f87 100644
--- a/bigbone/src/main/kotlin/social/bigbone/api/exception/BigBoneClientInstantiationException.kt
+++ b/bigbone/src/main/kotlin/social/bigbone/api/exception/BigBoneClientInstantiationException.kt
@@ -28,11 +28,19 @@ class ServerInfoRetrievalException : BigBoneClientInstantiationException {
 /**
  * Exception thrown if we could not successfully get the [NodeInfo] server URL during [MastodonClient] instantiation.
  */
-class ServerInfoUrlRetrievalException(response: Response, message: String? = null) : BigBoneClientInstantiationException(
-    message = "${message ?: ""}${response.message}"
-)
+class ServerInfoUrlRetrievalException(
+    response: Response,
+    message: String? = null
+) : BigBoneClientInstantiationException(message = "${message ?: ""}${response.message}")
 
 /**
- * Exception thrown if we could not retrieve the instance version of a Mastodon server during [MastodonClient] instantiation.
+ * Exception thrown if the server does not support the required features during [MastodonClient] instantiation.
  */
-class InstanceVersionRetrievalException(message: String, cause: Throwable? = null) : BigBoneClientInstantiationException(message, cause)
+class UnsupportedServerException(message: String, cause: Throwable? = null) :
+    BigBoneClientInstantiationException(message, cause)
+
+/**
+ * Exception thrown if we could not retrieve the instance details of a Mastodon server during [MastodonClient] instantiation.
+ */
+class InstanceRetrievalException(message: String, cause: Throwable? = null) :
+    BigBoneClientInstantiationException(message, cause)
diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/OAuthMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/OAuthMethods.kt
index 3b3f43178..8d59727ea 100644
--- a/bigbone/src/main/kotlin/social/bigbone/api/method/OAuthMethods.kt
+++ b/bigbone/src/main/kotlin/social/bigbone/api/method/OAuthMethods.kt
@@ -56,7 +56,7 @@ class OAuthMethods(private val client: MastodonClient) {
         return MastodonClient
             .fullUrl(
                 scheme = client.getScheme(),
-                instanceName = client.getInstanceName(),
+                instanceName = client.getInstance().domain,
                 port = client.getPort(),
                 path = endpoint,
                 query = params


Test patch:
diff --git a/bigbone/src/test/kotlin/social/bigbone/MastodonClientTest.kt b/bigbone/src/test/kotlin/social/bigbone/MastodonClientTest.kt
index d0010969d..91df5a0c4 100644
--- a/bigbone/src/test/kotlin/social/bigbone/MastodonClientTest.kt
+++ b/bigbone/src/test/kotlin/social/bigbone/MastodonClientTest.kt
@@ -12,8 +12,9 @@ import org.amshove.kluent.shouldBeEqualTo
 import org.amshove.kluent.shouldThrow
 import org.amshove.kluent.withMessage
 import org.junit.jupiter.api.Test
-import social.bigbone.api.exception.InstanceVersionRetrievalException
+import social.bigbone.api.exception.InstanceRetrievalException
 import social.bigbone.api.exception.ServerInfoRetrievalException
+import social.bigbone.api.exception.UnsupportedServerException
 import social.bigbone.nodeinfo.NodeInfoClient
 import social.bigbone.nodeinfo.entity.Server
 import java.net.UnknownHostException
@@ -45,7 +46,7 @@ class MastodonClientTest {
                 every { isSuccessful } answers { true }
                 every { close() } returns Unit
             }
-            every { executeInstanceRequest() } answers { responseMock }
+            every { executeInstanceRequest(any()) } answers { responseMock }
         }
             .withHttpsDisabled()
             .withPort(port)
@@ -72,7 +73,7 @@ class MastodonClientTest {
                 every { isSuccessful } answers { true }
                 every { close() } returns Unit
             }
-            every { executeInstanceRequest() } answers { responseMock }
+            every { executeInstanceRequest(any()) } answers { responseMock }
         }
 
         invoking(clientBuilder::build)
@@ -81,7 +82,7 @@ class MastodonClientTest {
     }
 
     @Test
-    fun `Given a server that doesn't run Mastodon, when building MastodonClient, then fail with InstanceVersionRetrievalException`() {
+    fun `Given a server that doesn't run Mastodon, when building MastodonClient, then fail with UnsupportedServerException`() {
         val serverUrl = "diasp.eu"
         val scheme = "http"
         val port = 443
@@ -99,17 +100,17 @@ class MastodonClientTest {
                 every { isSuccessful } answers { false }
                 every { close() } returns Unit
             }
-            every { executeInstanceRequest() } answers { responseMock }
+            every { executeInstanceRequest(any()) } answers { responseMock }
         }
             .withHttpsDisabled()
             .withPort(port)
         invoking(clientBuilder::build)
-            .shouldThrow(InstanceVersionRetrievalException::class)
+            .shouldThrow(UnsupportedServerException::class)
             .withMessage("Server $serverUrl doesn't appear to run Mastodon")
     }
 
     @Test
-    fun `Given a server that doesn't run Mastodon, when building MastodonClient with defaults, then fail with InstanceVersionRetrievalException`() {
+    fun `Given a server that doesn't run Mastodon, when building MastodonClient with defaults, then fail with UnsupportedServerException`() {
         val serverUrl = "diasp.eu"
         val clientBuilder = spyk(MastodonClient.Builder(serverUrl)) {
             // Mock internal NodeInfoClient so that we don't open the site in unit testing
@@ -125,10 +126,10 @@ class MastodonClientTest {
                 every { isSuccessful } answers { false }
                 every { close() } returns Unit
             }
-            every { executeInstanceRequest() } answers { responseMock }
+            every { executeInstanceRequest(any()) } answers { responseMock }
         }
         invoking(clientBuilder::build)
-            .shouldThrow(InstanceVersionRetrievalException::class)
+            .shouldThrow(UnsupportedServerException::class)
             .withMessage("Server $serverUrl doesn't appear to run Mastodon")
     }
 
@@ -142,7 +143,8 @@ class MastodonClientTest {
     @Test
     fun `Given streaming URL in instance response, when building MastodonClient, then use that streaming URL`() {
         val serverUrl = "mastodon.example"
-        val expectedStreamingUrl = "wss://streaming.example.com"
+        val streamingUrl = "wss://streaming.example.com"
+
         val clientBuilder = spyk(MastodonClient.Builder(serverUrl)) {
             // Mock internal NodeInfoClient so that we don't open the site in unit testing
             mockkObject(NodeInfoClient)
@@ -160,7 +162,7 @@ class MastodonClientTest {
                   "description": "A Mastodon instance for testing",
                   "configuration": {
                     "urls": {
-                      "streaming": "$expectedStreamingUrl"
+                      "streaming": "$streamingUrl"
                     }
                   }
                 }
@@ -171,11 +173,16 @@ class MastodonClientTest {
                 every { isSuccessful } answers { true }
                 every { close() } returns Unit
             }
-            every { executeInstanceRequest() } answers { responseMock }
+            every { executeInstanceRequest(any()) } answers { responseMock }
         }
 
         val mastodonClient = clientBuilder.build()
-        mastodonClient.streamingUrl shouldBeEqualTo expectedStreamingUrl.replace("wss", "https")
+        with(mastodonClient.streamingUrl) {
+            scheme shouldBeEqualTo "https"
+            host shouldBeEqualTo "streaming.example.com"
+            encodedPath shouldBeEqualTo "/"
+            toString() shouldBeEqualTo "https://streaming.example.com/"
+        }
     }
 
     @Test
@@ -207,16 +214,24 @@ class MastodonClientTest {
                 every { isSuccessful } answers { true }
                 every { close() } returns Unit
             }
-            every { executeInstanceRequest() } answers { responseMock }
+            every { executeInstanceRequest(any()) } answers { responseMock }
         }
 
         val mastodonClient = clientBuilder.build()
-        mastodonClient.streamingUrl shouldBeEqualTo "https://$serverUrl/"
+        with(mastodonClient.streamingUrl) {
+            scheme shouldBeEqualTo "https"
+            host shouldBeEqualTo serverUrl
+            encodedPath shouldBeEqualTo "/"
+            toString() shouldBeEqualTo "https://$serverUrl/"
+        }
     }
 
     @Test
-    fun `Given valid server response and no response to instance request, when building server, then use fallback streaming url`() {
+    fun `Given valid server response and no response to instance request, when building server, then fail`() {
         val serverUrl = "mastodon.example"
+        val failureCode = 418
+        val failureMessage = "I’m a teapot"
+
         val clientBuilder = spyk(MastodonClient.Builder(serverUrl)) {
             // Mock internal NodeInfoClient so that we don't open the site in unit testing
             mockkObject(NodeInfoClient)
@@ -230,12 +245,41 @@ class MastodonClientTest {
                     "{\"error\": \"Instance info not found\"}".toResponseBody("application/json".toMediaType())
                 }
                 every { isSuccessful } answers { false }
+                every { code } answers { failureCode }
+                every { message } answers { failureMessage }
                 every { close() } returns Unit
             }
-            every { executeInstanceRequest() } answers { responseMock }
+            every { executeInstanceRequest(any()) } answers { responseMock }
         }
 
-        val mastodonClient = clientBuilder.build()
-        mastodonClient.streamingUrl shouldBeEqualTo "https://$serverUrl/"
+        invoking(clientBuilder::build)
+            .shouldThrow(InstanceRetrievalException::class)
+            .withMessage("Instance request failed with code $failureCode and message \"$failureMessage\".")
+    }
+
+    @Test
+    fun `Given valid server response and non-parseable response to instance request, when building server, then fail`() {
+        val serverUrl = "mastodon.example"
+        val jsonResponse = "[]"
+
+        val clientBuilder = spyk(MastodonClient.Builder(serverUrl)) {
+            // Mock internal NodeInfoClient so that we don't open the site in unit testing
+            mockkObject(NodeInfoClient)
+            every { NodeInfoClient.retrieveServerInfo(host = serverUrl) } returns Server(
+                schemaVersion = "2.0",
+                software = Server.Software(name = "mastodon", version = "4.3.5")
+            )
+
+            val responseMock = mockk<Response> {
+                every { body } answers { jsonResponse.toResponseBody("application/json".toMediaType()) }
+                every { isSuccessful } answers { true }
+                every { close() } returns Unit
+            }
+            every { executeInstanceRequest(any()) } answers { responseMock }
+        }
+
+        invoking(clientBuilder::build)
+            .shouldThrow(InstanceRetrievalException::class)
+            .withMessage("Could not decode response from server into Instance: $jsonResponse")
     }
 }
diff --git a/bigbone/src/test/kotlin/social/bigbone/api/method/AppMethodsTest.kt b/bigbone/src/test/kotlin/social/bigbone/api/method/AppMethodsTest.kt
index 836baf6b7..39cf38612 100644
--- a/bigbone/src/test/kotlin/social/bigbone/api/method/AppMethodsTest.kt
+++ b/bigbone/src/test/kotlin/social/bigbone/api/method/AppMethodsTest.kt
@@ -13,7 +13,7 @@ class AppMethodsTest {
     @Test
     fun createApp() {
         val client: MastodonClient = MockClient.mock("application.json")
-        every { client.getInstanceName() } returns "mastodon.cloud"
+        every { client.getInstance().domain } returns "mastodon.cloud"
 
         val appMethods = AppMethods(client)
         val application = appMethods.createApp(
@@ -40,7 +40,7 @@ class AppMethodsTest {
     @Test
     fun verifyCredentials() {
         val client: MastodonClient = MockClient.mock("application_no_client_data.json")
-        every { client.getInstanceName() } returns "mastodon.cloud"
+        every { client.getInstance().domain } returns "mastodon.cloud"
 
         val appMethods = AppMethods(client)
         val application = appMethods.verifyCredentials().execute()
diff --git a/bigbone/src/test/kotlin/social/bigbone/api/method/OAuthMethodsTest.kt b/bigbone/src/test/kotlin/social/bigbone/api/method/OAuthMethodsTest.kt
index af7c24b98..adc383ed2 100644
--- a/bigbone/src/test/kotlin/social/bigbone/api/method/OAuthMethodsTest.kt
+++ b/bigbone/src/test/kotlin/social/bigbone/api/method/OAuthMethodsTest.kt
@@ -20,7 +20,7 @@ class OAuthMethodsTest {
     @Test
     fun getOAuthUrl() {
         val client: MastodonClient = mockk()
-        every { client.getInstanceName() } returns "mastodon.cloud"
+        every { client.getInstance().domain } returns "mastodon.cloud"
         every { client.getScheme() } returns "https"
         every { client.getPort() } returns 443
 
@@ -37,7 +37,7 @@ class OAuthMethodsTest {
     @Test
     fun getOAuthUrlWithoutScopeWithLanguageWithForceLogin() {
         val client: MastodonClient = mockk()
-        every { client.getInstanceName() } returns "mastodon.cloud"
+        every { client.getInstance().domain } returns "mastodon.cloud"
         every { client.getScheme() } returns "https"
         every { client.getPort() } returns 443
 
@@ -55,7 +55,7 @@ class OAuthMethodsTest {
     @Test
     fun getUserAccessTokenWithAuthorizationCodeGrant() {
         val client: MastodonClient = MockClient.mock("access_token.json")
-        every { client.getInstanceName() } returns "mastodon.cloud"
+        every { client.getInstance().domain } returns "mastodon.cloud"
         val oauth = OAuthMethods(client)
         val accessToken = oauth.getUserAccessTokenWithAuthorizationCodeGrant(
             clientId = "test",
@@ -73,7 +73,7 @@ class OAuthMethodsTest {
     fun getAccessTokenWithAuthorizationCodeGrantWithException() {
         Assertions.assertThrows(BigBoneRequestException::class.java) {
             val client: MastodonClient = MockClient.ioException()
-            every { client.getInstanceName() } returns "mastodon.cloud"
+            every { client.getInstance().domain } returns "mastodon.cloud"
             val oauth = OAuthMethods(client)
             oauth.getUserAccessTokenWithAuthorizationCodeGrant(
                 clientId = "test",
@@ -87,7 +87,7 @@ class OAuthMethodsTest {
     @Test
     fun getAccessTokenWithClientCredentialsGrant() {
         val client: MastodonClient = MockClient.mock("access_token.json")
-        every { client.getInstanceName() } returns "mastodon.cloud"
+        every { client.getInstance().domain } returns "mastodon.cloud"
         val oauth = OAuthMethods(client)
         val accessToken = oauth.getAccessTokenWithClientCredentialsGrant(
             clientId = "test",
@@ -104,7 +104,7 @@ class OAuthMethodsTest {
     fun getAccessTokenWithClientCredentialsGrantWithException() {
         Assertions.assertThrows(BigBoneRequestException::class.java) {
             val client: MastodonClient = MockClient.ioException()
-            every { client.getInstanceName() } returns "mastodon.cloud"
+            every { client.getInstance().domain } returns "mastodon.cloud"
             val oauth = OAuthMethods(client)
             oauth.getAccessTokenWithClientCredentialsGrant(
                 clientId = "test",
@@ -117,7 +117,7 @@ class OAuthMethodsTest {
     @Test
     fun getAccessTokenWithPasswordGrant() {
         val client: MastodonClient = MockClient.mock("access_token.json")
-        every { client.getInstanceName() } returns "mastodon.cloud"
+        every { client.getInstance().domain } returns "mastodon.cloud"
         val oauth = OAuthMethods(client)
         val accessToken = oauth.getUserAccessTokenWithPasswordGrant(
             "test",
@@ -136,7 +136,7 @@ class OAuthMethodsTest {
     fun getAccessTokenWithPasswordGrantWithException() {
         Assertions.assertThrows(BigBoneRequestException::class.java) {
             val client: MastodonClient = MockClient.ioException()
-            every { client.getInstanceName() } returns "mastodon.cloud"
+            every { client.getInstance().domain } returns "mastodon.cloud"
             val oauth = OAuthMethods(client)
             oauth.getUserAccessTokenWithPasswordGrant(
                 clientId = "test",