From 076697890c504505d4189df79571b7bf98b11600 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:48:17 +0200 Subject: [PATCH 1/2] Migrate queryMembers to generated MembersResponse with custom-collecting UserResponse mapper --- .../client/api2/endpoint/GeneralApi.kt | 4 +- .../models/MembersResponse.kt} | 25 +++++++--- .../getstream/chat/android/client/Mother.kt | 15 ++++++ .../android/client/api2/MoshiChatApiTest.kt | 4 +- .../client/api2/MoshiChatApiTestArguments.kt | 8 +-- .../client/api2/mapping/DomainMappingTest.kt | 49 +++++++++++++++++++ 6 files changed, 92 insertions(+), 13 deletions(-) rename stream-chat-android-client/src/main/java/io/getstream/chat/android/{client/api2/model/response/QueryMembersResponse.kt => network/models/MembersResponse.kt} (55%) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/GeneralApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/GeneralApi.kt index 8b66de30052..63c3852dea3 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/GeneralApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/GeneralApi.kt @@ -21,10 +21,10 @@ import io.getstream.chat.android.client.api.QueryParams import io.getstream.chat.android.client.api2.UrlQueryPayload import io.getstream.chat.android.client.api2.model.requests.QueryMembersRequest import io.getstream.chat.android.client.api2.model.requests.SyncHistoryRequest -import io.getstream.chat.android.client.api2.model.response.QueryMembersResponse import io.getstream.chat.android.client.api2.model.response.SearchMessagesResponse import io.getstream.chat.android.client.api2.model.response.SyncHistoryResponse import io.getstream.chat.android.client.call.RetrofitCall +import io.getstream.chat.android.network.models.MembersResponse import io.getstream.chat.android.network.models.SearchPayload import io.getstream.chat.android.network.models.WrappedUnreadCountsResponse import okhttp3.ResponseBody @@ -53,7 +53,7 @@ internal interface GeneralApi { @GET("/members") fun queryMembers( @UrlQueryPayload @Query("payload") payload: QueryMembersRequest, - ): RetrofitCall + ): RetrofitCall @GET("/unread") fun getUnreadCounts(): RetrofitCall diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryMembersResponse.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/MembersResponse.kt similarity index 55% rename from stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryMembersResponse.kt rename to stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/MembersResponse.kt index d4434720448..43bda4078c3 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryMembersResponse.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/MembersResponse.kt @@ -14,12 +14,25 @@ * limitations under the License. */ -package io.getstream.chat.android.client.api2.model.response +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.getstream.chat.android.network.models + +import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import io.getstream.chat.android.client.api2.model.dto.DownstreamMemberDto +/** + * + */ +@com.squareup.moshi.JsonClass(generateAdapter = true) +internal data class MembersResponse( + @Json(name = "duration") + internal val duration: String, -@JsonClass(generateAdapter = true) -internal data class QueryMembersResponse( - val members: List, + @Json(name = "members") + internal val members: List = emptyList(), ) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt index 3ad193aa658..59ff9fb4d83 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt @@ -92,6 +92,7 @@ import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.network.models.AppResponseFields import io.getstream.chat.android.network.models.BlockUsersResponse import io.getstream.chat.android.network.models.BlockedUserResponse +import io.getstream.chat.android.network.models.ChannelMemberResponse import io.getstream.chat.android.network.models.ChannelResponse import io.getstream.chat.android.network.models.CreateGuestResponse import io.getstream.chat.android.network.models.DeviceResponse @@ -728,6 +729,20 @@ internal object Mother { last_reaction_at = lastReactionAt, ) + fun randomChannelMemberResponse( + user: UserResponse = randomUserResponse(), + channelRole: String = randomString(), + ): ChannelMemberResponse = ChannelMemberResponse( + user = user, + userId = user.id, + channelRole = channelRole, + createdAt = randomDate(), + updatedAt = randomDate(), + banned = randomBoolean(), + shadowBanned = randomBoolean(), + notificationsMuted = randomBoolean(), + ) + fun randomDownstreamMemberDto( user: DownstreamUserDto = randomDownstreamUserDto(), createdAt: Date = randomDate(), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt index 34fadec9c0e..225a652b474 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt @@ -65,7 +65,6 @@ import io.getstream.chat.android.client.api2.model.response.QueryChannelsRespons import io.getstream.chat.android.client.api2.model.response.QueryDraftMessagesResponse import io.getstream.chat.android.client.api2.model.response.QueryGroupedChannelsGroup import io.getstream.chat.android.client.api2.model.response.QueryGroupedChannelsResponse -import io.getstream.chat.android.client.api2.model.response.QueryMembersResponse import io.getstream.chat.android.client.api2.model.response.QueryPollVotesResponse import io.getstream.chat.android.client.api2.model.response.QueryPollsResponse import io.getstream.chat.android.client.api2.model.response.QueryReactionsResponse @@ -147,6 +146,7 @@ import io.getstream.chat.android.network.models.ListUserGroupsResponse import io.getstream.chat.android.network.models.MarkDeliveredRequest import io.getstream.chat.android.network.models.MarkReadRequest import io.getstream.chat.android.network.models.MarkUnreadRequest +import io.getstream.chat.android.network.models.MembersResponse import io.getstream.chat.android.network.models.MessageActionRequest import io.getstream.chat.android.network.models.MessageRequest import io.getstream.chat.android.network.models.MuteChannelRequest @@ -2378,7 +2378,7 @@ internal class MoshiChatApiTest { @ParameterizedTest @MethodSource("io.getstream.chat.android.client.api2.MoshiChatApiTestArguments#queryMembersInput") - fun testQueryMembers(call: RetrofitCall, expected: KClass<*>) = runTest { + fun testQueryMembers(call: RetrofitCall, expected: KClass<*>) = runTest { // given val api = mock() whenever(api.queryMembers(any())).doReturn(call) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt index f2b60fd2092..8d42f8761ae 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt @@ -45,7 +45,6 @@ import io.getstream.chat.android.client.api2.model.response.QueryChannelsRespons import io.getstream.chat.android.client.api2.model.response.QueryDraftMessagesResponse import io.getstream.chat.android.client.api2.model.response.QueryGroupedChannelsGroup import io.getstream.chat.android.client.api2.model.response.QueryGroupedChannelsResponse -import io.getstream.chat.android.client.api2.model.response.QueryMembersResponse import io.getstream.chat.android.client.api2.model.response.QueryPollVotesResponse import io.getstream.chat.android.client.api2.model.response.QueryPollsResponse import io.getstream.chat.android.client.api2.model.response.QueryReactionsResponse @@ -77,6 +76,7 @@ import io.getstream.chat.android.network.models.GetBlockedUsersResponse import io.getstream.chat.android.network.models.GetUserGroupResponse import io.getstream.chat.android.network.models.ListDevicesResponse import io.getstream.chat.android.network.models.ListUserGroupsResponse +import io.getstream.chat.android.network.models.MembersResponse import io.getstream.chat.android.network.models.PollOptionResponse import io.getstream.chat.android.network.models.RemoveUserGroupMembersResponse import io.getstream.chat.android.network.models.Response @@ -532,10 +532,12 @@ internal object MoshiChatApiTestArguments { @JvmStatic fun queryMembersInput() = listOf( Arguments.of( - RetroSuccess(QueryMembersResponse(listOf(Mother.randomDownstreamMemberDto()))).toRetrofitCall(), + RetroSuccess( + MembersResponse(duration = randomString(), members = listOf(Mother.randomChannelMemberResponse())), + ).toRetrofitCall(), Result.Success::class, ), - Arguments.of(RetroError(statusCode = 500).toRetrofitCall(), Result.Failure::class), + Arguments.of(RetroError(statusCode = 500).toRetrofitCall(), Result.Failure::class), ) @JvmStatic diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt index 4cbb5c4843a..e398f027b5b 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt @@ -27,6 +27,7 @@ import io.getstream.chat.android.client.Mother.randomBannedUserResponse import io.getstream.chat.android.client.Mother.randomBlockUsersResponse import io.getstream.chat.android.client.Mother.randomBlockedUserResponse import io.getstream.chat.android.client.Mother.randomChannelInfoDto +import io.getstream.chat.android.client.Mother.randomChannelMemberResponse import io.getstream.chat.android.client.Mother.randomChannelResponse import io.getstream.chat.android.client.Mother.randomCommandDto import io.getstream.chat.android.client.Mother.randomConfigDto @@ -673,6 +674,54 @@ internal class DomainMappingTest { assertEquals(expected, member) } + @Test + fun `ChannelMemberResponse is correctly mapped to Member`() { + val memberResponse = randomChannelMemberResponse() + val sut = Fixture().get() + + val member = with(sut) { memberResponse.toDomain() } + + val expected = Member( + user = with(sut) { memberResponse.user!!.toDomain() }, + createdAt = memberResponse.createdAt, + updatedAt = memberResponse.updatedAt, + isInvited = memberResponse.invited, + inviteAcceptedAt = memberResponse.inviteAcceptedAt, + inviteRejectedAt = memberResponse.inviteRejectedAt, + shadowBanned = memberResponse.shadowBanned, + banned = memberResponse.banned, + channelRole = memberResponse.channelRole, + notificationsMuted = memberResponse.notificationsMuted, + status = memberResponse.status, + banExpires = memberResponse.banExpires, + pinnedAt = memberResponse.pinnedAt, + archivedAt = memberResponse.archivedAt, + extraData = emptyMap(), + ) + assertEquals(expected, member) + } + + @Test + fun `ChannelMemberResponse without a user is mapped to a Member holding only the user id`() { + val memberResponse = randomChannelMemberResponse().copy(user = null) + val sut = Fixture().get() + + val member = with(sut) { memberResponse.toDomain() } + + assertEquals(User(id = memberResponse.userId.orEmpty()), member.user) + } + + @Test + fun `ChannelMemberResponse custom data is mapped to extraData without its null values`() { + val memberResponse = randomChannelMemberResponse() + .copy(custom = mapOf("customKey" to "customValue", "nullKey" to null)) + val sut = Fixture().get() + + val member = with(sut) { memberResponse.toDomain() } + + assertEquals(mapOf("customKey" to "customValue"), member.extraData) + } + @Test @Suppress("LongMethod") fun `DownstreamPollDto is correctly mapped to Poll`() { From 482f6fd6af65b318b8885228db5e7771e76e2289 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:30:34 +0200 Subject: [PATCH 2/2] Collect the flattened member custom data into ChannelMemberResponse --- .../ChannelMemberResponseParsingTest.kt | 76 +++++++++++++++++++ .../parser2/GeneratedExtraDataParityTest.kt | 14 ++-- 2 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/ChannelMemberResponseParsingTest.kt diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/ChannelMemberResponseParsingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/ChannelMemberResponseParsingTest.kt new file mode 100644 index 00000000000..d232ae08cf1 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/ChannelMemberResponseParsingTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.parser2 + +import io.getstream.chat.android.network.models.MembersResponse +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldHaveSize +import org.junit.jupiter.api.Test + +internal class ChannelMemberResponseParsingTest { + private val parser = ParserFactory.createMoshiChatParser() + + @Test + fun `Parse the members of a MembersResponse`() { + val members = parser.fromJson(MEMBERS_JSON, MembersResponse::class.java).members + + members shouldHaveSize 1 + members.first().channelRole shouldBeEqualTo "channel_member" + members.first().userId shouldBeEqualTo "leandro" + members.first().custom["memberProbe"] shouldBeEqualTo "sentinel" + } + + @Test + fun `Collect the root-level custom fields of a nested user`() { + val member = parser.fromJson(MEMBERS_JSON, MembersResponse::class.java).members.first() + + member.user?.custom shouldBeEqualTo mapOf("birthland" to "Polis Massa") + } + + companion object { + private const val MEMBERS_JSON = + """{ + "duration": "7ms", + "members": [ + { + "user_id": "leandro", + "channel_role": "channel_member", + "created_at": "2021-10-22T00:07:24.000Z", + "updated_at": "2026-05-28T07:40:11.000Z", + "banned": false, + "shadow_banned": false, + "notifications_muted": false, + "role": "member", + "is_moderator": true, + "deleted_at": "2026-08-14T12:00:00.000Z", + "deleted_messages": ["m1"], + "memberProbe": "sentinel", + "user": { + "id": "leandro", + "role": "user", + "language": "pt", + "banned": false, + "online": true, + "created_at": "2021-07-20T14:17:07.000Z", + "updated_at": "2026-07-31T11:38:42.000Z", + "birthland": "Polis Massa" + } + } + ] + }""" + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GeneratedExtraDataParityTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GeneratedExtraDataParityTest.kt index 7aab440d3f6..179538a5d75 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GeneratedExtraDataParityTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GeneratedExtraDataParityTest.kt @@ -91,12 +91,16 @@ internal class GeneratedExtraDataParityTest { ChannelMemberResponse::class.java, ) - member.custom.keys shouldContainAll setOf( - "sentinel", "user_id", "role", "is_moderator", "deleted_messages", "deleted_at", + // Asserted as an exact map so a key silently dropping out of the keep set fails here. + member.custom shouldBeEqualTo mapOf( + "user_id" to "u1", + "role" to "member", + "is_moderator" to true, + "deleted_messages" to emptyList(), + "deleted_at" to "2026-08-14T12:00:00.000Z", + "sentinel" to "keep-me", ) - member.custom["role"] shouldBeEqualTo "member" - member.custom["is_moderator"] shouldBeEqualTo true - member.custom["deleted_at"] shouldBeEqualTo "2026-08-14T12:00:00.000Z" + // Still parsed into its own field, not only kept in the map. member.role shouldBeEqualTo "member" } }