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

@@ -0,0 +1,43 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GatewayMigrationPlan(
@SerialName("default_home") val defaultHome: String? = null,
val profiles: List<GatewayMigrationProfile> = emptyList(),
@SerialName("multiplex_flag_on") val multiplexFlagOn: Boolean = false,
@SerialName("live_served") val liveServed: List<String>? = null,
@SerialName("already_multiplexed") val alreadyMultiplexed: Boolean = false,
val interrupted: Boolean = false,
val blockers: List<String> = emptyList(),
val notices: List<String> = emptyList(),
val eligible: Boolean = false,
val command: String? = null,
)
@Serializable
data class GatewayMigrationProfile(
val profile: String? = null,
val home: String? = null,
val pid: Int? = null,
val service: GatewayMigrationService? = null,
val services: List<GatewayMigrationService> = emptyList(),
@SerialName("run_as_user") val runAsUser: String? = null,
val uid: Int? = null,
@SerialName("runtime_home") val runtimeHome: String? = null,
)
@Serializable
data class GatewayMigrationService(
val kind: String? = null,
val system: Boolean? = null,
)
@Serializable
data class GatewayMigrationStartResponse(
val ok: Boolean = false,
val pid: Int? = null,
val name: String? = null,
)
@@ -17,6 +17,7 @@ data class MessagingPlatform(
@SerialName("env_vars") val envVars: List<EnvVarField>? = null,
@SerialName("updated_at") val updatedAt: String? = null,
@SerialName("home_channel") val homeChannel: HomeChannelInfo? = null,
@SerialName("ingress_url") val ingressUrl: String? = null,
)
@Serializable
@@ -64,6 +65,13 @@ data class MessagingPlatformTestResult(
val message: String,
)
@Serializable
data class MessagingPlatformUpdateResponse(
val ok: Boolean = false,
val platform: String? = null,
@SerialName("hot_served") val hotServed: Boolean = false,
)
// ── Telegram onboarding ─────────────────────────────────────────────────
@Serializable
@@ -1,4 +1,5 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@@ -8,6 +9,8 @@ data class StatusResponse(
val active_sessions: Int? = null,
val auth_required: Boolean? = null,
val gateway_platforms: Map<String, PlatformStatus?>? = null,
@SerialName("gateway_shared_with")
val gatewaySharedWith: List<String>? = null,
val memory: MemoryPressureStatus? = null,
val disk: DiskPressureStatus? = null,
)
@@ -33,6 +33,8 @@ import com.m57.hermescontrol.data.model.EnvVarDeleteRequest
import com.m57.hermescontrol.data.model.EnvVarRevealRequest
import com.m57.hermescontrol.data.model.EnvVarRevealResponse
import com.m57.hermescontrol.data.model.EnvVarUpdate
import com.m57.hermescontrol.data.model.GatewayMigrationPlan
import com.m57.hermescontrol.data.model.GatewayMigrationStartResponse
import com.m57.hermescontrol.data.model.HealthStatus
import com.m57.hermescontrol.data.model.HookResponse
import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest
@@ -65,6 +67,7 @@ import com.m57.hermescontrol.data.model.MemoryResponse
import com.m57.hermescontrol.data.model.MessagingPlatformResponse
import com.m57.hermescontrol.data.model.MessagingPlatformTestResult
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
import com.m57.hermescontrol.data.model.MessagingPlatformUpdateResponse
import com.m57.hermescontrol.data.model.MoaConfigResponse
import com.m57.hermescontrol.data.model.ModelAssignmentRequest
import com.m57.hermescontrol.data.model.ModelAssignmentResponse
@@ -390,6 +393,12 @@ interface HermesApiService : KanbanApiService {
@POST("api/gateway/restart")
suspend fun restartGateway(): Response<Unit>
@GET("api/gateway/migrate/plan")
suspend fun getGatewayMigrationPlan(): Response<GatewayMigrationPlan>
@POST("api/gateway/migrate")
suspend fun startGatewayMigration(): Response<GatewayMigrationStartResponse>
@GET("api/profiles")
suspend fun getProfiles(): Response<ProfilesResponse>
@@ -677,7 +686,7 @@ interface HermesApiService : KanbanApiService {
suspend fun configurePlatform(
@Path("platform_id") platformId: String,
@Body config: MessagingPlatformUpdate,
): Response<Unit>
): Response<MessagingPlatformUpdateResponse>
@POST("api/messaging/platforms/{platform_id}/test")
suspend fun testMessagingPlatform(
@@ -125,7 +125,8 @@ class ChannelsViewModel :
}
},
onStart = {},
onSuccess = {
onSuccess = { response ->
val hotServed = response.hotServed
_uiState.update { state ->
state.copy(
platforms =
@@ -133,16 +134,28 @@ class ChannelsViewModel :
if (p.id == platformId) {
p.copy(
enabled = enabled,
state = if (enabled) "pending_restart" else "disabled",
state =
when {
!enabled -> "disabled"
hotServed -> "connected"
else -> "pending_restart"
},
)
} else {
p
}
},
restartNeeded = true,
restartNeeded = !hotServed,
togglingId = null,
toastMessage =
if (hotServed) {
"$platformId updated live"
} else {
null
},
)
}
loadPlatforms()
},
onError = { error ->
_uiState.update {
@@ -201,12 +214,18 @@ class ChannelsViewModel :
it.copy(isLoading = true, errorMessage = null)
}
},
onSuccess = {
val message = "$platformId configured successfully — restart the gateway for changes to take effect"
onSuccess = { response ->
val hotServed = response.hotServed
val message =
if (hotServed) {
"$platformId configured and reloaded live"
} else {
"$platformId configured successfully — restart the gateway for changes to take effect"
}
_uiState.update {
it.copy(
isLoading = false,
restartNeeded = true,
restartNeeded = !hotServed,
toastMessage = message,
)
}
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.channels.components
import android.content.ClipData
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.filled.RadioButtonChecked
@@ -34,18 +36,23 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.MessagingPlatform
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.channels.ConfigureForm
import kotlinx.coroutines.launch
private enum class StatusRole { SUCCESS, WARNING, ERROR, NEUTRAL }
@@ -110,6 +117,8 @@ internal fun PlatformCard(
) {
var showConfigureForm by remember { mutableStateOf(false) }
var showRemoveConfirm by remember { mutableStateOf(false) }
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val style = platformStateStyle(platform.state)
Card(modifier = Modifier.fillMaxWidth()) {
@@ -166,6 +175,40 @@ internal fun PlatformCard(
)
}
platform.ingressUrl?.takeIf { it.isNotBlank() }?.let { ingressUrl ->
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.channels_ingress_url),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = ingressUrl,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, ingressUrl)))
}
},
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = stringResource(R.string.channels_copy_ingress_url),
modifier = Modifier.size(18.dp),
)
}
}
}
// ── Error message ──
if (!platform.errorMessage.isNullOrBlank()) {
Spacer(modifier = Modifier.height(4.dp))
@@ -54,6 +54,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
@@ -232,8 +233,15 @@ fun ProfilesScreen(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (state.sharedGatewayProfiles.isNotEmpty()) {
item(key = "shared_gateway_summary") {
SharedGatewaySummary(state.sharedGatewayProfiles)
}
}
items(state.displayProfiles, key = { it.name }) { profile ->
val isActive = profile.name == state.activeProfileName
val isProfileShared = profile.name in state.sharedGatewayProfiles
Card(
modifier = Modifier.fillMaxWidth(),
colors =
@@ -293,6 +301,29 @@ fun ProfilesScreen(
)
}
}
if (isProfileShared) {
val sharedGatewayContainer =
MaterialTheme.colorScheme.secondaryContainer
val onSharedGatewayContainer =
MaterialTheme.colorScheme.onSecondaryContainer
val sharedGatewayLabel =
stringResource(R.string.profiles_shared_gateway_badge)
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier =
Modifier
.clip(RoundedCornerShape(4.dp))
.background(sharedGatewayContainer)
.padding(horizontal = 6.dp, vertical = 2.dp),
) {
Text(
text = sharedGatewayLabel,
color = onSharedGatewayContainer,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
)
}
}
}
val descriptionText =
if (!profile.description.isNullOrBlank()) {
@@ -971,6 +1002,43 @@ fun ProfilesScreen(
}
}
@Composable
private fun SharedGatewaySummary(profiles: List<String>) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.profiles_shared_gateway_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text =
pluralStringResource(
R.plurals.profiles_shared_gateway_count,
profiles.size,
profiles.size,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = profiles.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
// ---------------------------------------------------------------------
// Profile Builder Wizard
// ---------------------------------------------------------------------
@@ -34,6 +34,7 @@ data class ProfilesUiState(
val isLoading: Boolean = false,
val profiles: List<ProfileInfo> = emptyList(),
val activeProfileName: String? = null,
val sharedGatewayProfiles: List<String> = emptyList(),
val selectedSoulContent: String? = null,
val isLoadingSoul: Boolean = false,
val errorMessage: String? = null,
@@ -67,6 +68,15 @@ data class ProfilesUiState(
}
}
private fun normalizeSharedGatewayProfiles(names: List<String>?): List<String> =
names
.orEmpty()
.asSequence()
.map(String::trim)
.filter(String::isNotEmpty)
.distinct()
.toList()
class ProfilesViewModel(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : ViewModel(),
@@ -110,6 +120,7 @@ class ProfilesViewModel(
val profilesDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getProfiles() } }
val activeDeferred =
async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getActiveProfile() } }
val statusDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getStatus() } }
val profilesResult = profilesDeferred.await()
val activeResult = activeDeferred.await()
@@ -120,9 +131,20 @@ class ProfilesViewModel(
isLoading = false,
profiles = profilesResult.data.profiles.orEmpty(),
activeProfileName = activeResult.data.active,
sharedGatewayProfiles = emptyList(),
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
)
}
val statusResult = statusDeferred.await()
if (statusResult is NetworkResult.Success) {
_uiState.update {
it.copy(
sharedGatewayProfiles =
normalizeSharedGatewayProfiles(statusResult.data.gatewaySharedWith),
)
}
}
} else {
val profilesError = (profilesResult as? NetworkResult.Failure)?.error?.message ?: "Success"
val activeError = (activeResult as? NetworkResult.Failure)?.error?.message ?: "Success"
@@ -133,6 +133,10 @@ fun SystemScreen(
controller = resolvedViewModel.actionProgress,
title = stringResource(R.string.system_update_progress_title),
)
ActionProgressDialog(
controller = resolvedViewModel.migrationProgress,
title = stringResource(R.string.system_migration_progress_title),
)
// Credential remove confirmation using ConfirmDialog
var credToRemove by remember { mutableStateOf<Pair<String, Int>?>(null) }
@@ -15,6 +15,7 @@ import com.m57.hermescontrol.data.model.CredentialPoolProvider
import com.m57.hermescontrol.data.model.CuratorResponse
import com.m57.hermescontrol.data.model.DebugShareResponse
import com.m57.hermescontrol.data.model.DoctorResponse
import com.m57.hermescontrol.data.model.GatewayMigrationPlan
import com.m57.hermescontrol.data.model.HookResponse
import com.m57.hermescontrol.data.model.PortalResponse
import com.m57.hermescontrol.data.model.StatusResponse
@@ -26,6 +27,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ActionProgressController
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
@@ -53,6 +55,8 @@ data class SystemUiState(
val updateInfo: UpdateCheckResponse? = null,
val status: StatusResponse? = null,
val doctorReport: DoctorResponse? = null,
val migrationPlan: GatewayMigrationPlan? = null,
val migrationSupported: Boolean? = null,
val activeAction: String? = null,
val actionLog: ActionStatusResponse? = null,
val backupArchive: String? = null,
@@ -88,6 +92,7 @@ data class SystemUiState(
class SystemViewModel(
application: Application,
private val migrationDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : AndroidViewModel(application),
ToastHost {
companion object {
@@ -114,12 +119,22 @@ class SystemViewModel(
onFinished = { status -> onActionFinished(status) },
)
/** Progress popup for the optional gateway migration action. */
val migrationProgress =
ActionProgressController(
scope = viewModelScope,
onFinished = { status -> onMigrationFinished(status) },
)
private var actionPollingJob: Job? = null
private var downloadJob: Job? = null
// ── Full parallel data load ────────────────────────────────────────
fun loadAll() {
// Migration support is optional; probe it independently so an older or
// slow backend cannot hold up the required System data.
loadMigrationPlan()
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
@@ -138,6 +153,7 @@ class SystemViewModel(
val statsResult = statsDeferred.await()
val statusResult = statusDeferred.await()
val portalResult = portalDeferred.await()
val curatorResult = curatorDeferred.await()
@@ -224,6 +240,66 @@ class SystemViewModel(
}
}
/** Load optional migration capability state without affecting other System data. */
fun loadMigrationPlan() {
viewModelScope.launch {
val result =
withContext(migrationDispatcher) {
safeApiCall { ApiClient.hermesApi.getGatewayMigrationPlan() }
}
_uiState.update {
it.copy(
migrationPlan = (result as? NetworkResult.Success)?.data,
migrationSupported = migrationSupport(result),
)
}
}
}
/**
* Start the backend migration only after the Gateway section's confirmation.
* The POST starts an asynchronous `gateway-migrate` action; completion is
* owned by [migrationProgress], not by the trigger response.
*/
fun startMigration() {
val plan = _uiState.value.migrationPlan ?: return
if (plan.alreadyMultiplexed) {
_uiState.update { it.copy(toastMessage = "Gateway is already multiplexed") }
return
}
if (plan.blockers.isNotEmpty()) {
_uiState.update { it.copy(toastMessage = "Migration is blocked by the preflight plan") }
return
}
migrationProgress.open()
viewModelScope.launch {
val result =
withContext(migrationDispatcher) {
safeApiCall { ApiClient.hermesApi.startGatewayMigration() }
}
when (result) {
is NetworkResult.Success -> {
val response = result.data
val actionName = response.name
if (!response.ok) {
migrationProgress.fail("Migration was rejected by the backend")
} else if (actionName != null) {
migrationProgress.markStarted(actionName)
} else {
migrationProgress.fail(
"Migration was accepted but the backend did not report an action name",
)
}
}
is NetworkResult.Failure -> {
migrationProgress.fail("Failed to start migration: ${result.error.message}")
}
}
}
}
/**
* Fired when [actionProgress] finishes tracking a backend action. For the
* `hermes-update` action we additionally fetch the durable update receipt
@@ -242,6 +318,12 @@ class SystemViewModel(
loadAll()
}
private fun onMigrationFinished(status: ActionStatusResponse?) {
if (status?.exit_code == 0) {
loadAll()
}
}
/**
* Pull `GET /api/hermes/update/receipt` and render a compact summary block
* for the popup. Returns an empty list when the endpoint is unavailable
@@ -862,3 +944,15 @@ class SystemViewModel(
_uiState.update { it.copy(toastMessage = null) }
}
}
private fun migrationSupport(result: NetworkResult<GatewayMigrationPlan>): Boolean? =
when (result) {
is NetworkResult.Success -> {
true
}
is NetworkResult.Failure -> {
val code = (result.error as? NetworkError.Http)?.code
if (code == 404 || code == 405) false else null
}
}
@@ -22,12 +22,19 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.GatewayMigrationPlan
import com.m57.hermescontrol.theme.Spacing
import com.m57.hermescontrol.ui.common.ConfirmDialog
import com.m57.hermescontrol.ui.common.InfoRow
import com.m57.hermescontrol.ui.common.SectionHeader
import com.m57.hermescontrol.ui.common.StatusBadge
@@ -183,5 +190,134 @@ fun LazyListScope.gatewaySection(
}
}
}
state.migrationPlan?.let { plan ->
item {
GatewayMigrationCard(
plan = plan,
onStartMigration = viewModel::startMigration,
)
}
}
}
}
@Composable
private fun GatewayMigrationCard(
plan: GatewayMigrationPlan,
onStartMigration: () -> Unit,
) {
var showConfirmation by remember(plan) { mutableStateOf(false) }
val profileNames =
plan.profiles
.mapNotNull { it.profile?.takeIf(String::isNotBlank) }
.ifEmpty { plan.liveServed.orEmpty() }
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.system_migration_title),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.system_migration_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (profileNames.isNotEmpty()) {
InfoRow(
label = stringResource(R.string.system_migration_profiles),
value = profileNames.joinToString(" · "),
)
}
InfoRow(
label = stringResource(R.string.system_migration_automatic_eligibility),
value =
stringResource(
if (plan.eligible) {
R.string.system_migration_yes
} else {
R.string.system_migration_no
},
),
)
if (plan.alreadyMultiplexed) {
Spacer(modifier = Modifier.height(8.dp))
StatusBadge(
text = stringResource(R.string.system_migration_already_multiplexed),
status = StatusBadgeType.SUCCESS,
)
}
if (plan.interrupted) {
Spacer(modifier = Modifier.height(8.dp))
StatusBadge(
text = stringResource(R.string.system_migration_interrupted),
status = StatusBadgeType.WARNING,
)
}
if (plan.blockers.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.system_migration_blockers),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.error,
)
plan.blockers.forEach { blocker ->
Text(
text = "• $blocker",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
if (plan.notices.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.system_migration_notices),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
plan.notices.forEach { notice ->
Text(
text = "• $notice",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (!plan.alreadyMultiplexed && plan.blockers.isEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = { showConfirmation = true },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.system_migration_start))
}
}
}
}
if (showConfirmation) {
ConfirmDialog(
title = stringResource(R.string.system_migration_confirm_title),
message = stringResource(R.string.system_migration_confirm_description),
onConfirm = {
showConfirmation = false
onStartMigration()
},
onDismiss = { showConfirmation = false },
confirmText = stringResource(R.string.system_migration_start),
)
}
}
+26
View File
@@ -422,6 +422,20 @@
<string name="system_gateway_start">بدء</string>
<string name="system_gateway_restart">إعادة التشغيل</string>
<string name="system_gateway_stop">إيقاف</string>
<string name="system_migration_title">ترحيل البوابة</string>
<string name="system_migration_description">راجع خطة الترحيل من الخادم قبل نقل بوابات الملفات الشخصية إلى مُضاعِف مشترك واحد.</string>
<string name="system_migration_profiles">الملفات الشخصية</string>
<string name="system_migration_automatic_eligibility">الأهلية التلقائية</string>
<string name="system_migration_yes">نعم</string>
<string name="system_migration_no">لا</string>
<string name="system_migration_already_multiplexed">تعمل بالمضاعفة مسبقًا</string>
<string name="system_migration_interrupted">تمت مقاطعة عملية ترحيل سابقة. راجع الخطة قبل إعادة المحاولة.</string>
<string name="system_migration_blockers">العوائق</string>
<string name="system_migration_notices">التنبيهات</string>
<string name="system_migration_start">بدء الترحيل</string>
<string name="system_migration_confirm_title">بدء ترحيل البوابة؟</string>
<string name="system_migration_confirm_description">سيوقف هذا بوابات الملفات الشخصية المستقلة ويعيد تشغيل البوابة الافتراضية كمضاعِف مشترك. ستعمل العملية في الخلفية.</string>
<string name="system_migration_progress_title">ترحيل البوابة</string>
<string name="memory_active">النشط: %1$s</string>
<string name="memory_builtin">الذاكرة المدمجة</string>
<string name="memory_builtin_sizes">MEMORY.md: %1$s · USER.md: %2$s</string>
@@ -638,6 +652,16 @@
<string name="profiles_show_hidden">إظهار الملفات الشخصية المخفية</string>
<string name="profiles_hide_hidden">إخفاء الملفات الشخصية المخفية</string>
<string name="profiles_hidden_badge">مخفي</string>
<string name="profiles_shared_gateway_title">بوابة مشتركة</string>
<string name="profiles_shared_gateway_badge">مشترك</string>
<plurals name="profiles_shared_gateway_count">
<item quantity="zero">لا تُخدم أي ملفات شخصية معًا</item>
<item quantity="one">يُخدم %1$d ملف شخصي معًا</item>
<item quantity="two">يُخدم ملفان شخصيان معًا</item>
<item quantity="few">تُخدم %1$d ملفات شخصية معًا</item>
<item quantity="many">تُخدم %1$d ملفًا شخصيًا معًا</item>
<item quantity="other">تُخدم %1$d ملفات شخصية معًا</item>
</plurals>
<string name="profiles_all_hidden_desc">جميع الملفات الشخصية مخفية حاليًا. انقر أدناه لإظهار الملفات الشخصية المخفية.</string>
<string name="profiles_content_desc_active">الملف الشخصي النشط</string>
<string name="profiles_title_edit_soul">تعديل ملف الـ Soul: %1$s</string>
@@ -934,6 +958,8 @@
<string name="channels_action_test">اختبار</string>
<string name="channels_action_restart_now">إعادة التشغيل الآن</string>
<string name="channels_restart_banner">تم حفظ التغييرات. أعد تشغيل البوابة (Gateway) لتصبح سارية المفعول.</string>
<string name="channels_ingress_url">رابط الدخول</string>
<string name="channels_copy_ingress_url">نسخ رابط الدخول</string>
<string name="pairing_sec_pending">الموافقات المعلقة</string>
<string name="pairing_sec_approved">المستخدمون المعتمدون</string>
<string name="pairing_action_clear_all">مسح الكل</string>
+22
View File
@@ -364,6 +364,20 @@
<string name="system_action_update_now">지금 업데이트</string>
<string name="system_update_confirm_title">Hermes 업데이트?</string>
<string name="system_update_confirm_desc">Hermes 업데이트를 실행하고 게이트웨이를 재시작합니다. 다음 재시작 시 적용됩니다.</string>
<string name="system_migration_title">게이트웨이 마이그레이션</string>
<string name="system_migration_description">프로필 게이트웨이를 하나의 공유 멀티플렉서로 옮기기 전에 서버의 마이그레이션 계획을 확인하세요.</string>
<string name="system_migration_profiles">프로필</string>
<string name="system_migration_automatic_eligibility">자동 마이그레이션 가능 여부</string>
<string name="system_migration_yes">예</string>
<string name="system_migration_no">아니요</string>
<string name="system_migration_already_multiplexed">이미 멀티플렉싱됨</string>
<string name="system_migration_interrupted">이전 마이그레이션이 중단되었습니다. 다시 시도하기 전에 계획을 확인하세요.</string>
<string name="system_migration_blockers">차단 요인</string>
<string name="system_migration_notices">알림</string>
<string name="system_migration_start">마이그레이션 시작</string>
<string name="system_migration_confirm_title">게이트웨이 마이그레이션을 시작할까요?</string>
<string name="system_migration_confirm_description">독립 프로필 게이트웨이를 중지하고 기본 게이트웨이를 공유 멀티플렉서로 재시작합니다. 작업은 백그라운드에서 실행됩니다.</string>
<string name="system_migration_progress_title">게이트웨이 마이그레이션</string>
<string name="system_hooks_modal_title">새 셸 훅</string>
<string name="system_hooks_field_event">이벤트</string>
<string name="system_hooks_field_command">명령어 (절대 경로)</string>
@@ -490,6 +504,12 @@
<string name="profiles_show_hidden">숨겨진 프로필 표시</string>
<string name="profiles_hide_hidden">숨겨진 프로필 숨기기</string>
<string name="profiles_hidden_badge">숨김</string>
<string name="profiles_shared_gateway_title">공유 게이트웨이</string>
<string name="profiles_shared_gateway_badge">공유</string>
<plurals name="profiles_shared_gateway_count">
<item quantity="one">프로필 %1$d개가 함께 제공됨</item>
<item quantity="other">프로필 %1$d개가 함께 제공됨</item>
</plurals>
<string name="profiles_all_hidden_desc">모든 프로필이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 프로필을 표시하세요.</string>
<string name="profiles_content_desc_active">활성 프로필</string>
<string name="profiles_title_edit_soul">소울 프로필 편집: %1$s</string>
@@ -749,6 +769,8 @@
<string name="channels_action_test">테스트</string>
<string name="channels_action_restart_now">지금 재시작</string>
<string name="channels_restart_banner">변경사항이 저장되었습니다. 게이트웨이를 재시작하면 적용됩니다.</string>
<string name="channels_ingress_url">수신 URL</string>
<string name="channels_copy_ingress_url">수신 URL 복사</string>
<!-- Pairing Screen -->
<string name="pairing_sec_pending">승인 대기</string>
+22
View File
@@ -413,6 +413,20 @@
<string name="system_gateway_start">启动</string>
<string name="system_gateway_restart">重启</string>
<string name="system_gateway_stop">停止</string>
<string name="system_migration_title">网关迁移</string>
<string name="system_migration_description">将配置文件网关迁移到共享多路复用器前,先查看服务器返回的迁移计划。</string>
<string name="system_migration_profiles">配置文件</string>
<string name="system_migration_automatic_eligibility">自动迁移资格</string>
<string name="system_migration_yes">是</string>
<string name="system_migration_no">否</string>
<string name="system_migration_already_multiplexed">已使用多路复用</string>
<string name="system_migration_interrupted">上一次迁移被中断。重试前请查看计划。</string>
<string name="system_migration_blockers">阻碍</string>
<string name="system_migration_notices">注意事项</string>
<string name="system_migration_start">开始迁移</string>
<string name="system_migration_confirm_title">开始网关迁移?</string>
<string name="system_migration_confirm_description">这会停止独立的配置文件网关,并将默认网关重启为共享多路复用器。操作将在后台运行。</string>
<string name="system_migration_progress_title">网关迁移</string>
<string name="memory_active">当前:%1$s</string>
<string name="memory_builtin">内置记忆</string>
<string name="memory_builtin_sizes">MEMORY.md:%1$s · USER.md:%2$s</string>
@@ -625,6 +639,12 @@
<string name="profiles_show_hidden">显示隐藏的配置文件</string>
<string name="profiles_hide_hidden">隐藏已隐藏的配置文件</string>
<string name="profiles_hidden_badge">已隐藏</string>
<string name="profiles_shared_gateway_title">共享网关</string>
<string name="profiles_shared_gateway_badge">共享</string>
<plurals name="profiles_shared_gateway_count">
<item quantity="one">%1$d 个配置文件共同服务</item>
<item quantity="other">%1$d 个配置文件共同服务</item>
</plurals>
<string name="profiles_all_hidden_desc">所有配置文件当前均已隐藏。点击下方以显示已隐藏的配置文件。</string>
<string name="profiles_content_desc_active">当前配置</string>
<string name="profiles_title_edit_soul">编辑 Soul 配置:%1$s</string>
@@ -921,6 +941,8 @@
<string name="channels_action_test">测试</string>
<string name="channels_action_restart_now">立即重启</string>
<string name="channels_restart_banner">更改已保存。重启网关后生效。</string>
<string name="channels_ingress_url">入口 URL</string>
<string name="channels_copy_ingress_url">复制入口 URL</string>
<string name="pairing_sec_pending">待批准</string>
<string name="pairing_sec_approved">已批准用户</string>
<string name="pairing_action_clear_all">全部清除</string>
+22
View File
@@ -487,6 +487,20 @@
<string name="system_gateway_start">Start</string>
<string name="system_gateway_restart">Restart</string>
<string name="system_gateway_stop">Stop</string>
<string name="system_migration_title">Gateway migration</string>
<string name="system_migration_description">Review the backend migration plan before moving profile gateways onto one shared multiplexer.</string>
<string name="system_migration_profiles">Profiles</string>
<string name="system_migration_automatic_eligibility">Automatic eligibility</string>
<string name="system_migration_yes">Yes</string>
<string name="system_migration_no">No</string>
<string name="system_migration_already_multiplexed">Already multiplexed</string>
<string name="system_migration_interrupted">An earlier migration was interrupted. Review the plan before retrying.</string>
<string name="system_migration_blockers">Blockers</string>
<string name="system_migration_notices">Notices</string>
<string name="system_migration_start">Start migration</string>
<string name="system_migration_confirm_title">Start gateway migration?</string>
<string name="system_migration_confirm_description">This stops standalone profile gateways and restarts the default gateway as a shared multiplexer. The operation runs in the background.</string>
<string name="system_migration_progress_title">Gateway migration</string>
<!-- Memory section -->
<string name="memory_active">Active: %1$s</string>
@@ -739,6 +753,12 @@
<string name="profiles_show_hidden">Show hidden profiles</string>
<string name="profiles_hide_hidden">Hide hidden profiles</string>
<string name="profiles_hidden_badge">Hidden</string>
<string name="profiles_shared_gateway_title">Shared gateway</string>
<string name="profiles_shared_gateway_badge">Shared</string>
<plurals name="profiles_shared_gateway_count">
<item quantity="one">%1$d profile served together</item>
<item quantity="other">%1$d profiles served together</item>
</plurals>
<string name="profiles_all_hidden_desc">All profiles are currently hidden. Tap below to show hidden profiles.</string>
<string name="profiles_content_desc_active">Active Profile</string>
<string name="profiles_title_edit_soul">Edit Soul Profile: %1$s</string>
@@ -1077,6 +1097,8 @@
<string name="channels_action_test">Test</string>
<string name="channels_action_restart_now">Restart now</string>
<string name="channels_restart_banner">Changes saved. Restart the gateway for them to take effect.</string>
<string name="channels_ingress_url">Ingress URL</string>
<string name="channels_copy_ingress_url">Copy ingress URL</string>
<!-- Pairing Screen -->
<string name="pairing_sec_pending">Pending Approvals</string>
@@ -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() }
}
}