diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/BillingRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/BillingRepository.kt index 277162e6..8f6a602d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/BillingRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/BillingRepository.kt @@ -8,6 +8,11 @@ import com.m57.hermescontrol.data.model.SubscriptionStateResponse import com.m57.hermescontrol.data.model.SubscriptionUpgradeResponse import com.m57.hermescontrol.data.model.UsageBarsResponse import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.ws.contract.EmptyParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SubscriptionChangeParams +import com.m57.hermescontrol.data.ws.contract.SubscriptionPreviewParams +import com.m57.hermescontrol.data.ws.contract.SubscriptionUpgradeParams import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull @@ -90,50 +95,47 @@ object BillingRepository { } suspend fun getSubscriptionState(): SubscriptionStateResponse? { - val result = HermesWsClient.request(WsMethods.SUBSCRIPTION_STATE).await() + val result = HermesWsClient.call(RpcMethods.SUBSCRIPTION_STATE, EmptyParams) return decode(result) } suspend fun getUsageBars(): UsageBarsResponse? { - val result = HermesWsClient.request(WsMethods.USAGE_BARS).await() + val result = HermesWsClient.call(RpcMethods.USAGE_BARS, EmptyParams) return decode(result) } suspend fun previewSubscription(subscriptionTypeId: String): SubscriptionPreviewResponse? { val result = - HermesWsClient - .request( - WsMethods.SUBSCRIPTION_PREVIEW, - mapOf("subscription_type_id" to subscriptionTypeId), - ).await() + HermesWsClient.call( + RpcMethods.SUBSCRIPTION_PREVIEW, + SubscriptionPreviewParams(subscriptionTypeId = subscriptionTypeId), + ) return decode(result) } suspend fun changeSubscription(request: SubscriptionChangeRequest): SubscriptionChangeResponse? { val result = - HermesWsClient - .request( - WsMethods.SUBSCRIPTION_CHANGE, - buildMap { - request.subscription_type_id?.let { put("subscription_type_id", it) } - request.cancel?.let { put("cancel", it) } - }, - ).await() + HermesWsClient.call( + RpcMethods.SUBSCRIPTION_CHANGE, + SubscriptionChangeParams( + subscriptionTypeId = request.subscription_type_id, + cancel = request.cancel, + ), + ) return decode(result) } suspend fun resumeSubscription(): SubscriptionResumeResponse? { - val result = HermesWsClient.request(WsMethods.SUBSCRIPTION_RESUME).await() + val result = HermesWsClient.call(RpcMethods.SUBSCRIPTION_RESUME, EmptyParams) return decode(result) } suspend fun upgradeSubscription(subscriptionTypeId: String): SubscriptionUpgradeResponse? { val result = - HermesWsClient - .request( - WsMethods.SUBSCRIPTION_UPGRADE, - mapOf("subscription_type_id" to subscriptionTypeId), - ).await() + HermesWsClient.call( + RpcMethods.SUBSCRIPTION_UPGRADE, + SubscriptionUpgradeParams(subscriptionTypeId = subscriptionTypeId), + ) return decode(result) } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/VaultRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/VaultRepository.kt index 56d7a6cc..234fece1 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/VaultRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/VaultRepository.kt @@ -8,6 +8,11 @@ import com.m57.hermescontrol.data.model.VaultSourceSetResponse import com.m57.hermescontrol.data.model.VaultSourcesResponse import com.m57.hermescontrol.data.model.VaultUnlockResponse import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.ws.contract.EmptyParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.VaultLockParams +import com.m57.hermescontrol.data.ws.contract.VaultSourceSetParams +import com.m57.hermescontrol.data.ws.contract.VaultUnlockParams import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull @@ -48,7 +53,7 @@ object VaultRepository { } suspend fun getSources(): List { - val result = HermesWsClient.request(WsMethods.VAULT_SOURCES).await() + val result = HermesWsClient.call(RpcMethods.VAULT_SOURCES, EmptyParams) return decode(result)?.sources ?: emptyList() } @@ -57,11 +62,10 @@ object VaultRepository { enabled: Boolean, ): Boolean { val result = - HermesWsClient - .request( - WsMethods.VAULT_SOURCE_SET, - mapOf("name" to name, "enabled" to enabled), - ).await() + HermesWsClient.call( + RpcMethods.VAULT_SOURCE_SET, + VaultSourceSetParams(name = name, enabled = enabled), + ) return decode(result)?.enabled == enabled } @@ -70,22 +74,21 @@ object VaultRepository { password: String, ): Boolean { val result = - HermesWsClient - .request( - WsMethods.VAULT_UNLOCK, - mapOf("name" to name, "password" to password), - ).await() + HermesWsClient.call( + RpcMethods.VAULT_UNLOCK, + VaultUnlockParams(name = name, password = password), + ) return decode(result)?.unlocked == true } suspend fun lockSource(name: String? = null): Boolean { - val params = if (name != null) mapOf("name" to name) else emptyMap() - val result = HermesWsClient.request(WsMethods.VAULT_LOCK, params).await() + val params = VaultLockParams(name = name) + val result = HermesWsClient.call(RpcMethods.VAULT_LOCK, params) return decode(result)?.locked == true } suspend fun listItems(): List { - val result = HermesWsClient.request(WsMethods.VAULT_LIST).await() + val result = HermesWsClient.call(RpcMethods.VAULT_LIST, EmptyParams) return decode(result)?.items ?: emptyList() } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/BillingVault.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/BillingVault.kt new file mode 100644 index 00000000..28ae5871 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/BillingVault.kt @@ -0,0 +1,40 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data object EmptyParams + +@Serializable +data class SubscriptionPreviewParams( + @SerialName("subscription_type_id") val subscriptionTypeId: String? = null, +) + +@Serializable +data class SubscriptionChangeParams( + @SerialName("subscription_type_id") val subscriptionTypeId: String? = null, + @SerialName("cancel") val cancel: Boolean? = null, +) + +@Serializable +data class SubscriptionUpgradeParams( + @SerialName("subscription_type_id") val subscriptionTypeId: String? = null, +) + +@Serializable +data class VaultSourceSetParams( + @SerialName("name") val name: String? = null, + @SerialName("enabled") val enabled: Boolean? = null, +) + +@Serializable +data class VaultUnlockParams( + @SerialName("name") val name: String? = null, + @SerialName("password") val password: String? = null, +) + +@Serializable +data class VaultLockParams( + @SerialName("name") val name: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt index 3d817fce..80722210 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -150,6 +150,84 @@ object RpcMethods { JsonElement.serializer(), ) + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val SUBSCRIPTION_STATE: RpcMethod = + RpcMethod( + WsMethods.SUBSCRIPTION_STATE, + EmptyParams.serializer(), + JsonElement.serializer(), + ) + + val SUBSCRIPTION_PREVIEW: RpcMethod = + RpcMethod( + WsMethods.SUBSCRIPTION_PREVIEW, + SubscriptionPreviewParams.serializer(), + JsonElement.serializer(), + ) + + val SUBSCRIPTION_CHANGE: RpcMethod = + RpcMethod( + WsMethods.SUBSCRIPTION_CHANGE, + SubscriptionChangeParams.serializer(), + JsonElement.serializer(), + ) + + val SUBSCRIPTION_UPGRADE: RpcMethod = + RpcMethod( + WsMethods.SUBSCRIPTION_UPGRADE, + SubscriptionUpgradeParams.serializer(), + JsonElement.serializer(), + ) + + val SUBSCRIPTION_RESUME: RpcMethod = + RpcMethod( + WsMethods.SUBSCRIPTION_RESUME, + EmptyParams.serializer(), + JsonElement.serializer(), + ) + + val USAGE_BARS: RpcMethod = + RpcMethod( + WsMethods.USAGE_BARS, + EmptyParams.serializer(), + JsonElement.serializer(), + ) + + val VAULT_LIST: RpcMethod = + RpcMethod( + WsMethods.VAULT_LIST, + EmptyParams.serializer(), + JsonElement.serializer(), + ) + + val VAULT_SOURCES: RpcMethod = + RpcMethod( + WsMethods.VAULT_SOURCES, + EmptyParams.serializer(), + JsonElement.serializer(), + ) + + val VAULT_SOURCE_SET: RpcMethod = + RpcMethod( + WsMethods.VAULT_SOURCE_SET, + VaultSourceSetParams.serializer(), + JsonElement.serializer(), + ) + + val VAULT_UNLOCK: RpcMethod = + RpcMethod( + WsMethods.VAULT_UNLOCK, + VaultUnlockParams.serializer(), + JsonElement.serializer(), + ) + + val VAULT_LOCK: RpcMethod = + RpcMethod( + WsMethods.VAULT_LOCK, + VaultLockParams.serializer(), + JsonElement.serializer(), + ) + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ val all: List> = listOf( @@ -171,5 +249,16 @@ object RpcMethods { APPROVAL_RECEIVED, APPROVAL_RESPOND, SESSION_ACTIVE_LIST, + SUBSCRIPTION_STATE, + SUBSCRIPTION_PREVIEW, + SUBSCRIPTION_CHANGE, + SUBSCRIPTION_UPGRADE, + SUBSCRIPTION_RESUME, + USAGE_BARS, + VAULT_LIST, + VAULT_SOURCES, + VAULT_SOURCE_SET, + VAULT_UNLOCK, + VAULT_LOCK, ) } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientBillingVaultTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientBillingVaultTest.kt new file mode 100644 index 00000000..fc804bf2 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientBillingVaultTest.kt @@ -0,0 +1,431 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import com.m57.hermescontrol.data.ws.contract.EmptyParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SubscriptionChangeParams +import com.m57.hermescontrol.data.ws.contract.VaultLockParams +import com.m57.hermescontrol.data.ws.contract.VaultUnlockParams +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class HermesWsClientBillingVaultTest { + private lateinit var mockWebServer: MockWebServer + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + AuthManager.resetAuthStateForTest() + } + + @After + fun tearDown() { + AuthManager.resetAuthStateForTest() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + withTimeoutOrNull(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } + } + } + runCatching { mockWebServer.shutdown() } + unmockkAll() + } + + private fun connectClient() { + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } + } + } + + @Test + fun typedCallSubscriptionChangeEncodesOnlyNonNullFieldsAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("status", "ok") + put("subscription_id", "sub-12345") + put("scheduled_cancellation", false) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "subscription.change") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.SUBSCRIPTION_CHANGE, + SubscriptionChangeParams( + subscriptionTypeId = null, + cancel = false, + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("subscription.change", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put("cancel", false) + } + assertEquals(expectedParams, params) + assertEquals(false, (params?.get("cancel") as? JsonPrimitive)?.booleanOrNull) + assertFalse(params?.containsKey("subscription_type_id") ?: true) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallVaultUnlockPutsNameAndPasswordOnWireAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("unlocked", true) + put("expires_in", 3600) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "vault.unlock") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.VAULT_UNLOCK, + VaultUnlockParams( + name = "op-work", + password = "super-secret-pass", + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals("vault.unlock", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put("name", "op-work") + put("password", "super-secret-pass") + } + assertEquals(expectedParams, params) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallVaultLockWithNamePutsNameOnWireAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("locked", true) + put("vault", "op-work") + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "vault.lock") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.VAULT_LOCK, + VaultLockParams(name = "op-work"), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals("vault.lock", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put("name", "op-work") + } + assertEquals(expectedParams, params) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallVaultLockWithoutNameLeavesParamsAbsentOrEmptyAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("locked_all", true) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "vault.lock") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.VAULT_LOCK, + VaultLockParams(name = null), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals("vault.lock", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + assertTrue( + "vault.lock with null name carries no params", + params == null || params.isEmpty(), + ) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallEmptyParamsMethodLeavesParamsAbsentOrEmptyAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("status", "active") + put("tier", "standard") + put("valid_until", "2026-12-31T23:59:59Z") + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "subscription.state") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.SUBSCRIPTION_STATE, + EmptyParams, + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals("subscription.state", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + assertTrue( + "subscription.state with EmptyParams carries no params", + params == null || params.isEmpty(), + ) + assertEquals(serverResultJson, result) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt index 9e7a02d1..dae2bca2 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -325,12 +325,6 @@ class RawRpcParamsRatchetTest { "data/ws/AccountConnectorRepository.kt::CONNECTORS_POLICY_GET", "data/ws/AccountConnectorRepository.kt::CONNECTORS_POLICY_SET", "data/ws/AccountConnectorRepository.kt::CONNECTORS_TOOLS", - "data/ws/BillingRepository.kt::SUBSCRIPTION_CHANGE", - "data/ws/BillingRepository.kt::SUBSCRIPTION_PREVIEW", - "data/ws/BillingRepository.kt::SUBSCRIPTION_RESUME", - "data/ws/BillingRepository.kt::SUBSCRIPTION_STATE", - "data/ws/BillingRepository.kt::SUBSCRIPTION_UPGRADE", - "data/ws/BillingRepository.kt::USAGE_BARS", "data/ws/ConnectorRepository.kt::CONNECTORS_CONNECT", "data/ws/ConnectorRepository.kt::CONNECTORS_LIST", "data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES", @@ -341,11 +335,6 @@ class RawRpcParamsRatchetTest { "data/ws/ProjectsSource.kt::PROJECTS_LIST", "data/ws/SubagentRepository.kt::SUBAGENT_LIST", "data/ws/SubagentRepository.kt::SUBAGENT_TAIL", - "data/ws/VaultRepository.kt::VAULT_LIST", - "data/ws/VaultRepository.kt::VAULT_LOCK", - "data/ws/VaultRepository.kt::VAULT_SOURCES", - "data/ws/VaultRepository.kt::VAULT_SOURCE_SET", - "data/ws/VaultRepository.kt::VAULT_UNLOCK", "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/BillingVaultTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/BillingVaultTypesTest.kt new file mode 100644 index 00000000..46adba43 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/BillingVaultTypesTest.kt @@ -0,0 +1,333 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class BillingVaultTypesTest { + @Test + fun emptyParamsEncodesToEmptyJsonObject() { + val params = EmptyParams + val encoded = OkHttpProvider.json.encodeToJsonElement(EmptyParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun subscriptionPreviewParamsEncodesSubscriptionTypeIdWhenPresent() { + val params = SubscriptionPreviewParams(subscriptionTypeId = "pro-monthly") + val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionPreviewParams.serializer(), params) + + val expected = + buildJsonObject { + put("subscription_type_id", "pro-monthly") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("subscription_type_id"), (encoded as JsonObject).keys) + } + + @Test + fun subscriptionPreviewParamsOmitsNullSubscriptionTypeId() { + val params = SubscriptionPreviewParams(subscriptionTypeId = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionPreviewParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun subscriptionChangeParamsEncodesAllFieldsWhenPresent() { + val params = + SubscriptionChangeParams( + subscriptionTypeId = "tier-2", + cancel = true, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), params) + + val expected = + buildJsonObject { + put("subscription_type_id", "tier-2") + put("cancel", true) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("subscription_type_id", "cancel"), (encoded as JsonObject).keys) + } + + @Test + fun subscriptionChangeParamsEncodesExplicitFalseForCancel() { + val params = + SubscriptionChangeParams( + subscriptionTypeId = "tier-1", + cancel = false, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), params) + + val expected = + buildJsonObject { + put("subscription_type_id", "tier-1") + put("cancel", false) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("subscription_type_id", "cancel"), (encoded as JsonObject).keys) + } + + @Test + fun subscriptionChangeParamsOmitsOnlyNullFields() { + val paramsOnlyCancel = SubscriptionChangeParams(subscriptionTypeId = null, cancel = false) + val encodedOnlyCancel = + OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), paramsOnlyCancel) + val expectedOnlyCancel = + buildJsonObject { + put("cancel", false) + } + assertEquals(expectedOnlyCancel, encodedOnlyCancel) + assertTrue(encodedOnlyCancel is JsonObject) + assertEquals(setOf("cancel"), (encodedOnlyCancel as JsonObject).keys) + + val paramsOnlyType = SubscriptionChangeParams(subscriptionTypeId = "tier-3", cancel = null) + val encodedOnlyType = + OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), paramsOnlyType) + val expectedOnlyType = + buildJsonObject { + put("subscription_type_id", "tier-3") + } + assertEquals(expectedOnlyType, encodedOnlyType) + assertTrue(encodedOnlyType is JsonObject) + assertEquals(setOf("subscription_type_id"), (encodedOnlyType as JsonObject).keys) + + val paramsEmpty = SubscriptionChangeParams(subscriptionTypeId = null, cancel = null) + val encodedEmpty = + OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), paramsEmpty) + val expectedEmpty = buildJsonObject {} + assertEquals(expectedEmpty, encodedEmpty) + assertTrue(encodedEmpty is JsonObject) + assertTrue((encodedEmpty as JsonObject).isEmpty()) + } + + @Test + fun subscriptionUpgradeParamsEncodesSubscriptionTypeIdWhenPresent() { + val params = SubscriptionUpgradeParams(subscriptionTypeId = "enterprise-yearly") + val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionUpgradeParams.serializer(), params) + + val expected = + buildJsonObject { + put("subscription_type_id", "enterprise-yearly") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("subscription_type_id"), (encoded as JsonObject).keys) + } + + @Test + fun subscriptionUpgradeParamsOmitsNullSubscriptionTypeId() { + val params = SubscriptionUpgradeParams(subscriptionTypeId = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionUpgradeParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun vaultSourceSetParamsEncodesAllFieldsWhenPresent() { + val params = + VaultSourceSetParams( + name = "1password", + enabled = true, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(VaultSourceSetParams.serializer(), params) + + val expected = + buildJsonObject { + put("name", "1password") + put("enabled", true) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("name", "enabled"), (encoded as JsonObject).keys) + } + + @Test + fun vaultSourceSetParamsEncodesExplicitFalseForEnabled() { + val params = + VaultSourceSetParams( + name = "bitwarden", + enabled = false, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(VaultSourceSetParams.serializer(), params) + + val expected = + buildJsonObject { + put("name", "bitwarden") + put("enabled", false) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("name", "enabled"), (encoded as JsonObject).keys) + } + + @Test + fun vaultSourceSetParamsOmitsNullFields() { + val params = VaultSourceSetParams(name = "keepass", enabled = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(VaultSourceSetParams.serializer(), params) + + val expected = + buildJsonObject { + put("name", "keepass") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("name"), (encoded as JsonObject).keys) + + val paramsEmpty = VaultSourceSetParams(name = null, enabled = null) + val encodedEmpty = + OkHttpProvider.json.encodeToJsonElement(VaultSourceSetParams.serializer(), paramsEmpty) + val expectedEmpty = buildJsonObject {} + assertEquals(expectedEmpty, encodedEmpty) + assertTrue(encodedEmpty is JsonObject) + assertTrue((encodedEmpty as JsonObject).isEmpty()) + } + + @Test + fun vaultUnlockParamsEncodesAllFieldsWhenPresent() { + val params = + VaultUnlockParams( + name = "local-vault", + password = "secret-password", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(VaultUnlockParams.serializer(), params) + + val expected = + buildJsonObject { + put("name", "local-vault") + put("password", "secret-password") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("name", "password"), (encoded as JsonObject).keys) + } + + @Test + fun vaultUnlockParamsOmitsNullFields() { + val params = VaultUnlockParams(name = "local-vault", password = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(VaultUnlockParams.serializer(), params) + + val expected = + buildJsonObject { + put("name", "local-vault") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("name"), (encoded as JsonObject).keys) + + val paramsEmpty = VaultUnlockParams(name = null, password = null) + val encodedEmpty = + OkHttpProvider.json.encodeToJsonElement(VaultUnlockParams.serializer(), paramsEmpty) + val expectedEmpty = buildJsonObject {} + assertEquals(expectedEmpty, encodedEmpty) + assertTrue(encodedEmpty is JsonObject) + assertTrue((encodedEmpty as JsonObject).isEmpty()) + } + + @Test + fun vaultLockParamsEncodesEmptyJsonObjectWhenNameNull() { + val params = VaultLockParams(name = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(VaultLockParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun vaultLockParamsEncodesNameWhenSet() { + val params = VaultLockParams(name = "primary-vault") + val encoded = OkHttpProvider.json.encodeToJsonElement(VaultLockParams.serializer(), params) + + val expected = + buildJsonObject { + put("name", "primary-vault") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("name"), (encoded as JsonObject).keys) + } + + @Test + fun rpcMethodsRegistrationMatchesContract() { + assertEquals("subscription.state", RpcMethods.SUBSCRIPTION_STATE.name) + assertEquals("usage.bars", RpcMethods.USAGE_BARS.name) + assertEquals("subscription.resume", RpcMethods.SUBSCRIPTION_RESUME.name) + assertEquals("vault.list", RpcMethods.VAULT_LIST.name) + assertEquals("vault.sources", RpcMethods.VAULT_SOURCES.name) + assertEquals("subscription.preview", RpcMethods.SUBSCRIPTION_PREVIEW.name) + assertEquals("subscription.change", RpcMethods.SUBSCRIPTION_CHANGE.name) + assertEquals("subscription.upgrade", RpcMethods.SUBSCRIPTION_UPGRADE.name) + assertEquals("vault.source.set", RpcMethods.VAULT_SOURCE_SET.name) + assertEquals("vault.unlock", RpcMethods.VAULT_UNLOCK.name) + assertEquals("vault.lock", RpcMethods.VAULT_LOCK.name) + + assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_STATE)) + assertTrue(RpcMethods.all.contains(RpcMethods.USAGE_BARS)) + assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_RESUME)) + assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_LIST)) + assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_SOURCES)) + assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_PREVIEW)) + assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_CHANGE)) + assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_UPGRADE)) + assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_SOURCE_SET)) + assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_UNLOCK)) + assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_LOCK)) + } + + @Test + fun jsonElementPassthroughMethodsDecodeResultUntouched() { + val arbitraryPayload = + buildJsonObject { + put("tier", "pro") + put("credits", 4200) + put("details", buildJsonObject { put("active", true) }) + } + + val methods = + listOf( + RpcMethods.SUBSCRIPTION_STATE, + RpcMethods.USAGE_BARS, + RpcMethods.SUBSCRIPTION_RESUME, + RpcMethods.VAULT_LIST, + RpcMethods.VAULT_SOURCES, + RpcMethods.SUBSCRIPTION_PREVIEW, + RpcMethods.SUBSCRIPTION_CHANGE, + RpcMethods.SUBSCRIPTION_UPGRADE, + RpcMethods.VAULT_SOURCE_SET, + RpcMethods.VAULT_UNLOCK, + RpcMethods.VAULT_LOCK, + ) + + for (method in methods) { + val decoded: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + method.result, + arbitraryPayload, + ) + assertEquals("Method ${method.name} must pass raw result untouched", arbitraryPayload, decoded) + } + } +}