fix(#1230,#1221): clarify inactive plugins and verify uninstall (#1236)

This commit is contained in:
m57 authored and GitHub committed 2026-09-23 12:25:21 +03:00
1 parent 721edef618
commit 33d87a11ec
13 files changed
+475 -94

No files matched your search

@@ -0,0 +1,48 @@
package com.m57.hermescontrol.data.ws
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PluginRemovalRepositoryTest {
@Test
fun `remove sends scoped gateway action and decodes success`() =
runTest {
var method = ""
var params: Map<String, Any> = emptyMap()
val repository =
PluginRemovalRepository { requestedMethod, requestedParams ->
method = requestedMethod
params = requestedParams
Json.parseToJsonElement("""{"ok":true,"name":"demo"}""")
}
val result = repository.remove("demo")
assertEquals(WsMethods.PLUGINS_MANAGE, method)
assertTrue(WsMethods.PLUGINS_MANAGE in WsMethods.PROFILE_SCOPED_METHODS)
assertEquals(mapOf("action" to "remove", "name" to "demo"), params)
assertTrue(result.ok)
assertEquals("demo", result.name)
}
@Test
fun `refusal is a typed failure and missing ok cannot masquerade as success`() =
runTest {
val refused = PluginRemovalRepository { _, _ -> mapOf("ok" to false, "error" to "bundled plugin") }
assertFalse(refused.remove("builtin").ok)
assertEquals("bundled plugin", refused.remove("builtin").error)
val malformed = PluginRemovalRepository { _, _ -> mapOf("name" to "demo") }
try {
malformed.remove("demo")
throw AssertionError("Missing ok must fail decoding")
} catch (_: SerializationException) {
// A partial response is not removal confirmation.
}
}
}
@@ -8,12 +8,15 @@ import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.data.model.PluginsHubResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.ws.PluginRemovalRepository
import com.m57.hermescontrol.data.ws.PluginRemovalResult
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.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -34,6 +37,8 @@ import retrofit2.Response
class PluginsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var mockApi: HermesApiService
private val removablePlugin =
PluginInfo(name = "snyk", runtimeStatus = "inactive", canRemove = true, source = "user")
private val sampleCatalogEntry =
PluginCatalogEntry(
@@ -191,4 +196,178 @@ class PluginsViewModelTest {
?.contains("Failed to install") == true,
)
}
@Test
fun `inactive hub plugin enables without invoking install`() {
coEvery { mockApi.enablePlugin("snyk") } returns Response.success(Unit)
coEvery { mockApi.getPlugins() } returns
Response.success(PluginsHubResponse(plugins = listOf(removablePlugin.copy(runtimeStatus = "enabled"))))
val viewModel = PluginsViewModel()
viewModel.activatePlugin(removablePlugin)
assertEquals("snyk", viewModel.uiState.value.rowBusy)
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(
viewModel.uiState.value.plugins
.single()
.enabled,
)
assertNull(viewModel.uiState.value.rowBusy)
coVerify(exactly = 1) { mockApi.enablePlugin("snyk") }
coVerify(exactly = 0) { mockApi.installPlugin(any()) }
}
@Test
fun `inactive plugin can be removed after confirmation and verified refresh`() {
coEvery { mockApi.getPlugins() } returnsMany
listOf(
Response.success(PluginsHubResponse(plugins = listOf(removablePlugin))),
Response.success(PluginsHubResponse(plugins = emptyList())),
)
val removal = mockk<PluginRemovalRepository>()
coEvery { removal.remove("snyk") } returns PluginRemovalResult(ok = true, name = "snyk")
val viewModel = PluginsViewModel(removal)
viewModel.loadPlugins()
testDispatcher.scheduler.advanceUntilIdle()
viewModel.requestRemovePlugin("snyk")
assertEquals("snyk", viewModel.uiState.value.removeConfirmPlugin)
viewModel.confirmRemovePlugin()
assertEquals("snyk", viewModel.uiState.value.rowBusy)
assertEquals(1, viewModel.uiState.value.plugins.size)
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(
viewModel.uiState.value.plugins
.isEmpty(),
)
assertEquals("Plugin uninstalled successfully", viewModel.uiState.value.toastMessage)
assertNull(viewModel.uiState.value.rowBusy)
coVerify(exactly = 1) { removal.remove("snyk") }
coVerify(exactly = 2) { mockApi.getPlugins() }
}
@Test
fun `built in plugin cannot enter removal flow even if asked directly`() {
coEvery { mockApi.getPlugins() } returns
Response.success(
PluginsHubResponse(
plugins = listOf(PluginInfo(name = "builtin", source = "bundled", canRemove = true)),
),
)
val removal = mockk<PluginRemovalRepository>()
val viewModel = PluginsViewModel(removal)
viewModel.loadPlugins()
testDispatcher.scheduler.advanceUntilIdle()
viewModel.requestRemovePlugin("builtin")
viewModel.confirmRemovePlugin()
testDispatcher.scheduler.advanceUntilIdle()
assertNull(viewModel.uiState.value.removeConfirmPlugin)
coVerify(exactly = 0) { removal.remove(any()) }
}
@Test
fun `gateway refusal and disconnection leave plugin in list with reason`() {
coEvery { mockApi.getPlugins() } returns
Response.success(PluginsHubResponse(plugins = listOf(removablePlugin)))
val removal = mockk<PluginRemovalRepository>()
coEvery { removal.remove("snyk") } returns PluginRemovalResult(ok = false, error = "not a user install")
val viewModel = PluginsViewModel(removal)
viewModel.loadPlugins()
testDispatcher.scheduler.advanceUntilIdle()
viewModel.requestRemovePlugin("snyk")
viewModel.confirmRemovePlugin()
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.plugins.size)
assertTrue(
viewModel.uiState.value.toastMessage!!
.contains("not a user install"),
)
coEvery { removal.remove("snyk") } throws IllegalStateException("socket disconnected")
viewModel.requestRemovePlugin("snyk")
viewModel.confirmRemovePlugin()
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.plugins.size)
assertTrue(
viewModel.uiState.value.toastMessage!!
.contains("socket disconnected"),
)
coVerify(exactly = 1) { mockApi.getPlugins() }
}
@Test
fun `success reply without verified removal retains row and reports mismatch`() {
coEvery { mockApi.getPlugins() } returns
Response.success(PluginsHubResponse(plugins = listOf(removablePlugin)))
val removal = mockk<PluginRemovalRepository>()
coEvery { removal.remove("snyk") } returns PluginRemovalResult(ok = true, name = "snyk")
val viewModel = PluginsViewModel(removal)
viewModel.loadPlugins()
testDispatcher.scheduler.advanceUntilIdle()
viewModel.requestRemovePlugin("snyk")
viewModel.confirmRemovePlugin()
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.plugins.size)
assertTrue(
viewModel.uiState.value.toastMessage!!
.contains("still appears"),
)
}
@Test
fun `failed verification preserves row without success toast`() {
coEvery { mockApi.getPlugins() } returnsMany
listOf(
Response.success(PluginsHubResponse(plugins = listOf(removablePlugin))),
Response.error(503, "offline".toResponseBody()),
)
val removal = mockk<PluginRemovalRepository>()
coEvery { removal.remove("snyk") } returns PluginRemovalResult(ok = true, name = "snyk")
val viewModel = PluginsViewModel(removal)
viewModel.loadPlugins()
testDispatcher.scheduler.advanceUntilIdle()
viewModel.requestRemovePlugin("snyk")
viewModel.confirmRemovePlugin()
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.plugins.size)
assertTrue(
viewModel.uiState.value.toastMessage!!
.contains("could not verify"),
)
}
@Test
fun `scope switch discards late remove reply`() {
coEvery { mockApi.getPlugins() } returns
Response.success(PluginsHubResponse(plugins = listOf(removablePlugin)))
val reply = CompletableDeferred<PluginRemovalResult>()
val removal = mockk<PluginRemovalRepository>()
coEvery { removal.remove("snyk") } coAnswers { reply.await() }
val viewModel = PluginsViewModel(removal)
viewModel.loadPlugins()
testDispatcher.scheduler.advanceUntilIdle()
viewModel.requestRemovePlugin("snyk")
viewModel.confirmRemovePlugin()
testDispatcher.scheduler.runCurrent()
viewModel.clearScopeOwnedState()
reply.complete(PluginRemovalResult(ok = true, name = "snyk"))
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(
viewModel.uiState.value.plugins
.isEmpty(),
)
assertNull(viewModel.uiState.value.toastMessage)
coVerify(exactly = 1) { mockApi.getPlugins() }
}
}