feat(#776): pairing management screen (#791)

REST + Channels-style UI for DM pairing (list pending/approved, approve, revoke, clear).

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-03 19:49:31 +03:00
1 parent b91ac044b9
commit 5870b73397
9 files changed
+785

No files matched your search

@@ -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
@@ -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,
@@ -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<PairingItem> = emptyList(),
val approved: List<PairingItem> = 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,
)
@@ -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<Unit>
// ── 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<PairingResponse>
@POST("api/pairing/approve")
suspend fun approvePairing(
@Body body: PairingApproveRequest,
): Response<Unit>
@POST("api/pairing/revoke")
suspend fun revokePairing(
@Body body: PairingRevokeRequest,
): Response<Unit>
@POST("api/pairing/clear-pending")
suspend fun clearPendingPairing(): Response<Unit>
// ── 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
@@ -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)
}
}
@@ -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<PairingUiState> = _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<Unit>,
) {
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) }
}
}
+14
View File
@@ -70,6 +70,7 @@
<string name="screen_config">설정</string>
<string name="screen_mcp_servers">MCP 서버</string>
<string name="screen_models">모델</string>
<string name="screen_pairing">페어링</string>
<string name="screen_keys">키</string>
<string name="screen_channels">채널</string>
<string name="screen_logs">로그</string>
@@ -617,6 +618,19 @@
<string name="channels_action_restart_now">지금 재시작</string>
<string name="channels_restart_banner">변경사항이 저장되었습니다. 게이트웨이를 재시작하면 적용됩니다.</string>
<!-- Pairing Screen -->
<string name="pairing_sec_pending">승인 대기</string>
<string name="pairing_sec_approved">승인된 사용자</string>
<string name="pairing_action_clear_all">모두 지우기</string>
<string name="pairing_action_approve">승인</string>
<string name="pairing_action_revoke">해제</string>
<string name="pairing_empty_title">페어링 요청 없음</string>
<string name="pairing_empty_desc">메시징 채널에 대한 접근을 요청한 사용자가 여기에 표시됩니다.</string>
<string name="pairing_empty_approved">아직 승인된 사용자가 없습니다.</string>
<string name="pairing_label_unknown_user">알 수 없는 사용자</string>
<string name="pairing_label_request_age_m">%1$d분 전</string>
<string name="pairing_label_request_age_hm">%1$d시간 %2$d분 전</string>
<!-- Settings -->
<string name="settings_saved_profiles">저장된 프로필 (%1$d)</string>
<string name="settings_logout">로그아웃</string>
+14
View File
@@ -82,6 +82,7 @@
<string name="screen_config">Config</string>
<string name="screen_mcp_servers">MCP Servers</string>
<string name="screen_models">Models</string>
<string name="screen_pairing">Pairing</string>
<string name="screen_keys">Keys</string>
<string name="screen_channels">Channels</string>
<string name="screen_logs">Logs</string>
@@ -812,6 +813,19 @@
<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>
<!-- Pairing Screen -->
<string name="pairing_sec_pending">Pending Approvals</string>
<string name="pairing_sec_approved">Approved Users</string>
<string name="pairing_action_clear_all">Clear All</string>
<string name="pairing_action_approve">Approve</string>
<string name="pairing_action_revoke">Revoke</string>
<string name="pairing_empty_title">No pairing requests</string>
<string name="pairing_empty_desc">Users who request access to your messaging channels will appear here for approval.</string>
<string name="pairing_empty_approved">No approved users yet.</string>
<string name="pairing_label_unknown_user">Unknown user</string>
<string name="pairing_label_request_age_m">%1$dm ago</string>
<string name="pairing_label_request_age_hm">%1$dh %2$dm ago</string>
<!-- Settings -->
<string name="settings_saved_profiles">Saved Profiles (%1$d)</string>
<string name="settings_logout">Logout</string>
@@ -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<HermesApiService>(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<PairingItem> = emptyList(),
approved: List<PairingItem> = 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<PairingApproveRequest>()
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<PairingRevokeRequest>()
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)
}
}