fix(mobile): align profile routing, catalog timeout and replay cancellation

This commit is contained in:
yilsem committed 2026-10-07 20:23:17 +03:00
1 parent 827c74b3c3
commit cb7e6acb59
21 files changed
+297 -13

No files matched your search

@@ -42,6 +42,7 @@ import org.junit.jupiter.api.Test
class AiturkCompatTest {
private val unavailable = "Bu özellik AITURK IDE'nin bu sürümünde yok."
private val forbidden = "Güvenlik nedeniyle bu işlem telefondan yapılamaz."
private val backendNotReady = "Seçili profil IDE’de henüz hazır değil."
@BeforeEach
fun setUp() {
@@ -50,6 +51,7 @@ class AiturkCompatTest {
when (id) {
R.string.aiturk_feature_unavailable -> unavailable
R.string.aiturk_phone_forbidden -> forbidden
R.string.aiturk_backend_not_ready -> backendNotReady
else -> null
}
}
@@ -58,6 +60,24 @@ class AiturkCompatTest {
@AfterEach
fun tearDown() = AiturkCompat.resetForTest()
@Test
fun phoneProfileIsExplicitOnWebSocketsAndPlainHermesIsUnchanged() {
AiturkCompat.linkedOverride = true
assertEquals(mapOf("profile" to "default"), AiturkCompat.webSocketProfile(null))
assertEquals(mapOf("profile" to "kodlama"), AiturkCompat.webSocketProfile("kodlama"))
AiturkCompat.linkedOverride = false
assertTrue(AiturkCompat.webSocketProfile("kodlama").isEmpty())
}
@Test
fun unresolvedIdeBackendGetsAnActionableMessageWithoutChangingOtherServerErrors() {
AiturkCompat.linkedOverride = true
assertEquals(backendNotReady, AiturkCompat.httpErrorMessage(503, """{"error":"backend_unavailable"}"""))
assertNull(AiturkCompat.httpErrorMessage(503, """{"error":"different_failure"}"""))
AiturkCompat.linkedOverride = false
assertNull(AiturkCompat.httpErrorMessage(503, """{"error":"backend_unavailable"}"""))
}
private class Gateway(
scope: CoroutineScope,
) {
@@ -0,0 +1,36 @@
package com.m57.hermescontrol.data.remote
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Test
import java.util.concurrent.TimeUnit
class CatalogTimeoutInterceptorTest {
@Test
fun `only catalog reads receive the discovery timeout including proxy prefixes`() {
for (path in listOf("/api/model/options", "/hermes/api/model/options", "/api/cron/jobs", "/api/model/options-extra")) {
var timeout = 0
val client =
OkHttpClient.Builder()
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(CatalogTimeoutInterceptor)
.addInterceptor { chain ->
timeout = chain.readTimeoutMillis()
Response.Builder()
.request(chain.request())
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body("{}".toResponseBody())
.build()
}
.build()
client.newCall(Request.Builder().url("https://test.invalid$path").build()).execute().close()
assertEquals(if (path.endsWith("/api/model/options")) 120_000 else 30_000, timeout)
}
}
}
@@ -98,9 +98,9 @@ class GatewayFileClientTest {
}
@Test
fun `normalizePath expands tilde`() {
val home = System.getenv("HOME") ?: "/home/test"
assertEquals("$home/foo.png", GatewayFileClient.normalizePath("~/foo.png"))
fun `normalizePath expands tilde only when home is known`() {
val home = System.getenv("HOME")
assertEquals(home?.let { "$it/foo.png" }, GatewayFileClient.normalizePath("~/foo.png"))
}
@Test
@@ -44,7 +44,7 @@ class RawRpcParamsRatchetTest {
rootDir
.walkTopDown()
.filter { it.isFile && it.extension == "kt" }
.filter { !it.path.contains("data/ws/contract") && it.name != "WsMethods.kt" }
.filter { !it.invariantSeparatorsPath.contains("data/ws/contract") && it.name != "WsMethods.kt" }
.forEach { file ->
val relativePath = file.relativeTo(rootDir).invariantSeparatorsPath
val code = stripComments(file.readText())
@@ -27,6 +27,30 @@ class ReplayTrackerTest {
private fun jsonElement(raw: String): JsonObject = json.parseToJsonElement(raw) as JsonObject
@Test
fun `cancelled replay does not report failure or request stale transcript`() = runTest {
val started = CompletableDeferred<Unit>()
var failures = 0
val events = mutableListOf<WsEvent>()
val tracker = ReplayTracker(
scope = this,
fetchEventsSince = {
started.complete(Unit)
kotlinx.coroutines.awaitCancellation()
},
emitEvent = { events.add(it) },
onReplayFailure = { _, _ -> failures++ },
)
tracker.setSeqWatermark("old-profile", 1)
tracker.triggerReplay()
started.await()
tracker.clear()
advanceUntilIdle()
assertEquals(0, failures)
assertTrue(events.isEmpty())
assertFalse(tracker.isReplayInFlight())
}
@Test
fun `initial state has empty watermarks and no replay in flight`() {
val testDispatcher = StandardTestDispatcher()
@@ -555,4 +555,30 @@ class CronJobsViewModelTest {
assertEquals("gateway unreachable", job.last_fire_error?.detail)
assertEquals("2026-08-18T09:00:00", job.last_fire_error?.at)
}
@Test
fun `lost create response does not create duplicate scheduled jobs`() {
val vm = createViewModel()
vm.openNewJobDialog()
settle()
vm.updateEditorField("name", "Retry safety")
vm.updateEditorField("schedule", "every 1d")
coEvery { mockApi.createCronJob(any()) } throws java.net.SocketTimeoutException("reply lost")
vm.saveEditor()
settle()
coVerify(exactly = 1) { mockApi.createCronJob(any()) }
assertTrue(vm.uiState.value.editorState.isOpen)
assertFalse(vm.uiState.value.editorState.isSaving)
assertNotNull(vm.uiState.value.editorState.toastMessage)
}
@Test
fun `lost trigger response does not run a scheduled job twice`() {
val vm = createViewModel()
coEvery { mockApi.triggerCronJob("j-test") } throws java.net.SocketTimeoutException("reply lost")
vm.triggerCronJob("j-test")
settle()
coVerify(exactly = 1) { mockApi.triggerCronJob("j-test") }
assertNotNull(vm.uiState.value.toastMessage)
}
}