diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index 077dfc416b..e661850687 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -365,6 +365,16 @@ class EventStreamDelegate @Inject constructor( // and a mute and the unmute after it can land either way round. metadataDataSource.updateViewerState(chatId, metaUpdate.viewerState) } + is MetadataUpdate.TitleChanged -> { + // Best-effort, applied as received (unversioned, unlike the roster and + // viewer-state overlays above): a client that suspects a miss refetches via + // GetChat rather than waiting on a later update to correct it. + metadataDataSource.updateTitle(chatId, metaUpdate.newTitle) + } + is MetadataUpdate.PictureChanged -> { + // Same best-effort contract as TitleChanged above. + metadataDataSource.updatePicture(chatId, metaUpdate.newPicture) + } } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatMetadataEditStreamTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatMetadataEditStreamTest.kt new file mode 100644 index 0000000000..a8e7718bee --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatMetadataEditStreamTest.kt @@ -0,0 +1,217 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.app.persistence.sources.ContactDataSource +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.controllers.EventStreamingController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatUpdate +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.models.chat.MetadataUpdate +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.internal.ChatIdGenerator +import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.internal.RealChatCoordinator +import com.flipcash.shared.chat.internal.delegates.DmChatResolverDelegate +import com.flipcash.shared.chat.internal.delegates.EventStreamDelegate +import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate +import com.flipcash.shared.chat.internal.delegates.MessagingDelegate +import com.getcode.utils.network.NetworkConnectivityListener +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.time.Duration.Companion.milliseconds + +/** + * The title/picture arm of the event stream: `TitleChanged` and `PictureChanged` are applied + * unconditionally, unlike the versioned [MetadataUpdate.ViewerStateChanged] handling covered in + * [ViewerStateStreamTest] — neither carries a version, so there is nothing for the delegate to + * gate the write on. Delivery is best-effort, by contract; a client that suspects a miss is + * expected to refetch via `GetChat` rather than relying on the stream to correct itself. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ChatMetadataEditStreamTest { + + private val selfId = listOf(1, 2, 3) + private val chatId = ChatId("aabbccdd") + + private val chatUpdatesChannel = Channel(capacity = Channel.UNLIMITED) + + private lateinit var metadataDataSource: ChatMetadataDataSource + private lateinit var coordinator: RealChatCoordinator + private lateinit var testDispatchers: TestDispatchers + + @Before + fun setUp() { + val userManager = mockk(relaxed = true) + every { userManager.accountId } returns selfId + val eventStreamingController = mockk(relaxed = true) + every { eventStreamingController.chatUpdates } returns chatUpdatesChannel.receiveAsFlow() + every { eventStreamingController.isConnected } returns true + every { eventStreamingController.isStreamActive } returns true + + val chatController = mockk(relaxed = true) + coEvery { chatController.getDmChatFeed(any(), any()) } returns + Result.failure(RuntimeException("not needed")) + + testDispatchers = TestDispatchers(TestCoroutineScheduler()) + + val stateHolder = ChatStateHolder() + val memberDataSource = mockk(relaxed = true) + val messagingController = mockk(relaxed = true) + metadataDataSource = mockk(relaxed = true) + val messageDataSource = mockk(relaxed = true) + + val feedDelegate = FeedSyncDelegate( + chatController = chatController, + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + stateHolder = stateHolder, + userManager = userManager, + ) + + val eventStreamDelegate = EventStreamDelegate( + eventStreamingController = eventStreamingController, + messagingController = messagingController, + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + tokenCoordinator = mockk(relaxed = true), + userManager = userManager, + stateHolder = stateHolder, + analytics = mockk(relaxed = true), + exchange = mockk(relaxed = true), + ) + + val messagingDelegate = MessagingDelegate( + chatController = chatController, + messagingController = messagingController, + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + notificationManager = mockk(relaxed = true), + userManager = userManager, + stateHolder = stateHolder, + analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), + ) + + val dmChatResolverDelegate = DmChatResolverDelegate( + chatIdGenerator = ChatIdGenerator(), + userManager = userManager, + contactDataSource = mockk(relaxed = true), + memberDataSource = memberDataSource, + ) + + coordinator = RealChatCoordinator( + feedDelegate = feedDelegate, + eventStreamDelegate = eventStreamDelegate, + dmChatResolverDelegate = dmChatResolverDelegate, + messagingDelegate = messagingDelegate, + stateHolder = stateHolder, + draftStore = mockk(relaxed = true), + userManager = userManager, + networkObserver = mockk(relaxed = true), + dispatchers = testDispatchers, + groupFeedDelegate = mockk(relaxed = true), + ) + } + + private suspend fun triggerCollection() { + coordinator.onUserLoggedIn(mockk(relaxed = true)) + } + + @Test + fun `a title change from the stream is stored`() = runTest(testDispatchers.dispatcher) { + tornDown { + triggerCollection() + + chatUpdatesChannel.send( + ChatUpdate( + chatId = chatId, + metadataUpdates = listOf(MetadataUpdate.TitleChanged("New title")), + ), + ) + advanceTimeBy(1_000.milliseconds) + runCurrent() + + coVerify(exactly = 1) { metadataDataSource.updateTitle(chatId, "New title") } + } + } + + @Test + fun `a picture change from the stream is stored`() = runTest(testDispatchers.dispatcher) { + tornDown { + triggerCollection() + val picture = MediaItem(renditions = emptyList()) + + chatUpdatesChannel.send( + ChatUpdate( + chatId = chatId, + metadataUpdates = listOf(MetadataUpdate.PictureChanged(picture)), + ), + ) + advanceTimeBy(1_000.milliseconds) + runCurrent() + + coVerify(exactly = 1) { metadataDataSource.updatePicture(chatId, picture) } + } + } + + @Test + fun `an update carrying no title or picture change leaves them alone`() = + runTest(testDispatchers.dispatcher) { + tornDown { + triggerCollection() + + chatUpdatesChannel.send( + ChatUpdate( + chatId = chatId, + metadataUpdates = listOf( + MetadataUpdate.LastActivityChanged(kotlin.time.Instant.fromEpochSeconds(2_000)), + ), + ), + ) + advanceTimeBy(1_000.milliseconds) + runCurrent() + + coVerify(exactly = 0) { metadataDataSource.updateTitle(any(), any()) } + coVerify(exactly = 0) { metadataDataSource.updatePicture(any(), any()) } + } + } + + /** + * Runs [block], then tears the coordinator down however it ends. + * + * The `finally` is the point. Logging in starts a heartbeat that is a `while (true)` of delays + * on the coordinator's own scope rather than the test's `backgroundScope`, and `runTest` drains + * the scheduler once the body returns. A failing assertion that skipped the teardown would + * leave that loop advancing virtual time with nothing to stop it, hanging the run instead of + * reporting the failure. + */ + private suspend fun tornDown(block: suspend () -> Unit) { + try { + block() + } finally { + coordinator.teardown() + } + } +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt index c4ec537bf1..f16dc5bc7f 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt @@ -241,6 +241,19 @@ interface ChatMetadataDao { @Query("UPDATE chat_metadata SET is_hidden = :hidden WHERE chat_id_hex = :chatIdHex") suspend fun updateHidden(chatIdHex: String, hidden: Boolean) + /** + * Applied unconditionally, unlike [updateRosterIfNewer] and [updateViewerStateIfNewer]: + * `MetadataUpdate.TitleChanged` carries no version, so there is nothing to gate on. Delivery + * is best-effort; a client that suspects a miss refetches via `GetChat` rather than relying + * on this to converge on its own. + */ + @Query("UPDATE chat_metadata SET title = :title WHERE chat_id_hex = :chatIdHex") + suspend fun updateTitle(chatIdHex: String, title: String) + + /** Unconditional, for the same reason as [updateTitle]: `MetadataUpdate.PictureChanged` carries no version. */ + @Query("UPDATE chat_metadata SET picture_json = :pictureJson WHERE chat_id_hex = :chatIdHex") + suspend fun updatePicture(chatIdHex: String, pictureJson: MediaItem) + @Query("DELETE FROM chat_metadata") suspend fun deleteAll() } diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt index 576451dee1..29f74cc2c2 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt @@ -170,6 +170,30 @@ class ChatMetadataDaoTest { assertEquals(false, stored?.isMember) } + /** + * `MetadataUpdate.TitleChanged` carries no version, unlike the roster and viewer-state + * overlays above, so there is nothing to gate the write on. + */ + @Test + fun `updateTitle overwrites the title unconditionally`() = runTest { + dao.upsert(entity(chatType = "GROUP", title = "Old title")) + + dao.updateTitle(CHAT_HEX, "New title") + + assertEquals("New title", dao.getById(CHAT_HEX)?.title) + } + + /** Same unconditional contract as `updateTitle`, for `MetadataUpdate.PictureChanged`. */ + @Test + fun `updatePicture overwrites the picture unconditionally`() = runTest { + dao.upsert(entity(chatType = "GROUP")) + val picture = MediaItem(renditions = emptyList()) + + dao.updatePicture(CHAT_HEX, picture) + + assertEquals(picture, dao.getById(CHAT_HEX)?.pictureJson) + } + @Test fun `a newer roster version replaces the roster columns`() = runTest { dao.upsert(entity(chatType = "GROUP", memberCount = 12, rosterVersion = 4)) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index 1c26bbb3bb..d751bf88a6 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -9,6 +9,7 @@ import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.models.chat.ViewerState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow @@ -130,6 +131,16 @@ class ChatMetadataDataSource @Inject constructor( suspend fun getTitle(chatId: ChatId): String? = db?.chatMetadataDao()?.getTitle(mapper.chatIdHex(chatId)) + /** Applies a `MetadataUpdate.TitleChanged` for [chatId]. See ChatMetadataDao.updateTitle. */ + suspend fun updateTitle(chatId: ChatId, title: String) { + db?.chatMetadataDao()?.updateTitle(mapper.chatIdHex(chatId), title) + } + + /** Applies a `MetadataUpdate.PictureChanged` for [chatId]. See ChatMetadataDao.updatePicture. */ + suspend fun updatePicture(chatId: ChatId, picture: MediaItem) { + db?.chatMetadataDao()?.updatePicture(mapper.chatIdHex(chatId), picture) + } + suspend fun getAnalyticsCountedThrough(chatId: ChatId): Long = db?.chatMetadataDao()?.getAnalyticsCountedThrough(mapper.chatIdHex(chatId)) ?: 0L diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e915839f85..c18427bbf9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,7 +69,7 @@ protovalidate-kt = "0.1.3" # 0.3.0 is the first release of either package to ship R8 keep rules for its generated # messages, which is what lets proguard-rules.pro drop its own. ocp-client-protocol = "0.5.0" -flipcash2-client-protocol = "0.10.0" +flipcash2-client-protocol = "0.11.0" # The Android port is the ONLY libphonenumber this app depends on, deliberately. Google's # `com.googlecode` artifact used to sit alongside it; the two ship separate copies of the metadata, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt index 577dfa903c..2deadf2a22 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt @@ -5,8 +5,10 @@ import com.flipcash.services.models.chat.ChatFeedPage import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.RosterPage import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.chat.ViewMode @@ -68,6 +70,31 @@ class ChatController @Inject constructor( return repository.startChat(owner, parameters, idempotencyKey) } + /** + * One page of [chatId]'s roster. See [ChatRepository.getRoster] for paging and merge + * semantics. + */ + suspend fun getRoster( + chatId: ChatId, + queryOptions: QueryOptions = QueryOptions(), + ): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.getRoster(owner, chatId, queryOptions) + } + + /** Edits [chatId] per [parameters]. See [ChatRepository.editChat] for no-op semantics. */ + suspend fun editChat( + chatId: ChatId, + parameters: EditChatParameters, + ): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.editChat(owner, chatId, parameters) + } + suspend fun joinChat(chatId: ChatId): Result { val owner = userManager.accountCluster?.authority?.keyPair ?: return Result.failure(Throwable("No account cluster in UserManager")) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt index 4bbc5fff41..7732551631 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt @@ -33,6 +33,12 @@ class ChatMetadataMapper @Inject constructor( userProfile = userProfileMapper.map(member.userProfile) .let { if (it.userId == null) it.copy(userId = member.userId.toId()) else it }, pointers = member.pointersList.map { it.toPointer() }, + joinedAt = if (member.hasJoinedAt()) { + Instant.fromEpochSeconds(member.joinedAt.seconds, member.joinedAt.nanos) + } else { + null + }, + version = member.version, ) }, lastMessage = if (from.hasLastMessage()) from.lastMessage.toChatMessage() else null, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt index 82bf7160eb..d6ac5b89e1 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt @@ -15,6 +15,7 @@ import com.flipcash.services.internal.network.extensions.authenticate import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.StartChatParameters @@ -129,6 +130,58 @@ internal class ChatApi @Inject constructor( } } + suspend fun getRoster( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions = QueryOptions(), + ): RpcChatService.GetRosterResponse { + val request = RpcChatService.GetRosterRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setQueryOptions(queryOptions.asQueryOptions()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getRoster(request) + } + } + + suspend fun editChat( + owner: KeyPair, + chatId: ChatId, + parameters: EditChatParameters, + ): RpcChatService.EditChatResponse { + val request = RpcChatService.EditChatRequest.newBuilder() + .setChatId(chatId.asChatId()) + .apply { + parameters.title?.let { + setTitle( + RpcChatService.EditChatRequest.Title.newBuilder() + .setValue(it) + ) + } + parameters.picture?.let { + setPicture( + RpcChatService.EditChatRequest.Picture.newBuilder() + .setBlobId( + com.codeinc.flipcash.gen.blob.v1.Model.BlobId.newBuilder() + .setValue(it.bytes.toByteString()) + ) + ) + } + } + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.editChat(request) + } + } + suspend fun joinChat( owner: KeyPair, chatId: ChatId, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index 00de8f5ee2..73353bca7a 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -364,6 +364,10 @@ internal fun ChatModel.MetadataUpdate.toMetadataUpdate( ) ChatModel.MetadataUpdate.KindCase.VIEWER_STATE_CHANGED -> MetadataUpdate.ViewerStateChanged(viewerStateChanged.viewerState.toViewerState()) + ChatModel.MetadataUpdate.KindCase.TITLE_CHANGED -> + MetadataUpdate.TitleChanged(titleChanged.newTitle) + ChatModel.MetadataUpdate.KindCase.PICTURE_CHANGED -> + MetadataUpdate.PictureChanged(pictureChanged.newPicture.toMediaItem()) else -> MetadataUpdate.LastActivityChanged(Instant.fromEpochSeconds(0)) } } @@ -404,6 +408,11 @@ internal fun ChatModel.ViewerState.toViewerState(): ViewerState { return ViewerState( mute = if (hasSettings() && settings.hasMute()) settings.mute.toMuteState() else null, version = version, + permissions = if (hasPermissions()) { + ViewerState.Permissions(canEdit = permissions.canEdit) + } else { + ViewerState.Permissions() + }, ) } @@ -438,6 +447,8 @@ internal fun ChatModel.Member.toChatMember(): ChatMember { ) }, pointers = pointersList.map { it.toPointer() }, + joinedAt = if (hasJoinedAt()) Instant.fromEpochSeconds(joinedAt.seconds, joinedAt.nanos) else null, + version = version, ) } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt index 9d4f90eadb..18dd75ecb8 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt @@ -4,9 +4,11 @@ import com.codeinc.flipcash.gen.chat.v1.ChatService as RpcChatService import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel import com.flipcash.services.internal.network.api.ChatApi import com.flipcash.services.internal.network.extensions.toFlaggedCategory +import com.flipcash.services.models.EditChatError import com.flipcash.services.models.GetChatError import com.flipcash.services.models.GetDmChatFeedError import com.flipcash.services.models.GetGroupChatFeedError +import com.flipcash.services.models.GetRosterError import com.flipcash.services.models.JoinChatError import com.flipcash.services.models.LeaveChatError import com.flipcash.services.models.MuteChatError @@ -14,6 +16,7 @@ import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.StartChatError import com.flipcash.services.models.UnmuteChatError import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.StartChatParameters @@ -95,6 +98,29 @@ internal class ChatService @Inject constructor( ) } + suspend fun getRoster( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): Result { + return runCatching { + api.getRoster(owner, chatId, queryOptions) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.GetRosterResponse.Result.OK -> Result.success(response) + RpcChatService.GetRosterResponse.Result.DENIED -> Result.failure(GetRosterError.Denied()) + RpcChatService.GetRosterResponse.Result.NOT_FOUND -> Result.failure(GetRosterError.NotFound()) + RpcChatService.GetRosterResponse.Result.UNRECOGNIZED -> Result.failure(GetRosterError.Unrecognized()) + else -> Result.failure(GetRosterError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetRosterError.Other(cause = it) }) + } + ) + } + suspend fun joinChat( owner: KeyPair, chatId: ChatId, @@ -145,6 +171,32 @@ internal class ChatService @Inject constructor( ) } + suspend fun editChat( + owner: KeyPair, + chatId: ChatId, + parameters: EditChatParameters, + ): Result { + return runCatching { + api.editChat(owner, chatId, parameters) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.EditChatResponse.Result.OK -> Result.success(response.chat) + RpcChatService.EditChatResponse.Result.DENIED -> Result.failure(EditChatError.Denied()) + RpcChatService.EditChatResponse.Result.NOT_FOUND -> Result.failure(EditChatError.NotFound()) + RpcChatService.EditChatResponse.Result.TITLE_MODERATED -> + Result.failure(EditChatError.TitleModerated(response.flaggedCategory.toFlaggedCategory())) + RpcChatService.EditChatResponse.Result.PICTURE_BLOB_NOT_ACCEPTED -> Result.failure(EditChatError.PictureBlobNotAccepted()) + RpcChatService.EditChatResponse.Result.UNRECOGNIZED -> Result.failure(EditChatError.Unrecognized()) + else -> Result.failure(EditChatError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { EditChatError.Other(cause = it) }) + } + ) + } + suspend fun leaveChat( owner: KeyPair, chatId: ChatId, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt index 8f9a93ee0c..556bb55b04 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt @@ -1,7 +1,9 @@ package com.flipcash.services.internal.repositories import com.flipcash.services.internal.domain.ChatMetadataMapper +import com.flipcash.services.internal.network.extensions.toChatMember import com.flipcash.services.internal.network.extensions.toPagingToken +import com.flipcash.services.internal.network.extensions.toRosterSummary import com.flipcash.services.internal.network.extensions.toViewerState import com.flipcash.services.internal.network.services.ChatService import com.flipcash.services.models.LeaveChatError @@ -10,8 +12,10 @@ import com.flipcash.services.models.chat.ChatFeedPage import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.RosterPage import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.chat.ViewMode @@ -66,6 +70,29 @@ internal class InternalChatRepository( .onFailure { ErrorUtils.handleError(it) } .map { mapper.map(it) } + override suspend fun getRoster( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): Result = service.getRoster(owner, chatId, queryOptions) + .onFailure { ErrorUtils.handleError(it) } + .map { response -> + RosterPage( + members = response.membersList.map { it.toChatMember() }, + rosterSummary = response.rosterSummary.toRosterSummary(), + pagingToken = if (response.hasPagingToken()) response.pagingToken.toPagingToken() else null, + hasMore = response.hasMore, + ) + } + + override suspend fun editChat( + owner: KeyPair, + chatId: ChatId, + parameters: EditChatParameters, + ): Result = service.editChat(owner, chatId, parameters) + .onFailure { ErrorUtils.handleError(it) } + .map { mapper.map(it) } + override suspend fun joinChat( owner: KeyPair, chatId: ChatId, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt index a77f33ab28..0145d77e90 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt @@ -370,6 +370,16 @@ sealed class GetGroupChatFeedError( data class Other(override val cause: Throwable? = null) : GetGroupChatFeedError(message = cause?.message, cause = cause), NotifiableError } +sealed class GetRosterError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetRosterError("Denied") + class NotFound : GetRosterError("Not found") + class Unrecognized : GetRosterError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetRosterError(message = cause?.message, cause = cause), NotifiableError +} + sealed class JoinChatError( override val message: String? = null, override val cause: Throwable? = null @@ -412,6 +422,18 @@ sealed class StartChatError( data class Other(override val cause: Throwable? = null) : StartChatError(message = cause?.message, cause = cause), NotifiableError } +sealed class EditChatError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : EditChatError("Denied") + class NotFound : EditChatError("Not found") + class TitleModerated(val category: ModerationResult.FlaggedCategory) : EditChatError("Title flagged: $category") + class PictureBlobNotAccepted : EditChatError("Picture blob not accepted") + class Unrecognized : EditChatError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : EditChatError(message = cause?.message, cause = cause), NotifiableError +} + sealed class GetMessageError( override val message: String? = null, override val cause: Throwable? = null diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMember.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMember.kt index 267b063537..4cdf656e6f 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMember.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMember.kt @@ -2,9 +2,16 @@ package com.flipcash.services.models.chat import com.flipcash.services.models.UserProfile import com.getcode.opencode.model.core.ID +import kotlin.time.Instant data class ChatMember( val userId: ID, val userProfile: UserProfile, val pointers: List, + // When this member joined, if known. Set wherever version is; unset (null) for DM + // participants and on Metadata.members (the viewer's own entry only). + val joinedAt: Instant? = null, + // Merge key for a roster fetched in pages: greater wins. Zero for chat-creation joins + // and all DM participants, since neither carries a meaningful join order. + val version: Long = 0, ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EditChatParameters.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EditChatParameters.kt new file mode 100644 index 0000000000..af01fb1dec --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EditChatParameters.kt @@ -0,0 +1,14 @@ +package com.flipcash.services.models.chat + +/** + * Parameters for `Chat.EditChat`. Every field is optional and independently no-op when unset — + * an all-null instance is a valid, well-defined call that changes nothing. Group chats only. + */ +data class EditChatParameters( + // New title, 1-64 characters, moderated the same way as StartChat's. Null leaves the + // title unchanged. + val title: String? = null, + // The already-uploaded-and-READY blob to use as the new picture. The client uploads only + // the ORIGINAL rendition; the server derives the rest. Null leaves the picture unchanged. + val picture: BlobId? = null, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MetadataUpdate.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MetadataUpdate.kt index 031d5b2f1e..857a815927 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MetadataUpdate.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MetadataUpdate.kt @@ -7,4 +7,9 @@ sealed interface MetadataUpdate { data class LastActivityChanged(val newLastActivity: Instant) : MetadataUpdate // The receiving user's own state for the chat has changed (e.g. mute set/cleared). data class ViewerStateChanged(val viewerState: ViewerState) : MetadataUpdate + // A group chat's title was edited. Best-effort delivery: applied as received, no ordering + // guaranteed against other updates for the same chat. + data class TitleChanged(val newTitle: String) : MetadataUpdate + // A group chat's picture was edited. Best-effort delivery, same caveat as TitleChanged. + data class PictureChanged(val newPicture: MediaItem) : MetadataUpdate } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/RosterPage.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/RosterPage.kt new file mode 100644 index 0000000000..0ed9b63949 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/RosterPage.kt @@ -0,0 +1,24 @@ +package com.flipcash.services.models.chat + +import com.flipcash.services.models.PagingToken + +/** + * One page of `Chat.GetRoster`, most recently joined member first. + * + * For a DM or small group this is the exact roster as of [rosterSummary]'s version. For a large + * group the page can lag [rosterSummary]: merge [members] into a locally held roster per member, + * by [ChatMember.version] — the greater value wins. A cached member absent from a page is only + * gone once the roster has been read to the end (`hasMore == false`) *and* the client holds no + * join for that member at a version above this page's [rosterSummary] version; otherwise the + * absence just means that member's page has not been reached yet. + */ +data class RosterPage( + val members: List, + // The roster's staleness watermark as of this fetch. Compare like RosterSummary elsewhere: + // a page can be behind this value for a large group (see class doc), never ahead of it. + val rosterSummary: RosterSummary, + // Opaque, server-generated, bound to the chat this page was fetched for. Present when + // hasMore is true; pass back as QueryOptions.token to continue paging. + val pagingToken: PagingToken?, + val hasMore: Boolean, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewerState.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewerState.kt index 91940896be..4eca19c89e 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewerState.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewerState.kt @@ -2,7 +2,7 @@ package com.flipcash.services.models.chat /** * Per-viewer state a chat holds about the requesting user, independent of membership. Mirrors - * `chat.v1.ViewerState`. + * `chat.v1.ViewerState`. Always present for a member. */ data class ViewerState( // Present if and only if the chat is muted for the viewer. @@ -10,4 +10,12 @@ data class ViewerState( // Advanced by exactly one on every real change to this state. Compared like // RosterSummary.version: apply the greater value, drop the rest. val version: Long = 0, -) + // Server-computed grants for the viewer. Never client-derivable; defaults false until + // the server says otherwise. + val permissions: Permissions = Permissions(), +) { + data class Permissions( + // Whether the viewer may call EditChat on this chat. + val canEdit: Boolean = false, + ) +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt index b7199a8f24..d7cbc9931f 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt @@ -5,8 +5,10 @@ import com.flipcash.services.models.chat.ChatFeedPage import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.RosterPage import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.chat.ViewMode @@ -45,6 +47,30 @@ interface ChatRepository { idempotencyKey: IdempotencyKey, ): Result + /** + * One page of [chatId]'s roster, most recently joined first. Pass the previous page's + * [RosterPage.pagingToken] in [queryOptions] to continue; omit it for the first page. + * + * See [RosterPage] for how to merge a page into a locally held roster — a page can lag + * the roster's true state for a large group, unlike the exact list [getChat] returns. + */ + suspend fun getRoster( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions = QueryOptions(), + ): Result + + /** + * Edits [chatId], a group chat, applying only the fields set in [parameters]. Unset fields + * are left unchanged; an all-null [parameters] is a valid no-op. Requires + * `ViewerState.Permissions.canEdit` on the caller. + */ + suspend fun editChat( + owner: KeyPair, + chatId: ChatId, + parameters: EditChatParameters, + ): Result + /** Adds the caller to [chatId]'s roster, returning the chat as the caller now sees it. */ suspend fun joinChat( owner: KeyPair, diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt index f10b944197..15ed325610 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt @@ -3,13 +3,18 @@ package com.flipcash.services.controllers import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatFeedPage import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.RosterPage +import com.flipcash.services.models.chat.RosterSummary import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.chat.ViewMode +import com.flipcash.services.models.UserProfile import com.flipcash.services.repository.ChatRepository import com.flipcash.services.user.UserManager import com.getcode.ed25519.Ed25519 @@ -233,6 +238,115 @@ class ChatControllerTest { // endregion + // region getRoster + + @Test + fun `getRoster fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + + val result = controller.getRoster(ChatId(ByteArray(32))) + + assertTrue(result.isFailure) + } + + @Test + fun `getRoster uses default QueryOptions when none provided`() = runTest { + stubOwner() + repository.getRosterResult = Result.success(stubRosterPage()) + + controller.getRoster(ChatId(ByteArray(32))) + + assertEquals(QueryOptions(), repository.lastQueryOptions) + } + + @Test + fun `getRoster forwards the chatId and paging token`() = runTest { + stubOwner() + val chatId = ChatId(ByteArray(32) { 0x42 }) + val token = listOf(0xAB.toByte()) + repository.getRosterResult = Result.success(stubRosterPage()) + + controller.getRoster(chatId, QueryOptions(token = token)) + + assertEquals(chatId, repository.lastChatId) + assertEquals(token, repository.lastQueryOptions?.token) + } + + @Test + fun `getRoster returns the page from the repository`() = runTest { + stubOwner() + val expected = stubRosterPage() + repository.getRosterResult = Result.success(expected) + + val result = controller.getRoster(ChatId(ByteArray(32))) + + assertSame(expected, result.getOrThrow()) + } + + @Test + fun `getRoster surfaces repository failures without swallowing`() = runTest { + stubOwner() + val cause = RuntimeException("not found") + repository.getRosterResult = Result.failure(cause) + + val result = controller.getRoster(ChatId(ByteArray(32))) + + assertTrue(result.isFailure) + assertSame(cause, result.exceptionOrNull()) + } + + // endregion + + // region editChat + + @Test + fun `editChat fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + + val result = controller.editChat(ChatId(ByteArray(32)), EditChatParameters()) + + assertTrue(result.isFailure) + } + + @Test + fun `editChat forwards the chatId and parameters to the repository`() = runTest { + stubOwner() + val chatId = ChatId(ByteArray(32) { 0x42 }) + val parameters = EditChatParameters(title = "New title") + repository.editChatResult = Result.success(stubMetadata(chatId)) + + controller.editChat(chatId, parameters) + + assertEquals(chatId, repository.lastChatId) + assertSame(parameters, repository.lastEditChatParameters) + } + + @Test + fun `editChat returns the updated metadata from the repository`() = runTest { + stubOwner() + val chatId = ChatId(ByteArray(32) { 0x42 }) + val expected = stubMetadata(chatId) + repository.editChatResult = Result.success(expected) + + val result = controller.editChat(chatId, EditChatParameters()) + + assertSame(expected, result.getOrThrow()) + } + + @Test + fun `editChat surfaces repository failures without swallowing`() = runTest { + stubOwner() + val cause = RuntimeException("title moderated") + repository.editChatResult = Result.failure(cause) + + val result = controller.editChat(ChatId(ByteArray(32)), EditChatParameters()) + + assertTrue(result.isFailure) + assertSame(cause, result.exceptionOrNull()) + } + + // endregion + // region joinChat @Test @@ -318,6 +432,19 @@ class ChatControllerTest { lastActivity = Instant.fromEpochSeconds(1000), ) + private fun stubRosterPage() = RosterPage( + members = listOf( + ChatMember( + userId = listOf(1.toByte()), + userProfile = UserProfile("Member", emptyList(), null, null), + pointers = emptyList(), + ) + ), + rosterSummary = RosterSummary(memberCount = 1, version = 1), + pagingToken = null, + hasMore = false, + ) + // endregion } @@ -332,10 +459,13 @@ private class FakeChatRepository : ChatRepository { var leaveChatResult: Result = Result.failure(RuntimeException("not configured")) var muteChatResult: Result = Result.failure(RuntimeException("not configured")) var unmuteChatResult: Result = Result.failure(RuntimeException("not configured")) + var getRosterResult: Result = Result.failure(RuntimeException("not configured")) + var editChatResult: Result = Result.failure(RuntimeException("not configured")) var lastChatId: ChatId? = null var lastQueryOptions: QueryOptions? = null var lastChatType: ChatType? = null var lastMuteState: MuteState? = null + var lastEditChatParameters: EditChatParameters? = null override suspend fun getChat( owner: Ed25519.KeyPair, @@ -372,6 +502,26 @@ private class FakeChatRepository : ChatRepository { return startChatResult } + override suspend fun getRoster( + owner: Ed25519.KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): Result { + lastChatId = chatId + lastQueryOptions = queryOptions + return getRosterResult + } + + override suspend fun editChat( + owner: Ed25519.KeyPair, + chatId: ChatId, + parameters: EditChatParameters, + ): Result { + lastChatId = chatId + lastEditChatParameters = parameters + return editChatResult + } + override suspend fun joinChat(owner: Ed25519.KeyPair, chatId: ChatId): Result { lastChatId = chatId return joinChatResult diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapperTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapperTest.kt index 0833759ccd..3cafd4a720 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapperTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapperTest.kt @@ -111,6 +111,24 @@ class ChatMetadataMapperTest { assertEquals(5L, result.members[0].pointers[0].value) } + @Test + fun `maps member joinedAt and version when present`() { + val withJoin = ChatModel.Member.newBuilder(member()) + .setJoinedAt(Timestamp.newBuilder().setSeconds(3000)) + .setVersion(7) + .build() + val result = mapper.map(metadata { addMembers(withJoin) }) + assertEquals(3000L, result.members[0].joinedAt?.epochSeconds) + assertEquals(7L, result.members[0].version) + } + + @Test + fun `member joinedAt is null and version is zero when absent`() { + val result = mapper.map(metadata { addMembers(member()) }) + assertNull(result.members[0].joinedAt) + assertEquals(0L, result.members[0].version) + } + @Test fun `member profile takes the member's user id when the nested profile omits it`() { val result = mapper.map(metadata { addMembers(member(userIdByte = 9)) }) diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/DomainModelsTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/DomainModelsTest.kt index b61b656858..11ab9829d8 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/DomainModelsTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/DomainModelsTest.kt @@ -107,4 +107,80 @@ class DomainModelsTest { assertEquals("Test", member.userProfile.displayName) assertEquals(1, member.pointers.size) } + + @Test + fun `ChatMember joinedAt and version default to null and zero`() { + val member = ChatMember( + userId = listOf(1.toByte()), + userProfile = UserProfile("Test", emptyList(), null, null), + pointers = emptyList(), + ) + assertNull(member.joinedAt) + assertEquals(0L, member.version) + } + + @Test + fun `ChatMember joinedAt and version round-trip`() { + val joinedAt = Instant.fromEpochSeconds(500) + val member = ChatMember( + userId = listOf(1.toByte()), + userProfile = UserProfile("Test", emptyList(), null, null), + pointers = emptyList(), + joinedAt = joinedAt, + version = 3, + ) + assertEquals(joinedAt, member.joinedAt) + assertEquals(3L, member.version) + } + + @Test + fun `ViewerState permissions default to canEdit false`() { + val state = ViewerState() + assertEquals(false, state.permissions.canEdit) + } + + @Test + fun `ViewerState permissions round-trip`() { + val state = ViewerState(permissions = ViewerState.Permissions(canEdit = true)) + assertEquals(true, state.permissions.canEdit) + } + + @Test + fun `MetadataUpdate TitleChanged holds the new title`() { + val update = MetadataUpdate.TitleChanged("New title") + assertEquals("New title", update.newTitle) + } + + @Test + fun `MetadataUpdate PictureChanged holds the new picture`() { + val picture = MediaItem(renditions = emptyList()) + val update = MetadataUpdate.PictureChanged(picture) + assertEquals(picture, update.newPicture) + } + + @Test + fun `RosterPage holds members, summary and paging state`() { + val page = RosterPage( + members = listOf( + ChatMember( + userId = listOf(1.toByte()), + userProfile = UserProfile("Test", emptyList(), null, null), + pointers = emptyList(), + ) + ), + rosterSummary = RosterSummary(memberCount = 1, version = 1), + pagingToken = listOf(0xAB.toByte()), + hasMore = true, + ) + assertEquals(1, page.members.size) + assertEquals(1L, page.rosterSummary.version) + assertEquals(true, page.hasMore) + } + + @Test + fun `EditChatParameters is a no-op when every field is unset`() { + val parameters = EditChatParameters() + assertNull(parameters.title) + assertNull(parameters.picture) + } }