feat(#1121): add multiplexed gateway awareness (#1127)

This commit is contained in:
M57 authored and GitHub committed 2026-09-15 15:56:32 +03:00
1 parent fe465ad93c
commit 528ec4f28f
20 files changed
+1065 -8

No files matched your search

@@ -27,6 +27,7 @@ import com.m57.hermescontrol.data.model.MemoryResponse
import com.m57.hermescontrol.data.model.MemoryStats
import com.m57.hermescontrol.data.model.MessagingPlatform
import com.m57.hermescontrol.data.model.MessagingPlatformResponse
import com.m57.hermescontrol.data.model.MessagingPlatformUpdateResponse
import com.m57.hermescontrol.data.model.MoaConfigResponse
import com.m57.hermescontrol.data.model.MoaModelSlot
import com.m57.hermescontrol.data.model.ModelOptionsResponse
@@ -1118,7 +1119,8 @@ class E2eIntegrationTest {
platforms = listOf(platform),
),
)
coEvery { mockApiService.configurePlatform("telegram", any()) } returns Response.success(Unit)
coEvery { mockApiService.configurePlatform("telegram", any()) } returns
Response.success(MessagingPlatformUpdateResponse(ok = true))
val viewModel = ChannelsViewModel()
viewModel.loadPlatforms()
@@ -0,0 +1,174 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class GatewayMultiplexSerializationTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `status decodes shared gateway profile names`() {
val response =
json.decodeFromString<StatusResponse>(
"""
{
"gateway_running": true,
"gateway_shared_with": ["default", "coding"]
}
""".trimIndent(),
)
assertEquals(listOf("default", "coding"), response.gatewaySharedWith)
}
@Test
fun `status accepts null and absent shared gateway field`() {
val nullResponse = json.decodeFromString<StatusResponse>("""{"gateway_shared_with": null}""")
val oldResponse = json.decodeFromString<StatusResponse>("""{"gateway_running": true}""")
assertNull(nullResponse.gatewaySharedWith)
assertNull(oldResponse.gatewaySharedWith)
}
@Test
fun `messaging platform decodes backend ingress url`() {
val platform =
json.decodeFromString<MessagingPlatform>(
"""
{
"id": "telegram",
"name": "Telegram",
"enabled": true,
"configured": true,
"ingress_url": "/some/backend/value"
}
""".trimIndent(),
)
assertEquals("/some/backend/value", platform.ingressUrl)
}
@Test
fun `messaging platform update decodes hot served and old response safely`() {
val hotServed =
json.decodeFromString<MessagingPlatformUpdateResponse>(
"""{"ok": true, "platform": "telegram", "hot_served": true}""",
)
val oldResponse =
json.decodeFromString<MessagingPlatformUpdateResponse>("""{"ok": true, "platform": "telegram"}""")
assertTrue(hotServed.ok)
assertEquals("telegram", hotServed.platform)
assertTrue(hotServed.hotServed)
assertFalse(oldResponse.hotServed)
}
@Test
fun `migration plan decodes operational states and ignores low level fields in ui`() {
val plan =
json.decodeFromString<GatewayMigrationPlan>(
"""
{
"default_home": "/home/hermes",
"profiles": [
{
"profile": "default",
"home": "/home/hermes",
"pid": 1234,
"service": {"kind": "systemd", "system": false},
"services": [
{"kind": "systemd", "system": false},
{"kind": "systemd", "system": true}
],
"uid": 1000,
"runtime_home": "/home/hermes"
},
{"profile": "coding"}
],
"multiplex_flag_on": false,
"live_served": null,
"already_multiplexed": false,
"interrupted": true,
"blockers": ["duplicate credential"],
"notices": ["callback URL changes"],
"eligible": false,
"command": "hermes gateway migrate --multiplex"
}
""".trimIndent(),
)
assertEquals(listOf("default", "coding"), plan.profiles.map { it.profile })
assertEquals(1234, plan.profiles.first().pid)
assertEquals(
2,
plan.profiles
.first()
.services.size,
)
assertEquals(
"systemd",
plan.profiles
.first()
.services[0]
.kind,
)
assertFalse(
plan.profiles
.first()
.services[0]
.system ?: true,
)
assertTrue(
plan.profiles
.first()
.services[1]
.system == true,
)
assertTrue(plan.interrupted)
assertEquals(listOf("duplicate credential"), plan.blockers)
assertEquals(listOf("callback URL changes"), plan.notices)
assertFalse(plan.eligible)
}
@Test
fun `migration profile service fields remain optional`() {
val emptyServices =
json.decodeFromString<GatewayMigrationPlan>(
"""
{
"profiles": [
{"profile": "default", "service": null, "services": []}
]
}
""".trimIndent(),
)
val missingServices =
json.decodeFromString<GatewayMigrationPlan>(
"""
{
"profiles": [
{"profile": "default"}
]
}
""".trimIndent(),
)
assertNull(emptyServices.profiles.first().service)
assertTrue(
emptyServices.profiles
.first()
.services
.isEmpty(),
)
assertTrue(
missingServices.profiles
.first()
.services
.isEmpty(),
)
}
}
@@ -0,0 +1,109 @@
package com.m57.hermescontrol.ui.channels
import com.m57.hermescontrol.data.model.MessagingPlatform
import com.m57.hermescontrol.data.model.MessagingPlatformResponse
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
import com.m57.hermescontrol.data.model.MessagingPlatformUpdateResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class ChannelsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var mockApi: HermesApiService
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkObject(ApiClient)
mockApi = mockk(relaxed = true)
every { ApiClient.hermesApi } returns mockApi
coEvery { mockApi.getMessagingPlatforms() } returns Response.success(platformsResponse())
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun `hot served update clears restart requirement and refreshes platforms`() {
coEvery { mockApi.configurePlatform("telegram", any()) } returns
Response.success(
MessagingPlatformUpdateResponse(
ok = true,
platform = "telegram",
hotServed = true,
),
)
val viewModel = ChannelsViewModel()
viewModel.loadPlatforms()
testDispatcher.scheduler.advanceUntilIdle()
viewModel.configurePlatform("telegram", MessagingPlatformUpdate(env = mapOf("token" to "redacted")))
testDispatcher.scheduler.advanceUntilIdle()
assertFalse(viewModel.uiState.value.restartNeeded)
assertTrue(
viewModel.uiState.value.toastMessage
?.contains("reloaded live") == true,
)
coVerify { mockApi.configurePlatform("telegram", any()) }
coVerify(atLeast = 2) { mockApi.getMessagingPlatforms() }
}
@Test
fun `update without hot served preserves restart required behavior`() {
coEvery { mockApi.configurePlatform("telegram", any()) } returns
Response.success(MessagingPlatformUpdateResponse(ok = true, platform = "telegram"))
val viewModel = ChannelsViewModel()
viewModel.loadPlatforms()
testDispatcher.scheduler.advanceUntilIdle()
viewModel.configurePlatform("telegram", MessagingPlatformUpdate(enabled = true))
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(viewModel.uiState.value.restartNeeded)
assertTrue(
viewModel.uiState.value.toastMessage
?.contains("restart the gateway") == true,
)
}
private fun platformsResponse(): MessagingPlatformResponse =
MessagingPlatformResponse(
envPath = "/tmp/.env",
gatewayStartCommand = "hermes gateway start",
platforms =
listOf(
MessagingPlatform(
id = "telegram",
name = "Telegram",
enabled = true,
configured = true,
),
),
)
}
@@ -12,6 +12,7 @@ import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.ProfileSetupCommandResponse
import com.m57.hermescontrol.data.model.ProfilesResponse
import com.m57.hermescontrol.data.model.RenameProfileRequest
import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.remote.NetworkError
@@ -62,6 +63,7 @@ class ProfilesViewModelTest {
coEvery { mockApi.getProfiles() } returns
Response.success(ProfilesResponse(listOf(ProfileInfo(name = "default", is_default = true))))
coEvery { mockApi.getActiveProfile() } returns Response.success(ActiveProfileResponse(active = "default"))
coEvery { mockApi.getStatus() } returns Response.success(StatusResponse())
}
@Before
@@ -360,6 +362,53 @@ class ProfilesViewModelTest {
)
}
@Test
fun `loadProfiles displays normalized shared gateway profiles`() {
coEvery { mockApi.getProfiles() } returns
Response.success(
ProfilesResponse(
listOf(
ProfileInfo(name = "default", is_default = true),
ProfileInfo(name = "coding"),
),
),
)
coEvery { mockApi.getStatus() } returns
Response.success(
StatusResponse(
gatewaySharedWith = listOf("default", "coding", "coding", " ", ""),
),
)
val vm = createViewModel()
vm.loadProfiles()
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(listOf("default", "coding"), vm.uiState.value.sharedGatewayProfiles)
assertNull(vm.uiState.value.errorMessage)
}
@Test
fun `loadProfiles keeps profiles usable when optional status request fails`() {
coEvery { mockApi.getStatus() } returns errorResponse(404)
val vm = createViewModel()
vm.loadProfiles()
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
listOf("default"),
vm.uiState.value.profiles
.map { it.name },
)
assertEquals("default", vm.uiState.value.activeProfileName)
assertTrue(
vm.uiState.value.sharedGatewayProfiles
.isEmpty(),
)
assertNull(vm.uiState.value.errorMessage)
}
@Test
fun `cloneProfile success calls createProfile with clone_from and reloads`() {
coEvery { mockApi.createProfile(any()) } returns Response.success(Unit)
@@ -0,0 +1,182 @@
package com.m57.hermescontrol.ui.system
import android.app.Application
import com.m57.hermescontrol.data.model.GatewayMigrationPlan
import com.m57.hermescontrol.data.model.GatewayMigrationProfile
import com.m57.hermescontrol.data.model.GatewayMigrationStartResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.ui.common.ActionProgressPhase
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class GatewayMigrationViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val mockApp = mockk<Application>(relaxed = true)
private lateinit var mockApi: HermesApiService
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkObject(ApiClient)
mockApi = mockk(relaxed = true)
every { ApiClient.hermesApi } returns mockApi
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun `unsupported migration plan is capability local`() {
coEvery { mockApi.getGatewayMigrationPlan() } returns
Response.error(404, "unsupported".toResponseBody(null))
val viewModel = SystemViewModel(mockApp, migrationDispatcher = testDispatcher)
viewModel.loadMigrationPlan()
testDispatcher.scheduler.advanceUntilIdle()
assertFalse(viewModel.uiState.value.migrationSupported == true)
assertEquals(false, viewModel.uiState.value.migrationSupported)
assertNull(viewModel.uiState.value.migrationPlan)
}
@Test
fun `migration plan exposes backend states without inventing eligibility rules`() {
val plan =
GatewayMigrationPlan(
profiles = listOf(GatewayMigrationProfile(profile = "default")),
eligible = false,
blockers = emptyList(),
notices = listOf("explicit migration is available"),
)
coEvery { mockApi.getGatewayMigrationPlan() } returns Response.success(plan)
val viewModel = SystemViewModel(mockApp, migrationDispatcher = testDispatcher)
viewModel.loadMigrationPlan()
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(plan, viewModel.uiState.value.migrationPlan)
assertEquals(
false,
viewModel.uiState.value.migrationPlan
?.eligible,
)
assertTrue(
viewModel.uiState.value.migrationPlan
?.blockers
?.isEmpty() == true,
)
}
@Test
fun `migration POST only starts action tracking and does not report completion`() {
val plan = GatewayMigrationPlan(eligible = false)
coEvery { mockApi.getGatewayMigrationPlan() } returns Response.success(plan)
coEvery { mockApi.startGatewayMigration() } returns
Response.success(
GatewayMigrationStartResponse(
ok = true,
pid = 1234,
name = "gateway-migrate",
),
)
val viewModel = SystemViewModel(mockApp, migrationDispatcher = testDispatcher)
viewModel.loadMigrationPlan()
testDispatcher.scheduler.advanceUntilIdle()
viewModel.startMigration()
testDispatcher.scheduler.runCurrent()
assertTrue(viewModel.migrationProgress.state.value.visible)
assertEquals(ActionProgressPhase.RUNNING, viewModel.migrationProgress.state.value.phase)
assertEquals("gateway-migrate", viewModel.migrationProgress.state.value.actionName)
coVerify { mockApi.startGatewayMigration() }
}
@Test
fun `migration POST with an unsuccessful body is shown as rejected`() {
coEvery { mockApi.getGatewayMigrationPlan() } returns Response.success(GatewayMigrationPlan())
coEvery { mockApi.startGatewayMigration() } returns
Response.success(
GatewayMigrationStartResponse(
ok = false,
name = "gateway-migrate",
),
)
val viewModel = SystemViewModel(mockApp, migrationDispatcher = testDispatcher)
viewModel.loadMigrationPlan()
testDispatcher.scheduler.advanceUntilIdle()
viewModel.startMigration()
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(ActionProgressPhase.FAILED, viewModel.migrationProgress.state.value.phase)
assertEquals("Migration was rejected by the backend", viewModel.migrationProgress.state.value.error)
coVerify { mockApi.startGatewayMigration() }
}
@Test
fun `already multiplexed plan never starts backend action`() {
coEvery { mockApi.getGatewayMigrationPlan() } returns
Response.success(GatewayMigrationPlan(alreadyMultiplexed = true))
val viewModel = SystemViewModel(mockApp, migrationDispatcher = testDispatcher)
viewModel.loadMigrationPlan()
testDispatcher.scheduler.advanceUntilIdle()
viewModel.startMigration()
testDispatcher.scheduler.runCurrent()
assertFalse(viewModel.migrationProgress.state.value.visible)
assertTrue(
viewModel.uiState.value.toastMessage
?.contains("already multiplexed") == true,
)
coVerify(exactly = 0) { mockApi.startGatewayMigration() }
}
@Test
fun `migration with blockers never starts backend action`() {
coEvery { mockApi.getGatewayMigrationPlan() } returns
Response.success(GatewayMigrationPlan(blockers = listOf("duplicate credential")))
val viewModel = SystemViewModel(mockApp, migrationDispatcher = testDispatcher)
viewModel.loadMigrationPlan()
testDispatcher.scheduler.advanceUntilIdle()
viewModel.startMigration()
testDispatcher.scheduler.runCurrent()
assertFalse(viewModel.migrationProgress.state.value.visible)
assertTrue(
viewModel.uiState.value.toastMessage
?.contains("blocked") == true,
)
coVerify(exactly = 0) { mockApi.startGatewayMigration() }
}
}