Initial commit of HermesControl Android app

This commit is contained in:
Hy4ri committed 2026-06-16 00:00:36 +03:00
commit d6ff3de594
92 files changed
+10004

No files matched your search

@@ -0,0 +1,757 @@
package com.m57.hermescontrol
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.CronJob
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.model.ToggleSkillRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.ui.connect.ConnectViewModel
import com.m57.hermescontrol.ui.cron.CronJobsViewModel
import com.m57.hermescontrol.ui.skills.SkillsViewModel
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.*
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
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
val testMainDispatcher = Dispatchers.Main
mockkStatic(Dispatchers::class)
every { Dispatchers.IO } returns testDispatcher
every { Dispatchers.Main } returns testMainDispatcher
mockkObject(AuthManager)
mockkObject(ApiClient)
mockApiService = mockk()
every { ApiClient.hermesApi } 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
}
@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()
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()
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()
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()
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()
viewModel.loadSkills()
advanceUntilIdle()
assertEquals(
"Skill A",
viewModel.uiState.value.skills[0]
.name,
)
viewModel.loadSkills()
advanceUntilIdle()
assertEquals(
"Skill B",
viewModel.uiState.value.skills[0]
.name,
)
}
// Cron Jobs Screen:
@Test
fun testCronJobsListing_success() =
runTest {
val job = CronJob("id1", "Job 1", "*/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", "*/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", "*/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"),
)
}
@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 key settings adds to stack
NavigationController.navigateTo(SettingsScreen)
assertEquals(SettingsScreen, 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()
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()
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()
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()
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()
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()
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()
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()
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
verify { AuthManager.setToken(null) }
}
// ── 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()
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()
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)
}
}
@@ -0,0 +1,134 @@
package com.m57.hermescontrol.data.local
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import io.mockk.*
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class AuthManagerTest {
private lateinit var mockPrefs: SharedPreferences
private lateinit var mockEditor: SharedPreferences.Editor
private lateinit var mockContext: Context
@Before
fun setUp() {
mockPrefs = mockk(relaxed = true)
mockEditor = mockk(relaxed = true)
mockContext = mockk(relaxed = true)
every { mockPrefs.edit() } returns mockEditor
every { mockEditor.putString(any(), any()) } returns mockEditor
every { mockEditor.putInt(any(), any()) } returns mockEditor
every { mockEditor.putBoolean(any(), any()) } returns mockEditor
// Mock EncryptedSharedPreferences static methods
mockkStatic(EncryptedSharedPreferences::class)
every {
EncryptedSharedPreferences.create(
any<Context>(),
any<String>(),
any<MasterKey>(),
any<EncryptedSharedPreferences.PrefKeyEncryptionScheme>(),
any<EncryptedSharedPreferences.PrefValueEncryptionScheme>(),
)
} returns mockPrefs
// Mock MasterKey.Builder constructor and its build method
mockkConstructor(MasterKey.Builder::class)
val mockMasterKey = mockk<MasterKey>(relaxed = true)
every { anyConstructed<MasterKey.Builder>().build() } returns mockMasterKey
// Reset prefs singleton instance using reflection
val field = AuthManager::class.java.getDeclaredField("prefs")
field.isAccessible = true
field.set(AuthManager, null)
// Initialise AuthManager
AuthManager.init(mockContext)
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun testGetAndSetToken() {
every { mockPrefs.getString("auth_token", null) } returns "my_secret_token"
assertEquals("my_secret_token", AuthManager.getToken())
AuthManager.setToken("new_token")
verify { mockEditor.putString("auth_token", "new_token") }
verify { mockEditor.apply() }
}
@Test
fun testGetAndSetToken_null() {
every { mockPrefs.getString("auth_token", null) } returns null
assertNull(AuthManager.getToken())
AuthManager.setToken(null)
verify { mockEditor.putString("auth_token", null) }
verify { mockEditor.apply() }
}
@Test
fun testGetAndSetHost() {
every { mockPrefs.getString("host", "127.0.0.1") } returns "192.168.1.1"
assertEquals("192.168.1.1", AuthManager.getHost())
AuthManager.setHost("10.0.0.1")
verify { mockEditor.putString("host", "10.0.0.1") }
verify { mockEditor.apply() }
}
@Test
fun testGetAndSetPort() {
every { mockPrefs.getInt("port", 9119) } returns 8080
assertEquals(8080, AuthManager.getPort())
AuthManager.setPort(9090)
verify { mockEditor.putInt("port", 9090) }
verify { mockEditor.apply() }
}
@Test
fun testGetAndSetAutoReconnect() {
every { mockPrefs.getBoolean("auto_reconnect", true) } returns false
assertEquals(false, AuthManager.isAutoReconnect())
AuthManager.setAutoReconnect(true)
verify { mockEditor.putBoolean("auto_reconnect", true) }
verify { mockEditor.apply() }
}
@Test
fun testBaseUrl() {
every { mockPrefs.getString("host", "127.0.0.1") } returns "hermes.local"
every { mockPrefs.getInt("port", 9119) } returns 1234
assertEquals("http://hermes.local:1234/", AuthManager.baseUrl())
}
@Test
fun testWsUrl() {
every { mockPrefs.getString("host", "127.0.0.1") } returns "hermes.local"
every { mockPrefs.getInt("port", 9119) } returns 1234
every { mockPrefs.getString("auth_token", null) } returns "token123"
assertEquals("ws://hermes.local:1234/api/ws?token=token123", AuthManager.wsUrl())
}
@Test
fun testWsUrl_nullToken() {
every { mockPrefs.getString("host", "127.0.0.1") } returns "hermes.local"
every { mockPrefs.getInt("port", 9119) } returns 1234
every { mockPrefs.getString("auth_token", null) } returns null
assertEquals("ws://hermes.local:1234/api/ws?token=", AuthManager.wsUrl())
}
}
@@ -0,0 +1,428 @@
package com.m57.hermescontrol.data.model
import com.google.gson.Gson
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class ModelSerializationTest {
private val gson = Gson()
@Test
fun testSkillDeserialization_allFields() {
val json =
"""
{
"name": "weather",
"description": "Get current weather info",
"category": "utility",
"enabled": true
}
""".trimIndent()
val skill = gson.fromJson(json, Skill::class.java)
assertEquals("weather", skill.name)
assertEquals("Get current weather info", skill.description)
assertEquals("utility", skill.category)
assertEquals(true, skill.enabled)
}
@Test
fun testSkillDeserialization_optionalFieldsNull() {
val json =
"""
{
"name": "light_control",
"description": null,
"category": null,
"enabled": false
}
""".trimIndent()
val skill = gson.fromJson(json, Skill::class.java)
assertEquals("light_control", skill.name)
assertNull(skill.description)
assertNull(skill.category)
assertEquals(false, skill.enabled)
}
@Test
fun testCronJobDeserialization_allFields() {
val json =
"""
{
"id": "job-123",
"name": "Daily Backup",
"schedule": "0 0 * * *",
"state": "active",
"last_run_status": "success",
"next_run": "2026-06-16T00:00:00Z"
}
""".trimIndent()
val job = gson.fromJson(json, CronJob::class.java)
assertEquals("job-123", job.id)
assertEquals("Daily Backup", job.name)
assertEquals("0 0 * * *", job.schedule)
assertEquals("active", job.state)
assertEquals("success", job.last_run_status)
assertEquals("2026-06-16T00:00:00Z", job.next_run)
}
@Test
fun testCronJobDeserialization_optionalFieldsNull() {
val json =
"""
{
"id": "job-456",
"name": "One-off Task",
"schedule": null,
"state": null,
"last_run_status": null,
"next_run": null
}
""".trimIndent()
val job = gson.fromJson(json, CronJob::class.java)
assertEquals("job-456", job.id)
assertEquals("One-off Task", job.name)
assertNull(job.schedule)
assertNull(job.state)
assertNull(job.last_run_status)
assertNull(job.next_run)
}
@Test
fun testToggleSkillRequestSerialization() {
val request = ToggleSkillRequest(name = "weather", enabled = false)
val json = gson.toJson(request)
// Ensure serialization produces correct JSON structure
val parsed = gson.fromJson(json, Map::class.java)
assertEquals("weather", parsed["name"])
assertEquals(false, parsed["enabled"])
}
@Test
fun testSkillDeserialization_missingRequiredFields() {
val json = "{}"
val skill = gson.fromJson(json, Skill::class.java)
// Verify null-safety issue: Gson allows non-nullable 'name' to be null at runtime
assertNull(skill.name)
// Primitive boolean defaults to false in Gson/JVM when missing
assertEquals(false, skill.enabled)
}
@Test
fun testSkillDeserialization_explicitNullForNonNullable() {
val json =
"""
{
"name": null,
"enabled": null
}
""".trimIndent()
val skill = gson.fromJson(json, Skill::class.java)
assertNull(skill.name)
assertEquals(false, skill.enabled)
}
@Test
fun testCronJobDeserialization_missingRequiredFields() {
val json = "{}"
val job = gson.fromJson(json, CronJob::class.java)
assertNull(job.id)
assertNull(job.name)
}
@Test
fun testSessionListResponseDeserialization_missingRequiredFields() {
val json = "{}"
val response = gson.fromJson(json, SessionListResponse::class.java)
assertNull(response.sessions)
}
@Test
fun testSessionInfoDeserialization_missingRequiredFields() {
val json = "{}"
val info = gson.fromJson(json, SessionInfo::class.java)
assertNull(info.id)
assertNull(info.title)
}
@Test
fun testSessionMessagesResponseDeserialization_missingRequiredFields() {
val json = "{}"
val response = gson.fromJson(json, SessionMessagesResponse::class.java)
assertNull(response.messages)
}
@Test
fun testSkillDeserialization_malformedJson() {
val json = "{\"name\": \"weather\","
org.junit.jupiter.api.Assertions.assertThrows(com.google.gson.JsonSyntaxException::class.java) {
gson.fromJson(json, Skill::class.java)
}
}
@Test
fun testSkillDeserialization_typeMismatchObject() {
val json =
"""
{
"name": "weather",
"enabled": {}
}
""".trimIndent()
org.junit.jupiter.api.Assertions.assertThrows(com.google.gson.JsonSyntaxException::class.java) {
gson.fromJson(json, Skill::class.java)
}
}
@Test
fun testSkillDeserialization_typeMismatchArray() {
val json =
"""
{
"name": "weather",
"enabled": []
}
""".trimIndent()
org.junit.jupiter.api.Assertions.assertThrows(com.google.gson.JsonSyntaxException::class.java) {
gson.fromJson(json, Skill::class.java)
}
}
@Test
fun testSkillDeserialization_missingName_causesNullOnNonNullableField() {
val json =
"""
{
"description": "Get current weather info",
"category": "utility",
"enabled": true
}
""".trimIndent()
val skill = gson.fromJson(json, Skill::class.java)
// Gson bypasses Kotlin null-safety constraints, resulting in null on non-nullable field
val nameNullable: String? = skill.name
assertNull(nameNullable)
}
@Test
fun testSkillDeserialization_nullName_causesNullOnNonNullableField() {
val json =
"""
{
"name": null,
"description": "Get current weather info",
"category": "utility",
"enabled": true
}
""".trimIndent()
val skill = gson.fromJson(json, Skill::class.java)
val nameNullable: String? = skill.name
assertNull(nameNullable)
}
@Test
fun testSkillDeserialization_missingEnabled_usesJvmDefaultValue() {
val json =
"""
{
"name": "weather",
"description": "Get current weather info",
"category": "utility"
}
""".trimIndent()
val skill = gson.fromJson(json, Skill::class.java)
// Boolean primitive defaults to false
assertEquals(false, skill.enabled)
}
@Test
fun testCronJobDeserialization_missingId_causesNullOnNonNullableField() {
val json =
"""
{
"name": "Daily Backup",
"schedule": "0 0 * * *",
"state": "active"
}
""".trimIndent()
val job = gson.fromJson(json, CronJob::class.java)
val idNullable: String? = job.id
assertNull(idNullable)
}
@Test
fun testSessionListResponseDeserialization_missingSessions_causesNullOnNonNullableField() {
val json = "{}"
val response = gson.fromJson(json, SessionListResponse::class.java)
val sessionsNullable: List<SessionInfo>? = response.sessions
assertNull(sessionsNullable)
}
@Test
fun testSessionInfoDeserialization_missingId_causesNullOnNonNullableField() {
val json =
"""
{
"title": "My Session",
"created_at": "2026-06-15T12:00:00Z"
}
""".trimIndent()
val info = gson.fromJson(json, SessionInfo::class.java)
val idNullable: String? = info.id
assertNull(idNullable)
}
@Test
fun testSessionMessagesResponseDeserialization_missingMessages_causesNullOnNonNullableField() {
val json = "{}"
val response = gson.fromJson(json, SessionMessagesResponse::class.java)
val messagesNullable: List<SessionMessage>? = response.messages
assertNull(messagesNullable)
}
@Test
fun testDeserialization_malformedJson_throwsJsonSyntaxException() {
val json = """{"name": "weather", "enabled": """
org.junit.jupiter.api.Assertions.assertThrows(com.google.gson.JsonSyntaxException::class.java) {
gson.fromJson(json, Skill::class.java)
}
}
@Test
fun testStatusResponseDeserialization_allFields() {
val json =
"""
{
"version": "1.0.0",
"gateway_running": true,
"active_sessions": 5,
"auth_required": false,
"gateway_platforms": {
"platform1": {
"state": "connected",
"error_code": null
},
"platform2": {
"state": "error",
"error_code": "ERR_CONN"
}
}
}
""".trimIndent()
val response = gson.fromJson(json, StatusResponse::class.java)
assertEquals("1.0.0", response.version)
assertEquals(true, response.gateway_running)
assertEquals(5, response.active_sessions)
assertEquals(false, response.auth_required)
assertEquals(2, response.gateway_platforms?.size)
assertEquals("connected", response.gateway_platforms?.get("platform1")?.state)
assertNull(response.gateway_platforms?.get("platform1")?.error_code)
assertEquals("error", response.gateway_platforms?.get("platform2")?.state)
assertEquals("ERR_CONN", response.gateway_platforms?.get("platform2")?.error_code)
}
@Test
fun testStatusResponseDeserialization_missingFields() {
val json = "{}"
val response = gson.fromJson(json, StatusResponse::class.java)
assertNull(response.version)
assertNull(response.gateway_running)
assertNull(response.active_sessions)
assertNull(response.auth_required)
assertNull(response.gateway_platforms)
}
@Test
fun testStatusResponseDeserialization_gatewayPlatformsNullValue() {
val json =
"""
{
"gateway_platforms": {
"android": null,
"ios": {
"state": null
}
}
}
""".trimIndent()
val response = gson.fromJson(json, StatusResponse::class.java)
val platforms = response.gateway_platforms
assertNotNull(platforms)
assertNull(platforms!!["android"])
val iosPlatform = platforms["ios"]
assertNotNull(iosPlatform)
assertNull(iosPlatform!!.state)
assertNull(iosPlatform.error_code)
}
@Test
fun testStatusResponseDeserialization_typeMismatchInGatewayPlatforms() {
val json =
"""
{
"gateway_platforms": "not_a_map"
}
""".trimIndent()
org.junit.jupiter.api.Assertions.assertThrows(com.google.gson.JsonSyntaxException::class.java) {
gson.fromJson(json, StatusResponse::class.java)
}
}
@Test
fun testSystemStatsResponse_cpuPercentTypeSafety() {
val jsonNumber = """{"cpu":{"percent":45.2}}"""
val responseNumber = gson.fromJson(jsonNumber, SystemStatsResponse::class.java)
assertEquals(45.2, responseNumber.cpuPercent)
val jsonStringNum = """{"cpu":{"percent":"45.2"}}"""
val responseStringNum = gson.fromJson(jsonStringNum, SystemStatsResponse::class.java)
assertNull(responseStringNum.cpuPercent)
val jsonNullPercent = """{"cpu":{"percent":null}}"""
val responseNullPercent = gson.fromJson(jsonNullPercent, SystemStatsResponse::class.java)
assertNull(responseNullPercent.cpuPercent)
val jsonMissingPercent = """{"cpu":{}}"""
val responseMissingPercent = gson.fromJson(jsonMissingPercent, SystemStatsResponse::class.java)
assertNull(responseMissingPercent.cpuPercent)
val jsonNullCpu = """{"cpu":null}"""
val responseNullCpu = gson.fromJson(jsonNullCpu, SystemStatsResponse::class.java)
assertNull(responseNullCpu.cpuPercent)
val jsonMissingCpu = "{}"
val responseMissingCpu = gson.fromJson(jsonMissingCpu, SystemStatsResponse::class.java)
assertNull(responseMissingCpu.cpuPercent)
}
@Test
fun testToggleSkillRequestDeserialization_missingRequiredFields() {
val json = """{"enabled":true}"""
val request = gson.fromJson(json, ToggleSkillRequest::class.java)
val nameNullable: String? = request.name
assertNull(nameNullable)
assertEquals(true, request.enabled)
}
@Test
fun testToggleSkillRequestDeserialization_explicitNull() {
val json = """{"name":null,"enabled":null}"""
val request = gson.fromJson(json, ToggleSkillRequest::class.java)
val nameNullable: String? = request.name
assertNull(nameNullable)
assertEquals(false, request.enabled)
}
@Test
fun testToggleSkillRequestSerialization_withNullName() {
val jsonInput = """{"name":null,"enabled":true}"""
val request = gson.fromJson(jsonInput, ToggleSkillRequest::class.java)
val jsonOutput = gson.toJson(request)
val parsed = gson.fromJson(jsonOutput, Map::class.java)
assertFalse(parsed.containsKey("name"))
assertEquals(true, parsed["enabled"])
}
}
@@ -0,0 +1,355 @@
package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.model.ToggleSkillRequest
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
class HermesApiServiceTest {
private lateinit var mockWebServer: MockWebServer
private lateinit var apiService: HermesApiService
@BeforeEach
fun setUp() {
mockWebServer = MockWebServer()
mockWebServer.start()
val retrofit =
Retrofit
.Builder()
.baseUrl(mockWebServer.url("/"))
.addConverterFactory(GsonConverterFactory.create())
.build()
apiService = retrofit.create(HermesApiService::class.java)
}
@AfterEach
fun tearDown() {
mockWebServer.shutdown()
}
@Test
fun testGetSkills_requestsCorrectUrlAndMethod() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""[{"name":"weather","description":"info","category":"utils","enabled":true}]"""),
)
val response = apiService.getSkills()
assertTrue(response.isSuccessful)
assertEquals(1, response.body()?.size)
assertEquals("weather", response.body()?.get(0)?.name)
val recordedRequest = mockWebServer.takeRequest()
assertEquals("/api/skills", recordedRequest.path)
assertEquals("GET", recordedRequest.method)
}
@Test
fun testToggleSkill_postsCorrectBody() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(""),
)
val response = apiService.toggleSkill(ToggleSkillRequest(name = "weather", enabled = true))
assertTrue(response.isSuccessful)
val recordedRequest = mockWebServer.takeRequest()
assertEquals("/api/skills/toggle", recordedRequest.path)
assertEquals("PUT", recordedRequest.method)
assertEquals("""{"name":"weather","enabled":true}""", recordedRequest.body.readUtf8())
}
@Test
fun testGetCronJobs_requestsCorrectUrlAndMethod() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""[{"id":"job_1","name":"backup","schedule":"* * * * *","state":"active","last_run_status":null,"next_run":null}]""",
),
)
val response = apiService.getCronJobs()
assertTrue(response.isSuccessful)
assertEquals(1, response.body()?.size)
assertEquals("job_1", response.body()?.get(0)?.id)
val recordedRequest = mockWebServer.takeRequest()
assertEquals("/api/cron/jobs", recordedRequest.path)
assertEquals("GET", recordedRequest.method)
}
@Test
fun testPauseCronJob_sendsPostRequest() =
runTest {
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(""))
val response = apiService.pauseCronJob("job_1")
assertTrue(response.isSuccessful)
val recordedRequest = mockWebServer.takeRequest()
assertEquals("/api/cron/jobs/job_1/pause", recordedRequest.path)
assertEquals("POST", recordedRequest.method)
}
@Test
fun testResumeCronJob_sendsPostRequest() =
runTest {
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(""))
val response = apiService.resumeCronJob("job_1")
assertTrue(response.isSuccessful)
val recordedRequest = mockWebServer.takeRequest()
assertEquals("/api/cron/jobs/job_1/resume", recordedRequest.path)
assertEquals("POST", recordedRequest.method)
}
@Test
fun testTriggerCronJob_sendsPostRequest() =
runTest {
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(""))
val response = apiService.triggerCronJob("job_1")
assertTrue(response.isSuccessful)
val recordedRequest = mockWebServer.takeRequest()
assertEquals("/api/cron/jobs/job_1/trigger", recordedRequest.path)
assertEquals("POST", recordedRequest.method)
}
@Test
fun testDeleteCronJob_sendsDeleteRequest() =
runTest {
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(""))
val response = apiService.deleteCronJob("job_1")
assertTrue(response.isSuccessful)
val recordedRequest = mockWebServer.takeRequest()
assertEquals("/api/cron/jobs/job_1", recordedRequest.path)
assertEquals("DELETE", recordedRequest.method)
}
@Test
fun testGetSkills_missingFieldsInResponse() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""[{"description":"info"}]"""),
)
val response = apiService.getSkills()
assertTrue(response.isSuccessful)
val skills = response.body()
assertEquals(1, skills?.size)
val skill = skills?.get(0)
org.junit.jupiter.api.Assertions
.assertNull(skill?.name)
assertEquals(false, skill?.enabled)
}
@Test
fun testGetCronJobs_missingFieldsInResponse() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""[{"schedule":"* * * * *"}]"""),
)
val response = apiService.getCronJobs()
assertTrue(response.isSuccessful)
val jobs = response.body()
assertEquals(1, jobs?.size)
val job = jobs?.get(0)
org.junit.jupiter.api.Assertions
.assertNull(job?.id)
org.junit.jupiter.api.Assertions
.assertNull(job?.name)
}
@Test
fun testGetSessions_missingSessionsField() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{}"""),
)
val response = apiService.getSessions()
assertTrue(response.isSuccessful)
org.junit.jupiter.api.Assertions
.assertNull(response.body()?.sessions)
}
@Test
fun testGetSessionMessages_apiReturnsMalformedJson_throwsException() {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{"messages": ["""),
)
assertThrows(java.io.EOFException::class.java) {
runTest {
apiService.getSessionMessages("session_1")
}
}
}
@Test
fun testGetSessionMessages_missingMessagesField() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{}"""),
)
val response = apiService.getSessionMessages("session_1")
assertTrue(response.isSuccessful)
org.junit.jupiter.api.Assertions
.assertNull(response.body()?.messages)
}
@Test
fun testGetSkills_malformedJsonResponse_throwsException() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""[{"name":"weather""""),
)
org.junit.jupiter.api.Assertions.assertThrows(java.io.EOFException::class.java) {
kotlinx.coroutines.runBlocking {
apiService.getSkills()
}
}
}
@Test
fun testGetSkills_typeMismatchInResponse_throwsException() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""[{"name":"weather","enabled":{}}]"""),
)
org.junit.jupiter.api.Assertions.assertThrows(com.google.gson.JsonSyntaxException::class.java) {
kotlinx.coroutines.runBlocking {
apiService.getSkills()
}
}
}
@Test
fun testGetStatus_missingFieldsInResponse() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("{}"),
)
val response = apiService.getStatus()
assertTrue(response.isSuccessful)
val status = response.body()
assertNotNull(status)
assertNull(status?.version)
assertNull(status?.gateway_running)
assertNull(status?.active_sessions)
assertNull(status?.auth_required)
assertNull(status?.gateway_platforms)
}
@Test
fun testGetStatus_gatewayPlatformsNullValue() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{"gateway_platforms": {"android": null}}"""),
)
val response = apiService.getStatus()
assertTrue(response.isSuccessful)
val status = response.body()
assertNotNull(status)
val platforms = status?.gateway_platforms
assertNotNull(platforms)
assertNull(platforms!!["android"])
}
@Test
fun testGetSystemStats_missingFields() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("{}"),
)
val response = apiService.getSystemStats()
assertTrue(response.isSuccessful)
val stats = response.body()
assertNotNull(stats)
assertNull(stats?.cpuPercent)
assertNull(stats?.memoryPercent)
}
@Test
fun testGetSystemStats_nonNumericPercent_returnsNull() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{"cpu":{"percent":"not_a_number"}}"""),
)
val response = apiService.getSystemStats()
assertTrue(response.isSuccessful)
val stats = response.body()
assertNotNull(stats)
assertNull(stats?.cpuPercent)
}
@Test
fun testGetSystemStats_typeMismatchArray_behavior() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{"cpu": []}"""),
)
val response = apiService.getSystemStats()
assertTrue(response.isSuccessful)
val stats = response.body()
assertNotNull(stats)
// Gson deserializes an empty array [] into an empty map {} for Map fields
assertTrue(stats?.cpu?.isEmpty() == true)
}
}
@@ -0,0 +1,136 @@
package com.m57.hermescontrol.data.ws
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class EventParserTest {
@Test
fun testParseRpcResult_returnsRpcResultEvent() {
val response =
JsonRpcResponse(
jsonrpc = "2.0",
id = "123",
result = mapOf("status" to "success"),
error = null,
method = null,
params = null,
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.RpcResult)
val rpcResult = event as WsEvent.RpcResult
assertEquals("123", rpcResult.id)
assertEquals(mapOf("status" to "success"), rpcResult.result)
}
@Test
fun testParseRpcError_returnsRpcErrorEvent() {
val error = JsonRpcError(code = -32600, message = "Invalid Request")
val response =
JsonRpcResponse(
jsonrpc = "2.0",
id = "456",
result = null,
error = error,
method = null,
params = null,
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.RpcError)
val rpcError = event as WsEvent.RpcError
assertEquals("456", rpcError.id)
assertEquals(-32600, rpcError.error.code)
assertEquals("Invalid Request", rpcError.error.message)
}
@Test
fun testParseGatewayReady_returnsGatewayReadyEvent() {
val response =
JsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "gateway.ready",
"payload" to mapOf("session_id" to "session-1"),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.GatewayReady)
val gatewayReady = event as WsEvent.GatewayReady
assertEquals(mapOf("session_id" to "session-1"), gatewayReady.data)
}
@Test
fun testParseMessageToken_returnsMessageTokenEvent() {
val response =
JsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "message.token",
"payload" to mapOf("text" to "hello", "session_id" to "session-1"),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.MessageToken)
val tokenEvent = event as WsEvent.MessageToken
assertEquals("hello", tokenEvent.token)
assertEquals("session-1", tokenEvent.sessionId)
}
@Test
fun testParseThinkingDelta_returnsThinkingDeltaEvent() {
val response =
JsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "thinking.delta",
"payload" to mapOf("text" to "thinking token", "session_id" to "session-2"),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.ThinkingDelta)
val deltaEvent = event as WsEvent.ThinkingDelta
assertEquals("thinking token", deltaEvent.token)
assertEquals("session-2", deltaEvent.sessionId)
}
@Test
fun testParseClarifyRequest_returnsClarifyRequestEvent() {
val response =
JsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "clarify.request",
"payload" to
mapOf(
"text" to "Select option?",
"options" to listOf("Yes", "No"),
),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.ClarifyRequest)
val clarifyEvent = event as WsEvent.ClarifyRequest
assertEquals("Select option?", clarifyEvent.text)
assertEquals(listOf("Yes", "No"), clarifyEvent.options)
}
}
@@ -0,0 +1,354 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.JsonRpcError
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
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
@OptIn(ExperimentalCoroutinesApi::class)
class ChatViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val mockEventsFlow = MutableSharedFlow<WsEvent>(extraBufferCapacity = 64)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
val testMainDispatcher = Dispatchers.Main
mockkStatic(Dispatchers::class)
every { Dispatchers.IO } returns testDispatcher
every { Dispatchers.Main } returns testMainDispatcher
mockkObject(AuthManager)
mockkObject(HermesWsClient)
every { AuthManager.getToken() } returns "test-token"
every { HermesWsClient.events } returns mockEventsFlow
every { HermesWsClient.connect() } returns Unit
every { HermesWsClient.disconnect() } returns Unit
// Default mock stubs for requests returning unique IDs
var reqCount = 0
every { HermesWsClient.send(any(), any()) } answers {
reqCount++
"req-id-$reqCount"
}
every { HermesWsClient.sendMessage(any(), any()) } answers {
reqCount++
"req-msg-$reqCount"
}
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun testInitialStateAndConnection() =
runTest {
val viewModel = ChatViewModel()
advanceUntilIdle()
verify { HermesWsClient.connect() }
assertTrue(viewModel.uiState.value.isLoading)
assertFalse(viewModel.uiState.value.isConnected)
}
@Test
fun testGatewayReady_createsSessionIfNoneExists() =
runTest {
val viewModel = ChatViewModel()
advanceUntilIdle()
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
val state = viewModel.uiState.value
assertTrue(state.isConnected)
// Note: GatewayReady immediately calls createNewSession(), which sets isLoading = true and clears messages
assertTrue(state.isLoading)
assertEquals(0, state.messages.size)
// Verify that list sessions and create session requests are triggered
verify { HermesWsClient.send(WsMethods.SESSION_LIST) }
verify { HermesWsClient.send(WsMethods.SESSION_CREATE) }
}
@Test
fun testSessionCreateRpcResult() =
runTest {
var createReqId = ""
every { HermesWsClient.send(WsMethods.SESSION_CREATE, any()) } answers {
createReqId = "custom-create-id"
createReqId
}
val viewModel = ChatViewModel()
advanceUntilIdle()
// Trigger GatewayReady -> triggers createNewSession
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
// Feed SESSION_CREATE result
mockEventsFlow.emit(WsEvent.RpcResult(createReqId, mapOf("session_id" to "session-123")))
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals("session-123", state.currentSessionId)
assertFalse(state.isLoading)
// Connected to Hermes was cleared when createNewSession() was called, so only "Session created" exists
assertEquals(1, state.messages.size)
assertEquals("Session created", state.messages[0].content)
}
@Test
fun testSessionListRpcResult() =
runTest {
var listReqId = ""
every { HermesWsClient.send(WsMethods.SESSION_LIST, any()) } answers {
listReqId = "custom-list-id"
listReqId
}
val viewModel = ChatViewModel()
advanceUntilIdle()
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
// Feed SESSION_LIST result
mockEventsFlow.emit(
WsEvent.RpcResult(
listReqId,
mapOf(
"sessions" to
listOf(
mapOf(
"id" to "session-123",
"title" to "My Session Title",
"message_count" to 12.0,
),
),
),
),
)
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(1, state.sessions.size)
assertEquals("session-123", state.sessions[0].id)
assertEquals("My Session Title", state.sessions[0].title)
assertEquals(12, state.sessions[0].messageCount)
}
@Test
fun testMessageStreamingFlow() =
runTest {
val viewModel = ChatViewModel()
advanceUntilIdle()
// Set active session ID first
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
// Stream Start
mockEventsFlow.emit(WsEvent.MessageStart("session-123"))
advanceUntilIdle()
var state = viewModel.uiState.value
assertTrue(state.isAgentTyping)
assertEquals("", state.currentStreamingText)
assertFalse(state.isThinking)
assertEquals("", state.thinkingText)
// Thinking Delta 1
mockEventsFlow.emit(WsEvent.ThinkingDelta("Thinking...", "session-123"))
advanceUntilIdle()
state = viewModel.uiState.value
assertTrue(state.isThinking)
assertEquals("Thinking...", state.thinkingText)
// Thinking Delta 2
mockEventsFlow.emit(WsEvent.ThinkingDelta(" deeper", "session-123"))
advanceUntilIdle()
state = viewModel.uiState.value
assertTrue(state.isThinking)
assertEquals("Thinking... deeper", state.thinkingText)
// Token 1
mockEventsFlow.emit(WsEvent.MessageToken("Hello", "session-123"))
advanceUntilIdle()
state = viewModel.uiState.value
assertFalse(state.isThinking)
assertEquals("Hello", state.currentStreamingText)
// System message was cleared when createNewSession() was called, so only Assistant streaming message is present
assertEquals(1, state.messages.size)
assertEquals("Hello", state.messages[0].content)
assertTrue(state.messages[0].isStreaming)
// Token 2
mockEventsFlow.emit(WsEvent.MessageToken(" world", "session-123"))
advanceUntilIdle()
state = viewModel.uiState.value
assertEquals("Hello world", state.currentStreamingText)
assertEquals(1, state.messages.size)
assertEquals("Hello world", state.messages[0].content)
assertTrue(state.messages[0].isStreaming)
// Complete
mockEventsFlow.emit(WsEvent.MessageComplete("Hello world!", "session-123"))
advanceUntilIdle()
state = viewModel.uiState.value
assertFalse(state.isAgentTyping)
assertEquals("", state.currentStreamingText)
assertFalse(state.isThinking)
assertEquals("", state.thinkingText)
assertEquals(1, state.messages.size)
assertEquals("Hello world!", state.messages[0].content)
assertFalse(state.messages[0].isStreaming)
}
@Test
fun testClarifyRequestAndRespond() =
runTest {
var createReqId = ""
every { HermesWsClient.send(WsMethods.SESSION_CREATE, any()) } answers {
createReqId = "create-req-clarify"
createReqId
}
val viewModel = ChatViewModel()
advanceUntilIdle()
// Set session ID
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
mockEventsFlow.emit(WsEvent.RpcResult(createReqId, mapOf("session_id" to "session-123")))
advanceUntilIdle()
mockEventsFlow.emit(WsEvent.ClarifyRequest("Please choose:", listOf("Yes", "No")))
advanceUntilIdle()
var state = viewModel.uiState.value
assertEquals("Please choose:", state.clarifyRequest?.text)
assertEquals(listOf("Yes", "No"), state.clarifyRequest?.options)
// Respond to clarify
viewModel.respondToClarify("Yes")
advanceUntilIdle()
// verify clarify request is dismissed, and user message is sent
state = viewModel.uiState.value
assertNull(state.clarifyRequest)
// Session created (System) + user message = 2 messages
assertEquals(2, state.messages.size)
assertEquals("Session created", state.messages[0].content)
assertEquals("Yes", state.messages[1].content)
assertEquals(MessageRole.USER, state.messages[1].role)
}
@Test
fun testSendMessage() =
runTest {
val viewModel = ChatViewModel()
advanceUntilIdle()
// Trigger GatewayReady -> triggers createSession -> feed result to set active session
var createReqId = ""
every { HermesWsClient.send(WsMethods.SESSION_CREATE, any()) } answers {
createReqId = "create-req"
createReqId
}
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
mockEventsFlow.emit(WsEvent.RpcResult(createReqId, mapOf("session_id" to "session-123")))
advanceUntilIdle()
// Now send message
viewModel.sendMessage("Hello Hermes")
advanceUntilIdle()
val state = viewModel.uiState.value
// Connected to Hermes was cleared, so only Session created (System) + Hello Hermes (User) = 2 messages
assertEquals(2, state.messages.size)
assertEquals("Session created", state.messages[0].content)
assertEquals("Hello Hermes", state.messages[1].content)
assertEquals(MessageRole.USER, state.messages[1].role)
assertTrue(state.isAgentTyping)
verify { HermesWsClient.sendMessage("session-123", "Hello Hermes") }
}
@Test
fun testSwitchSession() =
runTest {
val viewModel = ChatViewModel()
advanceUntilIdle()
viewModel.switchSession("session-456")
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals("session-456", state.currentSessionId)
assertTrue(state.isLoading)
assertTrue(state.messages.isEmpty())
verify { HermesWsClient.send(WsMethods.SESSION_RESUME, mapOf("session_id" to "session-456")) }
}
@Test
fun testRpcErrorHandling() =
runTest {
var createReqId = ""
every { HermesWsClient.send(WsMethods.SESSION_CREATE, any()) } answers {
createReqId = "create-req-err"
createReqId
}
val viewModel = ChatViewModel()
advanceUntilIdle()
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
// Emit RpcError
mockEventsFlow.emit(
WsEvent.RpcError(
createReqId,
JsonRpcError(code = -32603, message = "Internal error during creation"),
),
)
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.isLoading)
assertTrue(state.errorMessage!!.contains("Internal error during creation"))
assertTrue(state.errorMessage.contains(WsMethods.SESSION_CREATE))
}
}
@@ -0,0 +1,287 @@
package com.m57.hermescontrol.ui.connect
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import io.mockk.*
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.runTest
import kotlinx.coroutines.test.setMain
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 ConnectViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var mockApiService: HermesApiService
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
val testMainDispatcher = Dispatchers.Main
mockkStatic(Dispatchers::class)
every { Dispatchers.IO } returns testDispatcher
every { Dispatchers.Main } returns testMainDispatcher
mockkObject(AuthManager)
mockkObject(ApiClient)
mockApiService = mockk()
every { ApiClient.hermesApi } returns mockApiService
every { ApiClient.rebuild() } returns Unit
// Default AuthManager stubs
every { AuthManager.getToken() } returns ""
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
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun testInitialLoadingFromAuthManager() {
every { AuthManager.getToken() } returns "saved-token"
every { AuthManager.getHost() } returns "hermes.local"
every { AuthManager.getPort() } returns 8888
val viewModel = ConnectViewModel()
val state = viewModel.uiState.value
assertEquals("saved-token", state.token)
assertEquals("hermes.local", state.host)
assertEquals("8888", state.port)
assertFalse(state.isConnecting)
assertFalse(state.connectionSuccess)
assertNull(state.errorMessage)
}
@Test
fun testOnTokenChange() {
val viewModel = ConnectViewModel()
viewModel.onTokenChange(" new-token ")
assertEquals("new-token", viewModel.uiState.value.token)
assertNull(viewModel.uiState.value.errorMessage)
}
@Test
fun testOnHostChange() {
val viewModel = ConnectViewModel()
viewModel.onHostChange(" 192.168.1.50 ")
assertEquals("192.168.1.50", viewModel.uiState.value.host)
assertNull(viewModel.uiState.value.errorMessage)
}
@Test
fun testOnPortChange() {
val viewModel = ConnectViewModel()
viewModel.onPortChange("90abc90")
assertEquals("9090", viewModel.uiState.value.port)
assertNull(viewModel.uiState.value.errorMessage)
}
@Test
fun testConnect_blankToken_showsError() {
val viewModel = ConnectViewModel()
viewModel.onTokenChange("")
viewModel.connect()
assertEquals("Token is required", viewModel.uiState.value.errorMessage)
}
@Test
fun testConnect_blankHost_showsError() {
val viewModel = ConnectViewModel()
viewModel.onTokenChange("valid-token")
viewModel.onHostChange("")
viewModel.connect()
assertEquals("Host is required", viewModel.uiState.value.errorMessage)
}
@Test
fun testConnect_invalidPortBounds_showsError() {
val viewModel = ConnectViewModel()
viewModel.onTokenChange("valid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("0")
viewModel.connect()
assertEquals("Port must be between 1 and 65535", viewModel.uiState.value.errorMessage)
viewModel.onPortChange("65536")
viewModel.connect()
assertEquals("Port must be between 1 and 65535", viewModel.uiState.value.errorMessage)
viewModel.onPortChange("")
viewModel.connect()
assertEquals("Port must be between 1 and 65535", viewModel.uiState.value.errorMessage)
}
@Test
fun testConnect_success() =
runTest {
val viewModel = ConnectViewModel()
viewModel.onTokenChange("valid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns true
coEvery { mockApiService.getStatus() } returns mockResponse
viewModel.connect()
assertTrue(viewModel.uiState.value.isConnecting)
advanceUntilIdle()
verify { AuthManager.setToken("valid-token") }
verify { AuthManager.setHost("127.0.0.1") }
verify { AuthManager.setPort(9119) }
verify { ApiClient.rebuild() }
val state = viewModel.uiState.value
assertFalse(state.isConnecting)
assertTrue(state.connectionSuccess)
assertNull(state.errorMessage)
}
@Test
fun testConnect_failure_unauthorized() =
runTest {
val viewModel = ConnectViewModel()
viewModel.onTokenChange("invalid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns false
every { mockResponse.code() } returns 401
coEvery { mockApiService.getStatus() } returns mockResponse
viewModel.connect()
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.isConnecting)
assertFalse(state.connectionSuccess)
assertEquals("Invalid token (401 Unauthorized)", state.errorMessage)
}
@Test
fun testConnect_failure_forbidden() =
runTest {
val viewModel = ConnectViewModel()
viewModel.onTokenChange("invalid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns false
every { mockResponse.code() } returns 403
coEvery { mockApiService.getStatus() } returns mockResponse
viewModel.connect()
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.isConnecting)
assertFalse(state.connectionSuccess)
assertEquals("Access denied (403 Forbidden)", state.errorMessage)
}
@Test
fun testConnect_failure_otherHttpCode() =
runTest {
val viewModel = ConnectViewModel()
viewModel.onTokenChange("token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns false
every { mockResponse.code() } returns 500
coEvery { mockApiService.getStatus() } returns mockResponse
viewModel.connect()
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.isConnecting)
assertFalse(state.connectionSuccess)
assertEquals("Server returned HTTP 500", state.errorMessage)
}
@Test
fun testConnect_exception_connectionRefused() =
runTest {
val viewModel = ConnectViewModel()
viewModel.onTokenChange("token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
coEvery { mockApiService.getStatus() } throws Exception("Connection refused")
viewModel.connect()
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.isConnecting)
assertFalse(state.connectionSuccess)
assertEquals("Connection refused – is Hermes running?", state.errorMessage)
}
@Test
fun testConnect_exception_timeout() =
runTest {
val viewModel = ConnectViewModel()
viewModel.onTokenChange("token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
coEvery { mockApiService.getStatus() } throws Exception("timeout exception")
viewModel.connect()
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.isConnecting)
assertFalse(state.connectionSuccess)
assertEquals("Connection timed out", state.errorMessage)
}
@Test
fun testConnect_exception_unknown() =
runTest {
val viewModel = ConnectViewModel()
viewModel.onTokenChange("token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
coEvery { mockApiService.getStatus() } throws Exception("Something went wrong")
viewModel.connect()
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.isConnecting)
assertFalse(state.connectionSuccess)
assertEquals("Connection failed: Something went wrong", state.errorMessage)
}
}