diff --git a/chat-android/build.gradle.kts b/chat-android/build.gradle.kts index ef4a0efa..9fdde4f7 100644 --- a/chat-android/build.gradle.kts +++ b/chat-android/build.gradle.kts @@ -38,6 +38,8 @@ dependencies { implementation(libs.gson) testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.coroutine.test) androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.junit) diff --git a/chat-android/src/main/java/com/ably/chat/ChatApi.kt b/chat-android/src/main/java/com/ably/chat/ChatApi.kt index 5b2ec0bd..57d1f47b 100644 --- a/chat-android/src/main/java/com/ably/chat/ChatApi.kt +++ b/chat-android/src/main/java/com/ably/chat/ChatApi.kt @@ -2,6 +2,7 @@ package com.ably.chat import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive import io.ably.lib.http.HttpCore import io.ably.lib.http.HttpUtils import io.ably.lib.types.AblyException @@ -17,8 +18,13 @@ private const val PROTOCOL_VERSION_PARAM_NAME = "v" private val apiProtocolParam = Param(PROTOCOL_VERSION_PARAM_NAME, API_PROTOCOL_VERSION.toString()) // TODO make this class internal -class ChatApi(private val realtimeClient: RealtimeClient) { +class ChatApi(private val realtimeClient: RealtimeClient, private val clientId: String) { + /** + * Get messages from the Chat Backend + * + * @return paginated result with messages + */ suspend fun getMessages(roomId: String, params: QueryOptions): PaginatedResult { return makeAuthorizedPaginatedRequest( url = "/chat/v1/rooms/$roomId/messages", @@ -26,18 +32,23 @@ class ChatApi(private val realtimeClient: RealtimeClient) { params = params.toParams(), ) { Message( - timeserial = it.asJsonObject.get("timeserial").asString, - clientId = it.asJsonObject.get("clientId").asString, - roomId = it.asJsonObject.get("roomId").asString, - text = it.asJsonObject.get("text").asString, - createdAt = it.asJsonObject.get("createdAt").asLong, - metadata = it.asJsonObject.get("metadata")?.asJsonObject?.toMap() ?: mapOf(), - headers = it.asJsonObject.get("headers")?.asJsonObject?.toMap() ?: mapOf(), + timeserial = it.requireString("timeserial"), + clientId = it.requireString("clientId"), + roomId = it.requireString("roomId"), + text = it.requireString("text"), + createdAt = it.requireLong("createdAt"), + metadata = it.asJsonObject.get("metadata")?.toMap() ?: mapOf(), + headers = it.asJsonObject.get("headers")?.toMap() ?: mapOf(), ) } } - suspend fun sendMessage(roomId: String, params: SendMessageParams): CreateMessageResponse { + /** + * Send message to the Chat Backend + * + * @return sent message instance + */ + suspend fun sendMessage(roomId: String, params: SendMessageParams): Message { val body = JsonObject().apply { addProperty("text", params.text) params.headers?.let { @@ -53,18 +64,26 @@ class ChatApi(private val realtimeClient: RealtimeClient) { "POST", body, )?.let { - CreateMessageResponse( - timeserial = it.asJsonObject.get("timeserial").asString, - createdAt = it.asJsonObject.get("createdAt").asLong, + Message( + timeserial = it.requireString("timeserial"), + clientId = clientId, + roomId = roomId, + text = params.text, + createdAt = it.requireLong("createdAt"), + metadata = params.metadata ?: mapOf(), + headers = params.headers ?: mapOf(), ) } ?: throw AblyException.fromErrorInfo(ErrorInfo("Send message endpoint returned empty value", HttpStatusCodes.InternalServerError)) } + /** + * return occupancy for specified room + */ suspend fun getOccupancy(roomId: String): OccupancyEvent { return this.makeAuthorizedRequest("/chat/v1/rooms/$roomId/occupancy", "GET")?.let { OccupancyEvent( - connections = it.asJsonObject.get("connections").asInt, - presenceMembers = it.asJsonObject.get("presenceMembers").asInt, + connections = it.requireInt("connections"), + presenceMembers = it.requireInt("presenceMembers"), ) } ?: throw AblyException.fromErrorInfo(ErrorInfo("Occupancy endpoint returned empty value", HttpStatusCodes.InternalServerError)) } @@ -81,8 +100,7 @@ class ChatApi(private val realtimeClient: RealtimeClient) { arrayOf(apiProtocolParam), requestBody, arrayOf(), - object : - AsyncHttpPaginatedResponse.Callback { + object : AsyncHttpPaginatedResponse.Callback { override fun onResponse(response: AsyncHttpPaginatedResponse?) { continuation.resume(response?.items()?.firstOrNull()) } @@ -106,8 +124,7 @@ class ChatApi(private val realtimeClient: RealtimeClient) { (params + apiProtocolParam).toTypedArray(), null, arrayOf(), - object : - AsyncHttpPaginatedResponse.Callback { + object : AsyncHttpPaginatedResponse.Callback { override fun onResponse(response: AsyncHttpPaginatedResponse?) { continuation.resume(response.toPaginatedResult(transform)) } @@ -120,8 +137,6 @@ class ChatApi(private val realtimeClient: RealtimeClient) { } } -data class CreateMessageResponse(val timeserial: String, val createdAt: Long) - private fun JsonElement?.toRequestBody(useBinaryProtocol: Boolean = false): HttpCore.RequestBody = HttpUtils.requestBodyFromGson(this, useBinaryProtocol) @@ -129,8 +144,8 @@ private fun Map.toJson() = JsonObject().apply { forEach { (key, value) -> addProperty(key, value) } } -private fun JsonObject.toMap() = buildMap { - entrySet().filter { (_, value) -> value.isJsonPrimitive }.forEach { (key, value) -> put(key, value.asString) } +private fun JsonElement.toMap() = buildMap { + requireJsonObject().entrySet().filter { (_, value) -> value.isJsonPrimitive }.forEach { (key, value) -> put(key, value.asString) } } private fun QueryOptions.toParams() = buildList { @@ -147,3 +162,61 @@ private fun QueryOptions.toParams() = buildList { ), ) } + +private fun JsonElement.requireJsonObject(): JsonObject { + if (!isJsonObject) { + throw AblyException.fromErrorInfo( + ErrorInfo("Response value expected to be JsonObject, got primitive instead", HttpStatusCodes.InternalServerError), + ) + } + return asJsonObject +} + +private fun JsonElement.requireString(memberName: String): String { + val memberElement = requireField(memberName) + if (!memberElement.isJsonPrimitive) { + throw AblyException.fromErrorInfo( + ErrorInfo("Value for \"$memberName\" field expected to be JsonPrimitive, got object instead", HttpStatusCodes.InternalServerError), + ) + } + return memberElement.asString +} + +private fun JsonElement.requireLong(memberName: String): Long { + val memberElement = requireJsonPrimitive(memberName) + try { + return memberElement.asLong + } catch (formatException: NumberFormatException) { + throw AblyException.fromErrorInfo( + formatException, + ErrorInfo("Required numeric field \"$memberName\" is not a valid long", HttpStatusCodes.InternalServerError), + ) + } +} + +private fun JsonElement.requireInt(memberName: String): Int { + val memberElement = requireJsonPrimitive(memberName) + try { + return memberElement.asInt + } catch (formatException: NumberFormatException) { + throw AblyException.fromErrorInfo( + formatException, + ErrorInfo("Required numeric field \"$memberName\" is not a valid int", HttpStatusCodes.InternalServerError), + ) + } +} + +private fun JsonElement.requireJsonPrimitive(memberName: String): JsonPrimitive { + val memberElement = requireField(memberName) + if (!memberElement.isJsonPrimitive) { + throw AblyException.fromErrorInfo( + ErrorInfo("Value for \"$memberName\" field expected to be JsonPrimitive, got object instead", HttpStatusCodes.InternalServerError), + ) + } + return memberElement.asJsonPrimitive +} + +private fun JsonElement.requireField(memberName: String): JsonElement = requireJsonObject().get(memberName) + ?: throw AblyException.fromErrorInfo( + ErrorInfo("Required field \"$memberName\" is missing", HttpStatusCodes.InternalServerError), + ) diff --git a/chat-android/src/main/java/com/ably/chat/PaginatedResult.kt b/chat-android/src/main/java/com/ably/chat/PaginatedResult.kt index e32e4c3f..814bddf9 100644 --- a/chat-android/src/main/java/com/ably/chat/PaginatedResult.kt +++ b/chat-android/src/main/java/com/ably/chat/PaginatedResult.kt @@ -47,8 +47,7 @@ private class AsyncPaginatedResultWrapper( val asyncPaginatedResult: AsyncHttpPaginatedResponse, val transform: (JsonElement) -> T, ) : PaginatedResult { - override val items: List - get() = asyncPaginatedResult.items()?.map(transform) ?: emptyList() + override val items: List = asyncPaginatedResult.items()?.map(transform) ?: emptyList() override suspend fun next(): PaginatedResult = suspendCoroutine { continuation -> asyncPaginatedResult.next(object : AsyncHttpPaginatedResponse.Callback { diff --git a/chat-android/src/test/java/com/ably/chat/ChatApiTest.kt b/chat-android/src/test/java/com/ably/chat/ChatApiTest.kt new file mode 100644 index 00000000..29e8069a --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/ChatApiTest.kt @@ -0,0 +1,172 @@ +package com.ably.chat + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import io.ably.lib.types.AblyException +import io.ably.lib.types.AsyncHttpPaginatedResponse +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatApiTest { + + private val realtime = mockk(relaxed = true) + private val chatApi = ChatApi(realtime, "clientId") + + @Test + fun `getMessages should ignore unknown fields for Chat Backend`() = runTest { + every { + realtime.requestAsync("GET", "/chat/v1/rooms/roomId/messages", any(), any(), any(), any()) + } answers { + val callback = lastArg() + callback.onResponse( + buildAsyncHttpPaginatedResponse( + listOf( + JsonObject().apply { + addProperty("foo", "bar") + addProperty("timeserial", "timeserial") + addProperty("roomId", "roomId") + addProperty("clientId", "clientId") + addProperty("text", "hello") + addProperty("createdAt", 1_000_000) + }, + ), + ), + ) + } + + val messages = chatApi.getMessages("roomId", QueryOptions()) + + assertEquals( + listOf( + Message( + timeserial = "timeserial", + roomId = "roomId", + clientId = "clientId", + text = "hello", + createdAt = 1_000_000L, + metadata = mapOf(), + headers = mapOf(), + ), + ), + messages.items, + ) + } + + @Test + fun `getMessages should throws AblyException if some required fields are missing`() = runTest { + every { + realtime.requestAsync("GET", "/chat/v1/rooms/roomId/messages", any(), any(), any(), any()) + } answers { + val callback = lastArg() + callback.onResponse( + buildAsyncHttpPaginatedResponse( + listOf( + JsonObject().apply { + addProperty("foo", "bar") + }, + ), + ), + ) + } + + val exception = assertThrows(AblyException::class.java) { + runBlocking { chatApi.getMessages("roomId", QueryOptions()) } + } + + assertTrue(exception.message!!.matches(""".*Required field "\w+" is missing""".toRegex())) + } + + @Test + fun `sendMessage should ignore unknown fields for Chat Backend`() = runTest { + every { + realtime.requestAsync("POST", "/chat/v1/rooms/roomId/messages", any(), any(), any(), any()) + } answers { + val callback = lastArg() + callback.onResponse( + buildAsyncHttpPaginatedResponse( + listOf( + JsonObject().apply { + addProperty("foo", "bar") + addProperty("timeserial", "timeserial") + addProperty("createdAt", 1_000_000) + }, + ), + ), + ) + } + + val message = chatApi.sendMessage("roomId", SendMessageParams(text = "hello")) + + assertEquals( + Message( + timeserial = "timeserial", + roomId = "roomId", + clientId = "clientId", + text = "hello", + createdAt = 1_000_000L, + headers = mapOf(), + metadata = mapOf(), + ), + message, + ) + } + + @Test + fun `sendMessage should throw exception if 'timeserial' field is not presented`() = runTest { + every { + realtime.requestAsync("POST", "/chat/v1/rooms/roomId/messages", any(), any(), any(), any()) + } answers { + val callback = lastArg() + callback.onResponse( + buildAsyncHttpPaginatedResponse( + listOf( + JsonObject().apply { + addProperty("foo", "bar") + addProperty("createdAt", 1_000_000) + }, + ), + ), + ) + } + + assertThrows(AblyException::class.java) { + runBlocking { chatApi.sendMessage("roomId", SendMessageParams(text = "hello")) } + } + } + + @Test + fun `getOccupancy should throw exception if 'connections' field is not presented`() = runTest { + every { + realtime.requestAsync("GET", "/chat/v1/rooms/roomId/occupancy", any(), any(), any(), any()) + } answers { + val callback = lastArg() + callback.onResponse( + buildAsyncHttpPaginatedResponse( + listOf( + JsonObject().apply { + addProperty("presenceMembers", 1_000) + }, + ), + ), + ) + } + + assertThrows(AblyException::class.java) { + runBlocking { chatApi.getOccupancy("roomId") } + } + } +} + +private fun buildAsyncHttpPaginatedResponse(items: List): AsyncHttpPaginatedResponse { + val response = mockk() + every { + response.items() + } returns items.toTypedArray() + return response +} diff --git a/chat-android/src/test/java/com/ably/chat/ExampleUnitTest.kt b/chat-android/src/test/java/com/ably/chat/ExampleUnitTest.kt deleted file mode 100644 index 66a4ad7f..00000000 --- a/chat-android/src/test/java/com/ably/chat/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.ably.chat - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/detekt.yml b/detekt.yml index cc8eda1b..3995e03b 100644 --- a/detekt.yml +++ b/detekt.yml @@ -148,6 +148,7 @@ complexity: ignoreAnnotation: true excludeStringsWithLessThan5Characters: true ignoreStringsRegex: '$^' + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] TooManyFunctions: active: true thresholdInFiles: 22 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 74c2c4ac..0bc87085 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,8 @@ lifecycle-runtime-ktx = "2.8.4" activity-compose = "1.9.1" compose-bom = "2024.06.00" gson = "2.11.0" +mockk = "1.13.12" +coroutine = "1.8.1" [libraries] junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -39,6 +41,11 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3" gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } + +coroutine-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutine" } +coroutine-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutine" } + [plugins] detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt"} android-kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }