feat(skills): hub sources + featured landing and security scan (#779) (#793)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-03 22:13:28 +03:00
1 parent 00a0eedc77
commit 1870b375b1
7 files changed
+812 -304

No files matched your search

@@ -0,0 +1,199 @@
package com.m57.hermescontrol.ui.skills
import android.app.Application
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.SkillHubSource
import com.m57.hermescontrol.data.model.SkillHubSourcesResponse
import com.m57.hermescontrol.data.model.SkillScanFinding
import com.m57.hermescontrol.data.model.SkillScanResponse
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.MediaType.Companion.toMediaTypeOrNull
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class SkillsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val mockApi = mockk<HermesApiService>(relaxed = true)
private val app = mockk<Application>(relaxed = true)
/**
* 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 val sourcesResponse =
SkillHubSourcesResponse(
sources =
listOf(
SkillHubSource(id = "hermes-index", label = "Hermes Index", available = true),
SkillHubSource(id = "github", label = "GitHub", rateLimited = true),
),
indexAvailable = true,
featured =
listOf(
HubSkill(
name = "web-research",
description = "Find and verify current web facts",
source = "hermes-index",
identifier = "hermes-index:web-research",
),
),
)
private val scanResponse =
SkillScanResponse(
identifier = "hermes-index:web-research",
name = "web-research",
source = "hermes-index",
trustLevel = "trusted",
verdict = "safe",
summary = "No issues found",
policy = "allow",
policyReason = "No dangerous patterns detected.",
findings =
listOf(
SkillScanFinding(
severity = "low",
category = "network",
file = "scripts/fetch.py",
line = 12,
description = "Uses urllib without a timeout.",
),
),
severityCounts = mapOf("low" to 1),
)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkObject(ApiClient)
every { ApiClient.hermesApi } returns mockApi
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun `loadHubSources stores sources and featured skills`() {
coEvery { mockApi.getSkillHubSources() } returns Response.success(sourcesResponse)
val vm = SkillsViewModel(app)
vm.loadHubSources()
settle()
assertEquals(sourcesResponse.sources, vm.uiState.value.hubSources)
assertEquals(sourcesResponse.featured, vm.uiState.value.hubFeatured)
assertEquals(false, vm.uiState.value.isHubSourcesLoading)
assertNull(vm.uiState.value.hubSourcesError)
}
@Test
fun `loadHubSources surfaces an error`() {
coEvery { mockApi.getSkillHubSources() } returns
Response.error(
404,
"""{"detail":"Hub sources unavailable"}""".toResponseBody("application/json".toMediaTypeOrNull()),
)
val vm = SkillsViewModel(app)
vm.loadHubSources()
settle()
assertEquals(true, vm.uiState.value.hubSourcesError?.contains("Failed to load hub sources"))
assertEquals(false, vm.uiState.value.isHubSourcesLoading)
}
@Test
fun `setViewMode HUB triggers sources load when landing is empty`() {
coEvery { mockApi.getSkillHubSources() } returns Response.success(sourcesResponse)
val vm = SkillsViewModel(app)
vm.setViewMode(SkillsViewMode.HUB)
settle()
coVerify { mockApi.getSkillHubSources() }
assertEquals(sourcesResponse.featured, vm.uiState.value.hubFeatured)
}
@Test
fun `scanHubSkill stores the scan result for the identifier`() {
coEvery { mockApi.scanHubSkill(identifier = "hermes-index:web-research") } returns
Response.success(scanResponse)
val vm = SkillsViewModel(app)
vm.scanHubSkill("hermes-index:web-research")
settle()
assertEquals("hermes-index:web-research", vm.uiState.value.hubScanIdentifier)
assertEquals(scanResponse, vm.uiState.value.hubScanResult)
assertEquals(false, vm.uiState.value.isHubScanning)
assertNull(vm.uiState.value.hubScanError)
}
@Test
fun `scanHubSkill surfaces an error`() {
coEvery { mockApi.scanHubSkill(identifier = "broken:skill") } returns
Response.error(
404,
"""{"detail":"Skill not found"}""".toResponseBody("application/json".toMediaTypeOrNull()),
)
val vm = SkillsViewModel(app)
vm.scanHubSkill("broken:skill")
settle()
assertEquals("broken:skill", vm.uiState.value.hubScanIdentifier)
assertNull(vm.uiState.value.hubScanResult)
assertEquals(false, vm.uiState.value.isHubScanning)
assertEquals("Skill not found", vm.uiState.value.hubScanError)
}
@Test
fun `scanHubSkill ignores blank identifiers`() {
val vm = SkillsViewModel(app)
vm.scanHubSkill(" ")
settle()
assertNull(vm.uiState.value.hubScanIdentifier)
coVerify(exactly = 0) { mockApi.scanHubSkill(any()) }
}
@Test
fun `clearHubScan resets scan state`() {
coEvery { mockApi.scanHubSkill(identifier = "hermes-index:web-research") } returns
Response.success(scanResponse)
val vm = SkillsViewModel(app)
vm.scanHubSkill("hermes-index:web-research")
settle()
vm.clearHubScan()
assertNull(vm.uiState.value.hubScanIdentifier)
assertNull(vm.uiState.value.hubScanResult)
assertNull(vm.uiState.value.hubScanError)
assertEquals(false, vm.uiState.value.isHubScanning)
}
}