fix(#1230,#1221): clarify inactive plugins and verify uninstall (#1236)
This commit is contained in:
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() }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user