diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/client/FeedsClientImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/client/FeedsClientImpl.kt index 69259b749..2b2300614 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/client/FeedsClientImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/client/FeedsClientImpl.kt @@ -196,7 +196,6 @@ internal class FeedsClientImpl( commentsRepository = commentsRepository, feedsRepository = feedsRepository, pollsRepository = pollsRepository, - socketSubscriptionManager = feedsEventsSubscriptionManager, subscriptionManager = stateEventsSubscriptionManager, feedWatchHandler = feedWatchHandler, ) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepository.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepository.kt index 525da07c8..4c517e4e4 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepository.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepository.kt @@ -76,7 +76,7 @@ internal interface FeedsRepository { suspend fun follow(request: FollowRequest): Result - suspend fun unfollow(source: FeedId, target: FeedId): Result + suspend fun unfollow(source: FeedId, target: FeedId): Result suspend fun acceptFollow(request: AcceptFollowRequest): Result diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepositoryImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepositoryImpl.kt index a433b3207..70cda6bc2 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepositoryImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepositoryImpl.kt @@ -135,8 +135,8 @@ internal class FeedsRepositoryImpl(private val api: FeedsApi) : FeedsRepository api.follow(request).follow.toModel() } - override suspend fun unfollow(source: FeedId, target: FeedId): Result = runSafely { - api.unfollow(source = source.rawValue, target = target.rawValue) + override suspend fun unfollow(source: FeedId, target: FeedId): Result = runSafely { + api.unfollow(source = source.rawValue, target = target.rawValue).follow.toModel() } override suspend fun acceptFollow(request: AcceptFollowRequest): Result = diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityImpl.kt index 14cb138c0..16e9eff29 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityImpl.kt @@ -118,7 +118,9 @@ internal class ActivityImpl( ): Result { return commentsRepository .addComment(request = request, attachmentUploadProgress = attachmentUploadProgress) - .onSuccess { subscriptionManager.onEvent(StateUpdateEvent.CommentAdded(it)) } + .onSuccess { + subscriptionManager.onEvent(StateUpdateEvent.CommentAdded(fid.rawValue, it)) + } } override suspend fun addCommentsBatch( @@ -127,7 +129,9 @@ internal class ActivityImpl( ): Result> { return commentsRepository.addCommentsBatch(requests, attachmentUploadProgress).onSuccess { comments -> - comments.forEach { subscriptionManager.onEvent(StateUpdateEvent.CommentAdded(it)) } + comments.forEach { + subscriptionManager.onEvent(StateUpdateEvent.CommentAdded(fid.rawValue, it)) + } } } @@ -135,7 +139,7 @@ internal class ActivityImpl( return commentsRepository .deleteComment(commentId, hardDelete) .onSuccess { (comment, activity) -> - subscriptionManager.onEvent(StateUpdateEvent.CommentDeleted(comment)) + subscriptionManager.onEvent(StateUpdateEvent.CommentDeleted(fid.rawValue, comment)) subscriptionManager.onEvent( StateUpdateEvent.ActivityUpdated(fid.rawValue, activity) ) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityListStateImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityListStateImpl.kt index 1a6f580a9..7a2fea766 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityListStateImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityListStateImpl.kt @@ -82,8 +82,8 @@ internal class ActivityListStateImpl( } } - override fun onActivityRemoved(activity: ActivityData) { - _activities.update { current -> current.filter { it.id != activity.id } } + override fun onActivityRemoved(activityId: String) { + _activities.update { current -> current.filter { it.id != activityId } } } override fun onActivityUpdated(activity: ActivityData) { @@ -194,9 +194,9 @@ internal interface ActivityListStateUpdates { /** * Called when an activity is removed from the list. * - * @param activity The activity that was removed. + * @param activityId The ID of the activity that was removed. */ - fun onActivityRemoved(activity: ActivityData) + fun onActivityRemoved(activityId: String) /** * Called when an activity is updated in the list. diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FeedImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FeedImpl.kt index 55fc12014..e433dcb57 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FeedImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FeedImpl.kt @@ -38,9 +38,10 @@ import io.getstream.feeds.android.client.internal.repository.BookmarksRepository import io.getstream.feeds.android.client.internal.repository.CommentsRepository import io.getstream.feeds.android.client.internal.repository.FeedsRepository import io.getstream.feeds.android.client.internal.repository.PollsRepository +import io.getstream.feeds.android.client.internal.state.event.StateUpdateEvent import io.getstream.feeds.android.client.internal.state.event.handler.FeedEventHandler -import io.getstream.feeds.android.client.internal.subscribe.FeedsEventListener import io.getstream.feeds.android.client.internal.subscribe.StateUpdateEventListener +import io.getstream.feeds.android.client.internal.subscribe.onEvent import io.getstream.feeds.android.client.internal.utils.flatMap import io.getstream.feeds.android.network.models.AcceptFollowRequest import io.getstream.feeds.android.network.models.AddActivityRequest @@ -83,8 +84,6 @@ import io.getstream.feeds.android.network.models.UpdateFeedRequest * @property feedsRepository The [FeedsRepository] used to manage feed data and operations. * @property pollsRepository The [PollsRepository] used to manage polls in the feed. * @property subscriptionManager The manager for state update subscriptions. - * @param socketSubscriptionManager The [StreamSubscriptionManager] used to manage WebSocket - * subscriptions for feed events. */ internal class FeedImpl( private val query: FeedQuery, @@ -95,7 +94,6 @@ internal class FeedImpl( private val feedsRepository: FeedsRepository, private val pollsRepository: PollsRepository, private val subscriptionManager: StreamSubscriptionManager, - socketSubscriptionManager: StreamSubscriptionManager, private val feedWatchHandler: FeedWatchHandler, ) : Feed { @@ -116,7 +114,7 @@ internal class FeedImpl( private val eventHandler = FeedEventHandler(fid = fid, state = _state) init { - socketSubscriptionManager.subscribe(eventHandler) + subscriptionManager.subscribe(eventHandler) } private val group: String @@ -149,13 +147,13 @@ internal class FeedImpl( override suspend fun updateFeed(request: UpdateFeedRequest): Result { return feedsRepository .updateFeed(feedGroupId = group, feedId = id, request = request) - .onSuccess { _state.onFeedUpdated(it) } + .onSuccess { subscriptionManager.onEvent(StateUpdateEvent.FeedUpdated(it)) } } override suspend fun deleteFeed(hardDelete: Boolean): Result { return feedsRepository .deleteFeed(feedGroupId = group, feedId = id, hardDelete = hardDelete) - .onSuccess { _state.onFeedDeleted() } + .onSuccess { subscriptionManager.onEvent(StateUpdateEvent.FeedDeleted(fid.rawValue)) } } override suspend fun addActivity( @@ -163,7 +161,7 @@ internal class FeedImpl( attachmentUploadProgress: ((FeedUploadPayload, Double) -> Unit)?, ): Result { return activitiesRepository.addActivity(request, attachmentUploadProgress).onSuccess { - _state.onActivityAdded(it) + subscriptionManager.onEvent(StateUpdateEvent.ActivityAdded(fid.rawValue, it)) } } @@ -172,13 +170,13 @@ internal class FeedImpl( request: UpdateActivityRequest, ): Result { return activitiesRepository.updateActivity(id, request).onSuccess { - _state.onActivityUpdated(it) + subscriptionManager.onEvent(StateUpdateEvent.ActivityUpdated(fid.rawValue, it)) } } override suspend fun deleteActivity(id: String, hardDelete: Boolean): Result { return activitiesRepository.deleteActivity(id, hardDelete).onSuccess { - _state.onActivityRemoved(id) + subscriptionManager.onEvent(StateUpdateEvent.ActivityDeleted(fid.rawValue, id)) } } @@ -199,7 +197,7 @@ internal class FeedImpl( parentId = activityId, ) return activitiesRepository.addActivity(FeedAddActivityRequest(request)).onSuccess { - _state.onActivityAdded(it) + subscriptionManager.onEvent(StateUpdateEvent.ActivityAdded(fid.rawValue, it)) } } @@ -237,7 +235,7 @@ internal class FeedImpl( request: AddBookmarkRequest, ): Result { return bookmarksRepository.addBookmark(activityId, request).onSuccess { - _state.onBookmarkAdded(it) + subscriptionManager.onEvent(StateUpdateEvent.BookmarkAdded(it)) } } @@ -245,7 +243,9 @@ internal class FeedImpl( activityId: String, request: UpdateBookmarkRequest, ): Result { - return bookmarksRepository.updateBookmark(activityId, request) + return bookmarksRepository.updateBookmark(activityId, request).onSuccess { + subscriptionManager.onEvent(StateUpdateEvent.BookmarkUpdated(it)) + } } override suspend fun deleteBookmark( @@ -254,31 +254,42 @@ internal class FeedImpl( ): Result { return bookmarksRepository .deleteBookmark(activityId = activityId, folderId = folderId) - .onSuccess { _state.onBookmarkRemoved(it) } + .onSuccess { subscriptionManager.onEvent(StateUpdateEvent.BookmarkDeleted(it)) } } override suspend fun getComment(commentId: String): Result { - return commentsRepository.getComment(commentId) + return commentsRepository.getComment(commentId).onSuccess { + subscriptionManager.onEvent(StateUpdateEvent.CommentUpdated(it)) + } } override suspend fun addComment( request: ActivityAddCommentRequest, attachmentUploadProgress: ((FeedUploadPayload, Double) -> Unit)?, ): Result { - return commentsRepository.addComment(request, attachmentUploadProgress) + return commentsRepository.addComment(request, attachmentUploadProgress).onSuccess { + subscriptionManager.onEvent(StateUpdateEvent.CommentAdded(fid.rawValue, it)) + } } override suspend fun updateComment( commentId: String, request: UpdateCommentRequest, ): Result { - return commentsRepository.updateComment(commentId, request) + return commentsRepository.updateComment(commentId, request).onSuccess { + subscriptionManager.onEvent(StateUpdateEvent.CommentUpdated(it)) + } } override suspend fun deleteComment(commentId: String, hardDelete: Boolean?): Result { return commentsRepository .deleteComment(commentId, hardDelete) - .onSuccess { _state.onActivityUpdated(it.second) } + .onSuccess { (comment, activity) -> + subscriptionManager.onEvent(StateUpdateEvent.CommentDeleted(fid.rawValue, comment)) + subscriptionManager.onEvent( + StateUpdateEvent.ActivityUpdated(fid.rawValue, activity) + ) + } .map {} } @@ -300,13 +311,16 @@ internal class FeedImpl( source = fid.rawValue, target = targetFid.rawValue, ) - return feedsRepository.follow(request).onSuccess { _state.onFollowAdded(it) } + return feedsRepository.follow(request).onSuccess { + subscriptionManager.onEvent(StateUpdateEvent.FollowAdded(it)) + } } override suspend fun unfollow(targetFid: FeedId): Result { - return feedsRepository.unfollow(source = fid, target = targetFid).onSuccess { - _state.onUnfollow(sourceFid = fid, targetFid = targetFid) - } + return feedsRepository + .unfollow(source = fid, target = targetFid) + .onSuccess { subscriptionManager.onEvent(StateUpdateEvent.FollowDeleted(it)) } + .map {} } override suspend fun acceptFollow(sourceFid: FeedId, role: String?): Result { @@ -317,16 +331,14 @@ internal class FeedImpl( target = fid.rawValue, ) return feedsRepository.acceptFollow(request).onSuccess { follow -> - _state.onFollowRequestRemoved(follow.id) - _state.onFollowAdded(follow) + subscriptionManager.onEvent(StateUpdateEvent.FollowAdded(follow)) } } override suspend fun rejectFollow(sourceFid: FeedId): Result { val request = RejectFollowRequest(source = sourceFid.rawValue, target = fid.rawValue) return feedsRepository.rejectFollow(request).onSuccess { follow -> - _state.onFollowRequestRemoved(follow.id) - _state.onFollowRemoved(follow) + subscriptionManager.onEvent(StateUpdateEvent.FollowDeleted(follow)) } } @@ -347,45 +359,61 @@ internal class FeedImpl( } override suspend fun acceptFeedMember(): Result { - return feedsRepository.acceptFeedMember(feedGroupId = group, feedId = id) + return feedsRepository.acceptFeedMember(feedGroupId = group, feedId = id).onSuccess { + subscriptionManager.onEvent(StateUpdateEvent.FeedMemberUpdated(fid.rawValue, it)) + } } override suspend fun rejectFeedMember(): Result { - return feedsRepository.rejectFeedMember(feedGroupId = group, feedId = id) + return feedsRepository.rejectFeedMember(feedGroupId = group, feedId = id).onSuccess { + subscriptionManager.onEvent(StateUpdateEvent.FeedMemberUpdated(fid.rawValue, it)) + } } override suspend fun addReaction( activityId: String, request: AddReactionRequest, ): Result { - return activitiesRepository - .addReaction(activityId, request) - .onSuccess(_state::onReactionAdded) + return activitiesRepository.addReaction(activityId, request).onSuccess { + subscriptionManager.onEvent(StateUpdateEvent.ActivityReactionAdded(fid.rawValue, it)) + } } override suspend fun deleteReaction( activityId: String, type: String, ): Result { - return activitiesRepository - .deleteReaction(activityId = activityId, type = type) - .onSuccess(_state::onReactionRemoved) + return activitiesRepository.deleteReaction(activityId = activityId, type = type).onSuccess { + subscriptionManager.onEvent(StateUpdateEvent.ActivityReactionDeleted(fid.rawValue, it)) + } } override suspend fun addCommentReaction( commentId: String, request: AddCommentReactionRequest, ): Result { - return commentsRepository.addCommentReaction(commentId, request).map { it.first } + return commentsRepository + .addCommentReaction(commentId, request) + .onSuccess { (reaction, comment) -> + subscriptionManager.onEvent( + StateUpdateEvent.CommentReactionAdded(comment, reaction) + ) + } + .map { it.first } } override suspend fun deleteCommentReaction( commentId: String, type: String, ): Result { - return commentsRepository.deleteCommentReaction(commentId = commentId, type = type).map { - it.first - } + return commentsRepository + .deleteCommentReaction(commentId = commentId, type = type) + .onSuccess { (reaction, comment) -> + subscriptionManager.onEvent( + StateUpdateEvent.CommentReactionDeleted(comment, reaction) + ) + } + .map { it.first } } override suspend fun createPoll( @@ -399,7 +427,9 @@ internal class FeedImpl( pollId = poll.id, type = activityType, ) - activitiesRepository.addActivity(FeedAddActivityRequest(request)) + activitiesRepository.addActivity(FeedAddActivityRequest(request)).onSuccess { + subscriptionManager.onEvent(StateUpdateEvent.ActivityAdded(fid.rawValue, it)) + } } } } diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FeedStateImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FeedStateImpl.kt index 5d6056f71..7525c4500 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FeedStateImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FeedStateImpl.kt @@ -366,6 +366,7 @@ internal class FeedStateImpl( _following.update { it.upsert(follow, FollowData::id) } } else if (follow.isFollowerOf(fid)) { _followers.update { it.upsert(follow, FollowData::id) } + _followRequests.update { current -> current.filter { it.id != follow.id } } } } diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/StateUpdateEvent.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/StateUpdateEvent.kt index 4c3237fa1..ef6f1c80d 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/StateUpdateEvent.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/StateUpdateEvent.kt @@ -16,6 +16,8 @@ package io.getstream.feeds.android.client.internal.state.event import io.getstream.feeds.android.client.api.model.ActivityData +import io.getstream.feeds.android.client.api.model.ActivityPinData +import io.getstream.feeds.android.client.api.model.AggregatedActivityData import io.getstream.feeds.android.client.api.model.BookmarkData import io.getstream.feeds.android.client.api.model.BookmarkFolderData import io.getstream.feeds.android.client.api.model.CommentData @@ -26,10 +28,15 @@ import io.getstream.feeds.android.client.api.model.FollowData import io.getstream.feeds.android.client.api.model.PollData import io.getstream.feeds.android.client.api.model.PollVoteData import io.getstream.feeds.android.client.api.model.toModel +import io.getstream.feeds.android.network.models.ActivityAddedEvent import io.getstream.feeds.android.network.models.ActivityDeletedEvent +import io.getstream.feeds.android.network.models.ActivityPinnedEvent import io.getstream.feeds.android.network.models.ActivityReactionAddedEvent import io.getstream.feeds.android.network.models.ActivityReactionDeletedEvent +import io.getstream.feeds.android.network.models.ActivityRemovedFromFeedEvent +import io.getstream.feeds.android.network.models.ActivityUnpinnedEvent import io.getstream.feeds.android.network.models.ActivityUpdatedEvent +import io.getstream.feeds.android.network.models.AggregatedActivityResponse import io.getstream.feeds.android.network.models.BookmarkAddedEvent import io.getstream.feeds.android.network.models.BookmarkDeletedEvent import io.getstream.feeds.android.network.models.BookmarkFolderDeletedEvent @@ -45,8 +52,11 @@ import io.getstream.feeds.android.network.models.FeedMemberAddedEvent import io.getstream.feeds.android.network.models.FeedMemberRemovedEvent import io.getstream.feeds.android.network.models.FeedMemberUpdatedEvent import io.getstream.feeds.android.network.models.FeedUpdatedEvent +import io.getstream.feeds.android.network.models.FollowCreatedEvent import io.getstream.feeds.android.network.models.FollowDeletedEvent import io.getstream.feeds.android.network.models.FollowUpdatedEvent +import io.getstream.feeds.android.network.models.NotificationFeedUpdatedEvent +import io.getstream.feeds.android.network.models.NotificationStatusResponse import io.getstream.feeds.android.network.models.PollClosedFeedEvent import io.getstream.feeds.android.network.models.PollDeletedFeedEvent import io.getstream.feeds.android.network.models.PollUpdatedFeedEvent @@ -61,10 +71,19 @@ import io.getstream.feeds.android.network.models.WSEvent */ internal sealed interface StateUpdateEvent { - data class ActivityDeleted(val activity: ActivityData) : StateUpdateEvent + data class ActivityAdded(val fid: String, val activity: ActivityData) : StateUpdateEvent + + data class ActivityDeleted(val fid: String, val activityId: String) : StateUpdateEvent + + data class ActivityRemovedFromFeed(val fid: String, val activityId: String) : StateUpdateEvent data class ActivityUpdated(val fid: String, val activity: ActivityData) : StateUpdateEvent + data class ActivityPinned(val fid: String, val pinnedActivity: ActivityPinData) : + StateUpdateEvent + + data class ActivityUnpinned(val fid: String, val activityId: String) : StateUpdateEvent + data class ActivityReactionAdded(val fid: String, val reaction: FeedsReactionData) : StateUpdateEvent @@ -81,9 +100,9 @@ internal sealed interface StateUpdateEvent { data class BookmarkFolderUpdated(val folder: BookmarkFolderData) : StateUpdateEvent - data class CommentAdded(val comment: CommentData) : StateUpdateEvent + data class CommentAdded(val fid: String, val comment: CommentData) : StateUpdateEvent - data class CommentDeleted(val comment: CommentData) : StateUpdateEvent + data class CommentDeleted(val fid: String, val comment: CommentData) : StateUpdateEvent data class CommentUpdated(val comment: CommentData) : StateUpdateEvent @@ -103,10 +122,18 @@ internal sealed interface StateUpdateEvent { data class FeedMemberUpdated(val fid: String, val member: FeedMemberData) : StateUpdateEvent + data class FollowAdded(val follow: FollowData) : StateUpdateEvent + data class FollowUpdated(val follow: FollowData) : StateUpdateEvent data class FollowDeleted(val follow: FollowData) : StateUpdateEvent + data class NotificationFeedUpdated( + val fid: String, + val aggregatedActivities: List, + val notificationStatus: NotificationStatusResponse?, + ) : StateUpdateEvent + data class PollClosed(val fid: String, val poll: PollData) : StateUpdateEvent data class PollDeleted(val fid: String, val pollId: String) : StateUpdateEvent @@ -125,10 +152,20 @@ internal sealed interface StateUpdateEvent { internal fun WSEvent.toModel(): StateUpdateEvent? = when (this) { - is ActivityDeletedEvent -> StateUpdateEvent.ActivityDeleted(activity.toModel()) + is ActivityAddedEvent -> StateUpdateEvent.ActivityAdded(fid, activity.toModel()) + + is ActivityDeletedEvent -> StateUpdateEvent.ActivityDeleted(fid, activity.id) + + is ActivityRemovedFromFeedEvent -> + StateUpdateEvent.ActivityRemovedFromFeed(fid, activity.id) is ActivityUpdatedEvent -> StateUpdateEvent.ActivityUpdated(fid, activity.toModel()) + is ActivityPinnedEvent -> StateUpdateEvent.ActivityPinned(fid, pinnedActivity.toModel()) + + is ActivityUnpinnedEvent -> + StateUpdateEvent.ActivityUnpinned(fid, pinnedActivity.activity.id) + is ActivityReactionAddedEvent -> StateUpdateEvent.ActivityReactionAdded(fid, reaction.toModel()) @@ -146,11 +183,11 @@ internal fun WSEvent.toModel(): StateUpdateEvent? = is BookmarkFolderUpdatedEvent -> StateUpdateEvent.BookmarkFolderUpdated(bookmarkFolder.toModel()) - is CommentAddedEvent -> StateUpdateEvent.CommentAdded(comment.toModel()) + is CommentAddedEvent -> StateUpdateEvent.CommentAdded(fid, comment.toModel()) is CommentUpdatedEvent -> StateUpdateEvent.CommentUpdated(comment.toModel()) - is CommentDeletedEvent -> StateUpdateEvent.CommentDeleted(comment.toModel()) + is CommentDeletedEvent -> StateUpdateEvent.CommentDeleted(fid, comment.toModel()) is CommentReactionAddedEvent -> StateUpdateEvent.CommentReactionAdded(comment.toModel(), reaction.toModel()) @@ -162,10 +199,20 @@ internal fun WSEvent.toModel(): StateUpdateEvent? = is FeedDeletedEvent -> StateUpdateEvent.FeedDeleted(fid) + is FollowCreatedEvent -> StateUpdateEvent.FollowAdded(follow.toModel()) + is FollowUpdatedEvent -> StateUpdateEvent.FollowUpdated(follow.toModel()) is FollowDeletedEvent -> StateUpdateEvent.FollowDeleted(follow.toModel()) + is NotificationFeedUpdatedEvent -> + StateUpdateEvent.NotificationFeedUpdated( + fid = fid, + aggregatedActivities = + aggregatedActivities?.map(AggregatedActivityResponse::toModel).orEmpty(), + notificationStatus = notificationStatus, + ) + is FeedMemberAddedEvent -> StateUpdateEvent.FeedMemberAdded(fid, member.toModel()) is FeedMemberRemovedEvent -> StateUpdateEvent.FeedMemberRemoved(fid, memberId) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityListEventHandler.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityListEventHandler.kt index c29706e06..1ea08d7b6 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityListEventHandler.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityListEventHandler.kt @@ -24,7 +24,7 @@ internal class ActivityListEventHandler(private val state: ActivityListStateUpda override fun onEvent(event: StateUpdateEvent) { when (event) { - is StateUpdateEvent.ActivityDeleted -> state.onActivityRemoved(event.activity) + is StateUpdateEvent.ActivityDeleted -> state.onActivityRemoved(event.activityId) is StateUpdateEvent.ActivityReactionAdded -> state.onReactionAdded(event.reaction) is StateUpdateEvent.ActivityReactionDeleted -> state.onReactionRemoved(event.reaction) is StateUpdateEvent.BookmarkAdded -> state.onBookmarkAdded(event.bookmark) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedEventHandler.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedEventHandler.kt index e3937b57c..a90ab0df9 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedEventHandler.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedEventHandler.kt @@ -16,34 +16,10 @@ package io.getstream.feeds.android.client.internal.state.event.handler import io.getstream.feeds.android.client.api.model.FeedId -import io.getstream.feeds.android.client.api.model.toModel +import io.getstream.feeds.android.client.api.model.FollowData import io.getstream.feeds.android.client.internal.state.FeedStateUpdates -import io.getstream.feeds.android.client.internal.subscribe.FeedsEventListener -import io.getstream.feeds.android.network.models.ActivityAddedEvent -import io.getstream.feeds.android.network.models.ActivityDeletedEvent -import io.getstream.feeds.android.network.models.ActivityPinnedEvent -import io.getstream.feeds.android.network.models.ActivityReactionAddedEvent -import io.getstream.feeds.android.network.models.ActivityReactionDeletedEvent -import io.getstream.feeds.android.network.models.ActivityRemovedFromFeedEvent -import io.getstream.feeds.android.network.models.ActivityUnpinnedEvent -import io.getstream.feeds.android.network.models.ActivityUpdatedEvent -import io.getstream.feeds.android.network.models.BookmarkAddedEvent -import io.getstream.feeds.android.network.models.BookmarkDeletedEvent -import io.getstream.feeds.android.network.models.CommentAddedEvent -import io.getstream.feeds.android.network.models.CommentDeletedEvent -import io.getstream.feeds.android.network.models.FeedDeletedEvent -import io.getstream.feeds.android.network.models.FeedUpdatedEvent -import io.getstream.feeds.android.network.models.FollowCreatedEvent -import io.getstream.feeds.android.network.models.FollowDeletedEvent -import io.getstream.feeds.android.network.models.FollowUpdatedEvent -import io.getstream.feeds.android.network.models.NotificationFeedUpdatedEvent -import io.getstream.feeds.android.network.models.PollClosedFeedEvent -import io.getstream.feeds.android.network.models.PollDeletedFeedEvent -import io.getstream.feeds.android.network.models.PollUpdatedFeedEvent -import io.getstream.feeds.android.network.models.PollVoteCastedFeedEvent -import io.getstream.feeds.android.network.models.PollVoteChangedFeedEvent -import io.getstream.feeds.android.network.models.PollVoteRemovedFeedEvent -import io.getstream.feeds.android.network.models.WSEvent +import io.getstream.feeds.android.client.internal.state.event.StateUpdateEvent +import io.getstream.feeds.android.client.internal.subscribe.StateUpdateEventListener /** * This class handles feed events and updates the feed state accordingly. It is responsible for @@ -54,160 +30,159 @@ import io.getstream.feeds.android.network.models.WSEvent * @property state The instance that manages updates to the feed state. */ internal class FeedEventHandler(private val fid: FeedId, private val state: FeedStateUpdates) : - FeedsEventListener { + StateUpdateEventListener { /** - * Processes a WebSocket event and updates the feed state. + * Processes a state update event and updates the feed state. * - * @param event The WebSocket event to process. + * @param event The state update event to process. */ - override fun onEvent(event: WSEvent) { + override fun onEvent(event: StateUpdateEvent) { when (event) { - is ActivityAddedEvent -> { + is StateUpdateEvent.ActivityAdded -> { if (event.fid == fid.rawValue) { - state.onActivityAdded(event.activity.toModel()) + state.onActivityAdded(event.activity) } } - is ActivityDeletedEvent -> { + is StateUpdateEvent.ActivityDeleted -> { if (event.fid == fid.rawValue) { - state.onActivityRemoved(event.activity.id) + state.onActivityRemoved(event.activityId) } } - is ActivityRemovedFromFeedEvent -> { + is StateUpdateEvent.ActivityRemovedFromFeed -> { if (event.fid == fid.rawValue) { - state.onActivityRemoved(event.activity.id) + state.onActivityRemoved(event.activityId) } } - is ActivityReactionAddedEvent -> { + is StateUpdateEvent.ActivityReactionAdded -> { if (event.fid == fid.rawValue) { - state.onReactionAdded(event.reaction.toModel()) + state.onReactionAdded(event.reaction) } } - is ActivityReactionDeletedEvent -> { + is StateUpdateEvent.ActivityReactionDeleted -> { if (event.fid == fid.rawValue) { - state.onReactionRemoved(event.reaction.toModel()) + state.onReactionRemoved(event.reaction) } } - is ActivityUpdatedEvent -> { + is StateUpdateEvent.ActivityUpdated -> { if (event.fid == fid.rawValue) { - state.onActivityUpdated(event.activity.toModel()) + state.onActivityUpdated(event.activity) } } - is ActivityPinnedEvent -> { + is StateUpdateEvent.ActivityPinned -> { if (event.fid == fid.rawValue) { - state.onActivityPinned(event.pinnedActivity.toModel()) + state.onActivityPinned(event.pinnedActivity) } } - is ActivityUnpinnedEvent -> { + is StateUpdateEvent.ActivityUnpinned -> { if (event.fid == fid.rawValue) { - state.onActivityUnpinned(event.pinnedActivity.activity.id) + state.onActivityUnpinned(event.activityId) } } - is BookmarkAddedEvent -> { + is StateUpdateEvent.BookmarkAdded -> { if (event.bookmark.activity.feeds.contains(fid.rawValue)) { - state.onBookmarkAdded(event.bookmark.toModel()) + state.onBookmarkAdded(event.bookmark) } } - is BookmarkDeletedEvent -> { + is StateUpdateEvent.BookmarkDeleted -> { if (event.bookmark.activity.feeds.contains(fid.rawValue)) { - state.onBookmarkRemoved(event.bookmark.toModel()) + state.onBookmarkRemoved(event.bookmark) } } - is CommentAddedEvent -> { + is StateUpdateEvent.CommentAdded -> { if (event.fid == fid.rawValue) { - state.onCommentAdded(event.comment.toModel()) + state.onCommentAdded(event.comment) } } - is CommentDeletedEvent -> { + is StateUpdateEvent.CommentDeleted -> { if (event.fid == fid.rawValue) { - state.onCommentRemoved(event.comment.toModel()) + state.onCommentRemoved(event.comment) } } - is FeedDeletedEvent -> { + is StateUpdateEvent.FeedDeleted -> { if (event.fid == fid.rawValue) { state.onFeedDeleted() } } - is FeedUpdatedEvent -> { - if (event.fid == fid.rawValue) { - state.onFeedUpdated(event.feed.toModel()) + is StateUpdateEvent.FeedUpdated -> { + if (event.feed.fid == fid) { + state.onFeedUpdated(event.feed) } } - is FollowCreatedEvent -> { - if (event.fid == fid.rawValue) { - state.onFollowAdded(event.follow.toModel()) + is StateUpdateEvent.FollowAdded -> { + if (event.follow.matchesFeed()) { + state.onFollowAdded(event.follow) } } - is FollowDeletedEvent -> { - if (event.fid == fid.rawValue) { - state.onFollowRemoved(event.follow.toModel()) + is StateUpdateEvent.FollowDeleted -> { + if (event.follow.matchesFeed()) { + state.onFollowRemoved(event.follow) } } - is FollowUpdatedEvent -> { - if (event.fid == fid.rawValue) { - state.onFollowUpdated(event.follow.toModel()) + is StateUpdateEvent.FollowUpdated -> { + if (event.follow.matchesFeed()) { + state.onFollowUpdated(event.follow) } } - is NotificationFeedUpdatedEvent -> { + is StateUpdateEvent.NotificationFeedUpdated -> { if (event.fid == fid.rawValue) { state.onNotificationFeedUpdated( - aggregatedActivities = - event.aggregatedActivities?.map { it.toModel() }.orEmpty(), + aggregatedActivities = event.aggregatedActivities, notificationStatus = event.notificationStatus, ) } } - is PollClosedFeedEvent -> { + is StateUpdateEvent.PollClosed -> { if (event.fid == fid.rawValue) { state.onPollClosed(event.poll.id) } } - is PollDeletedFeedEvent -> { + is StateUpdateEvent.PollDeleted -> { if (event.fid == fid.rawValue) { - state.onPollDeleted(event.poll.id) + state.onPollDeleted(event.pollId) } } - is PollUpdatedFeedEvent -> { + is StateUpdateEvent.PollUpdated -> { if (event.fid == fid.rawValue) { - state.onPollUpdated(event.poll.toModel()) + state.onPollUpdated(event.poll) } } - is PollVoteCastedFeedEvent -> { + is StateUpdateEvent.PollVoteCasted -> { if (event.fid == fid.rawValue) { - state.onPollVoteCasted(event.pollVote.toModel(), event.poll.id) + state.onPollVoteCasted(event.vote, event.pollId) } } - is PollVoteChangedFeedEvent -> { + is StateUpdateEvent.PollVoteChanged -> { if (event.fid == fid.rawValue) { - state.onPollVoteChanged(event.pollVote.toModel(), event.poll.id) + state.onPollVoteChanged(event.vote, event.pollId) } } - is PollVoteRemovedFeedEvent -> { + is StateUpdateEvent.PollVoteRemoved -> { if (event.fid == fid.rawValue) { - state.onPollVoteRemoved(event.pollVote.toModel(), event.poll.id) + state.onPollVoteRemoved(event.vote, event.pollId) } } @@ -216,4 +191,6 @@ internal class FeedEventHandler(private val fid: FeedId, private val state: Feed } } } + + private fun FollowData.matchesFeed() = sourceFeed.fid == fid || targetFeed.fid == fid } diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepositoryImplTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepositoryImplTest.kt index e91b5827f..67bd94507 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepositoryImplTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepositoryImplTest.kt @@ -48,6 +48,7 @@ import io.getstream.feeds.android.network.models.FollowRequest import io.getstream.feeds.android.network.models.QueryFeedMembersRequest import io.getstream.feeds.android.network.models.QueryFollowsRequest import io.getstream.feeds.android.network.models.RejectFollowRequest +import io.getstream.feeds.android.network.models.UnfollowResponse import io.getstream.feeds.android.network.models.UpdateFeedMembersRequest import io.getstream.feeds.android.network.models.UpdateFeedRequest import io.mockk.mockk @@ -190,12 +191,14 @@ internal class FeedsRepositoryImplTest { fun `on unfollow, delegate to api`() = runTest { val source = FeedId("user:user-1") val target = FeedId("user:user-2") + val followResponseData = followResponse() + val unfollowResponse = UnfollowResponse(duration = "duration", follow = followResponseData) testDelegation( apiFunction = { feedsApi.unfollow("user:user-1", "user:user-2") }, repositoryCall = { repository.unfollow(source, target) }, - apiResult = Unit, - repositoryResult = Unit, + apiResult = unfollowResponse, + repositoryResult = unfollowResponse.follow.toModel(), ) } diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/ActivityImplTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/ActivityImplTest.kt index 25f24b3c2..002f00890 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/ActivityImplTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/ActivityImplTest.kt @@ -83,7 +83,9 @@ internal class ActivityImplTest { activity.addComment(request, progress) - verify { stateEventListener.onEvent(StateUpdateEvent.CommentAdded(commentData)) } + verify { + stateEventListener.onEvent(StateUpdateEvent.CommentAdded("group:feed", commentData)) + } } @Test @@ -100,7 +102,7 @@ internal class ActivityImplTest { activity.addCommentsBatch(requests) commentData.forEach { data -> - verify { stateEventListener.onEvent(StateUpdateEvent.CommentAdded(data)) } + verify { stateEventListener.onEvent(StateUpdateEvent.CommentAdded("group:feed", data)) } } } @@ -173,7 +175,9 @@ internal class ActivityImplTest { assertEquals(Unit, result.getOrNull()) assertEquals(expectedActivity, activity.state.activity.value) verify { - stateEventListener.onEvent(StateUpdateEvent.CommentDeleted(deleteData.first)) + stateEventListener.onEvent( + StateUpdateEvent.CommentDeleted("group:feed", deleteData.first) + ) stateEventListener.onEvent( StateUpdateEvent.ActivityUpdated("group:feed", expectedActivity) ) diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/ActivityListStateImplTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/ActivityListStateImplTest.kt index 0b60c34bc..5b25580d8 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/ActivityListStateImplTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/ActivityListStateImplTest.kt @@ -71,7 +71,7 @@ internal class ActivityListStateImplTest { val paginationResult = defaultPaginationResult(initialActivities) activityListState.onQueryMoreActivities(paginationResult, queryConfig) - activityListState.onActivityRemoved(initialActivities[0]) + activityListState.onActivityRemoved(initialActivities[0].id) val remainingActivities = activityListState.activities.value assertEquals(listOf(initialActivities[1]), remainingActivities) diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FeedImplTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FeedImplTest.kt index 68cd18d68..b558c5b98 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FeedImplTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FeedImplTest.kt @@ -20,6 +20,8 @@ import io.getstream.feeds.android.client.api.model.ActivityData import io.getstream.feeds.android.client.api.model.FeedAddActivityRequest import io.getstream.feeds.android.client.api.model.FeedData import io.getstream.feeds.android.client.api.model.FeedId +import io.getstream.feeds.android.client.api.model.FeedMemberData +import io.getstream.feeds.android.client.api.model.FollowData import io.getstream.feeds.android.client.api.model.ModelUpdates import io.getstream.feeds.android.client.api.model.PaginationData import io.getstream.feeds.android.client.api.model.PaginationResult @@ -34,6 +36,8 @@ import io.getstream.feeds.android.client.internal.repository.CommentsRepository import io.getstream.feeds.android.client.internal.repository.FeedsRepository import io.getstream.feeds.android.client.internal.repository.GetOrCreateInfo import io.getstream.feeds.android.client.internal.repository.PollsRepository +import io.getstream.feeds.android.client.internal.state.event.StateUpdateEvent +import io.getstream.feeds.android.client.internal.subscribe.StateUpdateEventListener import io.getstream.feeds.android.client.internal.test.TestData.activityData import io.getstream.feeds.android.client.internal.test.TestData.bookmarkData import io.getstream.feeds.android.client.internal.test.TestData.commentData @@ -43,6 +47,7 @@ import io.getstream.feeds.android.client.internal.test.TestData.feedsReactionDat import io.getstream.feeds.android.client.internal.test.TestData.followData import io.getstream.feeds.android.client.internal.test.TestData.pollData import io.getstream.feeds.android.client.internal.test.TestData.reactionGroupData +import io.getstream.feeds.android.client.internal.test.TestSubscriptionManager import io.getstream.feeds.android.network.models.AddBookmarkRequest import io.getstream.feeds.android.network.models.AddCommentReactionRequest import io.getstream.feeds.android.network.models.AddReactionRequest @@ -62,7 +67,6 @@ import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -74,6 +78,7 @@ internal class FeedImplTest { private val feedsRepository: FeedsRepository = mockk(relaxed = true) private val pollsRepository: PollsRepository = mockk(relaxed = true) private val feedWatchHandler: FeedWatchHandler = mockk(relaxed = true) + private val stateEventListener: StateUpdateEventListener = mockk(relaxed = true) @Test fun `on getOrCreate with watch enabled, then call feedWatchHandler`() = runTest { @@ -86,6 +91,7 @@ internal class FeedImplTest { val result = feed.getOrCreate() assertEquals(feedInfo.feed, result.getOrNull()) + assertEquals(feedInfo.feed, feed.state.feed.value) verify { feedWatchHandler.onStartWatching(feedId) } } @@ -99,6 +105,7 @@ internal class FeedImplTest { val result = feed.getOrCreate() assertEquals(feedInfo.feed, result.getOrNull()) + assertEquals(feedInfo.feed, feed.state.feed.value) verify { feedWatchHandler wasNot called } } @@ -117,7 +124,7 @@ internal class FeedImplTest { } @Test - fun `on addActivity, delegate to repository and notify state on success`() = runTest { + fun `on addActivity, delegate to repository and fire event on success`() = runTest { val feed = createFeed() val request = FeedAddActivityRequest(type = "post", text = "Nice post") val attachmentUploadProgress: (FeedUploadPayload, Double) -> Unit = { _, _ -> } @@ -127,28 +134,41 @@ internal class FeedImplTest { feed.addActivity(request, attachmentUploadProgress) - coVerify { activitiesRepository.addActivity(request, attachmentUploadProgress) } + coVerify { + activitiesRepository.addActivity(request, attachmentUploadProgress) + stateEventListener.onEvent(StateUpdateEvent.ActivityAdded("group:id", activityData)) + } assertEquals(listOf(activityData), feed.state.activities.value) } @Test - fun `on addComment, delegate to repository`() = runTest { + fun `on addComment, delegate to repository and fire event`() = runTest { val feed = createFeed() - val request = ActivityAddCommentRequest(activityId = "activityId", comment = "Comment") + val activityId = "activityId" + val request = ActivityAddCommentRequest(activityId = activityId, comment = "Comment") val progress = { _: FeedUploadPayload, _: Double -> } - val commentData = commentData("id") - coEvery { commentsRepository.addComment(any(), any()) } returns Result.success(commentData) + val initialActivity = activityData(activityId) + setupInitialState(feed, activities = listOf(initialActivity)) - feed.addComment(request, progress) + val addedComment = commentData(id = "comment-1", objectId = activityId) + coEvery { commentsRepository.addComment(any(), any()) } returns Result.success(addedComment) - coVerify { commentsRepository.addComment(request, progress) } + val result = feed.addComment(request, progress) + + val updated = initialActivity.copy(comments = listOf(addedComment), commentCount = 1) + assertEquals(addedComment, result.getOrNull()) + assertEquals(listOf(updated), feed.state.activities.value) + coVerify { + commentsRepository.addComment(request, progress) + stateEventListener.onEvent(StateUpdateEvent.CommentAdded("group:id", addedComment)) + } } @Test - fun `on updateFeed, delegate to repository and update state`() = runTest { + fun `on updateFeed, delegate to repository and fire event`() = runTest { val feed = createFeed() val request = UpdateFeedRequest(custom = mapOf("key" to "value")) - val updatedFeedData = feedData("user-feed", "user", "Updated Feed") + val updatedFeedData = feedData("id", "group", "Updated Feed") coEvery { feedsRepository.updateFeed("group", "id", request) } returns Result.success(updatedFeedData) @@ -156,18 +176,17 @@ internal class FeedImplTest { assertEquals(updatedFeedData, result.getOrNull()) assertEquals(updatedFeedData, feed.state.feed.value) + verify { stateEventListener.onEvent(StateUpdateEvent.FeedUpdated(updatedFeedData)) } } @Test - fun `on deleteFeed, delegate to repository and clear state`() = runTest { + fun `on deleteFeed, delegate to repository and fire event`() = runTest { val feed = createFeed() val hardDelete = true // Set up initial state with some data val initialFeedData = feedData() val initialActivity = activityData("activity-1") - val feedInfo = getOrCreateInfo(initialFeedData, listOf(initialActivity)) - coEvery { feedsRepository.getOrCreateFeed(any()) } returns Result.success(feedInfo) - feed.getOrCreate() + setupInitialState(feed, initialFeedData, listOf(initialActivity)) coEvery { feedsRepository.deleteFeed("group", "id", hardDelete) } returns Result.success(Unit) @@ -176,11 +195,12 @@ internal class FeedImplTest { assertEquals(Unit, result.getOrNull()) assertNull(feed.state.feed.value) - assertTrue("Activities should be cleared", feed.state.activities.value.isEmpty()) + assertEquals(emptyList(), feed.state.activities.value) + verify { stateEventListener.onEvent(StateUpdateEvent.FeedDeleted("group:id")) } } @Test - fun `on updateActivity, delegate to repository and update state`() = runTest { + fun `on updateActivity, delegate to repository and fire event`() = runTest { val feed = createFeed() val activityId = "activity-1" val request = UpdateActivityRequest(text = "Updated activity") @@ -188,32 +208,32 @@ internal class FeedImplTest { val updatedActivity = activityData(activityId, "Updated activity") // Set up initial state with activity - val feedInfo = getOrCreateInfo(feedData(), listOf(originalActivity)) - coEvery { feedsRepository.getOrCreateFeed(any()) } returns Result.success(feedInfo) - feed.getOrCreate() + setupInitialState(feed, activities = listOf(originalActivity)) coEvery { activitiesRepository.updateActivity(activityId, request) } returns Result.success(updatedActivity) val result = feed.updateActivity(activityId, request) + val updated = originalActivity.copy(text = updatedActivity.text) assertEquals(updatedActivity, result.getOrNull()) - val stateActivities = feed.state.activities.value - assertEquals(1, stateActivities.size) - assertEquals("Updated activity", stateActivities.first().text) + assertEquals(listOf(updated), feed.state.activities.value) + verify { + stateEventListener.onEvent( + StateUpdateEvent.ActivityUpdated("group:id", updatedActivity) + ) + } } @Test - fun `on deleteActivity, delegate to repository and remove from state`() = runTest { + fun `on deleteActivity, delegate to repository and fire event`() = runTest { val feed = createFeed() val activityId = "activity-1" val hardDelete = false val activity = activityData(activityId) // Set up initial state with activity - val feedInfo = getOrCreateInfo(feedData(), listOf(activity)) - coEvery { feedsRepository.getOrCreateFeed(any()) } returns Result.success(feedInfo) - feed.getOrCreate() + setupInitialState(feed, activities = listOf(activity)) coEvery { activitiesRepository.deleteActivity(activityId, hardDelete) } returns Result.success(Unit) @@ -221,11 +241,14 @@ internal class FeedImplTest { val result = feed.deleteActivity(activityId, hardDelete) assertEquals(Unit, result.getOrNull()) - assertTrue("Activity should be removed from state", feed.state.activities.value.isEmpty()) + assertEquals(emptyList(), feed.state.activities.value) + verify { + stateEventListener.onEvent(StateUpdateEvent.ActivityDeleted("group:id", activityId)) + } } @Test - fun `on repost, delegate to repository and add to state`() = runTest { + fun `on repost, delegate to repository and fire event`() = runTest { val feed = createFeed() val parentActivityId = "parent-activity" val text = "Repost text" @@ -237,38 +260,40 @@ internal class FeedImplTest { val result = feed.repost(parentActivityId, text) assertEquals(repostActivity, result.getOrNull()) - val stateActivities = feed.state.activities.value - assertEquals(1, stateActivities.size) - assertEquals(text, stateActivities.first().text) + assertEquals(listOf(repostActivity), feed.state.activities.value) + verify { + stateEventListener.onEvent(StateUpdateEvent.ActivityAdded("group:id", repostActivity)) + } } @Test - fun `on addBookmark, delegate to repository and update activity state`() = runTest { + fun `on addBookmark, delegate to repository and fire event`() = runTest { val feed = createFeed() val activityId = "activity-1" val request = AddBookmarkRequest(folderId = "folder-1") - val activity = activityData(activityId) - val bookmark = bookmarkData(activityId) + val activity = activityData(activityId).copy(feeds = listOf("group:id")) + val bookmark = bookmarkData(activityId, userId = "user").copy(activity = activity) // Set up initial state with activity - val feedInfo = getOrCreateInfo(feedData(), listOf(activity)) - coEvery { feedsRepository.getOrCreateFeed(any()) } returns Result.success(feedInfo) - feed.getOrCreate() + setupInitialState(feed, activities = listOf(activity)) coEvery { bookmarksRepository.addBookmark(activityId, request) } returns Result.success(bookmark) val result = feed.addBookmark(activityId, request) + val updated = + activity.copy( + ownBookmarks = listOf(bookmark), + bookmarkCount = activity.bookmarkCount + 1, + ) assertEquals(bookmark, result.getOrNull()) - val stateActivities = feed.state.activities.value - assertEquals(1, stateActivities.size) - // The activity should be updated with bookmark info - assertNotNull("Activity should be updated with bookmark", stateActivities.first()) + assertEquals(listOf(updated), feed.state.activities.value) + verify { stateEventListener.onEvent(StateUpdateEvent.BookmarkAdded(bookmark)) } } @Test - fun `on deleteBookmark, delegate to repository and update activity state`() = runTest { + fun `on deleteBookmark, delegate to repository and fire event`() = runTest { val feed = createFeed() val activityId = "activity-1" val folderId = "folder-1" @@ -276,20 +301,21 @@ internal class FeedImplTest { val bookmark = bookmarkData(activityId) // Set up initial state with activity - val feedInfo = getOrCreateInfo(feedData(), listOf(activity)) - coEvery { feedsRepository.getOrCreateFeed(any()) } returns Result.success(feedInfo) - feed.getOrCreate() + setupInitialState(feed, activities = listOf(activity)) coEvery { bookmarksRepository.deleteBookmark(activityId, folderId) } returns Result.success(bookmark) val result = feed.deleteBookmark(activityId, folderId) + val updated = + activity.copy( + ownBookmarks = activity.ownBookmarks.filter { it.id != bookmark.id }, + bookmarkCount = 0, + ) assertEquals(bookmark, result.getOrNull()) - val stateActivities = feed.state.activities.value - assertEquals(1, stateActivities.size) - // The activity should be updated to remove bookmark - assertNotNull("Activity should be updated after bookmark removal", stateActivities.first()) + assertEquals(listOf(updated), feed.state.activities.value) + verify { stateEventListener.onEvent(StateUpdateEvent.BookmarkDeleted(bookmark)) } } @Test @@ -299,39 +325,35 @@ internal class FeedImplTest { val createNotificationActivity = true val custom = mapOf("key" to "value") val pushPreference = FollowRequest.PushPreference.All - val follow = followData("user", "target") + val follow = followData(sourceFid = "group:id", targetFid = "user:target") // Set up initial feed state - val feedInfo = getOrCreateInfo(feedData()) - coEvery { feedsRepository.getOrCreateFeed(any()) } returns Result.success(feedInfo) - feed.getOrCreate() + setupInitialState(feed) coEvery { feedsRepository.follow(any()) } returns Result.success(follow) val result = feed.follow(targetFid, createNotificationActivity, custom, pushPreference) assertEquals(follow, result.getOrNull()) - // State verification: the follow operation should succeed, indicating state was updated - assertTrue("Follow operation should succeed", result.isSuccess) + assertEquals(listOf(follow), feed.state.following.value) } @Test - fun `on unfollow, delegate to repository and update following state`() = runTest { + fun `on unfollow, delegate to repository and fire event`() = runTest { val feed = createFeed() val targetFid = FeedId("user:target") - val follow = followData("id", "target") + val follow = followData("group:id", "user:target") // Set up initial state with follow - val feedInfo = getOrCreateInfo(feedData()).copy(following = listOf(follow)) - coEvery { feedsRepository.getOrCreateFeed(any()) } returns Result.success(feedInfo) - feed.getOrCreate() + setupInitialState(feed, following = listOf(follow)) - coEvery { feedsRepository.unfollow(any(), any()) } returns Result.success(Unit) + coEvery { feedsRepository.unfollow(any(), any()) } returns Result.success(follow) val result = feed.unfollow(targetFid) assertEquals(Unit, result.getOrNull()) assertTrue("Following should be removed from state", feed.state.following.value.isEmpty()) + verify { stateEventListener.onEvent(StateUpdateEvent.FollowDeleted(follow)) } } @Test @@ -339,65 +361,53 @@ internal class FeedImplTest { val feed = createFeed() val sourceFid = FeedId("user:source") val role = "member" - val follow = followData("source", "id") + val follow = followData(sourceFid = "user:source", targetFid = "group:id") // Set up initial feed state - val feedInfo = getOrCreateInfo(feedData()) - coEvery { feedsRepository.getOrCreateFeed(any()) } returns Result.success(feedInfo) - feed.getOrCreate() + setupInitialState(feed) coEvery { feedsRepository.acceptFollow(any()) } returns Result.success(follow) val result = feed.acceptFollow(sourceFid, role) assertEquals(follow, result.getOrNull()) - // State verification: the accept operation should succeed, indicating state was updated - assertTrue("Accept follow operation should succeed", result.isSuccess) + assertEquals(listOf(follow), feed.state.followers.value) } @Test fun `on rejectFollow, delegate to repository and update state`() = runTest { val feed = createFeed() val sourceFid = FeedId("user:source") - val follow = followData("source", "id") + val follow = followData(sourceFid = "user:source", targetFid = "group:id") - // Set up initial feed state - val feedInfo = getOrCreateInfo(feedData()) - coEvery { feedsRepository.getOrCreateFeed(any()) } returns Result.success(feedInfo) - feed.getOrCreate() + // Set up initial feed state with follower present + setupInitialState(feed, followers = listOf(follow)) coEvery { feedsRepository.rejectFollow(any()) } returns Result.success(follow) val result = feed.rejectFollow(sourceFid) assertEquals(follow, result.getOrNull()) - // State verification: the reject operation should succeed, indicating state was updated - assertTrue("Reject follow operation should succeed", result.isSuccess) + assertEquals(emptyList(), feed.state.followRequests.value) } @Test - fun `on queryMoreActivities, delegate to repository and update state`() = runTest { + fun `on queryMoreActivities, delegate to repository`() = runTest { val feed = createFeed() val limit = 10 val newActivity = activityData("activity-2") + val initialActivities = listOf(activityData("activity-1")) - // Set up initial state with pagination - val activities = - PaginationResult(listOf(activityData("activity-1")), PaginationData(next = "cursor")) - val feedInfo = getOrCreateInfo(feedData()).copy(activities = activities) - coEvery { feedsRepository.getOrCreateFeed(any()) } returns Result.success(feedInfo) - feed.getOrCreate() + setupInitialState(feed, activities = initialActivities) // Mock the next query - val nextFeedInfo = - feedInfo.copy(activities = PaginationResult(listOf(newActivity), PaginationData.EMPTY)) + val nextFeedInfo = getOrCreateInfo(feedData(), activities = listOf(newActivity)) coEvery { feedsRepository.getOrCreateFeed(any()) } returns Result.success(nextFeedInfo) val result = feed.queryMoreActivities(limit) assertEquals(listOf(newActivity), result.getOrNull()) - val stateActivities = feed.state.activities.value - assertTrue("Should have activities", stateActivities.isNotEmpty()) + assertEquals(initialActivities + newActivity, feed.state.activities.value) } @Test @@ -426,7 +436,7 @@ internal class FeedImplTest { } @Test - fun `on updateBookmark, delegate to repository`() = runTest { + fun `on updateBookmark, delegate to repository and fire event`() = runTest { val feed = createFeed() val activityId = "activity-1" val request = UpdateBookmarkRequest(folderId = "new-folder") @@ -438,10 +448,11 @@ internal class FeedImplTest { val result = feed.updateBookmark(activityId, request) assertEquals(bookmark, result.getOrNull()) + verify { stateEventListener.onEvent(StateUpdateEvent.BookmarkUpdated(bookmark)) } } @Test - fun `on getComment, delegate to repository`() = runTest { + fun `on getComment, delegate to repository and fire event`() = runTest { val feed = createFeed() val commentId = "comment-1" val comment = commentData(commentId) @@ -451,10 +462,11 @@ internal class FeedImplTest { val result = feed.getComment(commentId) assertEquals(comment, result.getOrNull()) + verify { stateEventListener.onEvent(StateUpdateEvent.CommentUpdated(comment)) } } @Test - fun `on updateComment, delegate to repository`() = runTest { + fun `on updateComment, delegate to repository and fire event`() = runTest { val feed = createFeed() val commentId = "comment-1" val request = UpdateCommentRequest(comment = "Updated comment") @@ -466,30 +478,37 @@ internal class FeedImplTest { val result = feed.updateComment(commentId, request) assertEquals(comment, result.getOrNull()) + verify { stateEventListener.onEvent(StateUpdateEvent.CommentUpdated(comment)) } } @Test - fun `on deleteComment, delegate to repository and update activity state`() = runTest { + fun `on deleteComment, delegate to repository and fire events`() = runTest { val feed = createFeed() val commentId = "comment-1" val hardDelete = true val activityId = "activity-1" val originalActivity = activityData(activityId, "Original activity") val updatedActivity = activityData(activityId, "Updated activity after comment deletion") - val deleteData = commentData() to updatedActivity + val comment = commentData(commentId) + val deleteData = comment to updatedActivity // Set up initial state with the original activity - val feedInfo = getOrCreateInfo(feedData(), listOf(originalActivity)) - coEvery { feedsRepository.getOrCreateFeed(any()) } returns Result.success(feedInfo) - feed.getOrCreate() + setupInitialState(feed, activities = listOf(originalActivity)) coEvery { commentsRepository.deleteComment(commentId, hardDelete) } returns Result.success(deleteData) val result = feed.deleteComment(commentId, hardDelete) + val updated = originalActivity.copy(text = updatedActivity.text) assertEquals(Unit, result.getOrNull()) - assertEquals(listOf(updatedActivity), feed.state.activities.value) + assertEquals(listOf(updated), feed.state.activities.value) + verify { + stateEventListener.onEvent(StateUpdateEvent.CommentDeleted("group:id", comment)) + stateEventListener.onEvent( + StateUpdateEvent.ActivityUpdated("group:id", updatedActivity) + ) + } } @Test @@ -517,6 +536,7 @@ internal class FeedImplTest { val result = feed.queryFeedMembers() assertEquals(members, result.getOrNull()) + assertEquals(members, feed.state.members.value) } @Test @@ -535,6 +555,7 @@ internal class FeedImplTest { val result = feed.queryMoreFeedMembers(limit) assertEquals(moreMembers, result.getOrNull()) + assertEquals(moreMembers, feed.state.members.value) } @Test @@ -549,19 +570,23 @@ internal class FeedImplTest { ModelUpdates( added = emptyList(), removedIds = emptyList(), - updated = listOf(feedMemberData("user1")), + updated = listOf(feedMemberData("user1", role = "member")), ) + // Set up initial state so members can be updated + setupInitialState(feed, members = listOf(feedMemberData("user1", role = "admin"))) + coEvery { feedsRepository.updateFeedMembers("group", "id", request) } returns Result.success(memberUpdates) val result = feed.updateFeedMembers(request) assertEquals(memberUpdates, result.getOrNull()) + assertEquals(memberUpdates.updated, feed.state.members.value) } @Test - fun `on acceptFeedMember, delegate to repository`() = runTest { + fun `on acceptFeedMember, delegate to repository and fire event`() = runTest { val feed = createFeed() val member = feedMemberData() @@ -570,10 +595,13 @@ internal class FeedImplTest { val result = feed.acceptFeedMember() assertEquals(member, result.getOrNull()) + verify { + stateEventListener.onEvent(StateUpdateEvent.FeedMemberUpdated("group:id", member)) + } } @Test - fun `on rejectFeedMember, delegate to repository`() = runTest { + fun `on rejectFeedMember, delegate to repository and fire event`() = runTest { val feed = createFeed() val member = feedMemberData() @@ -582,10 +610,13 @@ internal class FeedImplTest { val result = feed.rejectFeedMember() assertEquals(member, result.getOrNull()) + verify { + stateEventListener.onEvent(StateUpdateEvent.FeedMemberUpdated("group:id", member)) + } } @Test - fun `on addReaction, delegate to repository and update state`() = runTest { + fun `on addReaction, delegate to repository and fire event`() = runTest { val feed = createFeed() val activityId = "activity-1" val request = AddReactionRequest(type = "like") @@ -593,23 +624,29 @@ internal class FeedImplTest { val activity = activityData(activityId) // Set up initial state with activity - val feedInfo = getOrCreateInfo(feedData(), listOf(activity)) - coEvery { feedsRepository.getOrCreateFeed(any()) } returns Result.success(feedInfo) - feed.getOrCreate() + setupInitialState(feed, activities = listOf(activity)) coEvery { activitiesRepository.addReaction(activityId, request) } returns Result.success(reaction) val result = feed.addReaction(activityId, request) + val updated = + activity.copy( + ownReactions = activity.ownReactions + reaction, + latestReactions = activity.latestReactions + reaction, + reactionGroups = mapOf("like" to reactionGroupData(count = 1)), + reactionCount = 1, + ) assertEquals(reaction, result.getOrNull()) - val updatedActivity = feed.state.activities.value.first() - assertEquals(listOf(reaction), updatedActivity.ownReactions) - assertEquals(1, updatedActivity.reactionCount) + assertEquals(listOf(updated), feed.state.activities.value) + verify { + stateEventListener.onEvent(StateUpdateEvent.ActivityReactionAdded("group:id", reaction)) + } } @Test - fun `on deleteReaction, delegate to repository and update state`() = runTest { + fun `on deleteReaction, delegate to repository and fire event`() = runTest { val feed = createFeed() val activityId = "activity-1" val type = "like" @@ -625,46 +662,62 @@ internal class FeedImplTest { reactionCount = 1, reactionGroups = mapOf("like" to reactionGroupData(count = 1)), ) - val feedInfo = getOrCreateInfo(feedData(), listOf(activityWithReaction)) - coEvery { feedsRepository.getOrCreateFeed(any()) } returns Result.success(feedInfo) - feed.getOrCreate() + setupInitialState(feed, activities = listOf(activityWithReaction)) val result = feed.deleteReaction(activityId, type) + val updated = + activityWithReaction.copy( + ownReactions = emptyList(), + latestReactions = emptyList(), + reactionGroups = emptyMap(), + reactionCount = 0, + ) assertEquals(reaction, result.getOrNull()) - val updatedActivity = feed.state.activities.value.first() - assertTrue("There should be no reaction", updatedActivity.ownReactions.isEmpty()) - assertEquals(0, updatedActivity.reactionCount) + assertEquals(listOf(updated), feed.state.activities.value) + verify { + stateEventListener.onEvent( + StateUpdateEvent.ActivityReactionDeleted("group:id", reaction) + ) + } } @Test - fun `on addCommentReaction, delegate to repository`() = runTest { + fun `on addCommentReaction, delegate to repository and fire event`() = runTest { val feed = createFeed() val commentId = "comment-1" val request = AddCommentReactionRequest(type = "like") val reaction = feedsReactionData() + val comment = commentData(commentId) coEvery { commentsRepository.addCommentReaction(commentId, request) } returns - Result.success(Pair(reaction, commentData(commentId))) + Result.success(Pair(reaction, comment)) val result = feed.addCommentReaction(commentId, request) assertEquals(reaction, result.getOrNull()) + verify { + stateEventListener.onEvent(StateUpdateEvent.CommentReactionAdded(comment, reaction)) + } } @Test - fun `on deleteCommentReaction, delegate to repository`() = runTest { + fun `on deleteCommentReaction, delegate to repository and fire event`() = runTest { val feed = createFeed() val commentId = "comment-1" val type = "like" val reaction = feedsReactionData() + val comment = commentData(commentId) coEvery { commentsRepository.deleteCommentReaction(commentId, type) } returns - Result.success(Pair(reaction, commentData(commentId))) + Result.success(Pair(reaction, comment)) val result = feed.deleteCommentReaction(commentId, type) assertEquals(reaction, result.getOrNull()) + verify { + stateEventListener.onEvent(StateUpdateEvent.CommentReactionDeleted(comment, reaction)) + } } @Test @@ -692,6 +745,7 @@ internal class FeedImplTest { } ) } + verify { stateEventListener.onEvent(StateUpdateEvent.ActivityAdded("group:id", activity)) } } private fun createFeed(watch: Boolean = false) = @@ -703,26 +757,47 @@ internal class FeedImplTest { commentsRepository = commentsRepository, feedsRepository = feedsRepository, pollsRepository = pollsRepository, - socketSubscriptionManager = mockk(relaxed = true), - subscriptionManager = mockk(relaxed = true), + subscriptionManager = TestSubscriptionManager(stateEventListener), feedWatchHandler = feedWatchHandler, ) private fun getOrCreateInfo( testFeedData: FeedData, activities: List = emptyList(), - ): GetOrCreateInfo = - GetOrCreateInfo( - activities = PaginationResult(models = activities, pagination = PaginationData.EMPTY), + followers: List = emptyList(), + following: List = emptyList(), + followRequests: List = emptyList(), + members: List = emptyList(), + ): GetOrCreateInfo { + val paginationData = PaginationData(next = "cursor") + + return GetOrCreateInfo( + activities = PaginationResult(models = activities, pagination = paginationData), activitiesQueryConfig = QueryConfiguration(filter = null, sort = ActivitiesSort.Default), feed = testFeedData, - followers = emptyList(), - following = emptyList(), - followRequests = emptyList(), - members = PaginationResult(models = emptyList(), pagination = PaginationData.EMPTY), + followers = followers, + following = following, + followRequests = followRequests, + members = PaginationResult(models = members, pagination = paginationData), pinnedActivities = emptyList(), aggregatedActivities = emptyList(), notificationStatus = null, ) + } + + private suspend fun setupInitialState( + feed: FeedImpl, + feedData: FeedData = feedData(), + activities: List = emptyList(), + members: List = emptyList(), + followers: List = emptyList(), + following: List = emptyList(), + followRequests: List = emptyList(), + ) { + val feedInfo = + getOrCreateInfo(feedData, activities, followers, following, followRequests, members) + coEvery { feedsRepository.getOrCreateFeed(any()) } returns Result.success(feedInfo) + feed.getOrCreate() + } } diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FollowListStateImplTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FollowListStateImplTest.kt index 875533932..88f218c6a 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FollowListStateImplTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FollowListStateImplTest.kt @@ -56,8 +56,8 @@ internal class FollowListStateImplTest { val updatedFollow = followData( - sourceUserId = "user-1", - targetUserId = "user-2", + sourceFid = "user:user-1", + targetFid = "user:user-2", createdAt = java.util.Date(1000), ) followListState.onFollowUpdated(updatedFollow) diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/StateUpdateEventToModelTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/StateUpdateEventToModelTest.kt new file mode 100644 index 000000000..96e2de87c --- /dev/null +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/StateUpdateEventToModelTest.kt @@ -0,0 +1,384 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-feeds-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.feeds.android.client.internal.state.event + +import io.getstream.feeds.android.client.internal.test.TestData +import io.getstream.feeds.android.network.models.ActivityAddedEvent +import io.getstream.feeds.android.network.models.ActivityDeletedEvent +import io.getstream.feeds.android.network.models.ActivityPinnedEvent +import io.getstream.feeds.android.network.models.ActivityReactionAddedEvent +import io.getstream.feeds.android.network.models.ActivityReactionDeletedEvent +import io.getstream.feeds.android.network.models.ActivityRemovedFromFeedEvent +import io.getstream.feeds.android.network.models.ActivityUnpinnedEvent +import io.getstream.feeds.android.network.models.ActivityUpdatedEvent +import io.getstream.feeds.android.network.models.BookmarkAddedEvent +import io.getstream.feeds.android.network.models.BookmarkDeletedEvent +import io.getstream.feeds.android.network.models.BookmarkFolderDeletedEvent +import io.getstream.feeds.android.network.models.BookmarkFolderUpdatedEvent +import io.getstream.feeds.android.network.models.BookmarkUpdatedEvent +import io.getstream.feeds.android.network.models.CommentAddedEvent +import io.getstream.feeds.android.network.models.CommentDeletedEvent +import io.getstream.feeds.android.network.models.CommentReactionAddedEvent +import io.getstream.feeds.android.network.models.CommentReactionDeletedEvent +import io.getstream.feeds.android.network.models.CommentUpdatedEvent +import io.getstream.feeds.android.network.models.FeedDeletedEvent +import io.getstream.feeds.android.network.models.FeedMemberAddedEvent +import io.getstream.feeds.android.network.models.FeedMemberRemovedEvent +import io.getstream.feeds.android.network.models.FeedMemberUpdatedEvent +import io.getstream.feeds.android.network.models.FeedUpdatedEvent +import io.getstream.feeds.android.network.models.FollowCreatedEvent +import io.getstream.feeds.android.network.models.FollowDeletedEvent +import io.getstream.feeds.android.network.models.FollowUpdatedEvent +import io.getstream.feeds.android.network.models.NotificationFeedUpdatedEvent +import io.getstream.feeds.android.network.models.NotificationStatusResponse +import io.getstream.feeds.android.network.models.PollClosedFeedEvent +import io.getstream.feeds.android.network.models.PollDeletedFeedEvent +import io.getstream.feeds.android.network.models.PollUpdatedFeedEvent +import io.getstream.feeds.android.network.models.PollVoteCastedFeedEvent +import io.getstream.feeds.android.network.models.PollVoteChangedFeedEvent +import io.getstream.feeds.android.network.models.PollVoteRemovedFeedEvent +import io.getstream.feeds.android.network.models.WSEvent +import java.util.Date +import kotlin.reflect.KClass +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +internal class StateUpdateEventToModelTest( + private val testName: String, + private val wsEvent: WSEvent, + private val expectedType: KClass, +) { + + @Test + fun testToModel() { + val result = wsEvent.toModel() + + assertNotNull("Should not return null for $testName", result) + assertEquals("Wrong return type for $testName", expectedType, result!!::class) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data(): Collection> = + listOf( + activityAdded().shouldMapTo(), + activityDeleted().shouldMapTo(), + activityRemovedFromFeed().shouldMapTo(), + activityUpdated().shouldMapTo(), + activityPinned().shouldMapTo(), + activityUnpinned().shouldMapTo(), + activityReactionAdded().shouldMapTo(), + activityReactionDeleted().shouldMapTo(), + bookmarkAdded().shouldMapTo(), + bookmarkDeleted().shouldMapTo(), + bookmarkUpdated().shouldMapTo(), + bookmarkFolderDeleted().shouldMapTo(), + bookmarkFolderUpdated().shouldMapTo(), + commentAdded().shouldMapTo(), + commentUpdated().shouldMapTo(), + commentDeleted().shouldMapTo(), + commentReactionAdded().shouldMapTo(), + commentReactionDeleted().shouldMapTo(), + feedUpdated().shouldMapTo(), + feedDeleted().shouldMapTo(), + followCreated().shouldMapTo(), + followUpdated().shouldMapTo(), + followDeleted().shouldMapTo(), + notificationFeedUpdated().shouldMapTo(), + feedMemberAdded().shouldMapTo(), + feedMemberRemoved().shouldMapTo(), + feedMemberUpdated().shouldMapTo(), + pollClosedFeed().shouldMapTo(), + pollDeletedFeed().shouldMapTo(), + pollUpdatedFeed().shouldMapTo(), + pollVoteCastedFeed().shouldMapTo(), + pollVoteChangedFeed().shouldMapTo(), + pollVoteRemovedFeed().shouldMapTo(), + ) + + private inline fun WSEvent.shouldMapTo() = + arrayOf(this::class.simpleName.orEmpty(), this, S::class) + + private fun activityAdded() = + ActivityAddedEvent( + createdAt = Date(1000), + fid = "group:feed", + activity = TestData.activityResponse(), + type = "activity.added", + ) + + private fun activityDeleted() = + ActivityDeletedEvent( + createdAt = Date(1000), + fid = "group:feed", + activity = TestData.activityResponse(), + type = "activity.deleted", + ) + + private fun activityUpdated() = + ActivityUpdatedEvent( + createdAt = Date(1000), + fid = "group:feed", + activity = TestData.activityResponse(), + type = "activity.updated", + ) + + private fun activityRemovedFromFeed() = + ActivityRemovedFromFeedEvent( + createdAt = Date(1000), + fid = "group:feed", + activity = TestData.activityResponse(), + type = "activity.removed", + ) + + private fun activityPinned() = + ActivityPinnedEvent( + createdAt = Date(1000), + fid = "group:feed", + pinnedActivity = TestData.activityPinResponse(), + type = "activity.pinned", + ) + + private fun activityUnpinned() = + ActivityUnpinnedEvent( + createdAt = Date(1000), + fid = "group:feed", + pinnedActivity = TestData.activityPinResponse(), + type = "activity.unpinned", + ) + + private fun activityReactionAdded() = + ActivityReactionAddedEvent( + createdAt = Date(1000), + fid = "group:feed", + activity = TestData.activityResponse(), + reaction = TestData.feedsReactionResponse(), + type = "reaction.added", + ) + + private fun activityReactionDeleted() = + ActivityReactionDeletedEvent( + createdAt = Date(1000), + fid = "group:feed", + activity = TestData.activityResponse(), + reaction = TestData.feedsReactionResponse(), + type = "reaction.deleted", + ) + + private fun bookmarkAdded() = + BookmarkAddedEvent( + createdAt = Date(1000), + bookmark = TestData.bookmarkResponse(), + type = "bookmark.added", + ) + + private fun bookmarkDeleted() = + BookmarkDeletedEvent( + createdAt = Date(1000), + bookmark = TestData.bookmarkResponse(), + type = "bookmark.deleted", + ) + + private fun bookmarkUpdated() = + BookmarkUpdatedEvent( + createdAt = Date(1000), + bookmark = TestData.bookmarkResponse(), + type = "bookmark.updated", + ) + + private fun bookmarkFolderDeleted() = + BookmarkFolderDeletedEvent( + createdAt = Date(1000), + bookmarkFolder = TestData.bookmarkFolderResponse(), + type = "bookmark_folder.deleted", + ) + + private fun bookmarkFolderUpdated() = + BookmarkFolderUpdatedEvent( + createdAt = Date(1000), + bookmarkFolder = TestData.bookmarkFolderResponse(), + type = "bookmark_folder.updated", + ) + + private fun commentAdded() = + CommentAddedEvent( + createdAt = Date(1000), + fid = "group:feed", + activity = TestData.activityResponse(), + comment = TestData.commentResponse(), + type = "comment.added", + ) + + private fun commentUpdated() = + CommentUpdatedEvent( + createdAt = Date(1000), + fid = "group:feed", + comment = TestData.commentResponse(), + type = "comment.updated", + ) + + private fun commentDeleted() = + CommentDeletedEvent( + createdAt = Date(1000), + fid = "group:feed", + comment = TestData.commentResponse(), + type = "comment.deleted", + ) + + private fun commentReactionAdded() = + CommentReactionAddedEvent( + createdAt = Date(1000), + fid = "group:feed", + activity = TestData.activityResponse(), + comment = TestData.commentResponse(), + reaction = TestData.feedsReactionResponse(), + type = "comment_reaction.added", + ) + + private fun commentReactionDeleted() = + CommentReactionDeletedEvent( + createdAt = Date(1000), + fid = "group:feed", + comment = TestData.commentResponse(), + reaction = TestData.feedsReactionResponse(), + type = "comment_reaction.deleted", + ) + + private fun feedUpdated() = + FeedUpdatedEvent( + createdAt = Date(1000), + fid = "group:feed", + feed = TestData.feedResponse(), + type = "feed.updated", + ) + + private fun feedDeleted() = + FeedDeletedEvent(createdAt = Date(1000), fid = "group:feed", type = "feed.deleted") + + private fun followCreated() = + FollowCreatedEvent( + createdAt = Date(1000), + fid = "group:feed", + follow = TestData.followResponse(), + type = "follow.created", + ) + + private fun followUpdated() = + FollowUpdatedEvent( + createdAt = Date(1000), + fid = "group:feed", + follow = TestData.followResponse(), + type = "follow.updated", + ) + + private fun followDeleted() = + FollowDeletedEvent( + createdAt = Date(1000), + fid = "group:feed", + follow = TestData.followResponse(), + type = "follow.deleted", + ) + + private fun notificationFeedUpdated() = + NotificationFeedUpdatedEvent( + createdAt = Date(1000), + fid = "group:feed", + aggregatedActivities = emptyList(), + notificationStatus = NotificationStatusResponse(unread = 5, unseen = 3), + type = "notification_feed.updated", + ) + + private fun feedMemberAdded() = + FeedMemberAddedEvent( + createdAt = Date(1000), + fid = "group:feed", + member = TestData.feedMemberResponse(), + type = "feed_member.added", + ) + + private fun feedMemberRemoved() = + FeedMemberRemovedEvent( + createdAt = Date(1000), + fid = "group:feed", + memberId = "user-1", + type = "feed_member.removed", + ) + + private fun feedMemberUpdated() = + FeedMemberUpdatedEvent( + createdAt = Date(1000), + fid = "group:feed", + member = TestData.feedMemberResponse(), + type = "feed_member.updated", + ) + + private fun pollClosedFeed() = + PollClosedFeedEvent( + createdAt = Date(1000), + fid = "group:feed", + poll = TestData.pollResponseData(), + type = "poll.closed", + ) + + private fun pollDeletedFeed() = + PollDeletedFeedEvent( + createdAt = Date(1000), + fid = "group:feed", + poll = TestData.pollResponseData(), + type = "poll.deleted", + ) + + private fun pollUpdatedFeed() = + PollUpdatedFeedEvent( + createdAt = Date(1000), + fid = "group:feed", + poll = TestData.pollResponseData(), + type = "poll.updated", + ) + + private fun pollVoteCastedFeed() = + PollVoteCastedFeedEvent( + createdAt = Date(1000), + fid = "group:feed", + poll = TestData.pollResponseData(), + pollVote = TestData.pollVoteResponseData(), + type = "poll_vote.casted", + ) + + private fun pollVoteChangedFeed() = + PollVoteChangedFeedEvent( + createdAt = Date(1000), + fid = "group:feed", + poll = TestData.pollResponseData(), + pollVote = TestData.pollVoteResponseData(), + type = "poll_vote.changed", + ) + + private fun pollVoteRemovedFeed() = + PollVoteRemovedFeedEvent( + createdAt = Date(1000), + fid = "group:feed", + poll = TestData.pollResponseData(), + pollVote = TestData.pollVoteResponseData(), + type = "poll_vote.removed", + ) + } +} diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityCommentListEventHandlerTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityCommentListEventHandlerTest.kt index 97db8f650..fcb38fcaf 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityCommentListEventHandlerTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityCommentListEventHandlerTest.kt @@ -34,7 +34,7 @@ internal class ActivityCommentListEventHandlerTest { @Test fun `on CommentAdded for matching object, then call onCommentAdded`() { val comment = commentData(objectId = objectId, objectType = objectType) - val event = StateUpdateEvent.CommentAdded(comment) + val event = StateUpdateEvent.CommentAdded("feed-1", comment) handler.onEvent(event) @@ -44,7 +44,7 @@ internal class ActivityCommentListEventHandlerTest { @Test fun `on CommentAdded for different object, then do not call onCommentAdded`() { val comment = commentData(objectId = "different-activity", objectType = objectType) - val event = StateUpdateEvent.CommentAdded(comment) + val event = StateUpdateEvent.CommentAdded("feed-1", comment) handler.onEvent(event) @@ -54,7 +54,7 @@ internal class ActivityCommentListEventHandlerTest { @Test fun `on CommentDeleted for matching object, then call onCommentRemoved`() { val comment = commentData(objectId = objectId, objectType = objectType) - val event = StateUpdateEvent.CommentDeleted(comment) + val event = StateUpdateEvent.CommentDeleted("feed-1", comment) handler.onEvent(event) diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityListEventHandlerTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityListEventHandlerTest.kt index bebcd102e..b740f59d8 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityListEventHandlerTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityListEventHandlerTest.kt @@ -17,7 +17,6 @@ package io.getstream.feeds.android.client.internal.state.event.handler import io.getstream.feeds.android.client.internal.state.ActivityListStateUpdates import io.getstream.feeds.android.client.internal.state.event.StateUpdateEvent -import io.getstream.feeds.android.client.internal.test.TestData.activityData import io.getstream.feeds.android.client.internal.test.TestData.bookmarkData import io.getstream.feeds.android.client.internal.test.TestData.commentData import io.getstream.feeds.android.client.internal.test.TestData.feedsReactionData @@ -33,12 +32,11 @@ internal class ActivityListEventHandlerTest { @Test fun `on ActivityDeleted, then call onActivityRemoved`() { - val activity = activityData() - val event = StateUpdateEvent.ActivityDeleted(activity) + val event = StateUpdateEvent.ActivityDeleted("feed-1", "activity-1") handler.onEvent(event) - verify { state.onActivityRemoved(activity) } + verify { state.onActivityRemoved("activity-1") } } @Test @@ -84,7 +82,7 @@ internal class ActivityListEventHandlerTest { @Test fun `on CommentAdded, then call onCommentAdded`() { val comment = commentData() - val event = StateUpdateEvent.CommentAdded(comment) + val event = StateUpdateEvent.CommentAdded("feed-1", comment) handler.onEvent(event) @@ -94,7 +92,7 @@ internal class ActivityListEventHandlerTest { @Test fun `on CommentDeleted, then call onCommentRemoved`() { val comment = commentData() - val event = StateUpdateEvent.CommentDeleted(comment) + val event = StateUpdateEvent.CommentDeleted("feed-1", comment) handler.onEvent(event) diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/BookmarkListEventHandlerTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/BookmarkListEventHandlerTest.kt index befd9e105..ca859b21b 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/BookmarkListEventHandlerTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/BookmarkListEventHandlerTest.kt @@ -73,7 +73,7 @@ internal class BookmarkListEventHandlerTest { @Test fun `on unknown event, then do nothing`() { val comment = commentData() - val unknownEvent = StateUpdateEvent.CommentAdded(comment) + val unknownEvent = StateUpdateEvent.CommentAdded("feed-1", comment) handler.onEvent(unknownEvent) diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/CommentListEventHandlerTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/CommentListEventHandlerTest.kt index d25149ec8..58d2f9f99 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/CommentListEventHandlerTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/CommentListEventHandlerTest.kt @@ -41,7 +41,7 @@ internal class CommentListEventHandlerTest { @Test fun `on CommentDeletedEvent, then call onCommentRemoved`() { val comment = commentData() - val event = StateUpdateEvent.CommentDeleted(comment) + val event = StateUpdateEvent.CommentDeleted("feed-1", comment) handler.onEvent(event) @@ -51,7 +51,7 @@ internal class CommentListEventHandlerTest { @Test fun `on unknown event, then do nothing`() { val comment = commentData() - val unknownEvent = StateUpdateEvent.CommentAdded(comment) + val unknownEvent = StateUpdateEvent.CommentAdded("feed-1", comment) handler.onEvent(unknownEvent) diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/CommentReplyListEventHandlerTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/CommentReplyListEventHandlerTest.kt index 1f2fe664e..5737e88ed 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/CommentReplyListEventHandlerTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/CommentReplyListEventHandlerTest.kt @@ -33,7 +33,7 @@ internal class CommentReplyListEventHandlerTest { @Test fun `on CommentAdded, then call onCommentAdded`() { val comment = commentData() - val event = StateUpdateEvent.CommentAdded(comment) + val event = StateUpdateEvent.CommentAdded("feed-1", comment) handler.onEvent(event) @@ -43,7 +43,7 @@ internal class CommentReplyListEventHandlerTest { @Test fun `on CommentDeleted, then call onCommentRemoved`() { val comment = commentData() - val event = StateUpdateEvent.CommentDeleted(comment) + val event = StateUpdateEvent.CommentDeleted("feed-1", comment) handler.onEvent(event) diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedEventHandlerTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedEventHandlerTest.kt index 807808929..023d3b556 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedEventHandlerTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedEventHandlerTest.kt @@ -15,492 +15,423 @@ */ package io.getstream.feeds.android.client.internal.state.event.handler +import io.getstream.feeds.android.client.api.model.ActivityPinData +import io.getstream.feeds.android.client.api.model.AggregatedActivityData import io.getstream.feeds.android.client.api.model.FeedId -import io.getstream.feeds.android.client.api.model.toModel import io.getstream.feeds.android.client.internal.state.FeedStateUpdates -import io.getstream.feeds.android.client.internal.test.TestData.activityResponse -import io.getstream.feeds.android.client.internal.test.TestData.bookmarkResponse -import io.getstream.feeds.android.client.internal.test.TestData.commentResponse -import io.getstream.feeds.android.client.internal.test.TestData.feedResponse -import io.getstream.feeds.android.client.internal.test.TestData.feedsReactionResponse -import io.getstream.feeds.android.client.internal.test.TestData.followResponse -import io.getstream.feeds.android.client.internal.test.TestData.pinActivityResponse -import io.getstream.feeds.android.client.internal.test.TestData.pollResponseData -import io.getstream.feeds.android.client.internal.test.TestData.pollVoteResponseData -import io.getstream.feeds.android.network.models.ActivityAddedEvent -import io.getstream.feeds.android.network.models.ActivityDeletedEvent -import io.getstream.feeds.android.network.models.ActivityPinnedEvent -import io.getstream.feeds.android.network.models.ActivityReactionAddedEvent -import io.getstream.feeds.android.network.models.ActivityReactionDeletedEvent -import io.getstream.feeds.android.network.models.ActivityRemovedFromFeedEvent -import io.getstream.feeds.android.network.models.ActivityUnpinnedEvent -import io.getstream.feeds.android.network.models.ActivityUpdatedEvent -import io.getstream.feeds.android.network.models.BookmarkAddedEvent -import io.getstream.feeds.android.network.models.BookmarkDeletedEvent -import io.getstream.feeds.android.network.models.CommentAddedEvent -import io.getstream.feeds.android.network.models.CommentDeletedEvent -import io.getstream.feeds.android.network.models.FeedDeletedEvent -import io.getstream.feeds.android.network.models.FeedUpdatedEvent -import io.getstream.feeds.android.network.models.FollowCreatedEvent -import io.getstream.feeds.android.network.models.FollowDeletedEvent -import io.getstream.feeds.android.network.models.FollowUpdatedEvent -import io.getstream.feeds.android.network.models.NotificationFeedUpdatedEvent -import io.getstream.feeds.android.network.models.PollClosedFeedEvent -import io.getstream.feeds.android.network.models.PollDeletedFeedEvent -import io.getstream.feeds.android.network.models.PollUpdatedFeedEvent -import io.getstream.feeds.android.network.models.PollVoteCastedFeedEvent -import io.getstream.feeds.android.network.models.PollVoteChangedFeedEvent -import io.getstream.feeds.android.network.models.PollVoteRemovedFeedEvent -import io.getstream.feeds.android.network.models.WSEvent +import io.getstream.feeds.android.client.internal.state.event.StateUpdateEvent +import io.getstream.feeds.android.client.internal.test.TestData.activityData +import io.getstream.feeds.android.client.internal.test.TestData.bookmarkData +import io.getstream.feeds.android.client.internal.test.TestData.commentData +import io.getstream.feeds.android.client.internal.test.TestData.feedData +import io.getstream.feeds.android.client.internal.test.TestData.feedsReactionData +import io.getstream.feeds.android.client.internal.test.TestData.followData +import io.getstream.feeds.android.client.internal.test.TestData.pollData +import io.getstream.feeds.android.client.internal.test.TestData.pollVoteData +import io.getstream.feeds.android.network.models.NotificationStatusResponse import io.mockk.called +import io.mockk.clearMocks import io.mockk.mockk import io.mockk.verify import java.util.Date import org.junit.Test internal class FeedEventHandlerTest { - private val fid = FeedId("user", "feed-1") + private val fid = FeedId("group", "feed-1") private val state: FeedStateUpdates = mockk(relaxed = true) private val handler = FeedEventHandler(fid, state) @Test - fun `on ActivityAddedEvent for matching feed, then call onActivityAdded`() { - val activity = activityResponse() - val event = - ActivityAddedEvent( - createdAt = Date(), - fid = fid.rawValue, - activity = activity, - type = "feeds.activity.added", - ) - - handler.onEvent(event) - - verify { state.onActivityAdded(activity.toModel()) } + fun `on ActivityAdded, then handle based on feed match`() { + val activity = activityData() + val matchingEvent = StateUpdateEvent.ActivityAdded(fid.rawValue, activity) + val nonMatchingEvent = StateUpdateEvent.ActivityAdded("group:different", activity) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onActivityAdded(activity) }, + ) } @Test - fun `on ActivityAddedEvent for different feed, then do not call onActivityAdded`() { - val activity = activityResponse() - val event = - ActivityAddedEvent( - createdAt = Date(), - fid = "user:different-feed", - activity = activity, - type = "feeds.activity.added", - ) - - handler.onEvent(event) - - verify(exactly = 0) { state.onActivityAdded(any()) } + fun `on ActivityRemovedFromFeed, then handle based on feed match`() { + val activityId = "activity-1" + val matchingEvent = StateUpdateEvent.ActivityRemovedFromFeed(fid.rawValue, activityId) + val nonMatchingEvent = + StateUpdateEvent.ActivityRemovedFromFeed("group:different", activityId) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onActivityRemoved(activityId) }, + ) } @Test - fun `on ActivityRemovedFromFeedEvent for matching feed, then call onActivityRemoved`() { - val activity = activityResponse() - val event = - ActivityRemovedFromFeedEvent( - createdAt = Date(), - fid = fid.rawValue, - activity = activity, - type = "feeds.activity.removed_from_feed", - ) - - handler.onEvent(event) - - verify { state.onActivityRemoved(activity.id) } + fun `on ActivityDeleted, then handle based on feed match`() { + val matchingEvent = StateUpdateEvent.ActivityDeleted(fid.rawValue, "activity-1") + val nonMatchingEvent = StateUpdateEvent.ActivityDeleted("group:different", "activity-1") + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onActivityRemoved("activity-1") }, + ) } @Test - fun `on ActivityDeletedEvent for matching feed, then call onActivityRemoved`() { - val activity = activityResponse() - val event = - ActivityDeletedEvent( - createdAt = Date(), - fid = fid.rawValue, - activity = activity, - type = "feeds.activity.deleted", - ) - - handler.onEvent(event) - - verify { state.onActivityRemoved(activity.id) } + fun `on ActivityUpdated, then handle based on feed match`() { + val activity = activityData() + val matchingEvent = StateUpdateEvent.ActivityUpdated(fid.rawValue, activity) + val nonMatchingEvent = StateUpdateEvent.ActivityUpdated("group:different", activity) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onActivityUpdated(activity) }, + ) } @Test - fun `on ActivityUpdatedEvent for matching feed, then call onActivityUpdated`() { - val activity = activityResponse() - val event = - ActivityUpdatedEvent( - createdAt = Date(), - fid = fid.rawValue, - activity = activity, - type = "feeds.activity.updated", - ) - - handler.onEvent(event) - - verify { state.onActivityUpdated(activity.toModel()) } + fun `on ActivityReactionAdded, then handle based on feed match`() { + val reaction = feedsReactionData("activity-1") + val matchingEvent = StateUpdateEvent.ActivityReactionAdded(fid.rawValue, reaction) + val nonMatchingEvent = StateUpdateEvent.ActivityReactionAdded("group:different", reaction) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onReactionAdded(reaction) }, + ) } @Test - fun `on ActivityReactionAddedEvent for matching feed, then call onReactionAdded`() { - val activity = activityResponse() - val reaction = feedsReactionResponse() - val event = - ActivityReactionAddedEvent( - createdAt = Date(), - fid = fid.rawValue, - activity = activity, - reaction = reaction, - type = "feeds.activity.reaction.added", - ) - - handler.onEvent(event) - - verify { state.onReactionAdded(reaction.toModel()) } + fun `on ActivityReactionDeleted, then handle based on feed match`() { + val reaction = feedsReactionData("activity-1") + val matchingEvent = StateUpdateEvent.ActivityReactionDeleted(fid.rawValue, reaction) + val nonMatchingEvent = StateUpdateEvent.ActivityReactionDeleted("group:different", reaction) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onReactionRemoved(reaction) }, + ) } @Test - fun `on ActivityReactionDeletedEvent for matching feed, then call onReactionRemoved`() { - val activity = activityResponse() - val reaction = feedsReactionResponse() - val event = - ActivityReactionDeletedEvent( - createdAt = Date(), - fid = fid.rawValue, + fun `on ActivityPinned, then handle based on feed match`() { + val activity = activityData() + val pinnedActivity = + ActivityPinData( activity = activity, - reaction = reaction, - type = "feeds.activity.reaction.deleted", - ) - - handler.onEvent(event) - - verify { state.onReactionRemoved(reaction.toModel()) } - } - - @Test - fun `on ActivityPinnedEvent for matching feed, then call onActivityPinned`() { - val pinnedActivity = pinActivityResponse() - val event = - ActivityPinnedEvent( createdAt = Date(), - fid = fid.rawValue, - pinnedActivity = pinnedActivity, - type = "feeds.activity.pinned", + fid = fid, + updatedAt = Date(), + userId = "user-1", ) - - handler.onEvent(event) - - verify { state.onActivityPinned(pinnedActivity.toModel()) } + val matchingEvent = StateUpdateEvent.ActivityPinned(fid.rawValue, pinnedActivity) + val nonMatchingEvent = StateUpdateEvent.ActivityPinned("group:different", pinnedActivity) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onActivityPinned(pinnedActivity) }, + ) } @Test - fun `on ActivityUnpinnedEvent for matching feed, then call onActivityUnpinned`() { - val pinnedActivity = pinActivityResponse() - val event = - ActivityUnpinnedEvent( - createdAt = Date(), - fid = fid.rawValue, - pinnedActivity = pinnedActivity, - type = "feeds.activity.unpinned", - ) - - handler.onEvent(event) - - verify { state.onActivityUnpinned(pinnedActivity.activity.id) } + fun `on ActivityUnpinned, then handle based on feed match`() { + val activityId = "activity-1" + val matchingEvent = StateUpdateEvent.ActivityUnpinned(fid.rawValue, activityId) + val nonMatchingEvent = StateUpdateEvent.ActivityUnpinned("group:different", activityId) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onActivityUnpinned(activityId) }, + ) } @Test - fun `on BookmarkAddedEvent for activity in feed, then call onBookmarkAdded`() { - val bookmark = - bookmarkResponse() - .copy( - activity = activityResponse().copy(feeds = listOf(fid.rawValue, "other:feed")) - ) - val event = - BookmarkAddedEvent( - createdAt = Date(), - bookmark = bookmark, - type = "feeds.bookmark.added", - ) - - handler.onEvent(event) - - verify { state.onBookmarkAdded(bookmark.toModel()) } + fun `on BookmarkAdded, then handle based on activity feed match`() { + val matchingActivity = activityData().copy(feeds = listOf(fid.rawValue, "other:feed")) + val matchingBookmark = bookmarkData().copy(activity = matchingActivity) + val matchingEvent = StateUpdateEvent.BookmarkAdded(matchingBookmark) + + val nonMatchingActivity = activityData().copy(feeds = listOf("other:feed", "another:feed")) + val nonMatchingBookmark = bookmarkData().copy(activity = nonMatchingActivity) + val nonMatchingEvent = StateUpdateEvent.BookmarkAdded(nonMatchingBookmark) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onBookmarkAdded(matchingBookmark) }, + ) } @Test - fun `on BookmarkAddedEvent for activity not in feed, then do not call onBookmarkAdded`() { - val bookmark = - bookmarkResponse() - .copy( - activity = activityResponse().copy(feeds = listOf("other:feed", "another:feed")) - ) - val event = - BookmarkAddedEvent( - createdAt = Date(), - bookmark = bookmark, - type = "feeds.bookmark.added", - ) - - handler.onEvent(event) - - verify(exactly = 0) { state.onBookmarkAdded(any()) } + fun `on BookmarkDeleted, then handle based on activity feed match`() { + val matchingActivity = activityData().copy(feeds = listOf(fid.rawValue)) + val matchingBookmark = bookmarkData().copy(activity = matchingActivity) + val matchingEvent = StateUpdateEvent.BookmarkDeleted(matchingBookmark) + + val nonMatchingActivity = activityData().copy(feeds = listOf("other:feed")) + val nonMatchingBookmark = bookmarkData().copy(activity = nonMatchingActivity) + val nonMatchingEvent = StateUpdateEvent.BookmarkDeleted(nonMatchingBookmark) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onBookmarkRemoved(matchingBookmark) }, + ) } @Test - fun `on BookmarkDeletedEvent for activity in feed, then call onBookmarkRemoved`() { - val bookmark = - bookmarkResponse() - .copy(activity = activityResponse().copy(feeds = listOf(fid.rawValue))) - val event = - BookmarkDeletedEvent( - createdAt = Date(), - bookmark = bookmark, - type = "feeds.bookmark.deleted", - ) - - handler.onEvent(event) - - verify { state.onBookmarkRemoved(bookmark.toModel()) } + fun `on CommentAdded, then handle based on feed match`() { + val comment = commentData() + val matchingEvent = StateUpdateEvent.CommentAdded(fid.rawValue, comment) + val nonMatchingEvent = StateUpdateEvent.CommentAdded("group:different", comment) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onCommentAdded(comment) }, + ) } @Test - fun `on CommentAddedEvent for matching feed, then call onCommentAdded`() { - val comment = commentResponse() - val event = - CommentAddedEvent( - createdAt = Date(), - fid = fid.rawValue, - comment = comment, - type = "feeds.comment.added", - activity = activityResponse(), - ) - - handler.onEvent(event) - - verify { state.onCommentAdded(comment.toModel()) } + fun `on CommentDeleted, then handle based on feed match`() { + val comment = commentData() + val matchingEvent = StateUpdateEvent.CommentDeleted(fid.rawValue, comment) + val nonMatchingEvent = StateUpdateEvent.CommentDeleted("group:different", comment) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onCommentRemoved(comment) }, + ) } @Test - fun `on CommentDeletedEvent for matching feed, then call onCommentRemoved`() { - val comment = commentResponse() - val event = - CommentDeletedEvent( - createdAt = Date(), - fid = fid.rawValue, - comment = comment, - type = "feeds.comment.deleted", - ) - - handler.onEvent(event) - - verify { state.onCommentRemoved(comment.toModel()) } + fun `on FeedDeleted, then handle based on feed match`() { + val matchingEvent = StateUpdateEvent.FeedDeleted(fid.rawValue) + val nonMatchingEvent = StateUpdateEvent.FeedDeleted("group:different") + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onFeedDeleted() }, + ) } @Test - fun `on FeedDeletedEvent for matching feed, then call onFeedDeleted`() { - val event = - FeedDeletedEvent(createdAt = Date(), fid = fid.rawValue, type = "feeds.feed.deleted") - - handler.onEvent(event) - - verify { state.onFeedDeleted() } + fun `on FeedUpdated, then handle based on feed match`() { + val matchingFeed = feedData(id = fid.id, groupId = fid.group) + val matchingEvent = StateUpdateEvent.FeedUpdated(matchingFeed) + + val nonMatchingFeed = feedData(id = "group:different", groupId = "group") + val nonMatchingEvent = StateUpdateEvent.FeedUpdated(nonMatchingFeed) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onFeedUpdated(matchingFeed) }, + ) } @Test - fun `on FeedUpdatedEvent for matching feed, then call onFeedUpdated`() { - val feed = feedResponse() - val event = - FeedUpdatedEvent( - createdAt = Date(), - fid = fid.rawValue, - feed = feed, - type = "feeds.feed.updated", - ) - - handler.onEvent(event) - - verify { state.onFeedUpdated(feed.toModel()) } + fun `on FollowAdded, then handle based on feed match`() { + val matchingFollow = followData(sourceFid = fid.rawValue) + val matchingEvent = StateUpdateEvent.FollowAdded(matchingFollow) + + val nonMatchingFollow = followData(sourceFid = "other:feed", targetFid = "another:feed") + val nonMatchingEvent = StateUpdateEvent.FollowAdded(nonMatchingFollow) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onFollowAdded(matchingFollow) }, + ) } @Test - fun `on FollowCreatedEvent for matching feed, then call onFollowAdded`() { - val follow = followResponse() - val event = - FollowCreatedEvent( - createdAt = Date(), - fid = fid.rawValue, - follow = follow, - type = "feeds.follow.created", - ) - - handler.onEvent(event) - - verify { state.onFollowAdded(follow.toModel()) } + fun `on FollowDeleted, then handle based on feed match`() { + val matchingFollow = followData(sourceFid = fid.rawValue) + val matchingEvent = StateUpdateEvent.FollowDeleted(matchingFollow) + + val nonMatchingFollow = followData(sourceFid = "other:feed", targetFid = "another:feed") + val nonMatchingEvent = StateUpdateEvent.FollowDeleted(nonMatchingFollow) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onFollowRemoved(matchingFollow) }, + ) } @Test - fun `on FollowDeletedEvent for matching feed, then call onFollowRemoved`() { - val follow = followResponse() - val event = - FollowDeletedEvent( - createdAt = Date(), - fid = fid.rawValue, - follow = follow, - type = "feeds.follow.deleted", - ) - - handler.onEvent(event) - - verify { state.onFollowRemoved(follow.toModel()) } + fun `on FollowUpdated, then handle based on feed match`() { + val matchingFollow = followData(sourceFid = fid.rawValue) + val matchingEvent = StateUpdateEvent.FollowUpdated(matchingFollow) + + val nonMatchingFollow = followData(sourceFid = "other:feed", targetFid = "another:feed") + val nonMatchingEvent = StateUpdateEvent.FollowUpdated(nonMatchingFollow) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onFollowUpdated(matchingFollow) }, + ) } @Test - fun `on FollowUpdatedEvent for matching feed, then call onFollowUpdated`() { - val follow = followResponse() - val event = - FollowUpdatedEvent( + fun `on NotificationFeedUpdated, then handle based on feed match`() { + val activity = activityData() + val aggregatedActivity = + AggregatedActivityData( + activities = listOf(activity), + activityCount = 1, createdAt = Date(), - fid = fid.rawValue, - follow = follow, - type = "feeds.follow.updated", + group = "test-group", + score = 1.0f, + updatedAt = Date(), + userCount = 1, + userCountTruncated = false, + ) + val aggregatedActivities = listOf(aggregatedActivity) + val notificationStatus = NotificationStatusResponse(unread = 0, unseen = 1) + val matchingEvent = + StateUpdateEvent.NotificationFeedUpdated( + fid.rawValue, + aggregatedActivities, + notificationStatus, + ) + val nonMatchingEvent = + StateUpdateEvent.NotificationFeedUpdated( + "group:different", + aggregatedActivities, + notificationStatus, ) - handler.onEvent(event) - - verify { state.onFollowUpdated(follow.toModel()) } + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { + state.onNotificationFeedUpdated(aggregatedActivities, notificationStatus) + }, + ) } @Test - fun `on NotificationFeedUpdatedEvent, then call onNotificationFeedUpdated`() { - val event = - NotificationFeedUpdatedEvent( - createdAt = Date(), - fid = fid.rawValue, - aggregatedActivities = emptyList(), - notificationStatus = null, - type = "feeds.notification.updated", - ) - - handler.onEvent(event) - - verify { state.onNotificationFeedUpdated(emptyList(), null) } + fun `on PollClosed, then handle based on feed match`() { + val poll = pollData() + val matchingEvent = StateUpdateEvent.PollClosed(fid.rawValue, poll) + val nonMatchingEvent = StateUpdateEvent.PollClosed("group:different", poll) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onPollClosed(poll.id) }, + ) } @Test - fun `on PollClosedFeedEvent for matching feed, then call onPollClosed`() { - val poll = pollResponseData() - val event = - PollClosedFeedEvent( - createdAt = Date(), - fid = fid.rawValue, - poll = poll, - type = "feeds.poll.closed", - ) - - handler.onEvent(event) - - verify { state.onPollClosed(poll.id) } + fun `on PollDeleted, then handle based on feed match`() { + val pollId = "poll-1" + val matchingEvent = StateUpdateEvent.PollDeleted(fid.rawValue, pollId) + val nonMatchingEvent = StateUpdateEvent.PollDeleted("group:different", pollId) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onPollDeleted(pollId) }, + ) } @Test - fun `on PollDeletedFeedEvent for matching feed, then call onPollDeleted`() { - val poll = pollResponseData() - val event = - PollDeletedFeedEvent( - createdAt = Date(), - fid = fid.rawValue, - poll = poll, - type = "feeds.poll.deleted", - ) - - handler.onEvent(event) - - verify { state.onPollDeleted(poll.id) } + fun `on PollUpdated, then handle based on feed match`() { + val poll = pollData() + val matchingEvent = StateUpdateEvent.PollUpdated(fid.rawValue, poll) + val nonMatchingEvent = StateUpdateEvent.PollUpdated("group:different", poll) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onPollUpdated(poll) }, + ) } @Test - fun `on PollUpdatedFeedEvent for matching feed, then call onPollUpdated`() { - val poll = pollResponseData() - val event = - PollUpdatedFeedEvent( - createdAt = Date(), - fid = fid.rawValue, - poll = poll, - type = "feeds.poll.updated", - ) - - handler.onEvent(event) - - verify { state.onPollUpdated(poll.toModel()) } + fun `on PollVoteCasted, then handle based on feed match`() { + val pollId = "poll-1" + val pollVote = pollVoteData() + val matchingEvent = StateUpdateEvent.PollVoteCasted(fid.rawValue, pollId, pollVote) + val nonMatchingEvent = StateUpdateEvent.PollVoteCasted("group:different", pollId, pollVote) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onPollVoteCasted(pollVote, pollId) }, + ) } @Test - fun `on PollVoteCastedFeedEvent for matching feed, then call onPollVoteCasted`() { - val poll = pollResponseData() - val pollVote = pollVoteResponseData() - val event = - PollVoteCastedFeedEvent( - createdAt = Date(), - fid = fid.rawValue, - poll = poll, - pollVote = pollVote, - type = "feeds.poll.vote.casted", - ) - - handler.onEvent(event) - - verify { state.onPollVoteCasted(pollVote.toModel(), poll.id) } + fun `on PollVoteChanged, then handle based on feed match`() { + val pollId = "poll-1" + val pollVote = pollVoteData() + val matchingEvent = StateUpdateEvent.PollVoteChanged(fid.rawValue, pollId, pollVote) + val nonMatchingEvent = StateUpdateEvent.PollVoteChanged("group:different", pollId, pollVote) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onPollVoteChanged(pollVote, pollId) }, + ) } @Test - fun `on PollVoteChangedFeedEvent for matching feed, then call onPollVoteChanged`() { - val poll = pollResponseData() - val pollVote = pollVoteResponseData() - val event = - PollVoteChangedFeedEvent( - createdAt = Date(), - fid = fid.rawValue, - poll = poll, - pollVote = pollVote, - type = "feeds.poll.vote.changed", - ) - - handler.onEvent(event) - - verify { state.onPollVoteChanged(pollVote.toModel(), poll.id) } + fun `on PollVoteRemoved, then handle based on feed match`() { + val pollId = "poll-1" + val pollVote = pollVoteData() + val matchingEvent = StateUpdateEvent.PollVoteRemoved(fid.rawValue, pollId, pollVote) + val nonMatchingEvent = StateUpdateEvent.PollVoteRemoved("group:different", pollId, pollVote) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onPollVoteRemoved(pollVote, pollId) }, + ) } @Test - fun `on PollVoteRemovedFeedEvent for matching feed, then call onPollVoteRemoved`() { - val poll = pollResponseData() - val pollVote = pollVoteResponseData() - val event = - PollVoteRemovedFeedEvent( - createdAt = Date(), - fid = fid.rawValue, - poll = poll, - pollVote = pollVote, - type = "feeds.poll.vote.removed", + fun `on unknown event, then do nothing`() { + val unknownEvent = + StateUpdateEvent.FeedMemberAdded( + "other:feed", + io.getstream.feeds.android.client.internal.test.TestData.feedMemberData(), ) - handler.onEvent(event) + handler.onEvent(unknownEvent) - verify { state.onPollVoteRemoved(pollVote.toModel(), poll.id) } + verify { state wasNot called } } - @Test - fun `on unknown event, then do nothing`() { - val unknownEvent = - object : WSEvent { - override fun getWSEventType(): String = "unknown.event" - } + private fun testEventHandling( + matchingEvent: StateUpdateEvent, + nonMatchingEvent: StateUpdateEvent, + verifyBlock: () -> Unit, + ) { + // Test matching event + handler.onEvent(matchingEvent) + verify { verifyBlock() } - handler.onEvent(unknownEvent) + // Reset mock for clean verification + clearMocks(state) + // Test non-matching event + handler.onEvent(nonMatchingEvent) verify { state wasNot called } } } diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/test/TestData.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/test/TestData.kt index 0266b7a9f..a893815af 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/test/TestData.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/test/TestData.kt @@ -355,12 +355,14 @@ internal object TestData { ) fun followData( - sourceUserId: String = "user-1", - targetUserId: String = "user-2", + sourceFid: String = "user:user-1", + targetFid: String = "user:user-2", createdAt: Date = Date(1000), updatedAt: Date = Date(1000), - ): FollowData = - FollowData( + ): FollowData { + val source = FeedId(sourceFid) + val target = FeedId(targetFid) + return FollowData( createdAt = createdAt, custom = emptyMap(), followerRole = "user", @@ -370,16 +372,16 @@ internal object TestData { sourceFeed = FeedData( createdAt = createdAt, - createdBy = userData(sourceUserId), + createdBy = userData(source.id), custom = emptyMap(), deletedAt = null, description = "Test feed", - fid = FeedId("user:$sourceUserId"), + fid = source, filterTags = emptyList(), followerCount = 0, followingCount = 0, groupId = "user", - id = sourceUserId, + id = source.id, memberCount = 0, ownCapabilities = emptyList(), ownMembership = null, @@ -392,16 +394,16 @@ internal object TestData { targetFeed = FeedData( createdAt = createdAt, - createdBy = userData(targetUserId), + createdBy = userData(target.id), custom = emptyMap(), deletedAt = null, description = "Target feed", - fid = FeedId("user:$targetUserId"), + fid = target, filterTags = emptyList(), followerCount = 0, followingCount = 0, groupId = "user", - id = targetUserId, + id = target.id, memberCount = 0, ownCapabilities = emptyList(), ownMembership = null, @@ -412,6 +414,7 @@ internal object TestData { ), updatedAt = updatedAt, ) + } fun feedMemberData( userId: String = "user-1", @@ -716,4 +719,13 @@ internal object TestData { models = list, pagination = PaginationData(next = "next-cursor", previous = null), ) + + fun activityPinResponse() = + PinActivityResponse( + activity = activityResponse(), + createdAt = Date(1000), + duration = "duration", + feed = "user:feed-1", + userId = "user-1", + ) }