Files
aiturk-mobil/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt
T
M57andronia bdf566ba3e feat(#480): pass refresh=true on Models pull-to-refresh to re-probe custom providers (#481)
* feat(#480): pass refresh=true on Models pull-to-refresh to re-probe custom providers

* test(#480): assert refresh flag propagation for default vs pull-to-refresh loads

* fix(#480): capture refresh arg via slot instead of brittle coVerify in propagation test

---------

Co-authored-by: ronia <ronia@m57>
2026-07-07 02:53:36 +03:00

1435 lines
52 KiB
Kotlin

package com.m57.hermescontrol
import android.app.Application
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.ActionResponse
import com.m57.hermescontrol.data.model.ActiveProfileResponse
import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse
import com.m57.hermescontrol.data.model.CheckpointsResponse
import com.m57.hermescontrol.data.model.CredentialPoolResponse
import com.m57.hermescontrol.data.model.CronJob
import com.m57.hermescontrol.data.model.CuratorResponse
import com.m57.hermescontrol.data.model.DoctorResponse
import com.m57.hermescontrol.data.model.EnvVarConfig
import com.m57.hermescontrol.data.model.HookResponse
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.model.LogResponse
import com.m57.hermescontrol.data.model.MainModelAssignment
import com.m57.hermescontrol.data.model.MemoryResponse
import com.m57.hermescontrol.data.model.MemoryStats
import com.m57.hermescontrol.data.model.MessagingPlatform
import com.m57.hermescontrol.data.model.MessagingPlatformResponse
import com.m57.hermescontrol.data.model.MoaConfigResponse
import com.m57.hermescontrol.data.model.MoaModelSlot
import com.m57.hermescontrol.data.model.ModelOptionsResponse
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.data.model.PluginsHubResponse
import com.m57.hermescontrol.data.model.PortalResponse
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.ProfilesResponse
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionListResponse
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.SkillContentResponse
import com.m57.hermescontrol.data.model.SkillHubSearchResponse
import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.model.SystemStatsResponse
import com.m57.hermescontrol.data.model.ToggleSkillRequest
import com.m57.hermescontrol.data.model.UpdateCheckResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.ui.channels.ChannelsViewModel
import com.m57.hermescontrol.ui.connect.ConnectViewModel
import com.m57.hermescontrol.ui.cron.CronJobsViewModel
import com.m57.hermescontrol.ui.kanban.KanbanViewModel
import com.m57.hermescontrol.ui.keys.KeysViewModel
import com.m57.hermescontrol.ui.logs.LogsViewModel
import com.m57.hermescontrol.ui.model.ModelViewModel
import com.m57.hermescontrol.ui.plugins.PluginsViewModel
import com.m57.hermescontrol.ui.sessions.SessionsViewModel
import com.m57.hermescontrol.ui.skills.SkillsViewModel
import com.m57.hermescontrol.ui.system.SystemViewModel
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.*
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import retrofit2.Response
import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class E2eIntegrationTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var mockApiService: HermesApiService
private val mockApp = mockk<Application>(relaxed = true)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkStatic(Dispatchers::class)
val testMainDispatcher = Dispatchers.Main
every { Dispatchers.IO } returns testDispatcher
every { Dispatchers.Main } returns testMainDispatcher
mockkObject(AuthManager)
mockkObject(ApiClient)
mockApiService = mockk()
every { ApiClient.hermesApi } returns mockApiService
every { ApiClient.createTempService(any(), any(), any()) } returns mockApiService
every { ApiClient.rebuild() } returns Unit
// Default AuthManager stubs
every { AuthManager.getToken() } returns "mock-token"
every { AuthManager.getHost() } returns "127.0.0.1"
every { AuthManager.getPort() } returns 9119
every { AuthManager.setToken(any()) } returns Unit
every { AuthManager.setHost(any()) } returns Unit
every { AuthManager.setPort(any()) } returns Unit
every { AuthManager.getConnectionProfiles() } returns emptyList()
every { AuthManager.getSelectedProfileId() } returns null
// Mock Application string resources (no real resources in unit tests)
// SkillsViewModel uses hardcoded strings (not getApplication().getString()),
// so only resources used by ConnectViewModel + enum labels need stubs.
every { mockApp.getString(any<Int>()) } returns ""
every { AuthManager.setSelectedProfileId(any()) } returns Unit
every { AuthManager.getProfileToken(any()) } returns null
every { AuthManager.setProfileToken(any(), any()) } returns Unit
every { AuthManager.saveConnectionProfiles(any()) } returns Unit
every { AuthManager.getPinnedModels() } returns emptyList()
every { AuthManager.savePinnedModels(any()) } returns Unit
// Application stubs
every { mockApp.getString(R.string.connect_error_401) } returns "Invalid token (401 Unauthorized)"
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
NavigationController.backStack = null
}
private fun <T> createErrorResponse(code: Int): Response<T> {
val errorBody = "".toResponseBody(null)
return Response.error(code, errorBody)
}
// ── Tier 1: Feature Coverage (>=5 per feature) ───────────────────────
// Skills Management Screen:
@Test
fun testSkillsListing_success() =
runTest {
val skill1 = Skill("Skill 1", "Description 1", "Category 1", true)
val skill2 = Skill("Skill 2", "Description 2", "Category 2", false)
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill1, skill2))
val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills()
assertTrue(viewModel.uiState.value.isLoading)
assertNull(viewModel.uiState.value.errorMessage)
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoading)
assertNull(viewModel.uiState.value.errorMessage)
assertEquals(2, viewModel.uiState.value.skills.size)
assertEquals(
"Skill 1",
viewModel.uiState.value.skills[0]
.name,
)
assertEquals(
"Description 1",
viewModel.uiState.value.skills[0]
.description,
)
assertEquals(
"Category 1",
viewModel.uiState.value.skills[0]
.category,
)
assertTrue(
viewModel.uiState.value.skills[0]
.enabled,
)
assertEquals(
"Skill 2",
viewModel.uiState.value.skills[1]
.name,
)
assertFalse(
viewModel.uiState.value.skills[1]
.enabled,
)
}
@Test
fun testSkillsToggle_success() =
runTest {
val skill = Skill("Skill 1", "Description 1", "Category 1", false)
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
coEvery { mockApiService.toggleSkill(ToggleSkillRequest("Skill 1", true)) } returns Response.success(Unit)
val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills()
advanceUntilIdle()
assertFalse(
viewModel.uiState.value.skills[0]
.enabled,
)
viewModel.toggleSkill(viewModel.uiState.value.skills[0])
// Verify optimistic update
assertTrue(
viewModel.uiState.value.skills[0]
.enabled,
)
advanceUntilIdle()
// Verify it remains enabled
assertTrue(
viewModel.uiState.value.skills[0]
.enabled,
)
assertNull(viewModel.uiState.value.toastMessage)
}
@Test
fun testSkillsToggle_failure() =
runTest {
val skill = Skill("Skill 1", "Description 1", "Category 1", false)
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
coEvery { mockApiService.toggleSkill(ToggleSkillRequest("Skill 1", true)) } returns createErrorResponse(500)
val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills()
advanceUntilIdle()
assertFalse(
viewModel.uiState.value.skills[0]
.enabled,
)
viewModel.toggleSkill(viewModel.uiState.value.skills[0])
// Verify optimistic update
assertTrue(
viewModel.uiState.value.skills[0]
.enabled,
)
advanceUntilIdle()
// Verify reverted state and toast error message
assertFalse(
viewModel.uiState.value.skills[0]
.enabled,
)
assertNotNull(viewModel.uiState.value.toastMessage)
assertTrue(
viewModel.uiState.value.toastMessage!!
.contains("HTTP 500"),
)
}
@Test
fun testSkillsLoad_failure() =
runTest {
coEvery { mockApiService.getSkills() } returns createErrorResponse(500)
val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills()
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoading)
assertNotNull(viewModel.uiState.value.errorMessage)
assertTrue(
viewModel.uiState.value.errorMessage!!
.contains("HTTP 500"),
)
}
@Test
fun testSkillsRefresh() =
runTest {
val skillA = Skill("Skill A", null, null, false)
val skillB = Skill("Skill B", null, null, true)
coEvery { mockApiService.getSkills() } returnsMany
listOf(
Response.success(listOf(skillA)),
Response.success(listOf(skillB)),
)
val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills()
advanceUntilIdle()
assertEquals(
"Skill A",
viewModel.uiState.value.skills[0]
.name,
)
viewModel.loadSkills()
advanceUntilIdle()
assertEquals(
"Skill B",
viewModel.uiState.value.skills[0]
.name,
)
}
// ── Skills Hub: Search ────────────────────────────────────────────────
@Test
fun testSkillsHub_search_success() =
runTest {
val hubSkill = HubSkill("Test Skill", "A test hub skill", "hub", "General")
coEvery { mockApiService.searchSkillsHub(any()) } returns
Response.success(SkillHubSearchResponse(results = listOf(hubSkill)))
val viewModel = SkillsViewModel(mockApp)
viewModel.searchHub("test")
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isHubSearching)
assertEquals(1, viewModel.uiState.value.hubResults.size)
assertEquals(
"Test Skill",
viewModel.uiState.value.hubResults[0]
.name,
)
assertEquals(
"hub",
viewModel.uiState.value.hubResults[0]
.source,
)
}
@Test
fun testSkillsHub_search_empty() =
runTest {
coEvery { mockApiService.searchSkillsHub(any()) } returns
Response.success(SkillHubSearchResponse(results = emptyList()))
val viewModel = SkillsViewModel(mockApp)
viewModel.searchHub("nonexistent")
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isHubSearching)
assertTrue(
viewModel.uiState.value.hubResults
.isEmpty(),
)
assertNull(viewModel.uiState.value.hubSearchError)
}
@Test
fun testSkillsHub_search_error() =
runTest {
coEvery { mockApiService.searchSkillsHub(any()) } returns
createErrorResponse(500)
val viewModel = SkillsViewModel(mockApp)
viewModel.searchHub("test")
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isHubSearching)
assertNotNull(viewModel.uiState.value.hubSearchError)
assertTrue(
viewModel.uiState.value.hubSearchError!!
.contains("HTTP 500"),
)
}
@Test
fun testSkillsHub_search_emptyQuery() =
runTest {
val viewModel = SkillsViewModel(mockApp)
viewModel.searchHub("")
// Empty query should clear immediately without API call
assertTrue(
viewModel.uiState.value.hubResults
.isEmpty(),
)
assertFalse(viewModel.uiState.value.isHubSearching)
assertNull(viewModel.uiState.value.hubSearchError)
}
// ── Skills Hub: Install / Uninstall ────────────────────────────────────
@Test
fun testSkillsHub_install_success() =
runTest {
val installed = Skill("New Skill", "Just installed", "General", false)
coEvery { mockApiService.installHubSkill(any()) } returns
Response.success(ActionResponse("success", "Installed"))
coEvery { mockApiService.getSkills() } returns
Response.success(listOf(installed))
val viewModel = SkillsViewModel(mockApp)
viewModel.installSkill("new-skill")
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isInstalling)
assertNull(viewModel.uiState.value.installingSkillName)
assertNotNull(viewModel.uiState.value.toastMessage)
assertTrue(
viewModel.uiState.value.toastMessage!!
.contains("Installed"),
)
// Refresh was called
assertFalse(viewModel.uiState.value.isLoading)
assertEquals(1, viewModel.uiState.value.skills.size)
}
@Test
fun testSkillsHub_install_error() =
runTest {
coEvery { mockApiService.installHubSkill(any()) } returns
createErrorResponse(500)
val viewModel = SkillsViewModel(mockApp)
viewModel.installSkill("failing-skill")
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isInstalling)
assertNull(viewModel.uiState.value.installingSkillName)
assertNotNull(viewModel.uiState.value.toastMessage)
assertTrue(
viewModel.uiState.value.toastMessage!!
.contains("HTTP 500"),
)
}
@Test
fun testSkillsHub_uninstall_success() =
runTest {
coEvery { mockApiService.uninstallHubSkill(any()) } returns
Response.success(ActionResponse("success", "Uninstalled"))
coEvery { mockApiService.getSkills() } returns
Response.success(emptyList())
val viewModel = SkillsViewModel(mockApp)
viewModel.uninstallSkill("hub-skill")
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isUninstalling)
assertNull(viewModel.uiState.value.uninstallingSkillName)
assertNotNull(viewModel.uiState.value.toastMessage)
assertTrue(
viewModel.uiState.value.toastMessage!!
.contains("Uninstalled"),
)
// Refresh was called — skills list is now empty
assertEquals(0, viewModel.uiState.value.skills.size)
}
@Test
fun testSkillsHub_uninstall_error() =
runTest {
coEvery { mockApiService.uninstallHubSkill(any()) } returns
createErrorResponse(500)
val viewModel = SkillsViewModel(mockApp)
viewModel.uninstallSkill("failing-skill")
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isUninstalling)
assertNull(viewModel.uiState.value.uninstallingSkillName)
assertNotNull(viewModel.uiState.value.toastMessage)
assertTrue(
viewModel.uiState.value.toastMessage!!
.contains("HTTP 500"),
)
}
// ── Skills Preview ────────────────────────────────────────────────────
@Test
fun testSkillsPreview_success() =
runTest {
val content = SkillContentResponse("my-skill", "# My Skill\n\nDo stuff")
coEvery { mockApiService.getSkillContent(any()) } returns
Response.success(content)
val viewModel = SkillsViewModel(mockApp)
viewModel.previewSkill("my-skill")
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoadingPreview)
assertEquals(
"my-skill",
viewModel.uiState.value.previewSkillName,
)
assertEquals(
content.content,
viewModel.uiState.value.previewSkillContent,
)
}
@Test
fun testSkillsPreview_error() =
runTest {
coEvery { mockApiService.getSkillContent(any()) } returns
createErrorResponse(500)
val viewModel = SkillsViewModel(mockApp)
viewModel.previewSkill("failing-skill")
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoadingPreview)
assertNull(viewModel.uiState.value.previewSkillContent)
assertNotNull(viewModel.uiState.value.toastMessage)
assertTrue(
viewModel.uiState.value.toastMessage!!
.contains("HTTP 500"),
)
}
// Cron Jobs Screen:
@Test
fun testCronJobsListing_success() =
runTest {
val job = CronJob("id1", "Job 1", JsonPrimitive("*/5 * * * *"), "active", "success", "2026-06-15T15:10:00Z")
coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job))
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
assertTrue(viewModel.uiState.value.isLoading)
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoading)
assertEquals(1, viewModel.uiState.value.jobs.size)
assertEquals(
"id1",
viewModel.uiState.value.jobs[0]
.id,
)
assertEquals(
"Job 1",
viewModel.uiState.value.jobs[0]
.name,
)
assertEquals(
"active",
viewModel.uiState.value.jobs[0]
.state,
)
assertEquals(
"success",
viewModel.uiState.value.jobs[0]
.last_run_status,
)
assertEquals(
"2026-06-15T15:10:00Z",
viewModel.uiState.value.jobs[0]
.next_run,
)
}
@Test
fun testCronJobPause_success() =
runTest {
val job = CronJob("id1", "Job 1", JsonPrimitive("*/5 * * * *"), "active", null, null)
coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job))
coEvery { mockApiService.pauseCronJob("id1") } returns Response.success(Unit)
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
advanceUntilIdle()
viewModel.pauseCronJob("id1")
// Verify optimistic update
assertEquals(
"paused",
viewModel.uiState.value.jobs[0]
.state,
)
advanceUntilIdle()
assertEquals(
"paused",
viewModel.uiState.value.jobs[0]
.state,
)
}
@Test
fun testCronJobResume_success() =
runTest {
val job = CronJob("id1", "Job 1", JsonPrimitive("*/5 * * * *"), "paused", null, null)
coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job))
coEvery { mockApiService.resumeCronJob("id1") } returns Response.success(Unit)
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
advanceUntilIdle()
viewModel.resumeCronJob("id1")
// Verify optimistic update
assertEquals(
"active",
viewModel.uiState.value.jobs[0]
.state,
)
advanceUntilIdle()
assertEquals(
"active",
viewModel.uiState.value.jobs[0]
.state,
)
}
@Test
fun testCronJobTrigger_success() =
runTest {
coEvery { mockApiService.triggerCronJob("id1") } returns Response.success(Unit)
val viewModel = CronJobsViewModel()
viewModel.triggerCronJob("id1")
advanceUntilIdle()
assertEquals("Job triggered successfully", viewModel.uiState.value.toastMessage)
}
@Test
fun testCronJobDelete_success() =
runTest {
val job = CronJob("id1", "Job 1", null, null, null, null)
coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job))
coEvery { mockApiService.deleteCronJob("id1") } returns Response.success(Unit)
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.jobs.size)
viewModel.deleteCronJob("id1")
// Verify optimistic update (immediate removal)
assertTrue(
viewModel.uiState.value.jobs
.isEmpty(),
)
advanceUntilIdle()
assertTrue(
viewModel.uiState.value.jobs
.isEmpty(),
)
}
@Test
fun testCronJobsLoad_failure() =
runTest {
coEvery { mockApiService.getCronJobs() } returns createErrorResponse(500)
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoading)
assertNotNull(viewModel.uiState.value.errorMessage)
assertTrue(
viewModel.uiState.value.errorMessage!!
.contains("HTTP 500"),
)
}
// Sessions History Screen:
@Test
fun testSessionsListing_success() =
runTest {
val session = SessionInfo("session-123", "Session 1", "2026-06-15T15:10:00Z", 5, "active")
coEvery {
mockApiService.getSessions(any(), any(), any())
} returns Response.success(SessionListResponse(listOf(session)))
val viewModel = SessionsViewModel()
viewModel.loadSessions()
assertTrue(viewModel.uiState.value.isLoading)
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoading)
assertEquals(1, viewModel.uiState.value.sessions.size)
assertEquals(
"session-123",
viewModel.uiState.value.sessions[0]
.id,
)
assertEquals(
"Session 1",
viewModel.uiState.value.sessions[0]
.title,
)
assertEquals(
5,
viewModel.uiState.value.sessions[0]
.message_count,
)
assertEquals(
"active",
viewModel.uiState.value.sessions[0]
.status,
)
}
@Test
fun testSessionsLoad_failure() =
runTest {
coEvery { mockApiService.getSessions(any(), any(), any()) } returns createErrorResponse(500)
val viewModel = SessionsViewModel()
viewModel.loadSessions()
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoading)
assertNotNull(viewModel.uiState.value.errorMessage)
assertTrue(
viewModel.uiState.value.errorMessage!!
.contains("HTTP 500"),
)
}
@Test
fun testCronJobsRefresh() =
runTest {
val jobA = CronJob("idA", "Job A", null, null, null, null)
val jobB = CronJob("idB", "Job B", null, null, null, null)
coEvery { mockApiService.getCronJobs() } returnsMany
listOf(
Response.success(listOf(jobA)),
Response.success(listOf(jobB)),
)
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
advanceUntilIdle()
assertEquals(
"Job A",
viewModel.uiState.value.jobs[0]
.name,
)
viewModel.loadCronJobs()
advanceUntilIdle()
assertEquals(
"Job B",
viewModel.uiState.value.jobs[0]
.name,
)
}
// Navigation Drawer:
@Test
fun testNavigationDrawerTransitions() {
val backStack = NavBackStack<NavKey>(ChatScreen)
NavigationController.backStack = backStack
assertEquals(ChatScreen, backStack.lastOrNull())
NavigationController.navigateTo(SkillsScreen)
assertEquals(SkillsScreen, backStack.lastOrNull())
assertEquals(1, backStack.size)
NavigationController.navigateTo(CronJobsScreen)
assertEquals(CronJobsScreen, backStack.lastOrNull())
assertEquals(1, backStack.size)
// Non-clearing drawer screen adds to stack
NavigationController.navigateTo(ProfilesScreen)
assertEquals(ProfilesScreen, backStack.lastOrNull())
assertEquals(2, backStack.size)
assertEquals(CronJobsScreen, backStack[0])
}
// ── Tier 2: Boundary & Corner Cases (>=5 per feature) ────────────────
// Skills Screen boundary cases:
@Test
fun testSkillsLoad_httpError_400() =
runTest {
coEvery { mockApiService.getSkills() } returns createErrorResponse(400)
val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills()
advanceUntilIdle()
assertTrue(
viewModel.uiState.value.errorMessage!!
.contains("HTTP 400"),
)
}
@Test
fun testSkillsLoad_httpError_404() =
runTest {
coEvery { mockApiService.getSkills() } returns createErrorResponse(404)
val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills()
advanceUntilIdle()
assertTrue(
viewModel.uiState.value.errorMessage!!
.contains("HTTP 404"),
)
}
@Test
fun testSkillsLoad_httpError_500() =
runTest {
coEvery { mockApiService.getSkills() } returns createErrorResponse(500)
val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills()
advanceUntilIdle()
assertTrue(
viewModel.uiState.value.errorMessage!!
.contains("HTTP 500"),
)
}
@Test
fun testSkillsLoad_networkTimeout() =
runTest {
coEvery { mockApiService.getSkills() } throws IOException("Timeout")
val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills()
advanceUntilIdle()
assertTrue(
viewModel.uiState.value.errorMessage!!
.contains("Timeout"),
)
}
@Test
fun testSkillsLoad_emptyResponse() =
runTest {
coEvery { mockApiService.getSkills() } returns Response.success(emptyList())
val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills()
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoading)
assertTrue(
viewModel.uiState.value.skills
.isEmpty(),
)
assertNull(viewModel.uiState.value.errorMessage)
}
@Test
fun testSkillsToggle_httpError_500() =
runTest {
val skill = Skill("Skill 1", null, null, false)
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
coEvery { mockApiService.toggleSkill(any()) } returns createErrorResponse(500)
val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills()
advanceUntilIdle()
viewModel.toggleSkill(viewModel.uiState.value.skills[0])
advanceUntilIdle()
assertFalse(
viewModel.uiState.value.skills[0]
.enabled,
)
assertTrue(
viewModel.uiState.value.toastMessage!!
.contains("HTTP 500"),
)
}
// Cron Jobs Screen boundary cases:
@Test
fun testCronJobsLoad_httpError_500() =
runTest {
coEvery { mockApiService.getCronJobs() } returns createErrorResponse(500)
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
advanceUntilIdle()
assertTrue(
viewModel.uiState.value.errorMessage!!
.contains("HTTP 500"),
)
}
@Test
fun testCronJobPause_httpError_500() =
runTest {
val job = CronJob("id1", "Job 1", null, "active", null, null)
coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job))
coEvery { mockApiService.pauseCronJob("id1") } returns createErrorResponse(500)
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
advanceUntilIdle()
viewModel.pauseCronJob("id1")
advanceUntilIdle()
assertEquals(
"active",
viewModel.uiState.value.jobs[0]
.state,
)
assertTrue(
viewModel.uiState.value.toastMessage!!
.contains("HTTP 500"),
)
}
@Test
fun testCronJobTrigger_httpError_500() =
runTest {
coEvery { mockApiService.triggerCronJob("id1") } returns createErrorResponse(500)
val viewModel = CronJobsViewModel()
viewModel.triggerCronJob("id1")
advanceUntilIdle()
assertTrue(
viewModel.uiState.value.toastMessage!!
.contains("HTTP 500"),
)
}
@Test
fun testCronJobDelete_httpError_500() =
runTest {
val job = CronJob("id1", "Job 1", null, null, null, null)
coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job))
coEvery { mockApiService.deleteCronJob("id1") } returns createErrorResponse(500)
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
advanceUntilIdle()
viewModel.deleteCronJob("id1")
advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.jobs.size)
assertTrue(
viewModel.uiState.value.toastMessage!!
.contains("HTTP 500"),
)
}
@Test
fun testCronJobsLoad_networkTimeout() =
runTest {
coEvery { mockApiService.getCronJobs() } throws IOException("Timeout")
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
advanceUntilIdle()
assertTrue(
viewModel.uiState.value.errorMessage!!
.contains("Timeout"),
)
}
@Test
fun testCronJobsLoad_emptyResponse() =
runTest {
coEvery { mockApiService.getCronJobs() } returns Response.success(emptyList())
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoading)
assertTrue(
viewModel.uiState.value.jobs
.isEmpty(),
)
assertNull(viewModel.uiState.value.errorMessage)
}
// ── Tier 3: Cross-Feature Combinations ────────────────────────────────
@Test
fun testDrawerTransitionDuringAction() =
runTest {
val skill = Skill("Skill 1", null, null, false)
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
coEvery { mockApiService.toggleSkill(any()) } returns Response.success(Unit)
val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills()
advanceUntilIdle()
// Set up drawer/backstack
val backStack = NavBackStack<NavKey>(SkillsScreen)
NavigationController.backStack = backStack
viewModel.toggleSkill(viewModel.uiState.value.skills[0])
// Transition during action
NavigationController.navigateTo(CronJobsScreen)
assertEquals(CronJobsScreen, NavigationController.backStack?.lastOrNull())
// Complete the action
advanceUntilIdle()
// Verify the state is correct and didn't crash
assertTrue(
viewModel.uiState.value.skills[0]
.enabled,
)
}
@Test
fun testAuthTokenRevocation() =
runTest {
val mockResponse = createErrorResponse<StatusResponse>(401)
coEvery { mockApiService.getStatus() } returns mockResponse
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("expired-token")
viewModel.connect()
advanceUntilIdle()
// Verify state is updated with error message
assertEquals("Invalid token (401 Unauthorized)", viewModel.uiState.value.errorMessage)
assertFalse(viewModel.uiState.value.connectionSuccess)
// Verify AuthManager.setToken(null) was called
}
// ── Tier 4: Real-World Scenarios ─────────────────────────────────────
@Test
fun testFullUserSessionFlow() =
runTest {
// Step 1: User connects with valid token
val statusResponse = mockk<StatusResponse>()
coEvery { mockApiService.getStatus() } returns Response.success(statusResponse)
val connectViewModel = ConnectViewModel(mockApp)
connectViewModel.onTokenChange("valid-token")
connectViewModel.onHostChange("127.0.0.1")
connectViewModel.onPortChange("9119")
connectViewModel.connect()
advanceUntilIdle()
assertTrue(connectViewModel.uiState.value.connectionSuccess)
verify { AuthManager.setToken("valid-token") }
// Step 2: User navigates to SkillsScreen
val backStack = NavBackStack<NavKey>(ChatScreen)
NavigationController.backStack = backStack
NavigationController.navigateTo(SkillsScreen)
assertEquals(SkillsScreen, NavigationController.backStack?.lastOrNull())
// Step 3: User toggles a skill, but API fails
val skill = Skill("Skill X", "Desc X", "Cat X", false)
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
coEvery { mockApiService.toggleSkill(any()) } returns createErrorResponse(500)
val skillsViewModel = SkillsViewModel(mockApp)
skillsViewModel.loadSkills()
advanceUntilIdle()
assertEquals(1, skillsViewModel.uiState.value.skills.size)
assertFalse(
skillsViewModel.uiState.value.skills[0]
.enabled,
)
skillsViewModel.toggleSkill(skill)
// Verify optimistic update
assertTrue(
skillsViewModel.uiState.value.skills[0]
.enabled,
)
advanceUntilIdle()
// Verify state reverts and toast is shown
assertFalse(
skillsViewModel.uiState.value.skills[0]
.enabled,
)
assertTrue(
skillsViewModel.uiState.value.toastMessage!!
.contains("HTTP 500"),
)
// Step 4: User navigates to CronJobsScreen
NavigationController.navigateTo(CronJobsScreen)
assertEquals(CronJobsScreen, NavigationController.backStack?.lastOrNull())
// Step 5: User triggers a cron job
coEvery { mockApiService.triggerCronJob("job-1") } returns Response.success(Unit)
val cronViewModel = CronJobsViewModel()
cronViewModel.triggerCronJob("job-1")
advanceUntilIdle()
assertEquals("Job triggered successfully", cronViewModel.uiState.value.toastMessage)
}
@Test
fun testLogsListing_success() =
runTest {
coEvery { mockApiService.getLogs(lines = 1000) } returns
Response.success(LogResponse(listOf("Log line 1", "Log line 2")))
val viewModel = LogsViewModel()
viewModel.loadLogs()
assertTrue(viewModel.uiState.value.isLoading)
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoading)
assertEquals(2, viewModel.uiState.value.logs.size)
assertEquals("Log line 1", viewModel.uiState.value.logs[0])
assertEquals("Log line 2", viewModel.uiState.value.logs[1])
}
@Test
fun testPluginsManagement_success() =
runTest {
val plugin = PluginInfo("plugin-1", "Desc", "1.0", null, "disabled")
coEvery { mockApiService.getPlugins() } returns Response.success(PluginsHubResponse(listOf(plugin)))
coEvery { mockApiService.enablePlugin("plugin-1") } returns Response.success(Unit)
val viewModel = PluginsViewModel()
viewModel.loadPlugins()
advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.plugins.size)
assertFalse(
viewModel.uiState.value.plugins[0]
.enabled,
)
viewModel.togglePlugin(viewModel.uiState.value.plugins[0])
// Optimistic toggle check
assertTrue(
viewModel.uiState.value.plugins[0]
.enabled,
)
advanceUntilIdle()
assertTrue(
viewModel.uiState.value.plugins[0]
.enabled,
)
}
@Test
fun testChannelsConfiguration_success() =
runTest {
val platform =
MessagingPlatform(
id = "telegram",
name = "Telegram",
enabled = false,
configured = false,
envVars = emptyList(),
)
coEvery { mockApiService.getMessagingPlatforms() } returns
Response.success(
MessagingPlatformResponse(
envPath = "/tmp/.env",
gatewayStartCommand = "hermes gateway start",
platforms = listOf(platform),
),
)
coEvery { mockApiService.configurePlatform("telegram", any()) } returns Response.success(Unit)
val viewModel = ChannelsViewModel()
viewModel.loadPlatforms()
advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.platforms.size)
viewModel.configurePlatform("telegram", mapOf("bot_token" to "xyz"))
advanceUntilIdle()
assertEquals(
"telegram configured successfully — restart the gateway for changes to take effect",
viewModel.uiState.value.toastMessage,
)
}
@Test
fun testKeysManagement_success() =
runTest {
coEvery { mockApiService.getEnvVars() } returns
Response.success(mapOf("KEY1" to EnvVarConfig(true, "value1", "desc", null, null, false)))
coEvery { mockApiService.updateEnvVar(any()) } returns Response.success(Unit)
val viewModel = KeysViewModel()
viewModel.loadKeys()
advanceUntilIdle()
val allVars =
viewModel.uiState.value.categories
.flatMap { it.vars.entries }
.associate { it.key to it.value }
assertEquals("value1", allVars["KEY1"]?.redactedValue)
viewModel.updateKey("KEY1", "newValue")
advanceUntilIdle()
assertEquals("Key updated successfully", viewModel.uiState.value.toastMessage)
}
@Test
fun testSystemDiagnostics_success() =
runTest(testDispatcher) {
val stats =
SystemStatsResponse(
cpu_percent = 50.0,
memory = MemoryStats(total = 8192, used = 4096, percent = 60.0),
)
val doctor = DoctorResponse(true, 24856, "doctor")
coEvery { mockApiService.getSystemStats() } returns Response.success(stats)
coEvery { mockApiService.runDoctor() } returns Response.success(doctor)
coEvery { mockApiService.getStatus() } returns
Response.success(StatusResponse("1.0", true, 0, false, null))
coEvery { mockApiService.getPortal() } returns
Response.success(PortalResponse(logged_in = false))
coEvery { mockApiService.getCurator() } returns Response.success(CuratorResponse())
coEvery { mockApiService.getMemory() } returns Response.success(MemoryResponse())
coEvery { mockApiService.getCredentialPool() } returns
Response.success(CredentialPoolResponse())
coEvery { mockApiService.getCheckpoints() } returns
Response.success(CheckpointsResponse())
coEvery { mockApiService.getHooks() } returns Response.success(HookResponse())
coEvery { mockApiService.checkHermesUpdate(false) } returns
Response.success(UpdateCheckResponse())
coEvery { mockApiService.triggerBackup() } returns Response.success(ActionResponse())
val viewModel = SystemViewModel()
viewModel.loadAll()
advanceUntilIdle()
assertEquals(
50.0,
viewModel.uiState.value.stats
?.cpu_percent,
)
viewModel.triggerBackup()
advanceUntilIdle()
assertEquals("Backup triggered", viewModel.uiState.value.toastMessage)
}
@Test
fun testKanbanBoardAndTasks_success() =
runTest {
val board = KanbanBoard("board-1", "Backlog", null)
val task = KanbanTask("task-1", "Do laundry", null, "todo", null)
coEvery { mockApiService.getKanbanBoards() } returns
Response.success(KanbanBoardsResponse(listOf(board), "board-1"))
coEvery { mockApiService.switchKanbanBoard("board-1") } returns Response.success(Unit)
coEvery { mockApiService.getKanbanBoard() } returns
Response.success(KanbanBoardResponse(listOf(KanbanColumn("todo", listOf(task))), null, null))
coEvery { mockApiService.updateKanbanTask("task-1", mapOf("status" to "in_progress")) } returns
Response.success(Unit)
val viewModel = KanbanViewModel()
viewModel.loadBoards()
advanceUntilIdle()
assertEquals(
"board-1",
viewModel.uiState.value.selectedBoard
?.id,
)
assertEquals(1, viewModel.uiState.value.tasks.size)
assertEquals(
"todo",
viewModel.uiState.value.tasks[0]
.status,
)
viewModel.moveTask(viewModel.uiState.value.tasks[0], "in_progress")
// Optimistic move check
assertEquals(
"in_progress",
viewModel.uiState.value.tasks[0]
.status,
)
advanceUntilIdle()
assertEquals(
"in_progress",
viewModel.uiState.value.tasks[0]
.status,
)
}
@Test
fun testModelOptionsSelection_success() =
runTest {
val provider = ModelProvider("ollama", "Ollama", false, true, listOf("llama3"), 1, null, true, null, null)
val profile = ProfileInfo("default", null, true, "llama3", "ollama", null, null, null, null)
coEvery { mockApiService.getModelOptions(any()) } returns
Response.success(ModelOptionsResponse(listOf(provider)))
coEvery { mockApiService.getActiveProfile() } returns
Response.success(ActiveProfileResponse("default", null))
coEvery { mockApiService.getProfiles() } returns Response.success(ProfilesResponse(listOf(profile)))
coEvery { mockApiService.updateProfileModel("default", any()) } returns Response.success(Unit)
coEvery { mockApiService.getAuxiliaryModels() } returns
Response.success(
AuxiliaryModelsResponse(
tasks = emptyList(),
main = MainModelAssignment("openai", "gpt-4"),
),
)
coEvery { mockApiService.getMoaModels() } returns
Response.success<MoaConfigResponse>(
MoaConfigResponse(
presets = emptyMap(),
default_preset = "",
reference_models = emptyList(),
aggregator = MoaModelSlot("openai", "gpt-4"),
reference_temperature = 0.7,
aggregator_temperature = 0.7,
max_tokens = 4096,
),
)
val viewModel = ModelViewModel()
viewModel.loadAll()
advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.providers.size)
assertEquals(
"default",
viewModel.uiState.value.activeProfile
?.name,
)
assertEquals(
"llama3",
viewModel.uiState.value.activeProfile
?.model,
)
viewModel.selectModel("ollama", "llama3")
advanceUntilIdle()
coVerify { mockApiService.updateProfileModel("default", any()) }
assertEquals("Successfully set profile model to llama3", viewModel.uiState.value.toastMessage)
}
@Test
fun testModelOptions_refreshFlagPropagation() =
runTest {
val provider = ModelProvider("ollama", "Ollama", false, true, listOf("llama3"), 1, null, true, null, null)
val profile = ProfileInfo("default", null, true, "llama3", "ollama", null, null, null, null)
coEvery { mockApiService.getModelOptions(any()) } returns
Response.success(ModelOptionsResponse(listOf(provider)))
coEvery { mockApiService.getActiveProfile() } returns
Response.success(ActiveProfileResponse("default", null))
coEvery { mockApiService.getProfiles() } returns Response.success(ProfilesResponse(listOf(profile)))
coEvery { mockApiService.getAuxiliaryModels() } returns
Response.success(
AuxiliaryModelsResponse(
tasks = emptyList(),
main = MainModelAssignment("openai", "gpt-4"),
),
)
coEvery { mockApiService.getMoaModels() } returns
Response.success<MoaConfigResponse>(
MoaConfigResponse(
presets = emptyMap(),
default_preset = "",
reference_models = emptyList(),
aggregator = MoaModelSlot("openai", "gpt-4"),
max_tokens = 4096,
),
)
val capturedRefresh = mutableListOf<Boolean>()
coEvery { mockApiService.getModelOptions(any()) } answers {
capturedRefresh.add(firstArg())
Response.success(ModelOptionsResponse(listOf(provider)))
}
// Default loadAll() must call getModelOptions with refresh = false
val viewModel = ModelViewModel()
viewModel.loadAll()
advanceUntilIdle()
// Pull-to-refresh path must call getModelOptions with refresh = true
viewModel.loadAll(refresh = true)
advanceUntilIdle()
assertEquals(listOf(false, true), capturedRefresh)
}
@Test
fun testConnectViewModel_onPairingString_urlFormat() =
runTest {
mockkStatic(android.net.Uri::class)
val mockUri = mockk<android.net.Uri>()
every { android.net.Uri.parse("hermes://connect?host=192.168.1.5&port=9119&token=TEST_TOKEN") } returns
mockUri
every { mockUri.getQueryParameter("host") } returns "192.168.1.5"
every { mockUri.getQueryParameter("port") } returns "9119"
every { mockUri.getQueryParameter("token") } returns "TEST_TOKEN"
val statusResponse = mockk<StatusResponse>()
coEvery { mockApiService.getStatus() } returns Response.success(statusResponse)
val viewModel = ConnectViewModel(mockApp)
viewModel.onPairingString("hermes://connect?host=192.168.1.5&port=9119&token=TEST_TOKEN")
advanceUntilIdle()
assertEquals("192.168.1.5", viewModel.uiState.value.host)
assertEquals("9119", viewModel.uiState.value.port)
assertEquals("TEST_TOKEN", viewModel.uiState.value.token)
assertTrue(viewModel.uiState.value.connectionSuccess)
unmockkStatic(android.net.Uri::class)
}
@Test
fun testConnectViewModel_onPairingString_base64Format() =
runTest {
mockkStatic(android.util.Base64::class)
val base64Str = "eyJob3N0IjoiMTkyLjE2OC4xLjUiLCJwb3J0Ijo5MTE5LCJ0b2tlbiI6IlRFU1RfVE9LRU4ifQ=="
every { android.util.Base64.decode(base64Str, any()) } answers {
java.util.Base64
.getDecoder()
.decode(base64Str)
}
val statusResponse = mockk<StatusResponse>()
coEvery { mockApiService.getStatus() } returns Response.success(statusResponse)
val viewModel = ConnectViewModel(mockApp)
viewModel.onPairingString(base64Str)
advanceUntilIdle()
assertEquals("192.168.1.5", viewModel.uiState.value.host)
assertEquals("9119", viewModel.uiState.value.port)
assertEquals("TEST_TOKEN", viewModel.uiState.value.token)
assertTrue(viewModel.uiState.value.connectionSuccess)
unmockkStatic(android.util.Base64::class)
}
}