diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/CallExt.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/CallExt.kt new file mode 100644 index 00000000..c37337e6 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/CallExt.kt @@ -0,0 +1,37 @@ +package com.m57.hermescontrol.data.remote + +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Response +import java.io.IOException +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +suspend fun Call.await(): Response = + suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { + cancel() + } + enqueue( + object : Callback { + override fun onFailure( + call: Call, + e: IOException, + ) { + if (continuation.isActive) { + continuation.resumeWithException(e) + } + } + + override fun onResponse( + call: Call, + response: Response, + ) { + if (continuation.isActive) { + continuation.resume(response) + } + } + }, + ) + } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt index 54a049a8..9185ae8a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt @@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.remote.AuthPayloads import com.m57.hermescontrol.data.remote.CleartextPolicy import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.ServerEndpoint +import com.m57.hermescontrol.data.remote.await import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.ws.HermesWsClient import kotlinx.coroutines.Dispatchers @@ -213,7 +214,7 @@ class AuthLoginViewModel( * is non-null, the session token was found embedded in the dashboard SPA HTML * (loopback mode only) and can be auto-populated. */ - private fun probeDashboardInternal(endpoint: ServerEndpoint): ProbeResult? { + private suspend fun probeDashboardInternal(endpoint: ServerEndpoint): ProbeResult? { // Step 1: reachability + auth mode from the public status endpoint. val statusJson = try { @@ -223,7 +224,7 @@ class AuthLoginViewModel( .url(endpoint.resolve("api/status").toString()) .get() .build() - val resp = probeClient.newCall(req).execute() + val resp = probeClient.newCall(req).await() if (!resp.isSuccessful) return null resp.body.string() } catch (e: Exception) { @@ -256,7 +257,7 @@ class AuthLoginViewModel( .url(endpoint.resolve("").toString()) .get() .build() - val spaResp = probeClient.newCall(spaReq).execute() + val spaResp = probeClient.newCall(spaReq).await() val body = spaResp.body.string() val tokenMatch = Regex("""__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"""").find(body) extractedToken = tokenMatch?.groupValues?.getOrNull(1) @@ -406,7 +407,7 @@ class AuthLoginViewModel( * Authenticate with basic auth, then mint a WS ticket. * Returns the WS ticket and the session cookie for REST auth. */ - private fun connectBasicAuth( + private suspend fun connectBasicAuth( endpoint: ServerEndpoint, username: String, password: String, @@ -439,7 +440,7 @@ class AuthLoginViewModel( .header("Content-Type", "application/json") .post(jsonBody.toRequestBody()) .build() - val loginResp = OkHttpProvider.probe.newCall(loginReq).execute() + val loginResp = OkHttpProvider.probe.newCall(loginReq).await() if (!loginResp.isSuccessful) { val msg = @@ -462,7 +463,7 @@ class AuthLoginViewModel( .url(endpoint.resolve("api/auth/ws-ticket").toString()) .post("{}".toRequestBody()) .build() - val ticketResp = OkHttpProvider.probe.newCall(ticketReq).execute() + val ticketResp = OkHttpProvider.probe.newCall(ticketReq).await() if (!ticketResp.isSuccessful) { _uiState.update { diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/CallExtTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/CallExtTest.kt new file mode 100644 index 00000000..fc0f62ec --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/CallExtTest.kt @@ -0,0 +1,61 @@ +package com.m57.hermescontrol.data.remote + +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +class CallExtTest { + @Test + fun testAwaitSuccess() = + runTest { + val call = mockk() + val request = Request.Builder().url("http://localhost").build() + val response = + Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .build() + + val callbackSlot = slot() + every { call.enqueue(capture(callbackSlot)) } answers { + callbackSlot.captured.onResponse(call, response) + } + every { call.cancel() } returns Unit + + val result = call.await() + assertEquals(200, result.code) + } + + @Test + fun testAwaitFailure() = + runTest { + val call = mockk() + val exception = IOException("Failed") + + val callbackSlot = slot() + every { call.isCanceled() } returns false + every { call.enqueue(capture(callbackSlot)) } answers { + callbackSlot.captured.onFailure(call, exception) + } + every { call.cancel() } returns Unit + + try { + call.await() + } catch (e: Exception) { + assertEquals("Failed", e.message) + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModelTest.kt index 69f34d0c..082e5e70 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModelTest.kt @@ -6,6 +6,8 @@ import com.m57.hermescontrol.R import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.ServerEndpoint +import com.m57.hermescontrol.data.remote.await +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -26,6 +28,7 @@ import java.io.IOException class AuthLoginViewModelTest { private lateinit var viewModel: AuthLoginViewModel private val app = mockk(relaxed = true) + private val mockProbeClient = mockk(relaxed = true) @Before fun setup() { @@ -34,11 +37,12 @@ class AuthLoginViewModelTest { every { AuthManager.getConnectionProfiles() } returns emptyList() mockkObject(OkHttpProvider) - every { OkHttpProvider.probe } returns mockk(relaxed = true) + every { OkHttpProvider.probe } returns mockProbeClient mockkStatic(Log::class) - every { Log.w(any(), any()) } returns 0 - every { Log.d(any(), any()) } returns 0 + mockkStatic("com.m57.hermescontrol.data.remote.CallExtKt") + every { android.util.Log.w(any(), any()) } returns 0 + every { android.util.Log.d(any(), any()) } returns 0 every { app.getString(R.string.auth_login_error_unreachable) } returns "Dashboard unreachable" @@ -84,8 +88,8 @@ class AuthLoginViewModelTest { @Test fun `probe when status probe throws IOException updates error state`() { val mockCall = mockk() - every { OkHttpProvider.probe.newCall(any()) } returns mockCall - every { mockCall.execute() } throws IOException("Connection refused") + every { mockProbeClient.newCall(any()) } returns mockCall + coEvery { mockCall.await() } throws IOException("Connection refused") viewModel.probe() @@ -99,8 +103,8 @@ class AuthLoginViewModelTest { fun `probe when status probe returns unsuccessful updates error state`() { val mockCall = mockk() val mockResponse = mockk() - every { OkHttpProvider.probe.newCall(any()) } returns mockCall - every { mockCall.execute() } returns mockResponse + every { mockProbeClient.newCall(any()) } returns mockCall + coEvery { mockCall.await() } returns mockResponse every { mockResponse.isSuccessful } returns false viewModel.probe()