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 387aaf37b..25f206a4f 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 @@ -16,6 +16,7 @@ import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MetadataUpdate import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.ReactionUpdate +import com.flipcash.services.models.chat.RosterUpdate import com.flipcash.services.models.chat.TypingNotification import com.flipcash.services.models.chat.TypingState import com.flipcash.services.models.GetDeltaError @@ -103,6 +104,13 @@ class EventStreamDelegate @Inject constructor( val events: Flow = _events.receiveAsFlow() private val sequenceTracker = EventSequenceTracker() + + // In-memory only: Room's chat_metadata has no roster-version column today (rosterSummary is + // only ever real when hydrated fresh from the network), so there is nothing durable to key + // this off. A cold start always applies the first roster update it sees for a chat, since any + // real version is >= 1 > the 0L default here — consistent with ChatMetadata's own + // reconstruct-from-Room default. + private val rosterVersions = mutableMapOf() private var scope: CoroutineScope? = null private var eventStreamCollectJob: Job? = null private var heartbeatJob: Job? = null @@ -299,7 +307,7 @@ class EventStreamDelegate @Inject constructor( trace( tag = TAG, - message = "applyUpdate: chatId=$chatId, messages=${resolvedMessages.size}, events=${update.events.size}, pointers=${update.pointerUpdates.size}, reactions=${update.reactionUpdates.size}, typing=${update.typingNotifications.size}", + message = "applyUpdate: chatId=$chatId, messages=${resolvedMessages.size}, events=${update.events.size}, pointers=${update.pointerUpdates.size}, reactions=${update.reactionUpdates.size}, typing=${update.typingNotifications.size}, roster=${update.rosterUpdates.size}", type = TraceType.Process, ) @@ -376,6 +384,43 @@ class EventStreamDelegate @Inject constructor( } } + // --- Process roster updates --- + // + // Durable (Room), not an overlay: unlike reactions/typing, a roster change mutates the + // chat list / membership itself, so it has to land in the same store SyncFeedRequested + // and getGroupChatFeed hydrate from. The version guard lives only in memory + // (`rosterVersions`) rather than a Room column — see the field's doc comment — so a + // process restart re-applies the first roster update it sees per chat, which is safe: + // the same upsert/delete calls below are already idempotent. + + for (rosterUpdate in update.rosterUpdates) { + val incomingVersion = rosterUpdate.rosterSummary.version + val trackedVersion = rosterVersions[chatId] ?: 0L + if (incomingVersion <= trackedVersion) { + continue + } + rosterVersions[chatId] = incomingVersion + + when (rosterUpdate) { + is RosterUpdate.MemberJoined -> { + memberDataSource.upsert(chatId, listOf(rosterUpdate.member)) + val metadata = rosterUpdate.metadata + if (metadata != null) { + metadataDataSource.upsert(metadata) + memberDataSource.upsert(chatId, metadata.members) + } + } + is RosterUpdate.MemberLeft -> { + if (rosterUpdate.userId == userManager.accountId) { + metadataDataSource.delete(chatId) + memberDataSource.deleteForChat(chatId) + } else { + memberDataSource.removeMember(chatId, rosterUpdate.userId) + } + } + } + } + // --- Eagerly update token balance + count receipts for analytics --- val selfId = userManager.accountId diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt index c9757aec7..dd20ea184 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt @@ -16,7 +16,13 @@ import com.flipcash.services.models.chat.ChatMutation import com.flipcash.services.models.chat.ChatUpdate import com.flipcash.services.models.chat.Emoji import com.flipcash.services.models.chat.MessageContent +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.ReactionUpdate +import com.flipcash.services.models.chat.RosterSummary +import com.flipcash.services.models.chat.RosterUpdate +import com.flipcash.services.models.UserProfile import com.flipcash.shared.chat.internal.ChatIdGenerator import com.flipcash.shared.chat.internal.ChatStateHolder import com.flipcash.shared.chat.internal.RealChatCoordinator @@ -60,6 +66,7 @@ class ChatCoordinatorEventsTest { private lateinit var metadataDataSource: ChatMetadataDataSource private lateinit var messageDataSource: ChatMessageDataSource + private lateinit var memberDataSource: ChatMemberDataSource private lateinit var coordinator: RealChatCoordinator private lateinit var testDispatchers: TestDispatchers @@ -77,7 +84,7 @@ class ChatCoordinatorEventsTest { metadataDataSource = mockk(relaxed = true) messageDataSource = mockk(relaxed = true) - val memberDataSource = mockk(relaxed = true) + memberDataSource = mockk(relaxed = true) val messagingController = mockk(relaxed = true) testDispatchers = TestDispatchers(TestCoroutineScheduler()) @@ -157,6 +164,21 @@ class ChatCoordinatorEventsTest { mutations = listOf(ChatMutation.MessageSent(message)), ) + private fun chatMember(userId: List) = ChatMember( + userId = userId, + userProfile = UserProfile.Empty, + pointers = emptyList(), + ) + + private fun chatMetadata(rosterVersion: Long) = ChatMetadata( + chatId = chatId, + type = ChatType.GROUP, + members = listOf(chatMember(selfId)), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(1000), + rosterSummary = RosterSummary(memberCount = 1, version = rosterVersion), + ) + private suspend fun triggerCollection() { coordinator.onUserLoggedIn(mockk(relaxed = true)) } @@ -445,6 +467,139 @@ class ChatCoordinatorEventsTest { // endregion + // region Roster updates + + @Test + fun `roster update with version not greater than tracked is dropped`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + + // First update establishes version 2. + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + rosterUpdates = listOf( + RosterUpdate.MemberJoined( + rosterSummary = RosterSummary(memberCount = 2, version = 2), + member = chatMember(otherId), + metadata = null, + ), + ), + )) + advanceTimeBy(500.milliseconds) + runCurrent() + + coVerify(exactly = 1) { memberDataSource.upsert(chatId, listOf(chatMember(otherId))) } + + // Stale update at version 1 (<= tracked version 2) must be dropped. + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + rosterUpdates = listOf( + RosterUpdate.MemberJoined( + rosterSummary = RosterSummary(memberCount = 3, version = 1), + member = chatMember(otherId), + metadata = null, + ), + ), + )) + advanceTimeBy(500.milliseconds) + runCurrent() + + // Still only the one upsert from the first, accepted update. + coVerify(exactly = 1) { memberDataSource.upsert(chatId, listOf(chatMember(otherId))) } + coordinator.teardown() + } + + @Test + fun `recipient join inserts chat metadata and members`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + + val metadata = chatMetadata(rosterVersion = 1) + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + rosterUpdates = listOf( + RosterUpdate.MemberJoined( + rosterSummary = metadata.rosterSummary, + member = chatMember(selfId), + metadata = metadata, + ), + ), + )) + advanceTimeBy(500.milliseconds) + runCurrent() + + coVerify { memberDataSource.upsert(chatId, listOf(chatMember(selfId))) } + coVerify { metadataDataSource.upsert(metadata) } + coVerify { memberDataSource.upsert(chatId, metadata.members) } + coordinator.teardown() + } + + @Test + fun `non-recipient join upserts member only`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + rosterUpdates = listOf( + RosterUpdate.MemberJoined( + rosterSummary = RosterSummary(memberCount = 2, version = 1), + member = chatMember(otherId), + metadata = null, + ), + ), + )) + advanceTimeBy(500.milliseconds) + runCurrent() + + coVerify { memberDataSource.upsert(chatId, listOf(chatMember(otherId))) } + coVerify(exactly = 0) { metadataDataSource.upsert(any()) } + coordinator.teardown() + } + + @Test + fun `recipient leave removes chat metadata and members`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + rosterUpdates = listOf( + RosterUpdate.MemberLeft( + rosterSummary = RosterSummary(memberCount = 0, version = 1), + userId = selfId, + ), + ), + )) + advanceTimeBy(500.milliseconds) + runCurrent() + + coVerify { metadataDataSource.delete(chatId) } + coVerify { memberDataSource.deleteForChat(chatId) } + coVerify(exactly = 0) { memberDataSource.removeMember(chatId, selfId) } + coordinator.teardown() + } + + @Test + fun `non-recipient leave removes member row only`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + rosterUpdates = listOf( + RosterUpdate.MemberLeft( + rosterSummary = RosterSummary(memberCount = 1, version = 1), + userId = otherId, + ), + ), + )) + advanceTimeBy(500.milliseconds) + runCurrent() + + coVerify { memberDataSource.removeMember(chatId, otherId) } + coVerify(exactly = 0) { metadataDataSource.delete(any()) } + coVerify(exactly = 0) { memberDataSource.deleteForChat(any()) } + coordinator.teardown() + } + + // endregion + // region Session lifecycle @Test diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt index 45f52955e..01af038c1 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt @@ -107,6 +107,9 @@ interface ChatMemberDao { @Query("DELETE FROM chat_members WHERE chat_id_hex = :chatIdHex") suspend fun deleteForChat(chatIdHex: String) + @Query("DELETE FROM chat_members WHERE chat_id_hex = :chatIdHex AND user_id_hex = :userIdHex") + suspend fun deleteMember(chatIdHex: String, userIdHex: String) + /** Drops the members of [chatIdHex] that are no longer in [keepUserIdHexes]. */ @Query( "DELETE FROM chat_members WHERE chat_id_hex = :chatIdHex " + 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 7841c95e7..12ff713fc 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 @@ -101,6 +101,9 @@ interface ChatMetadataDao { @Query("UPDATE chat_metadata SET is_hidden = :hidden WHERE chat_id_hex = :chatIdHex") suspend fun updateHidden(chatIdHex: String, hidden: Boolean) + @Query("DELETE FROM chat_metadata WHERE chat_id_hex = :chatIdHex") + suspend fun deleteById(chatIdHex: String) + @Query("DELETE FROM chat_metadata") suspend fun deleteAll() } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt index 089f609eb..9f24601e3 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt @@ -113,6 +113,10 @@ class ChatMemberDataSource @Inject constructor( db?.chatMemberDao()?.deleteForChat(mapper.chatIdHex(chatId)) } + suspend fun removeMember(chatId: ChatId, userId: ID) { + db?.chatMemberDao()?.deleteMember(mapper.chatIdHex(chatId), mapper.userIdHex(userId)) + } + suspend fun clear() { db?.chatMemberDao()?.deleteAll() } 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 7c1bec788..b4aca6275 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 @@ -73,6 +73,10 @@ class ChatMetadataDataSource @Inject constructor( suspend fun exists(chatId: ChatId): Boolean = db?.chatMetadataDao()?.getById(mapper.chatIdHex(chatId)) != null + suspend fun delete(chatId: ChatId) { + db?.chatMetadataDao()?.deleteById(mapper.chatIdHex(chatId)) + } + fun toMetadata( entity: ChatMetadataEntity, members: List, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b12d783dd..4a14781fd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,7 +69,7 @@ protovalidate-kt = "0.1.2" # 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.4.0" -flipcash2-client-protocol = "0.6.0" +flipcash2-client-protocol = "0.7.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 5e5861687..f2a1b1490 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 @@ -1,9 +1,11 @@ package com.flipcash.services.controllers import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.BlobId 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.ChatRules import com.flipcash.services.models.chat.ChatType import com.flipcash.services.repository.ChatRepository import com.flipcash.services.user.UserManager @@ -31,4 +33,38 @@ class ChatController @Inject constructor( return repository.getDmChatFeed(owner, queryOptions, chatType) } + + suspend fun getGroupChatFeed( + queryOptions: QueryOptions = QueryOptions(), + ): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.getGroupChatFeed(owner, queryOptions) + } + + suspend fun startChat( + title: String, + picture: BlobId? = null, + rules: ChatRules? = null, + ): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.startChat(owner, title, picture, rules) + } + + suspend fun joinChat(chatId: ChatId): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.joinChat(owner, chatId) + } + + suspend fun leaveChat(chatId: ChatId): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.leaveChat(owner, chatId) + } } 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 26a819131..ceb2f9221 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 @@ -5,11 +5,15 @@ import com.codeinc.flipcash.gen.chat.v1.ChatService as RpcChatService import com.codeinc.flipcash.gen.chat.v1.validate import com.flipcash.services.internal.annotations.FlipcashManagedChannel import com.flipcash.services.internal.network.extensions.asChatId +import com.flipcash.services.internal.network.extensions.asProtoBlobId import com.flipcash.services.internal.network.extensions.asProtoChatType +import com.flipcash.services.internal.network.extensions.asProtoRules import com.flipcash.services.internal.network.extensions.asQueryOptions import com.flipcash.services.internal.network.extensions.authenticate import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.BlobId import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatType import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.core.GrpcApi @@ -62,4 +66,78 @@ internal class ChatApi @Inject constructor( api.getDmChatFeed(request) } } + + suspend fun getGroupChatFeed( + owner: KeyPair, + queryOptions: QueryOptions, + ): RpcChatService.GetGroupChatFeedResponse { + val request = RpcChatService.GetGroupChatFeedRequest.newBuilder() + .setQueryOptions(queryOptions.asQueryOptions()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getGroupChatFeed(request) + } + } + + suspend fun startChat( + owner: KeyPair, + title: String, + picture: BlobId?, + rules: ChatRules?, + ): RpcChatService.StartChatResponse { + val groupParameters = RpcChatService.StartChatRequest.GroupChatParameters.newBuilder() + .setTitle(title) + .apply { + picture?.let { setPicture(it.asProtoBlobId()) } + rules?.let { setRules(it.asProtoRules()) } + } + .build() + + val request = RpcChatService.StartChatRequest.newBuilder() + .setGroup(groupParameters) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.startChat(request) + } + } + + suspend fun joinChat( + owner: KeyPair, + chatId: ChatId, + ): RpcChatService.JoinChatResponse { + val request = RpcChatService.JoinChatRequest.newBuilder() + .setChatId(chatId.asChatId()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.joinChat(request) + } + } + + suspend fun leaveChat( + owner: KeyPair, + chatId: ChatId, + ): RpcChatService.LeaveChatResponse { + val request = RpcChatService.LeaveChatRequest.newBuilder() + .setChatId(chatId.asChatId()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.leaveChat(request) + } + } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt index ff36aadb5..298136d29 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt @@ -7,7 +7,10 @@ import com.codeinc.flipcash.gen.thirdparty.v1.Model as ThirdPartyModels import com.flipcash.services.models.PagingToken import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.SocialAccountLinkRequest +import com.flipcash.services.models.chat.BlobId import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ClientMessageId import com.flipcash.services.models.chat.MessageContent @@ -228,4 +231,51 @@ internal fun TypingState.asTypingState(): MessagingModel.IsTypingNotification.St TypingState.TYPING_TIMED_OUT -> MessagingModel.IsTypingNotification.State.TYPING_TIMED_OUT TypingState.UNKNOWN -> MessagingModel.IsTypingNotification.State.UNKNOWN_TYPING_STATE } -} \ No newline at end of file +} + + +// -- Chat blob id -- + +internal fun BlobId.asProtoBlobId(): com.codeinc.flipcash.gen.blob.v1.Model.BlobId { + return com.codeinc.flipcash.gen.blob.v1.Model.BlobId.newBuilder() + .setValue(bytes.toByteString()) + .build() +} + +// -- Chat participation rules -- + +internal fun ChatRules.asProtoRules(): ChatModel.Rules { + return ChatModel.Rules.newBuilder() + .addAllListener(listener.map { it.asListenerRules() }) + .addAllSpeaker(speaker.map { it.asSpeakerRules() }) + .build() +} + +internal fun ChatRuleRequirement.asListenerRules(): ChatModel.ListenerRules { + return ChatModel.ListenerRules.newBuilder() + .apply { + when (this@asListenerRules) { + is ChatRuleRequirement.MinimumBalance -> setMinimumBalance(asMinimumBalanceRequirement()) + ChatRuleRequirement.Staff -> setStaff(ChatModel.StaffRequirement.getDefaultInstance()) + } + } + .build() +} + +internal fun ChatRuleRequirement.asSpeakerRules(): ChatModel.SpeakerRules { + return ChatModel.SpeakerRules.newBuilder() + .apply { + when (this@asSpeakerRules) { + is ChatRuleRequirement.MinimumBalance -> setMinimumBalance(asMinimumBalanceRequirement()) + ChatRuleRequirement.Staff -> setStaff(ChatModel.StaffRequirement.getDefaultInstance()) + } + } + .build() +} + +internal fun ChatRuleRequirement.MinimumBalance.asMinimumBalanceRequirement(): ChatModel.MinimumBalanceRequirement { + return ChatModel.MinimumBalanceRequirement.newBuilder() + .setAmount(amount.asFiatPaymentAmount()) + .addAllMints(mints.map { it.asPublicKey() }) + .build() +} 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 a3c6c2589..9fe0a4eb0 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 @@ -48,6 +48,7 @@ import com.flipcash.services.models.chat.PointerType import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.RosterSummary +import com.flipcash.services.models.chat.RosterUpdate import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.ReactionUpdate import com.flipcash.services.models.chat.Reactor @@ -377,28 +378,7 @@ internal fun ChatModel.Metadata.toChatMetadata(): ChatMetadata { return ChatMetadata( chatId = chatId.toChatId(), type = type.toChatType(), - members = membersList.map { member -> - ChatMember( - userId = member.userId.toId(), - userProfile = with (member.userProfile) { - UserProfile( - displayName = displayName, - socialAccounts = emptyList(), - phoneNumber = phoneNumber.value.takeIf { it.isNotEmpty() }?.let { VerifiableContactMethod(it, verified = true) }, - email = emailAddress.value.takeIf { it.isNotEmpty() }?.let { VerifiableContactMethod(it, verified = true) }, - profilePicture = if (hasProfilePicture()) profilePicture.toMediaItem() else null, - // Falls back to the member's own id: the server sets it on the member but - // usually not again inside the nested profile, and this profile is by - // definition that member's. Dropping it here leaves callers unable to name - // the profile that authorizes re-minting the picture's download URL, so the - // avatar can never recover once the stored URL expires. - userId = if (hasUserId()) userId.toId() else member.userId.toId(), - username = if (hasUsername()) username.value else null, - ) - }, - pointers = member.pointersList.map { it.toPointer() }, - ) - }, + members = membersList.map { it.toChatMember() }, lastMessage = if (hasLastMessage()) lastMessage.toChatMessage() else null, lastActivity = Instant.fromEpochSeconds(lastActivity.seconds, lastActivity.nanos), latestEventSequence = latestEventSequence, @@ -410,6 +390,29 @@ internal fun ChatModel.Metadata.toChatMetadata(): ChatMetadata { ) } +internal fun ChatModel.Member.toChatMember(): ChatMember { + return ChatMember( + userId = userId.toId(), + userProfile = with (userProfile) { + UserProfile( + displayName = displayName, + socialAccounts = emptyList(), + phoneNumber = phoneNumber.value.takeIf { it.isNotEmpty() }?.let { VerifiableContactMethod(it, verified = true) }, + email = emailAddress.value.takeIf { it.isNotEmpty() }?.let { VerifiableContactMethod(it, verified = true) }, + profilePicture = if (hasProfilePicture()) profilePicture.toMediaItem() else null, + // Falls back to the member's own id: the server sets it on the member but + // usually not again inside the nested profile, and this profile is by + // definition that member's. Dropping it here leaves callers unable to name + // the profile that authorizes re-minting the picture's download URL, so the + // avatar can never recover once the stored URL expires. + userId = if (hasUserId()) userId.toId() else this@toChatMember.userId.toId(), + username = if (hasUsername()) username.value else null, + ) + }, + pointers = pointersList.map { it.toPointer() }, + ) +} + // -- Chat roster summary -- internal fun ChatModel.RosterSummary.toRosterSummary(): RosterSummary { @@ -455,6 +458,30 @@ internal fun ChatModel.MinimumBalanceRequirement.toRuleRequirement(): ChatRuleRe ) } +// -- Chat roster updates -- + +// An unset/unrecognized kind (a future oneof arm an already-shipped client doesn't know about) +// returns null rather than a fabricated MemberLeft: the caller drops it via mapNotNull before it +// reaches EventStreamDelegate, so it can never advance the in-memory roster-version tracker. +// RosterSummary's own contract is that a version a client cannot reconcile gets caught up by a +// roster refetch, not silently recorded as applied. +internal fun ChatModel.RosterUpdate.toRosterUpdateOrNull( + metadataMapper: (ChatModel.Metadata) -> ChatMetadata = { it.toChatMetadata() }, +): RosterUpdate? { + return when (kindCase) { + ChatModel.RosterUpdate.KindCase.MEMBER_JOINED -> RosterUpdate.MemberJoined( + rosterSummary = rosterSummary.toRosterSummary(), + member = memberJoined.member.toChatMember(), + metadata = if (memberJoined.hasMetadata()) metadataMapper(memberJoined.metadata) else null, + ) + ChatModel.RosterUpdate.KindCase.MEMBER_LEFT -> RosterUpdate.MemberLeft( + rosterSummary = rosterSummary.toRosterSummary(), + userId = memberLeft.userId.toId(), + ) + else -> null + } +} + // -- EventModel.ChatUpdate -- internal fun EventModel.ChatUpdate.toChatUpdate( @@ -467,6 +494,7 @@ internal fun EventModel.ChatUpdate.toChatUpdate( metadataUpdates = metadataUpdatesList.map { it.toMetadataUpdate(metadataMapper) }, events = if (hasEvents()) events.eventsList.map { it.toChatEvent() } else emptyList(), reactionUpdates = if (hasReactionUpdates()) reactionUpdates.reactionUpdatesList.map { it.toReactionUpdate() } else emptyList(), + rosterUpdates = if (hasRosterUpdates()) rosterUpdates.rosterUpdatesList.mapNotNull { it.toRosterUpdateOrNull(metadataMapper) } else emptyList(), ) } 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 72e28ba45..8618753a5 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 @@ -5,8 +5,15 @@ import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel import com.flipcash.services.internal.network.api.ChatApi import com.flipcash.services.models.GetChatError import com.flipcash.services.models.GetDmChatFeedError +import com.flipcash.services.models.GetGroupChatFeedError +import com.flipcash.services.models.JoinChatError +import com.flipcash.services.models.LeaveChatError +import com.flipcash.services.models.ModerationResult import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.StartChatError +import com.flipcash.services.models.chat.BlobId import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatType import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.extensions.foldWithSuppression @@ -60,4 +67,108 @@ internal class ChatService @Inject constructor( } ) } + + suspend fun getGroupChatFeed( + owner: KeyPair, + queryOptions: QueryOptions, + ): Result { + return runCatching { + api.getGroupChatFeed(owner, queryOptions) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.GetGroupChatFeedResponse.Result.OK -> Result.success(response) + RpcChatService.GetGroupChatFeedResponse.Result.DENIED -> Result.failure(GetGroupChatFeedError.Denied()) + RpcChatService.GetGroupChatFeedResponse.Result.NOT_FOUND -> Result.failure(GetGroupChatFeedError.NotFound()) + RpcChatService.GetGroupChatFeedResponse.Result.UNRECOGNIZED -> Result.failure(GetGroupChatFeedError.Unrecognized()) + else -> Result.failure(GetGroupChatFeedError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetGroupChatFeedError.Other(cause = it) }) + } + ) + } + + /** + * Returns the raw response rather than just the [ChatModel.Metadata]: on TITLE_MODERATED the + * caller needs both the failure and [RpcChatService.StartChatResponse.getFlaggedCategory] to + * build [StartChatError.TitleModerated], so the category is folded in here rather than + * surfaced twice (once on the response, once on the domain result). + */ + suspend fun startChat( + owner: KeyPair, + title: String, + picture: BlobId?, + rules: ChatRules?, + ): Result { + return runCatching { + api.startChat(owner, title, picture, rules) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.StartChatResponse.Result.OK -> Result.success(response.chat) + RpcChatService.StartChatResponse.Result.DENIED -> Result.failure(StartChatError.Denied()) + RpcChatService.StartChatResponse.Result.TITLE_MODERATED -> Result.failure( + StartChatError.TitleModerated( + flaggedCategory = ModerationResult.FlaggedCategory.valueOf(response.flaggedCategory.name) + ) + ) + RpcChatService.StartChatResponse.Result.PICTURE_BLOB_NOT_ACCEPTED -> Result.failure(StartChatError.PictureBlobNotAccepted()) + RpcChatService.StartChatResponse.Result.INVALID_RULES -> Result.failure(StartChatError.InvalidRules()) + RpcChatService.StartChatResponse.Result.RULES_NOT_SATISFIED -> Result.failure(StartChatError.RulesNotSatisfied()) + RpcChatService.StartChatResponse.Result.UNRECOGNIZED -> Result.failure(StartChatError.Unrecognized()) + else -> Result.failure(StartChatError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { StartChatError.Other(cause = it) }) + } + ) + } + + suspend fun joinChat( + owner: KeyPair, + chatId: ChatId, + ): Result { + return runCatching { + api.joinChat(owner, chatId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.JoinChatResponse.Result.OK -> Result.success(response.chat) + RpcChatService.JoinChatResponse.Result.DENIED -> Result.failure(JoinChatError.Denied()) + RpcChatService.JoinChatResponse.Result.NOT_FOUND -> Result.failure(JoinChatError.NotFound()) + RpcChatService.JoinChatResponse.Result.RULES_NOT_SATISFIED -> Result.failure(JoinChatError.RulesNotSatisfied()) + RpcChatService.JoinChatResponse.Result.UNRECOGNIZED -> Result.failure(JoinChatError.Unrecognized()) + else -> Result.failure(JoinChatError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { JoinChatError.Other(cause = it) }) + } + ) + } + + suspend fun leaveChat( + owner: KeyPair, + chatId: ChatId, + ): Result { + return runCatching { + api.leaveChat(owner, chatId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.LeaveChatResponse.Result.OK -> Result.success(Unit) + RpcChatService.LeaveChatResponse.Result.DENIED -> Result.failure(LeaveChatError.Denied()) + RpcChatService.LeaveChatResponse.Result.NOT_FOUND -> Result.failure(LeaveChatError.NotFound()) + RpcChatService.LeaveChatResponse.Result.UNRECOGNIZED -> Result.failure(LeaveChatError.Unrecognized()) + else -> Result.failure(LeaveChatError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { LeaveChatError.Other(cause = it) }) + } + ) + } } 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 0f5f039d3..d172a3d33 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 @@ -4,9 +4,11 @@ import com.flipcash.services.internal.domain.ChatMetadataMapper import com.flipcash.services.internal.network.extensions.toPagingToken import com.flipcash.services.internal.network.services.ChatService import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.BlobId 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.ChatRules import com.flipcash.services.models.chat.ChatType import com.flipcash.services.repository.ChatRepository import com.getcode.ed25519.Ed25519.KeyPair @@ -36,4 +38,39 @@ internal class InternalChatRepository( hasMore = response.hasMore, ) } + + override suspend fun getGroupChatFeed( + owner: KeyPair, + queryOptions: QueryOptions, + ): Result = service.getGroupChatFeed(owner, queryOptions) + .onFailure { ErrorUtils.handleError(it) } + .map { response -> + ChatFeedPage( + chats = response.chatsList.map { mapper.map(it) }, + pagingToken = if (response.hasPagingToken()) response.pagingToken.toPagingToken() else null, + hasMore = response.hasMore, + ) + } + + override suspend fun startChat( + owner: KeyPair, + title: String, + picture: BlobId?, + rules: ChatRules?, + ): Result = service.startChat(owner, title, picture, rules) + .onFailure { ErrorUtils.handleError(it) } + .map { mapper.map(it) } + + override suspend fun joinChat( + owner: KeyPair, + chatId: ChatId, + ): Result = service.joinChat(owner, chatId) + .onFailure { ErrorUtils.handleError(it) } + .map { mapper.map(it) } + + override suspend fun leaveChat( + owner: KeyPair, + chatId: ChatId, + ): Result = service.leaveChat(owner, chatId) + .onFailure { ErrorUtils.handleError(it) } } 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 a88786471..862587be1 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 @@ -360,6 +360,50 @@ sealed class GetDmChatFeedError( data class Other(override val cause: Throwable? = null) : GetDmChatFeedError(message = cause?.message, cause = cause), NotifiableError } +sealed class GetGroupChatFeedError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetGroupChatFeedError("Denied") + class NotFound : GetGroupChatFeedError("Not found") + class Unrecognized : GetGroupChatFeedError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetGroupChatFeedError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class StartChatError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : StartChatError("Denied") + data class TitleModerated(val flaggedCategory: ModerationResult.FlaggedCategory) : StartChatError("Title moderated") + class PictureBlobNotAccepted : StartChatError("Picture blob not accepted") + class InvalidRules : StartChatError("Invalid rules") + class RulesNotSatisfied : StartChatError("Rules not satisfied") + class Unrecognized : StartChatError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : StartChatError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class JoinChatError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : JoinChatError("Denied") + class NotFound : JoinChatError("Not found") + class RulesNotSatisfied : JoinChatError("Rules not satisfied") + class Unrecognized : JoinChatError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : JoinChatError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class LeaveChatError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : LeaveChatError("Denied") + class NotFound : LeaveChatError("Not found") + class Unrecognized : LeaveChatError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : LeaveChatError(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/ChatUpdate.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt index e20312423..70350b5be 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt @@ -7,4 +7,5 @@ data class ChatUpdate( val metadataUpdates: List = emptyList(), val events: List = emptyList(), val reactionUpdates: List = emptyList(), + val rosterUpdates: List = emptyList(), ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/RosterUpdate.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/RosterUpdate.kt new file mode 100644 index 000000000..59a978bb7 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/RosterUpdate.kt @@ -0,0 +1,50 @@ +package com.flipcash.services.models.chat + +import com.getcode.opencode.model.core.ID + +/** + * A best-effort, real-time change to a chat's roster — a member joining (e.g. via + * `Chat.JoinChat`, or as the first member via `Chat.StartChat`) or leaving (e.g. via + * `Chat.LeaveChat`). Delivered to the chat's members, including the affected user's other + * devices. + * + * Roster changes are a convergent overlay, so they ride the event stream outside the + * gap-detected event log. Apply by [rosterSummary]'s version as described on [RosterSummary]: + * a greater version than the one held means the cached member list is stale and this update + * should be applied; a lesser-or-equal version should be dropped, so delivery order doesn't + * matter. A missed update is not caught up via `GetDelta` but reconciled by refetching the + * roster when a client observes a version it cannot reconcile. + */ +sealed interface RosterUpdate { + /** The chat's roster summary after this change, applied by version as described above. */ + val rosterSummary: RosterSummary + + /** + * A member joined the chat. + * + * When [metadata] is set, [member] IS the recipient: the recipient has just become a + * member of the chat and should insert [metadata] into their chat list. Otherwise this is + * another member joining a chat the recipient is already in. + */ + data class MemberJoined( + override val rosterSummary: RosterSummary, + // The member that joined, with their profile hydrated, so a client can update its + // cached member list without a refetch. + val member: ChatMember, + // Set only when [member] is the recipient; null for every other member of the chat. + // The recipient inserts the chat from this snapshot, then applies the enclosing + // [rosterSummary] by version like any other roster update. + val metadata: ChatMetadata?, + ) : RosterUpdate + + /** + * A member left the chat. + * + * When [userId] is the recipient's own id, the recipient is no longer a member of the chat + * and should remove it from their chat list. + */ + data class MemberLeft( + override val rosterSummary: RosterSummary, + val userId: ID, + ) : RosterUpdate +} 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 d26a5f3a9..76c0c9d96 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 @@ -1,9 +1,11 @@ package com.flipcash.services.repository import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.BlobId 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.ChatRules import com.flipcash.services.models.chat.ChatType import com.getcode.ed25519.Ed25519.KeyPair @@ -18,4 +20,26 @@ interface ChatRepository { queryOptions: QueryOptions, chatType: ChatType, ): Result + + suspend fun getGroupChatFeed( + owner: KeyPair, + queryOptions: QueryOptions, + ): Result + + suspend fun startChat( + owner: KeyPair, + title: String, + picture: BlobId?, + rules: ChatRules?, + ): Result + + suspend fun joinChat( + owner: KeyPair, + chatId: ChatId, + ): Result + + suspend fun leaveChat( + owner: KeyPair, + chatId: ChatId, + ): Result } 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 5de99c3e3..1a9b7c624 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 @@ -4,6 +4,8 @@ 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.ChatMetadata +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatType import com.flipcash.services.repository.ChatRepository import com.flipcash.services.user.UserManager @@ -162,6 +164,175 @@ class ChatControllerTest { // endregion + // region getGroupChatFeed + + @Test + fun `getGroupChatFeed fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + + val result = controller.getGroupChatFeed() + + assertTrue(result.isFailure) + } + + @Test + fun `getGroupChatFeed uses default QueryOptions when none provided`() = runTest { + stubOwner() + repository.getGroupChatFeedResult = Result.success(ChatFeedPage(emptyList(), null, false)) + + controller.getGroupChatFeed() + + assertEquals(QueryOptions(), repository.lastGroupQueryOptions) + } + + @Test + fun `getGroupChatFeed returns page from the repository`() = runTest { + stubOwner() + val chat = stubMetadata(ChatId(ByteArray(32) { 3 })) + val page = ChatFeedPage(chats = listOf(chat), pagingToken = null, hasMore = false) + repository.getGroupChatFeedResult = Result.success(page) + + val result = controller.getGroupChatFeed() + + assertSame(page, result.getOrThrow()) + } + + @Test + fun `getGroupChatFeed surfaces repository failures without swallowing`() = runTest { + stubOwner() + val cause = RuntimeException("server error") + repository.getGroupChatFeedResult = Result.failure(cause) + + val result = controller.getGroupChatFeed() + + assertTrue(result.isFailure) + assertSame(cause, result.exceptionOrNull()) + } + + // endregion + + // region startChat + + @Test + fun `startChat fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + + val result = controller.startChat(title = "Trip planning") + + assertTrue(result.isFailure) + } + + @Test + fun `startChat forwards title, picture and rules to the repository`() = runTest { + stubOwner() + val picture = BlobId(byteArrayOf(1, 2, 3)) + val rules = ChatRules(listener = emptyList(), speaker = emptyList()) + repository.startChatResult = Result.success(stubMetadata()) + + controller.startChat(title = "Trip planning", picture = picture, rules = rules) + + assertEquals("Trip planning", repository.lastStartChatTitle) + assertEquals(picture, repository.lastStartChatPicture) + assertSame(rules, repository.lastStartChatRules) + } + + @Test + fun `startChat returns the created chat metadata`() = runTest { + stubOwner() + val expected = stubMetadata() + repository.startChatResult = Result.success(expected) + + val result = controller.startChat(title = "Trip planning") + + assertSame(expected, result.getOrThrow()) + } + + @Test + fun `startChat surfaces repository failures without swallowing`() = runTest { + stubOwner() + val cause = RuntimeException("title moderated") + repository.startChatResult = Result.failure(cause) + + val result = controller.startChat(title = "Trip planning") + + assertTrue(result.isFailure) + assertSame(cause, result.exceptionOrNull()) + } + + // endregion + + // region joinChat + + @Test + fun `joinChat fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + + val result = controller.joinChat(ChatId(ByteArray(32))) + + assertTrue(result.isFailure) + } + + @Test + fun `joinChat forwards the chatId to the repository`() = runTest { + stubOwner() + val chatId = ChatId(ByteArray(32) { 0x11 }) + repository.joinChatResult = Result.success(stubMetadata(chatId)) + + controller.joinChat(chatId) + + assertEquals(chatId, repository.lastJoinChatId) + } + + @Test + fun `joinChat surfaces repository failures without swallowing`() = runTest { + stubOwner() + val cause = RuntimeException("denied") + repository.joinChatResult = Result.failure(cause) + + val result = controller.joinChat(ChatId(ByteArray(32))) + + assertTrue(result.isFailure) + assertSame(cause, result.exceptionOrNull()) + } + + // endregion + + // region leaveChat + + @Test + fun `leaveChat fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + + val result = controller.leaveChat(ChatId(ByteArray(32))) + + assertTrue(result.isFailure) + } + + @Test + fun `leaveChat forwards the chatId to the repository`() = runTest { + stubOwner() + val chatId = ChatId(ByteArray(32) { 0x22 }) + repository.leaveChatResult = Result.success(Unit) + + controller.leaveChat(chatId) + + assertEquals(chatId, repository.lastLeaveChatId) + } + + @Test + fun `leaveChat surfaces repository failures without swallowing`() = runTest { + stubOwner() + val cause = RuntimeException("not a member") + repository.leaveChatResult = Result.failure(cause) + + val result = controller.leaveChat(ChatId(ByteArray(32))) + + assertTrue(result.isFailure) + assertSame(cause, result.exceptionOrNull()) + } + + // endregion + // region helpers private fun stubMetadata(chatId: ChatId = ChatId(ByteArray(32))) = ChatMetadata( @@ -180,9 +351,20 @@ class ChatControllerTest { private class FakeChatRepository : ChatRepository { var getChatResult: Result = Result.failure(RuntimeException("not configured")) var getDmChatFeedResult: Result = Result.failure(RuntimeException("not configured")) + var getGroupChatFeedResult: Result = Result.failure(RuntimeException("not configured")) + var startChatResult: Result = Result.failure(RuntimeException("not configured")) + var joinChatResult: Result = Result.failure(RuntimeException("not configured")) + var leaveChatResult: Result = Result.failure(RuntimeException("not configured")) + var lastChatId: ChatId? = null var lastQueryOptions: QueryOptions? = null var lastChatType: ChatType? = null + var lastGroupQueryOptions: QueryOptions? = null + var lastStartChatTitle: String? = null + var lastStartChatPicture: BlobId? = null + var lastStartChatRules: ChatRules? = null + var lastJoinChatId: ChatId? = null + var lastLeaveChatId: ChatId? = null override suspend fun getChat(owner: Ed25519.KeyPair, chatId: ChatId): Result { lastChatId = chatId @@ -198,6 +380,36 @@ private class FakeChatRepository : ChatRepository { lastChatType = chatType return getDmChatFeedResult } + + override suspend fun getGroupChatFeed( + owner: Ed25519.KeyPair, + queryOptions: QueryOptions, + ): Result { + lastGroupQueryOptions = queryOptions + return getGroupChatFeedResult + } + + override suspend fun startChat( + owner: Ed25519.KeyPair, + title: String, + picture: BlobId?, + rules: ChatRules?, + ): Result { + lastStartChatTitle = title + lastStartChatPicture = picture + lastStartChatRules = rules + return startChatResult + } + + override suspend fun joinChat(owner: Ed25519.KeyPair, chatId: ChatId): Result { + lastJoinChatId = chatId + return joinChatResult + } + + override suspend fun leaveChat(owner: Ed25519.KeyPair, chatId: ChatId): Result { + lastLeaveChatId = chatId + return leaveChatResult + } } // endregion diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/RosterUpdateExtensionTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/RosterUpdateExtensionTest.kt new file mode 100644 index 000000000..bb444dca9 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/RosterUpdateExtensionTest.kt @@ -0,0 +1,57 @@ +package com.flipcash.services.internal.network.extensions + +import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel +import com.codeinc.flipcash.gen.common.v1.Common +import com.codeinc.flipcash.gen.events.v1.Model as EventModel +import com.google.protobuf.ByteString +import org.junit.Test +import kotlin.test.assertTrue + +/** + * An unset/unrecognized RosterUpdate.kind is how a future oneof arm looks to an already-shipped + * client. It must be dropped rather than turned into a fabricated update: EventStreamDelegate + * advances its in-memory roster-version tracker for every entry it sees, so a fabricated update + * would record a version as applied without actually applying anything, and the real change + * would never be caught up by the refetch RosterSummary's contract calls for. + */ +class RosterUpdateExtensionTest { + + private fun chatId(): Common.ChatId = + Common.ChatId.newBuilder() + .setValue(ByteString.copyFrom(ByteArray(32) { 1 })) + .build() + + private fun rosterSummary(version: Long): ChatModel.RosterSummary = + ChatModel.RosterSummary.newBuilder() + .setMemberCount(1) + .setVersion(version) + .build() + + @Test + fun `roster update with no kind set maps to null`() { + val unset = ChatModel.RosterUpdate.newBuilder() + .setRosterSummary(rosterSummary(version = 5)) + .build() + + assertTrue(unset.toRosterUpdateOrNull() == null) + } + + @Test + fun `chat update drops a roster update with no kind set instead of fabricating one`() { + val unset = ChatModel.RosterUpdate.newBuilder() + .setRosterSummary(rosterSummary(version = 5)) + .build() + + val chatUpdate = EventModel.ChatUpdate.newBuilder() + .setChat(chatId()) + .setRosterUpdates( + ChatModel.RosterUpdateBatch.newBuilder() + .addRosterUpdates(unset) + .build() + ) + .build() + .toChatUpdate() + + assertTrue(chatUpdate.rosterUpdates.isEmpty()) + } +}