diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 50b0789a..49ab8100 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -33,6 +33,8 @@ import kotlinx.serialization.Serializable @Serializable data object ModelScreen : NavKey +@Serializable data object PairingScreen : NavKey + @Serializable data object LogsScreen : NavKey @Serializable data object PluginsScreen : NavKey diff --git a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt index 6b592c16..a36361eb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt @@ -12,6 +12,7 @@ import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.Devices import androidx.compose.material.icons.filled.EmojiEvents import androidx.compose.material.icons.filled.Extension import androidx.compose.material.icons.filled.Folder @@ -41,6 +42,7 @@ import com.m57.hermescontrol.ui.keys.KeysScreen as KeysScreenContent import com.m57.hermescontrol.ui.logs.LogsScreen as LogsScreenContent import com.m57.hermescontrol.ui.mcp.McpServersScreen as McpServersScreenContent import com.m57.hermescontrol.ui.model.ModelScreen as ModelScreenContent +import com.m57.hermescontrol.ui.pairing.PairingScreen as PairingScreenContent import com.m57.hermescontrol.ui.plugins.PluginsScreen as PluginsScreenContent import com.m57.hermescontrol.ui.process.ProcessesScreen as ProcessesScreenContent import com.m57.hermescontrol.ui.profiles.ProfilesScreen as ProfilesScreenContent @@ -144,6 +146,12 @@ object ScreenRegistry { Icons.Filled.Psychology, DrawerSection.CONFIGURE, ) { sessionId, openDrawer -> ModelScreenContent(onOpenDrawer = openDrawer) }, + ScreenDefinition( + PairingScreen, + R.string.screen_pairing, + Icons.Filled.Devices, + DrawerSection.CONFIGURE, + ) { sessionId, openDrawer -> PairingScreenContent(onOpenDrawer = openDrawer) }, ScreenDefinition( KeysScreen, R.string.screen_keys, diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Pairing.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Pairing.kt new file mode 100644 index 00000000..9189b058 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Pairing.kt @@ -0,0 +1,46 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * DM pairing management (issue #776). + * + * Backend contract (hermes_cli/web_server.py + gateway/pairing.py): + * - `GET /api/pairing` → `{pending: [...], approved: [...]}` + * - Pending entries: `{platform, request_id, user_id, user_name, age_minutes}` + * - Approved entries: `{platform, user_id, user_name, approved_at}` + * + * Codes are stored hashed server-side and never returned; pending requests are + * approved by their `request_id` (the desktop dashboard does the same). + */ +@Serializable +data class PairingResponse( + val pending: List = emptyList(), + val approved: List = emptyList(), +) + +@Serializable +data class PairingItem( + val platform: String, + @SerialName("request_id") val requestId: String? = null, + @SerialName("user_id") val userId: String? = null, + @SerialName("user_name") val userName: String? = null, + @SerialName("age_minutes") val ageMinutes: Int? = null, +) { + /** Stable key for LazyColumn + per-item action state. */ + fun key(): String = "${platform}_${requestId ?: userId ?: ""}" +} + +@Serializable +data class PairingApproveRequest( + val platform: String, + @SerialName("request_id") val requestId: String? = null, + val code: String? = null, +) + +@Serializable +data class PairingRevokeRequest( + val platform: String, + @SerialName("user_id") val userId: String, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 68dcfa4b..8e4e01a0 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -63,6 +63,9 @@ import com.m57.hermescontrol.data.model.OAuthProvidersResponse import com.m57.hermescontrol.data.model.OAuthStartResponse import com.m57.hermescontrol.data.model.OAuthSubmitRequest import com.m57.hermescontrol.data.model.OAuthSubmitResponse +import com.m57.hermescontrol.data.model.PairingApproveRequest +import com.m57.hermescontrol.data.model.PairingResponse +import com.m57.hermescontrol.data.model.PairingRevokeRequest import com.m57.hermescontrol.data.model.PluginProvidersPutRequest import com.m57.hermescontrol.data.model.PluginsHubResponse import com.m57.hermescontrol.data.model.PortalResponse @@ -580,6 +583,28 @@ interface HermesApiService { @Path("pairing_id") pairingId: String, ): Response + // ── DM pairing management (issue #776) ────────────────────────────── + // Approve/revoke messaging users who requested access via pairing + // codes. Pending requests are approved by `request_id` (as returned by + // GET /api/pairing); the `code` field is the one-time code a user + // relays from their DM. + + @GET("api/pairing") + suspend fun getPairing(): Response + + @POST("api/pairing/approve") + suspend fun approvePairing( + @Body body: PairingApproveRequest, + ): Response + + @POST("api/pairing/revoke") + suspend fun revokePairing( + @Body body: PairingRevokeRequest, + ): Response + + @POST("api/pairing/clear-pending") + suspend fun clearPendingPairing(): Response + // ── OAuth provider management (issue #534) ────────────────────────── // Auth: the dashboard gates /start, /submit, /disconnect, /cancel via // `_require_token`, which accepts either `X-Hermes-Session-Token` OR the diff --git a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt new file mode 100644 index 00000000..f3560479 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt @@ -0,0 +1,337 @@ +package com.m57.hermescontrol.ui.pairing + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.PairingItem +import com.m57.hermescontrol.ui.common.EmptyState +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.NavIcon +import com.m57.hermescontrol.ui.common.SkeletonListState +import com.m57.hermescontrol.ui.common.ToastEffect +import com.m57.hermescontrol.ui.common.listContentPadding +import com.m57.hermescontrol.ui.common.listItemSpacing + +@Composable +fun PairingScreen( + modifier: Modifier = Modifier, + onOpenDrawer: (() -> Unit)? = null, + viewModel: PairingViewModel = viewModel { PairingViewModel() }, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.loadPairing() + } + + ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) + + HermesScaffold( + title = { Text(stringResource(R.string.screen_pairing)) }, + navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, + isRefreshing = state.isLoading, + onRefresh = { viewModel.loadPairing() }, + modifier = modifier, + ) { paddingValues -> + val pairing = state.pairing + when { + state.isLoading && pairing == null -> { + SkeletonListState(modifier = Modifier.padding(paddingValues)) + } + + state.errorMessage != null && pairing == null -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadPairing() }, + modifier = Modifier.padding(paddingValues), + ) + } + + pairing == null || + (pairing.pending.isEmpty() && pairing.approved.isEmpty()) -> { + EmptyState( + title = stringResource(R.string.pairing_empty_title), + subtitle = stringResource(R.string.pairing_empty_desc), + modifier = Modifier.padding(paddingValues), + ) + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listContentPadding, + verticalArrangement = listItemSpacing, + ) { + if (pairing.pending.isNotEmpty()) { + item(key = "pending_header") { + PendingHeader( + isClearing = state.actionKey == "clear", + onClearAll = { viewModel.clearPending() }, + ) + } + + items(pairing.pending, key = { it.key() }) { item -> + PendingCard( + item = item, + isActing = state.actionKey == "approve:${item.requestId}", + onApprove = { + item.requestId?.let { viewModel.approvePairing(item.platform, it) } + }, + ) + } + } + + item(key = "approved_header") { + SectionHeader(text = stringResource(R.string.pairing_sec_approved)) + } + + if (pairing.approved.isEmpty()) { + item(key = "approved_empty") { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + ) { + Box( + modifier = Modifier.fillMaxWidth().padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.pairing_empty_approved), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } else { + items(pairing.approved, key = { it.key() }) { item -> + ApprovedCard( + item = item, + isActing = state.actionKey == "revoke:${item.platform}:${item.userId}", + onRevoke = { + item.userId?.let { viewModel.revokePairing(item.platform, it) } + }, + ) + } + } + } + } + } + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) +} + +@Composable +private fun PendingHeader( + isClearing: Boolean, + onClearAll: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + SectionHeader(text = stringResource(R.string.pairing_sec_pending)) + OutlinedButton( + onClick = onClearAll, + enabled = !isClearing, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + if (isClearing) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = stringResource(R.string.pairing_action_clear_all), + style = MaterialTheme.typography.labelSmall, + ) + } + } +} + +@Composable +private fun PendingCard( + item: PairingItem, + isActing: Boolean, + onApprove: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f), + ), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.platform.uppercase(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + item.userName?.takeIf { it.isNotBlank() }?.let { name -> + Text( + text = name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = item.userId ?: stringResource(R.string.pairing_label_unknown_user), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + item.ageMinutes?.let { age -> + Text( + text = formatAge(age), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } + Spacer(modifier = Modifier.width(12.dp)) + Button( + onClick = onApprove, + enabled = !isActing, + ) { + if (isActing) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = stringResource(R.string.pairing_action_approve), + style = MaterialTheme.typography.labelSmall, + ) + } + } + } +} + +@Composable +private fun ApprovedCard( + item: PairingItem, + isActing: Boolean, + onRevoke: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.platform.uppercase(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + item.userName?.takeIf { it.isNotBlank() }?.let { name -> + Text( + text = name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = item.userId ?: stringResource(R.string.pairing_label_unknown_user), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(modifier = Modifier.width(12.dp)) + OutlinedButton( + onClick = onRevoke, + enabled = !isActing, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + if (isActing) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = stringResource(R.string.pairing_action_revoke), + style = MaterialTheme.typography.labelSmall, + ) + } + } + } +} + +@Composable +private fun formatAge(ageMinutes: Int): String { + return if (ageMinutes < 60) { + stringResource(R.string.pairing_label_request_age_m, ageMinutes) + } else { + stringResource(R.string.pairing_label_request_age_hm, ageMinutes / 60, ageMinutes % 60) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt new file mode 100644 index 00000000..dec45883 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt @@ -0,0 +1,136 @@ +package com.m57.hermescontrol.ui.pairing + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.model.PairingApproveRequest +import com.m57.hermescontrol.data.model.PairingResponse +import com.m57.hermescontrol.data.model.PairingRevokeRequest +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.NetworkResult +import com.m57.hermescontrol.data.remote.safeApiCall +import com.m57.hermescontrol.ui.common.ToastHost +import com.m57.hermescontrol.ui.common.safeLaunchLoad +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class PairingUiState( + val isLoading: Boolean = false, + val pairing: PairingResponse? = null, + val errorMessage: String? = null, + val toastMessage: String? = null, + /** Key of the item whose approve/revoke/clear action is in flight. */ + val actionKey: String? = null, +) + +class PairingViewModel : + ViewModel(), + ToastHost { + private val _uiState = MutableStateFlow(PairingUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var launchJob: Job? = null + + fun loadPairing() { + launchJob = + safeLaunchLoad( + currentJob = launchJob, + apiCall = { safeApiCall { ApiClient.hermesApi.getPairing() } }, + onStart = { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + }, + onSuccess = { data -> + _uiState.update { it.copy(isLoading = false, pairing = data) } + }, + onError = { errorMsg -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to load pairing: $errorMsg", + ) + } + }, + ) + } + + /** Approve a pending pairing request by its server-side `request_id`. */ + fun approvePairing( + platform: String, + requestId: String, + ) { + runAction( + actionKey = "approve:$requestId", + onSuccess = { it.copy(toastMessage = "Pairing request approved") }, + apiCall = { + safeApiCall { + ApiClient.hermesApi.approvePairing( + PairingApproveRequest(platform = platform, requestId = requestId), + ) + } + }, + ) + } + + /** Remove a user from the approved whitelist. */ + fun revokePairing( + platform: String, + userId: String, + ) { + runAction( + actionKey = "revoke:$platform:$userId", + onSuccess = { it.copy(toastMessage = "Access revoked for $userId") }, + apiCall = { + safeApiCall { + ApiClient.hermesApi.revokePairing( + PairingRevokeRequest(platform = platform, userId = userId), + ) + } + }, + ) + } + + /** Clear every pending pairing request. */ + fun clearPending() { + runAction( + actionKey = "clear", + onSuccess = { it.copy(toastMessage = "Pending requests cleared") }, + apiCall = { safeApiCall { ApiClient.hermesApi.clearPendingPairing() } }, + ) + } + + private fun runAction( + actionKey: String, + onSuccess: (PairingUiState) -> PairingUiState, + apiCall: suspend () -> NetworkResult, + ) { + if (_uiState.value.actionKey != null) return + _uiState.update { it.copy(actionKey = actionKey) } + viewModelScope.launch { + val result = withContext(Dispatchers.IO) { apiCall() } + when (result) { + is NetworkResult.Success -> { + _uiState.update { onSuccess(it.copy(actionKey = null)) } + loadPairing() + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + actionKey = null, + toastMessage = "Action failed: ${result.error.message}", + ) + } + } + } + } + } + + override fun clearToast() { + _uiState.update { it.copy(toastMessage = null) } + } +} diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b4051f49..bbd07f62 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -70,6 +70,7 @@ 설정 MCP 서버 모델 + 페어링 키 채널 로그 @@ -617,6 +618,19 @@ 지금 재시작 변경사항이 저장되었습니다. 게이트웨이를 재시작하면 적용됩니다. + + 승인 대기 + 승인된 사용자 + 모두 지우기 + 승인 + 해제 + 페어링 요청 없음 + 메시징 채널에 대한 접근을 요청한 사용자가 여기에 표시됩니다. + 아직 승인된 사용자가 없습니다. + 알 수 없는 사용자 + %1$d분 전 + %1$d시간 %2$d분 전 + 저장된 프로필 (%1$d) 로그아웃 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 21b01197..08a81ee3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -82,6 +82,7 @@ Config MCP Servers Models + Pairing Keys Channels Logs @@ -812,6 +813,19 @@ Restart now Changes saved. Restart the gateway for them to take effect. + + Pending Approvals + Approved Users + Clear All + Approve + Revoke + No pairing requests + Users who request access to your messaging channels will appear here for approval. + No approved users yet. + Unknown user + %1$dm ago + %1$dh %2$dm ago + Saved Profiles (%1$d) Logout diff --git a/app/src/test/java/com/m57/hermescontrol/ui/pairing/PairingViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/pairing/PairingViewModelTest.kt new file mode 100644 index 00000000..d9279a97 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/pairing/PairingViewModelTest.kt @@ -0,0 +1,203 @@ +package com.m57.hermescontrol.ui.pairing + +import com.m57.hermescontrol.data.model.PairingApproveRequest +import com.m57.hermescontrol.data.model.PairingItem +import com.m57.hermescontrol.data.model.PairingResponse +import com.m57.hermescontrol.data.model.PairingRevokeRequest +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.slot +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 PairingViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private val mockApi = mockk(relaxed = true) + + private fun createViewModel(): PairingViewModel { + val vm = PairingViewModel() + testDispatcher.scheduler.advanceUntilIdle() + return vm + } + + /** + * Pump the test scheduler while letting the real Dispatchers.IO hops + * (safeLaunchLoad / withContext(IO)) land their resumptions. + */ + private fun settle() { + repeat(20) { + testDispatcher.scheduler.advanceUntilIdle() + Thread.sleep(10) + } + testDispatcher.scheduler.advanceUntilIdle() + } + + private fun stubPairing( + pending: List = emptyList(), + approved: List = emptyList(), + ) { + coEvery { mockApi.getPairing() } returns + Response.success(PairingResponse(pending = pending, approved = approved)) + } + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockkObject(ApiClient) + every { ApiClient.hermesApi } returns mockApi + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `loadPairing populates pending and approved lists`() { + stubPairing( + pending = + listOf( + PairingItem( + platform = "telegram", + requestId = "req-1", + userId = "u1", + userName = "bob", + ageMinutes = 5, + ), + ), + approved = listOf(PairingItem(platform = "discord", userId = "u2", userName = "alice")), + ) + val vm = createViewModel() + + vm.loadPairing() + settle() + + val state = vm.uiState.value + assertFalse(state.isLoading) + assertNull(state.errorMessage) + assertEquals(1, state.pairing?.pending?.size) + assertEquals("bob", state.pairing?.pending?.first()?.userName) + assertEquals(1, state.pairing?.approved?.size) + assertEquals("alice", state.pairing?.approved?.first()?.userName) + } + + @Test + fun `loadPairing failure surfaces error message`() { + // 404: backend error detail surfaces; no 5xx/429 retry so the test stays fast. + coEvery { mockApi.getPairing() } returns + Response.error(404, "{\"detail\":\"boom\"}".toResponseBody(null)) + val vm = createViewModel() + + vm.loadPairing() + settle() + + val state = vm.uiState.value + assertFalse(state.isLoading) + assertNull(state.pairing) + assertTrue(state.errorMessage?.contains("Failed to load pairing") == true) + } + + @Test + fun `approvePairing sends request id and reloads the list`() { + val requestSlot = slot() + coEvery { mockApi.approvePairing(capture(requestSlot)) } returns Response.success(Unit) + stubPairing() + val vm = createViewModel() + + vm.approvePairing("telegram", "req-1") + settle() + + assertEquals("telegram", requestSlot.captured.platform) + assertEquals("req-1", requestSlot.captured.requestId) + assertNull(requestSlot.captured.code) + coVerify(exactly = 1) { mockApi.getPairing() } + assertEquals("Pairing request approved", vm.uiState.value.toastMessage) + } + + @Test + fun `revokePairing sends platform and user id then reloads`() { + val requestSlot = slot() + coEvery { mockApi.revokePairing(capture(requestSlot)) } returns Response.success(Unit) + stubPairing() + val vm = createViewModel() + + vm.revokePairing("discord", "u2") + settle() + + assertEquals("discord", requestSlot.captured.platform) + assertEquals("u2", requestSlot.captured.userId) + coVerify(exactly = 1) { mockApi.getPairing() } + assertTrue(vm.uiState.value.toastMessage?.contains("u2") == true) + } + + @Test + fun `clearPending calls clear endpoint and reloads`() { + coEvery { mockApi.clearPendingPairing() } returns Response.success(Unit) + stubPairing() + val vm = createViewModel() + + vm.clearPending() + settle() + + coVerify(exactly = 1) { mockApi.clearPendingPairing() } + coVerify(exactly = 1) { mockApi.getPairing() } + assertEquals("Pending requests cleared", vm.uiState.value.toastMessage) + } + + @Test + fun `action failure shows toast and clears action key`() { + // 404: backend error detail surfaces; no 5xx/429 retry so the test stays fast. + coEvery { mockApi.approvePairing(any()) } returns + Response.error( + 404, + "{\"detail\":\"Pairing request or code not found or expired for platform 'telegram'.\"}".toResponseBody( + null, + ), + ) + stubPairing() + val vm = createViewModel() + + vm.approvePairing("telegram", "req-1") + settle() + + val state = vm.uiState.value + assertNull(state.actionKey) + assertTrue(state.toastMessage?.contains("Action failed") == true) + assertTrue(state.toastMessage?.contains("not found or expired") == true) + } + + @Test + fun `clearToast resets toast message`() { + coEvery { mockApi.approvePairing(any()) } returns Response.success(Unit) + stubPairing() + val vm = createViewModel() + vm.approvePairing("telegram", "req-1") + settle() + assertTrue(vm.uiState.value.toastMessage != null) + + vm.clearToast() + + assertNull(vm.uiState.value.toastMessage) + } +}