diff --git a/melos.yaml b/melos.yaml index 47d94006..b7d2fb8d 100644 --- a/melos.yaml +++ b/melos.yaml @@ -48,7 +48,7 @@ command: shared_preferences: ^2.5.3 state_notifier: ^1.0.0 stream_feeds: ^0.5.0 - stream_core: ^0.3.2 + stream_core: ^0.3.3 video_player: ^2.10.0 uuid: ^4.5.1 diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index bb55530b..3b7363e0 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -1,3 +1,6 @@ +## Upcoming +- Added missing state updates for the websocket events. + ## 0.5.0 - [BREAKING] Unified `ThreadedCommentData` into `CommentData` to handle both flat and threaded comments. - [BREAKING] Renamed `ActivitiesFilterField.type` to `ActivitiesFilterField.activityType`. diff --git a/packages/stream_feeds/dart_test.yaml b/packages/stream_feeds/dart_test.yaml index 29d8c707..e66bfe9d 100644 --- a/packages/stream_feeds/dart_test.yaml +++ b/packages/stream_feeds/dart_test.yaml @@ -1,11 +1,17 @@ tags: - feed: - feed-list: activity: + activity-comment-list: activity-list: - bookmark-list: + activity-reaction-list: bookmark-folder-list: + bookmark-list: comment-list: + comment-reaction-list: + comment-reply-list: + feed: + feed-list: follow-list: + member-list: + moderation-config-list: poll-list: poll-vote-list: \ No newline at end of file diff --git a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart index 95fe2ca5..bee8acd5 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -388,6 +388,7 @@ class StreamFeedsClientImpl implements StreamFeedsClient { query: query, commentsRepository: _commentsRepository, eventsEmitter: events, + currentUserId: user.id, ); } @@ -444,6 +445,7 @@ class StreamFeedsClientImpl implements StreamFeedsClient { query: query, pollsRepository: _pollsRepository, eventsEmitter: events, + currentUserId: user.id, ); } diff --git a/packages/stream_feeds/lib/src/models/activity_data.dart b/packages/stream_feeds/lib/src/models/activity_data.dart index 25f37fef..56902537 100644 --- a/packages/stream_feeds/lib/src/models/activity_data.dart +++ b/packages/stream_feeds/lib/src/models/activity_data.dart @@ -341,6 +341,22 @@ extension ActivityDataMutations on ActivityData { ); } + /// Conditionally updates this activity based on a filter. + /// + /// If [filter] returns true for this activity, applies the [update] function + /// to produce a new updated activity. Otherwise, returns this activity unchanged. + /// + /// This is useful for applying updates only when certain conditions are met. + /// + /// Returns a new [ActivityData] instance, either updated or unchanged. + ActivityData updateIf({ + required bool Function(ActivityData) filter, + required ActivityData Function(ActivityData) update, + }) { + if (!filter(this)) return this; + return update(this); + } + /// Adds or updates a comment in this activity. /// /// Updates the comments list by adding or updating [comment]. If the comment already @@ -381,6 +397,75 @@ extension ActivityDataMutations on ActivityData { ); } + /// Adds or updates a reaction in a comment within this activity with unique enforcement. + /// + /// Updates the own reactions list of the comment by adding or updating [reaction]. Only adds reactions + /// that belong to [currentUserId]. When unique enforcement is enabled, replaces any + /// existing reaction from the same user. + /// + /// Returns a new [ActivityData] instance with the updated comment reactions. + ActivityData upsertUniqueCommentReaction( + CommentData updatedComment, + FeedsReactionData reaction, + String currentUserId, + ) { + return upsertCommentReaction( + updatedComment, + reaction, + currentUserId, + enforceUnique: true, + ); + } + + /// Adds or updates a reaction in a comment within this activity. + /// + /// Updates the own reactions list of the comment by adding or updating [reaction]. Only adds reactions + /// that belong to [currentUserId]. When [enforceUnique] is true, replaces any existing + /// reaction from the same user; otherwise, allows multiple reactions from the same user. + /// + /// Returns a new [ActivityData] instance with the updated comment reactions. + ActivityData upsertCommentReaction( + CommentData updatedComment, + FeedsReactionData reaction, + String currentUserId, { + bool enforceUnique = false, + }) { + final updatedComments = comments.updateWhere( + (it) => it.id == updatedComment.id, + update: (it) => it.upsertReaction( + updatedComment, + reaction, + currentUserId, + enforceUnique: enforceUnique, + ), + ); + + return copyWith(comments: updatedComments); + } + + /// Removes a reaction from a comment within this activity. + /// + /// Updates the own reactions list of the comment by removing [reaction]. Only removes reactions + /// that belong to [currentUserId]. + /// + /// Returns a new [ActivityData] instance with the updated comment reactions. + ActivityData removeCommentReaction( + CommentData updatedComment, + FeedsReactionData reaction, + String currentUserId, + ) { + final updatedComments = comments.updateWhere( + (it) => it.id == updatedComment.id, + update: (it) => it.removeReaction( + updatedComment, + reaction, + currentUserId, + ), + ); + + return copyWith(comments: updatedComments); + } + /// Adds or updates a bookmark in this activity. /// /// Updates the own bookmarks list by adding or updating [bookmark]. Only adds bookmarks diff --git a/packages/stream_feeds/lib/src/models/mark_activity_data.dart b/packages/stream_feeds/lib/src/models/mark_activity_data.dart index 82d9f67f..a953502a 100644 --- a/packages/stream_feeds/lib/src/models/mark_activity_data.dart +++ b/packages/stream_feeds/lib/src/models/mark_activity_data.dart @@ -45,8 +45,8 @@ class MarkActivityData with _$MarkActivityData { final List? markWatched; } -extension MarkActivityDataHandler on MarkActivityData { - R handle({ +extension MarkActivityDataHandler on MarkActivityData { + R handle({ R Function()? markAllRead, R Function()? markAllSeen, R Function(Set read)? markRead, diff --git a/packages/stream_feeds/lib/src/models/poll_data.dart b/packages/stream_feeds/lib/src/models/poll_data.dart index b928cf4a..28549508 100644 --- a/packages/stream_feeds/lib/src/models/poll_data.dart +++ b/packages/stream_feeds/lib/src/models/poll_data.dart @@ -176,10 +176,10 @@ extension PollDataMutations on PollData { /// exists (by ID), it will be updated. /// /// Returns a new [PollData] instance with the updated options. - PollData addOption(PollOptionData option) { + PollData upsertOption(PollOptionData option) { final updatedOptions = options.upsert( option, - key: (it) => it.id == option.id, + key: (it) => it.id, ); return copyWith(options: updatedOptions); @@ -191,44 +191,97 @@ extension PollDataMutations on PollData { /// /// Returns a new [PollData] instance with the updated options. PollData removeOption(String optionId) { - final updatedOptions = options.where((it) => it.id != optionId).toList(); + final updatedOptions = options.where((it) { + return it.id != optionId; + }).toList(); return copyWith(options: updatedOptions); } - /// Updates an existing option in this poll. + /// Adds or updates a vote in this poll. /// - /// Updates the options list by replacing the option with the same ID as [option] - /// with the new [option] data. + /// Updates the own votes and answers list by adding or updating [vote]. Only adds votes + /// that belong to [currentUserId]. /// - /// Returns a new [PollData] instance with the updated options. - PollData updateOption(PollOptionData option) { - final updatedOptions = options.map((it) { - if (it.id != option.id) return it; - return option; - }).toList(); + /// Returns a new [PollData] instance with the updated own votes and answers. + PollData upsertVote( + PollData updatedPoll, + PollVoteData vote, + String currentUserId, + ) { + final updatedOwnVotesAndAnswers = ownVotesAndAnswers.let((it) { + if (vote.userId != currentUserId) return it; + return it.upsert(vote, key: (it) => it.id); + }); - return copyWith(options: updatedOptions); + return updateWith( + updatedPoll, + ownVotesAndAnswers: updatedOwnVotesAndAnswers, + ); } - /// Casts an answer to this poll. + /// Adds or updates an answer in this poll. /// - /// Updates the latest answers and own votes/answers lists by adding or updating [answer]. - /// Only adds answers that belong to [currentUserId] to the own votes/answers list. + /// Updates the own votes and answers list by adding or updating [answer]. Only adds answers + /// that belong to [currentUserId]. /// - /// Returns a new [PollData] instance with the updated answers. - PollData castAnswer(PollVoteData answer, String currentUserId) { - final updatedLatestAnswers = latestAnswers.let((it) { - return it.upsert(answer, key: (it) => it.id == answer.id); + /// Returns a new [PollData] instance with the updated own votes and answers. + PollData upsertAnswer( + PollData updatedPoll, + PollVoteData answer, + String currentUserId, + ) { + final updatedOwnVotesAndAnswers = ownVotesAndAnswers.let((it) { + if (answer.userId != currentUserId) return it; + return it.upsert(answer, key: (it) => it.id); }); + return updateWith( + updatedPoll, + ownVotesAndAnswers: updatedOwnVotesAndAnswers, + ); + } + + /// Removes a vote from this poll. + /// + /// Updates the own votes and answers list by removing [vote]. Only removes votes + /// that belong to [currentUserId]. + /// + /// Returns a new [PollData] instance with the updated own votes and answers. + PollData removeVote( + PollData updatedPoll, + PollVoteData vote, + String currentUserId, + ) { + final updatedOwnVotesAndAnswers = ownVotesAndAnswers.let((it) { + if (vote.userId != currentUserId) return it; + return it.where((it) => it.id != vote.id).toList(); + }); + + return updateWith( + updatedPoll, + ownVotesAndAnswers: updatedOwnVotesAndAnswers, + ); + } + + /// Removes an answer from this poll. + /// + /// Updates the own votes and answers list by removing [answer]. Only removes answers + /// that belong to [currentUserId]. + /// + /// Returns a new [PollData] instance with the updated own votes and answers. + PollData removeAnswer( + PollData updatedPoll, + PollVoteData answer, + String currentUserId, + ) { final updatedOwnVotesAndAnswers = ownVotesAndAnswers.let((it) { if (answer.userId != currentUserId) return it; - return it.upsert(answer, key: (it) => it.id == answer.id); + return it.where((it) => it.id != answer.id).toList(); }); - return copyWith( - latestAnswers: updatedLatestAnswers, + return updateWith( + updatedPoll, ownVotesAndAnswers: updatedOwnVotesAndAnswers, ); } diff --git a/packages/stream_feeds/lib/src/repository/polls_repository.dart b/packages/stream_feeds/lib/src/repository/polls_repository.dart index 7b8137ef..7d195cda 100644 --- a/packages/stream_feeds/lib/src/repository/polls_repository.dart +++ b/packages/stream_feeds/lib/src/repository/polls_repository.dart @@ -33,6 +33,7 @@ class PollsRepository { const request = api.UpdatePollPartialRequest( set: {'is_closed': true}, ); + final result = await _api.updatePollPartial( pollId: pollId, updatePollPartialRequest: request, diff --git a/packages/stream_feeds/lib/src/state/activity.dart b/packages/stream_feeds/lib/src/state/activity.dart index be2ab8ad..bfa2c74d 100644 --- a/packages/stream_feeds/lib/src/state/activity.dart +++ b/packages/stream_feeds/lib/src/state/activity.dart @@ -57,7 +57,6 @@ class Activity with Disposable { final initialActivityState = ActivityState( activity: initialActivityData, - poll: initialActivityData?.poll, ); _stateNotifier = ActivityStateNotifier( @@ -110,7 +109,7 @@ class Activity with Disposable { final result = await activitiesRepository.getActivity(activityId); result.onSuccess((activity) { - _stateNotifier.onActivityUpdated(activity); + _stateNotifier.onActivityGet(activity); if (activity.currentFeed case final feed?) { capabilitiesRepository.cacheCapabilitiesForFeeds([feed]); } diff --git a/packages/stream_feeds/lib/src/state/activity_comment_list_state.dart b/packages/stream_feeds/lib/src/state/activity_comment_list_state.dart index 12a2545a..4b083794 100644 --- a/packages/stream_feeds/lib/src/state/activity_comment_list_state.dart +++ b/packages/stream_feeds/lib/src/state/activity_comment_list_state.dart @@ -45,6 +45,14 @@ class ActivityCommentListStateNotifier ); } + /// Handles the deletion of the activity. + void onActivityDeleted() { + state = state.copyWith( + comments: [], // Clear all comments when the activity is deleted + pagination: null, + ); + } + /// Handles the addition of a new comment. void onCommentAdded(CommentData comment) { final parentId = comment.parentId; diff --git a/packages/stream_feeds/lib/src/state/activity_list_state.dart b/packages/stream_feeds/lib/src/state/activity_list_state.dart index 68cce98c..59f60913 100644 --- a/packages/stream_feeds/lib/src/state/activity_list_state.dart +++ b/packages/stream_feeds/lib/src/state/activity_list_state.dart @@ -7,6 +7,8 @@ import '../models/bookmark_data.dart'; import '../models/comment_data.dart'; import '../models/feeds_reaction_data.dart'; import '../models/pagination_data.dart'; +import '../models/poll_data.dart'; +import '../models/poll_vote_data.dart'; import '../models/query_configuration.dart'; import 'query/activities_query.dart'; @@ -50,9 +52,9 @@ class ActivityListStateNotifier extends StateNotifier { } /// Handles the removal of an activity. - void onActivityRemoved(ActivityData activity) { + void onActivityRemoved(String activityId) { final updatedActivities = state.activities.where((it) { - return it.id != activity.id; + return it.id != activityId; }).toList(); state = state.copyWith(activities: updatedActivities); @@ -74,33 +76,80 @@ class ActivityListStateNotifier extends StateNotifier { required String activityId, required bool hidden, }) { - final updatedActivities = state.activities.map((activity) { - if (activity.id != activityId) return activity; - // Update the hidden status of the activity - return activity.copyWith(hidden: hidden); - }).toList(); + final updatedActivities = state.activities.updateWhere( + (it) => it.id == activityId, + update: (it) => it.copyWith(hidden: hidden), + ); + + state = state.copyWith(activities: updatedActivities); + } + + /// Handles the addition of a reaction. + void onReactionAdded( + ActivityData activity, + FeedsReactionData reaction, + ) { + final updatedActivities = state.activities.updateWhere( + (it) => it.id == reaction.activityId, + update: (it) => it.upsertReaction(activity, reaction, currentUserId), + ); + + state = state.copyWith(activities: updatedActivities); + } + + void onReactionUpdated( + ActivityData activity, + FeedsReactionData reaction, + ) { + final updatedActivities = state.activities.updateWhere( + (it) => it.id == reaction.activityId, + update: (it) { + return it.upsertUniqueReaction(activity, reaction, currentUserId); + }, + ); + + state = state.copyWith(activities: updatedActivities); + } + + /// Handles the removal of a reaction. + void onReactionRemoved( + ActivityData activity, + FeedsReactionData reaction, + ) { + final updatedActivities = state.activities.updateWhere( + (it) => it.id == reaction.activityId, + update: (it) => it.removeReaction(activity, reaction, currentUserId), + ); state = state.copyWith(activities: updatedActivities); } /// Handles the addition of a bookmark. void onBookmarkAdded(BookmarkData bookmark) { - final updatedActivities = state.activities.map((activity) { - if (activity.id != bookmark.activity.id) return activity; - // Add the bookmark to the activity - return activity.upsertBookmark(bookmark, currentUserId); - }).toList(); + final updatedActivities = state.activities.updateWhere( + (it) => it.id == bookmark.activity.id, + update: (it) => it.upsertBookmark(bookmark, currentUserId), + ); + + state = state.copyWith(activities: updatedActivities); + } + + /// Handles the update of a bookmark. + void onBookmarkUpdated(BookmarkData bookmark) { + final updatedActivities = state.activities.updateWhere( + (it) => it.id == bookmark.activity.id, + update: (it) => it.upsertBookmark(bookmark, currentUserId), + ); state = state.copyWith(activities: updatedActivities); } /// Handles the removal of a bookmark. void onBookmarkRemoved(BookmarkData bookmark) { - final updatedActivities = state.activities.map((activity) { - if (activity.id != bookmark.activity.id) return activity; - // Remove the bookmark from the activity - return activity.removeBookmark(bookmark, currentUserId); - }).toList(); + final updatedActivities = state.activities.updateWhere( + (it) => it.id == bookmark.activity.id, + update: (it) => it.removeBookmark(bookmark, currentUserId), + ); state = state.copyWith(activities: updatedActivities); } @@ -112,63 +161,148 @@ class ActivityListStateNotifier extends StateNotifier { /// Handles the update of a comment. void onCommentUpdated(CommentData comment) { - final updatedActivities = state.activities.map((activity) { - if (activity.id != comment.objectId) return activity; - // Add the comment to the activity - return activity.upsertComment(comment); - }).toList(); + final updatedActivities = state.activities.updateWhere( + (it) => it.id == comment.objectId, + update: (it) => it.upsertComment(comment), + ); state = state.copyWith(activities: updatedActivities); } /// Handles the removal of a comment. void onCommentRemoved(CommentData comment) { - final updatedActivities = state.activities.map((activity) { - if (activity.id != comment.objectId) return activity; - // Remove the comment from the activity - return activity.removeComment(comment); - }).toList(); + final updatedActivities = state.activities.updateWhere( + (it) => it.id == comment.objectId, + update: (it) => it.removeComment(comment), + ); state = state.copyWith(activities: updatedActivities); } - /// Handles the addition of a reaction. - void onReactionAdded( - ActivityData activity, + /// Handles the addition of a reaction to a comment. + void onCommentReactionAdded( + CommentData comment, FeedsReactionData reaction, ) { - final updatedActivities = state.activities.map((it) { - if (it.id != reaction.activityId) return it; - // Add the reaction to the activity - return it.upsertReaction(activity, reaction, currentUserId); - }).toList(); + final updatedActivities = state.activities.updateWhere( + (it) => it.id == comment.objectId, + update: (it) { + return it.upsertCommentReaction(comment, reaction, currentUserId); + }, + ); state = state.copyWith(activities: updatedActivities); } - void onReactionUpdated( - ActivityData activity, + /// Handles the update of a reaction on a comment. + void onCommentReactionUpdated( + CommentData comment, FeedsReactionData reaction, ) { - final updatedActivities = state.activities.map((it) { - if (it.id != reaction.activityId) return it; - // Update the reaction in the activity - return it.upsertUniqueReaction(activity, reaction, currentUserId); - }).toList(); + final updatedActivities = state.activities.updateWhere( + (it) => it.id == comment.objectId, + update: (it) { + return it.upsertUniqueCommentReaction(comment, reaction, currentUserId); + }, + ); state = state.copyWith(activities: updatedActivities); } - /// Handles the removal of a reaction. - void onReactionRemoved( - ActivityData activity, + /// Handles the removal of a reaction from a comment. + void onCommentReactionRemoved( + CommentData comment, FeedsReactionData reaction, ) { - final updatedActivities = state.activities.map((it) { - if (it.id != reaction.activityId) return it; - // Remove the reaction from the activity - return it.removeReaction(activity, reaction, currentUserId); - }).toList(); + final updatedActivities = state.activities.updateWhere( + (it) => it.id == comment.objectId, + update: (it) { + return it.removeCommentReaction(comment, reaction, currentUserId); + }, + ); + + state = state.copyWith(activities: updatedActivities); + } + + /// Handles when a poll is closed. + void onPollClosed(String pollId) { + final updatedActivities = state.activities.updateWhere( + (it) => it.poll?.id == pollId, + update: (it) => it.copyWith(poll: it.poll?.copyWith(isClosed: true)), + ); + + state = state.copyWith(activities: updatedActivities); + } + + /// Handles when a poll is deleted. + void onPollDeleted(String pollId) { + final updatedActivities = state.activities.updateWhere( + (it) => it.poll?.id == pollId, + update: (it) => it.copyWith(poll: null), + ); + + state = state.copyWith(activities: updatedActivities); + } + + /// Handles when a poll is updated. + void onPollUpdated(PollData poll) { + final updatedActivities = state.activities.updateWhere( + (it) => it.poll?.id == poll.id, + update: (it) => it.copyWith(poll: it.poll?.updateWith(poll)), + ); + + state = state.copyWith(activities: updatedActivities); + } + + /// Handles when a poll answer is casted. + void onPollAnswerCasted(PollData poll, PollVoteData answer) { + final updatedActivities = state.activities.updateWhere( + (it) => it.poll?.id == poll.id, + update: (it) => it.copyWith( + poll: it.poll?.upsertAnswer(poll, answer, currentUserId), + ), + ); + + state = state.copyWith(activities: updatedActivities); + } + + /// Handles when a poll vote is casted (with poll data). + void onPollVoteCasted(PollData poll, PollVoteData vote) { + return onPollVoteChanged(poll, vote); + } + + /// Handles when a poll vote is changed. + void onPollVoteChanged(PollData poll, PollVoteData vote) { + final updatedActivities = state.activities.updateWhere( + (it) => it.poll?.id == poll.id, + update: (it) => it.copyWith( + poll: it.poll?.upsertVote(poll, vote, currentUserId), + ), + ); + + state = state.copyWith(activities: updatedActivities); + } + + /// Handles when a poll answer is removed. + void onPollAnswerRemoved(PollData poll, PollVoteData answer) { + final updatedActivities = state.activities.updateWhere( + (it) => it.poll?.id == poll.id, + update: (it) => it.copyWith( + poll: it.poll?.removeAnswer(poll, answer, currentUserId), + ), + ); + + state = state.copyWith(activities: updatedActivities); + } + + /// Handles when a poll vote is removed (with poll data). + void onPollVoteRemoved(PollData poll, PollVoteData vote) { + final updatedActivities = state.activities.updateWhere( + (it) => it.poll?.id == poll.id, + update: (it) => it.copyWith( + poll: it.poll?.removeVote(poll, vote, currentUserId), + ), + ); state = state.copyWith(activities: updatedActivities); } diff --git a/packages/stream_feeds/lib/src/state/activity_reaction_list_state.dart b/packages/stream_feeds/lib/src/state/activity_reaction_list_state.dart index fa994459..db255273 100644 --- a/packages/stream_feeds/lib/src/state/activity_reaction_list_state.dart +++ b/packages/stream_feeds/lib/src/state/activity_reaction_list_state.dart @@ -44,6 +44,35 @@ class ActivityReactionListStateNotifier ); } + /// Handles the deletion of the activity. + void onActivityDeleted() { + state = state.copyWith( + reactions: [], // Clear all reactions when the activity is deleted + pagination: null, + ); + } + + /// Handles the addition of a new reaction. + void onReactionAdded(FeedsReactionData reaction) { + final updatedReactions = state.reactions.upsertReaction( + reaction, + compare: reactionsSort.compare, + ); + + state = state.copyWith(reactions: updatedReactions); + } + + /// Handles the update of an existing reaction. + void onReactionUpdated(FeedsReactionData reaction) { + final updatedReactions = state.reactions.upsertReaction( + reaction, + enforceUnique: true, + compare: reactionsSort.compare, + ); + + state = state.copyWith(reactions: updatedReactions); + } + /// Handles the removal of a reaction by reaction data. void onReactionRemoved(FeedsReactionData reaction) { final updatedReactions = state.reactions.where((it) { diff --git a/packages/stream_feeds/lib/src/state/activity_state.dart b/packages/stream_feeds/lib/src/state/activity_state.dart index 4b9bbb8c..675170cb 100644 --- a/packages/stream_feeds/lib/src/state/activity_state.dart +++ b/packages/stream_feeds/lib/src/state/activity_state.dart @@ -3,7 +3,9 @@ import 'package:state_notifier/state_notifier.dart'; import 'package:stream_core/stream_core.dart'; import '../models/activity_data.dart'; +import '../models/bookmark_data.dart'; import '../models/comment_data.dart'; +import '../models/feeds_reaction_data.dart'; import '../models/pagination_data.dart'; import '../models/poll_data.dart'; import '../models/poll_vote_data.dart'; @@ -39,137 +41,180 @@ class ActivityStateNotifier extends StateNotifier { }); } - /// Handles the update of an activity. - void onActivityUpdated(ActivityData activity) { + /// Handles the retrieval of the activity. + void onActivityGet(ActivityData activity) { + state = state.copyWith(activity: activity); + } + + /// Handles the deletion of the activity. + void onActivityDeleted() { state = state.copyWith( - activity: activity, - poll: activity.poll, + activity: null, + comments: [], // Clear all comments when the activity is deleted + commentsPagination: null, ); } + /// Handles the update of an activity. + void onActivityUpdated(ActivityData activity) { + final currentActivity = state.activity; + final updatedActivity = currentActivity?.updateWith(activity); + + state = state.copyWith(activity: updatedActivity); + } + /// Handles updates to the activity's hidden status. - void onActivityHidden({ - required bool hidden, - }) { + void onActivityHidden({required bool hidden}) { final currentActivity = state.activity; final updatedActivity = currentActivity?.copyWith(hidden: hidden); state = state.copyWith(activity: updatedActivity); } - /// Handles when a poll is closed. - void onPollClosed(PollData poll) { - if (state.poll?.id != poll.id) return; + /// Handles when a reaction is added to the activity. + void onReactionAdded( + ActivityData activity, + FeedsReactionData reaction, + ) { + final updatedActivity = state.activity?.let( + (it) => it.upsertReaction(activity, reaction, currentUserId), + ); - final updatedPoll = state.poll?.copyWith(isClosed: true); - state = state.copyWith(poll: updatedPoll ?? poll); + state = state.copyWith(activity: updatedActivity); } - /// Handles when a poll is deleted. - void onPollDeleted(String pollId) { - if (state.poll?.id != pollId) return; - state = state.copyWith(poll: null); + /// Handles when a reaction is updated on the activity. + void onReactionUpdated( + ActivityData activity, + FeedsReactionData reaction, + ) { + final updatedActivity = state.activity?.let( + (it) => it.upsertUniqueReaction(activity, reaction, currentUserId), + ); + + state = state.copyWith(activity: updatedActivity); } - /// Handles when a poll is updated. - void onPollUpdated(PollData poll) { - final currentPoll = state.poll; - if (currentPoll == null || currentPoll.id != poll.id) return; + /// Handles when a reaction is deleted from the activity. + void onReactionRemoved( + ActivityData activity, + FeedsReactionData reaction, + ) { + final updatedActivity = state.activity?.let( + (it) => it.removeReaction(activity, reaction, currentUserId), + ); - final latestAnswers = currentPoll.latestAnswers; - final ownVotesAndAnswers = currentPoll.ownVotesAndAnswers; + state = state.copyWith(activity: updatedActivity); + } - final updatedPoll = poll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: ownVotesAndAnswers, + /// Handles when a bookmark is added to the activity. + void onBookmarkAdded(BookmarkData bookmark) { + final updatedActivity = state.activity?.let( + (it) => it.upsertBookmark(bookmark, currentUserId), ); - state = state.copyWith(poll: updatedPoll); + state = state.copyWith(activity: updatedActivity); } - /// Handles when a poll answer is casted. - void onPollAnswerCasted(PollVoteData answer, PollData poll) { - final currentPoll = state.poll; - if (currentPoll == null || currentPoll.id != poll.id) return; + /// Handles when a bookmark is updated on the activity. + void onBookmarkUpdated(BookmarkData bookmark) { + final updatedActivity = state.activity?.let( + (it) => it.upsertBookmark(bookmark, currentUserId), + ); - final latestAnswers = currentPoll.latestAnswers.let((it) { - return it.upsert(answer, key: (it) => it.id == answer.id); - }); + state = state.copyWith(activity: updatedActivity); + } - final ownVotesAndAnswers = currentPoll.ownVotesAndAnswers.let((it) { - if (answer.userId != currentUserId) return it; - return it.upsert(answer, key: (it) => it.id == answer.id); - }); + /// Handles when a bookmark is removed from the activity. + void onBookmarkRemoved(BookmarkData bookmark) { + final updatedActivity = state.activity?.let( + (it) => it.removeBookmark(bookmark, currentUserId), + ); - final updatedPoll = poll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: ownVotesAndAnswers, + state = state.copyWith(activity: updatedActivity); + } + + /// Handles when a poll is closed. + void onPollClosed(String pollId) { + final updatedActivity = state.activity?.updateIf( + filter: (it) => it.poll?.id == pollId, + update: (it) => it.copyWith(poll: it.poll?.copyWith(isClosed: true)), ); - state = state.copyWith(poll: updatedPoll); + state = state.copyWith(activity: updatedActivity); } - /// Handles when a poll vote is casted (with poll data). - void onPollVoteCasted(PollVoteData vote, PollData poll) { - return onPollVoteChanged(vote, poll); + /// Handles when a poll is deleted. + void onPollDeleted(String pollId) { + final updatedActivity = state.activity?.updateIf( + filter: (it) => it.poll?.id == pollId, + update: (it) => it.copyWith(poll: null), + ); + + state = state.copyWith(activity: updatedActivity); } - /// Handles when a poll vote is changed. - void onPollVoteChanged(PollVoteData vote, PollData poll) { - final currentPoll = state.poll; - if (currentPoll == null || currentPoll.id != poll.id) return; - - final latestAnswers = currentPoll.latestAnswers; - final ownVotesAndAnswers = currentPoll.ownVotesAndAnswers.let((it) { - if (vote.userId != currentUserId) return it; - return it.upsert(vote, key: (it) => it.id == vote.id); - }); + /// Handles when a poll is updated. + void onPollUpdated(PollData poll) { + final updatedActivity = state.activity?.updateIf( + filter: (it) => it.poll?.id == poll.id, + update: (it) => it.copyWith(poll: it.poll?.updateWith(poll)), + ); - final updatedPoll = poll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: ownVotesAndAnswers, + state = state.copyWith(activity: updatedActivity); + } + + /// Handles when a poll answer is casted. + void onPollAnswerCasted(PollData poll, PollVoteData answer) { + final updatedActivity = state.activity?.updateIf( + filter: (it) => it.poll?.id == poll.id, + update: (it) => it.copyWith( + poll: it.poll?.upsertAnswer(poll, answer, currentUserId), + ), ); - state = state.copyWith(poll: updatedPoll); + state = state.copyWith(activity: updatedActivity); } - /// Handles when a poll answer is removed (with poll data). - void onPollAnswerRemoved(PollVoteData answer, PollData poll) { - final currentPoll = state.poll; - if (currentPoll == null || currentPoll.id != poll.id) return; + /// Handles when a poll vote is casted (with poll data). + void onPollVoteCasted(PollData poll, PollVoteData vote) { + return onPollVoteChanged(poll, vote); + } - final latestAnswers = currentPoll.latestAnswers.where((it) { - return it.id != answer.id; - }).toList(); + /// Handles when a poll vote is changed. + void onPollVoteChanged(PollData poll, PollVoteData vote) { + final updatedActivity = state.activity?.updateIf( + filter: (it) => it.poll?.id == poll.id, + update: (it) => it.copyWith( + poll: it.poll?.upsertVote(poll, vote, currentUserId), + ), + ); - final ownVotesAndAnswers = currentPoll.ownVotesAndAnswers.where((it) { - return it.id != answer.id; - }).toList(); + state = state.copyWith(activity: updatedActivity); + } - final updatedPoll = poll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: ownVotesAndAnswers, + /// Handles when a poll answer is removed (with poll data). + void onPollAnswerRemoved(PollData poll, PollVoteData answer) { + final updatedActivity = state.activity?.updateIf( + filter: (it) => it.poll?.id == poll.id, + update: (it) => it.copyWith( + poll: it.poll?.removeAnswer(poll, answer, currentUserId), + ), ); - state = state.copyWith(poll: updatedPoll); + state = state.copyWith(activity: updatedActivity); } /// Handles when a poll vote is removed (with poll data). - void onPollVoteRemoved(PollVoteData vote, PollData poll) { - final currentPoll = state.poll; - if (currentPoll == null || currentPoll.id != poll.id) return; - - final latestAnswers = currentPoll.latestAnswers; - final ownVotesAndAnswers = currentPoll.ownVotesAndAnswers.where((it) { - return it.id != vote.id; - }).toList(); - - final updatedPoll = poll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: ownVotesAndAnswers, + void onPollVoteRemoved(PollData poll, PollVoteData vote) { + final updatedActivity = state.activity?.updateIf( + filter: (it) => it.poll?.id == poll.id, + update: (it) => it.copyWith( + poll: it.poll?.removeVote(poll, vote, currentUserId), + ), ); - state = state.copyWith(poll: updatedPoll); + state = state.copyWith(activity: updatedActivity); } @override @@ -189,7 +234,6 @@ class ActivityState with _$ActivityState { this.activity, this.comments = const [], this.commentsPagination, - this.poll, }); /// The current activity data. @@ -199,6 +243,12 @@ class ActivityState with _$ActivityState { @override final ActivityData? activity; + /// The poll associated with this activity, if any. + /// + /// Contains poll information including options, votes, and poll state. + /// This field is automatically updated when the activity has poll data. + PollData? get poll => activity?.poll; + /// The comments associated with this activity. /// /// Contains a list of threaded comments related to the activity. @@ -213,11 +263,4 @@ class ActivityState with _$ActivityState { /// /// Returns true if there is a next page available for pagination. bool get canLoadMoreComments => commentsPagination?.next != null; - - /// The poll associated with this activity, if any. - /// - /// Contains poll information including options, votes, and poll state. - /// This field is automatically updated when the activity has poll data. - @override - final PollData? poll; } diff --git a/packages/stream_feeds/lib/src/state/activity_state.freezed.dart b/packages/stream_feeds/lib/src/state/activity_state.freezed.dart index ed16bed1..6acfe94b 100644 --- a/packages/stream_feeds/lib/src/state/activity_state.freezed.dart +++ b/packages/stream_feeds/lib/src/state/activity_state.freezed.dart @@ -18,7 +18,6 @@ mixin _$ActivityState { ActivityData? get activity; List get comments; PaginationData? get commentsPagination; - PollData? get poll; /// Create a copy of ActivityState /// with the given fields replaced by the non-null parameter values. @@ -37,17 +36,16 @@ mixin _$ActivityState { other.activity == activity) && const DeepCollectionEquality().equals(other.comments, comments) && (identical(other.commentsPagination, commentsPagination) || - other.commentsPagination == commentsPagination) && - (identical(other.poll, poll) || other.poll == poll)); + other.commentsPagination == commentsPagination)); } @override int get hashCode => Object.hash(runtimeType, activity, - const DeepCollectionEquality().hash(comments), commentsPagination, poll); + const DeepCollectionEquality().hash(comments), commentsPagination); @override String toString() { - return 'ActivityState(activity: $activity, comments: $comments, commentsPagination: $commentsPagination, poll: $poll)'; + return 'ActivityState(activity: $activity, comments: $comments, commentsPagination: $commentsPagination)'; } } @@ -60,8 +58,7 @@ abstract mixin class $ActivityStateCopyWith<$Res> { $Res call( {ActivityData? activity, List comments, - PaginationData? commentsPagination, - PollData? poll}); + PaginationData? commentsPagination}); } /// @nodoc @@ -80,7 +77,6 @@ class _$ActivityStateCopyWithImpl<$Res> Object? activity = freezed, Object? comments = null, Object? commentsPagination = freezed, - Object? poll = freezed, }) { return _then(ActivityState( activity: freezed == activity @@ -95,10 +91,6 @@ class _$ActivityStateCopyWithImpl<$Res> ? _self.commentsPagination : commentsPagination // ignore: cast_nullable_to_non_nullable as PaginationData?, - poll: freezed == poll - ? _self.poll - : poll // ignore: cast_nullable_to_non_nullable - as PollData?, )); } } diff --git a/packages/stream_feeds/lib/src/state/bookmark_folder_list_state.dart b/packages/stream_feeds/lib/src/state/bookmark_folder_list_state.dart index 6001e997..847024fe 100644 --- a/packages/stream_feeds/lib/src/state/bookmark_folder_list_state.dart +++ b/packages/stream_feeds/lib/src/state/bookmark_folder_list_state.dart @@ -46,10 +46,11 @@ class BookmarkFolderListStateNotifier /// Handles updates to a specific bookmark folder. void onBookmarkFolderUpdated(BookmarkFolderData folder) { - final updatedFolders = state.bookmarkFolders.map((it) { - if (it.id != folder.id) return it; - return folder; - }).toList(); + final updatedFolders = state.bookmarkFolders.sortedUpsert( + folder, + key: (it) => it.id, + compare: foldersSort.compare, + ); state = state.copyWith(bookmarkFolders: updatedFolders); } diff --git a/packages/stream_feeds/lib/src/state/bookmark_list_state.dart b/packages/stream_feeds/lib/src/state/bookmark_list_state.dart index ef51ba50..40ea598a 100644 --- a/packages/stream_feeds/lib/src/state/bookmark_list_state.dart +++ b/packages/stream_feeds/lib/src/state/bookmark_list_state.dart @@ -66,12 +66,24 @@ class BookmarkListStateNotifier extends StateNotifier { state = state.copyWith(bookmarks: updatedBookmarks); } + /// Handles the addition of a new bookmark. + void onBookmarkAdded(BookmarkData bookmark) { + final updatedBookmarks = state.bookmarks.sortedUpsert( + bookmark, + key: (it) => it.id, + compare: bookmarkSort.compare, + ); + + state = state.copyWith(bookmarks: updatedBookmarks); + } + /// Handles the update of an existing bookmark. void onBookmarkUpdated(BookmarkData bookmark) { - final updatedBookmarks = state.bookmarks.map((it) { - if (it.id != bookmark.id) return it; - return bookmark; - }).toList(); + final updatedBookmarks = state.bookmarks.sortedUpsert( + bookmark, + key: (it) => it.id, + compare: bookmarkSort.compare, + ); state = state.copyWith(bookmarks: updatedBookmarks); } diff --git a/packages/stream_feeds/lib/src/state/comment_list.dart b/packages/stream_feeds/lib/src/state/comment_list.dart index d3946cd5..81d2ddb6 100644 --- a/packages/stream_feeds/lib/src/state/comment_list.dart +++ b/packages/stream_feeds/lib/src/state/comment_list.dart @@ -25,9 +25,11 @@ class CommentList extends Disposable { required this.query, required this.commentsRepository, required this.eventsEmitter, + required this.currentUserId, }) { _stateNotifier = CommentListStateNotifier( initialState: const CommentListState(), + currentUserId: currentUserId, ); // Attach event handlers for real-time updates @@ -41,6 +43,7 @@ class CommentList extends Disposable { final CommentsQuery query; final CommentsRepository commentsRepository; + final String currentUserId; CommentListState get state => stateNotifier.value; StateNotifier get notifier => stateNotifier; diff --git a/packages/stream_feeds/lib/src/state/comment_list_state.dart b/packages/stream_feeds/lib/src/state/comment_list_state.dart index e91b4162..9a22a55c 100644 --- a/packages/stream_feeds/lib/src/state/comment_list_state.dart +++ b/packages/stream_feeds/lib/src/state/comment_list_state.dart @@ -3,6 +3,7 @@ import 'package:state_notifier/state_notifier.dart'; import 'package:stream_core/stream_core.dart'; import '../models/comment_data.dart'; +import '../models/feeds_reaction_data.dart'; import '../models/pagination_data.dart'; import 'query/comments_query.dart'; @@ -15,8 +16,11 @@ part 'comment_list_state.freezed.dart'; class CommentListStateNotifier extends StateNotifier { CommentListStateNotifier({ required CommentListState initialState, + required this.currentUserId, }) : super(initialState); + final String currentUserId; + ({Filter? filter, CommentsSort? sort})? _queryConfig; CommentsSort get commentSort => _queryConfig?.sort ?? CommentsSort.last; @@ -41,12 +45,26 @@ class CommentListStateNotifier extends StateNotifier { ); } + /// Handles the addition of a new comment. + void onCommentAdded(CommentData comment) { + final updatedComments = state.comments.sortedUpsert( + comment, + key: (it) => it.id, + compare: commentSort.compare, + update: (existing, updated) => existing.updateWith(updated), + ); + + state = state.copyWith(comments: updatedComments); + } + /// Handles updates to a specific comment. void onCommentUpdated(CommentData comment) { - final updatedComments = state.comments.map((it) { - if (it.id != comment.id) return it; - return comment; - }).toList(); + final updatedComments = state.comments.sortedUpsert( + comment, + key: (it) => it.id, + compare: commentSort.compare, + update: (existing, updated) => existing.updateWith(updated), + ); state = state.copyWith(comments: updatedComments); } @@ -59,6 +77,45 @@ class CommentListStateNotifier extends StateNotifier { state = state.copyWith(comments: updatedComments); } + + void onCommentReactionAdded( + CommentData comment, + FeedsReactionData reaction, + ) { + final updatedComments = state.comments.updateWhere( + (it) => it.id == comment.id, + update: (it) => it.upsertReaction(comment, reaction, currentUserId), + compare: commentSort.compare, + ); + + state = state.copyWith(comments: updatedComments); + } + + void onCommentReactionUpdated( + CommentData comment, + FeedsReactionData reaction, + ) { + final updatedComments = state.comments.updateWhere( + (it) => it.id == comment.id, + update: (it) => it.upsertUniqueReaction(comment, reaction, currentUserId), + compare: commentSort.compare, + ); + + state = state.copyWith(comments: updatedComments); + } + + void onCommentReactionRemoved( + CommentData comment, + FeedsReactionData reaction, + ) { + final updatedComments = state.comments.updateWhere( + (it) => it.id == comment.id, + update: (it) => it.removeReaction(comment, reaction, currentUserId), + compare: commentSort.compare, + ); + + state = state.copyWith(comments: updatedComments); + } } /// An observable state object that manages the current state of a comment list. diff --git a/packages/stream_feeds/lib/src/state/comment_reaction_list.dart b/packages/stream_feeds/lib/src/state/comment_reaction_list.dart index 59b868fa..38fa7cf8 100644 --- a/packages/stream_feeds/lib/src/state/comment_reaction_list.dart +++ b/packages/stream_feeds/lib/src/state/comment_reaction_list.dart @@ -32,7 +32,11 @@ class CommentReactionList with Disposable { ); // Attach event handlers for real-time updates - final handler = CommentReactionListEventHandler(state: _stateNotifier); + final handler = CommentReactionListEventHandler( + query: query, + state: _stateNotifier, + ); + _eventsSubscription = eventsEmitter.listen(handler.handleEvent); } diff --git a/packages/stream_feeds/lib/src/state/comment_reaction_list_state.dart b/packages/stream_feeds/lib/src/state/comment_reaction_list_state.dart index 072aa743..9b8b9001 100644 --- a/packages/stream_feeds/lib/src/state/comment_reaction_list_state.dart +++ b/packages/stream_feeds/lib/src/state/comment_reaction_list_state.dart @@ -44,6 +44,35 @@ class CommentReactionListStateNotifier ); } + /// Handles the deletion of the comment. + void onCommentDeleted() { + state = state.copyWith( + reactions: [], // Clear all reactions when the comment is deleted + pagination: null, + ); + } + + /// Handles the addition of a new reaction. + void onReactionAdded(FeedsReactionData reaction) { + final updatedReactions = state.reactions.upsertReaction( + reaction, + compare: reactionSort.compare, + ); + + state = state.copyWith(reactions: updatedReactions); + } + + /// Handles the update of an existing reaction. + void onReactionUpdated(FeedsReactionData reaction) { + final updatedReactions = state.reactions.upsertReaction( + reaction, + enforceUnique: true, + compare: reactionSort.compare, + ); + + state = state.copyWith(reactions: updatedReactions); + } + /// Handles the removal of a reaction. void onReactionRemoved(FeedsReactionData reaction) { final updatedReactions = state.reactions.where((it) { diff --git a/packages/stream_feeds/lib/src/state/comment_reply_list.dart b/packages/stream_feeds/lib/src/state/comment_reply_list.dart index 036cf0f3..6580a308 100644 --- a/packages/stream_feeds/lib/src/state/comment_reply_list.dart +++ b/packages/stream_feeds/lib/src/state/comment_reply_list.dart @@ -34,7 +34,11 @@ class CommentReplyList with Disposable { ); // Attach event handlers for real-time updates - final handler = CommentReplyListEventHandler(state: _stateNotifier); + final handler = CommentReplyListEventHandler( + query: query, + state: _stateNotifier, + ); + _eventsSubscription = eventsEmitter.listen(handler.handleEvent); } diff --git a/packages/stream_feeds/lib/src/state/comment_reply_list_state.dart b/packages/stream_feeds/lib/src/state/comment_reply_list_state.dart index a56d3459..fc43b78d 100644 --- a/packages/stream_feeds/lib/src/state/comment_reply_list_state.dart +++ b/packages/stream_feeds/lib/src/state/comment_reply_list_state.dart @@ -47,6 +47,14 @@ class CommentReplyListStateNotifier ); } + /// Handles the deletion of the parent comment. + void onParentCommentDeleted() { + state = state.copyWith( + replies: [], // Clear all replies when the parent comment is deleted + pagination: null, + ); + } + /// Handles the addition of a new comment reply. void onReplyAdded(CommentData reply) { final parentId = reply.parentId; diff --git a/packages/stream_feeds/lib/src/state/event/handler/activity_comment_list_event_handler.dart b/packages/stream_feeds/lib/src/state/event/handler/activity_comment_list_event_handler.dart index c0bc07ac..b39d5e5c 100644 --- a/packages/stream_feeds/lib/src/state/event/handler/activity_comment_list_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/handler/activity_comment_list_event_handler.dart @@ -23,6 +23,12 @@ class ActivityCommentListEventHandler implements StateEventHandler { @override void handleEvent(WsEvent event) { + if (event is api.ActivityDeletedEvent) { + // Only handle deletion for this specific activity + if (event.activity.id != objectId) return; + return state.onActivityDeleted(); + } + if (event is api.CommentAddedEvent) { final comment = event.comment.toModel(); // Only handle comments for this specific activity diff --git a/packages/stream_feeds/lib/src/state/event/handler/activity_event_handler.dart b/packages/stream_feeds/lib/src/state/event/handler/activity_event_handler.dart index 72c45d8d..b7677dd1 100644 --- a/packages/stream_feeds/lib/src/state/event/handler/activity_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/handler/activity_event_handler.dart @@ -1,7 +1,10 @@ import 'package:stream_core/stream_core.dart'; import '../../../generated/api/models.dart' as api; +import '../../../models/activity_data.dart'; +import '../../../models/bookmark_data.dart'; import '../../../models/feed_id.dart'; +import '../../../models/feeds_reaction_data.dart'; import '../../../models/poll_data.dart'; import '../../../models/poll_vote_data.dart'; import '../../../resolvers/poll/poll_answer_casted.dart'; @@ -22,14 +25,92 @@ class ActivityEventHandler implements StateEventHandler { }); final FeedId fid; - final ActivityStateNotifier state; final String activityId; final String currentUserId; + final ActivityStateNotifier state; @override void handleEvent(WsEvent event) { + if (event is api.ActivityUpdatedEvent) { + // Only process events for this activity + if (event.activity.id != activityId) return; + return state.onActivityUpdated(event.activity.toModel()); + } + + if (event is api.ActivityDeletedEvent) { + // Only process events for this activity + if (event.activity.id != activityId) return; + return state.onActivityDeleted(); + } + + if (event is api.ActivityFeedbackEvent) { + final payload = event.activityFeedback; + + // Only process events for this activity and current user + if (payload.activityId != activityId) return; + if (payload.user.id != currentUserId) return; + + // Only handle hide action for now + if (payload.action == api.ActivityFeedbackEventPayloadAction.hide) { + return state.onActivityHidden(hidden: payload.value == 'true'); + } + } + + if (event is api.ActivityReactionAddedEvent) { + final activity = event.activity.toModel(); + // Only process events for this activity + if (activity.id != activityId) return; + + final reaction = event.reaction.toModel(); + return state.onReactionAdded(activity, reaction); + } + + if (event is api.ActivityReactionUpdatedEvent) { + final activity = event.activity.toModel(); + // Only process events for this activity + if (activity.id != activityId) return; + + final reaction = event.reaction.toModel(); + return state.onReactionUpdated(activity, reaction); + } + + if (event is api.ActivityReactionDeletedEvent) { + final activity = event.activity.toModel(); + // Only process events for this activity + if (activity.id != activityId) return; + + final reaction = event.reaction.toModel(); + return state.onReactionRemoved(activity, reaction); + } + + if (event is api.BookmarkAddedEvent) { + // Only process events for this activity + if (event.bookmark.activity.id != activityId) return; + return state.onBookmarkAdded(event.bookmark.toModel()); + } + + if (event is api.BookmarkUpdatedEvent) { + // Only process events for this activity + if (event.bookmark.activity.id != activityId) return; + return state.onBookmarkUpdated(event.bookmark.toModel()); + } + + if (event is api.BookmarkDeletedEvent) { + // Only process events for this activity + if (event.bookmark.activity.id != activityId) return; + return state.onBookmarkRemoved(event.bookmark.toModel()); + } + + // Comment events are not handled in the CommentListEventHandler + if (event is api.CommentAddedEvent) {} + if (event is api.CommentUpdatedEvent) {} + if (event is api.CommentDeletedEvent) {} + if (event is api.CommentReactionAddedEvent) {} + if (event is api.CommentReactionUpdatedEvent) {} + if (event is api.CommentReactionDeletedEvent) {} + if (event is api.PollClosedFeedEvent) { - return state.onPollClosed(event.poll.toModel()); + return state.onPollClosed(event.poll.id); } if (event is api.PollDeletedFeedEvent) { @@ -41,50 +122,33 @@ class ActivityEventHandler implements StateEventHandler { } if (event is PollAnswerCastedFeedEvent) { - final answer = event.pollVote.toModel(); final poll = event.poll.toModel(); - return state.onPollAnswerCasted(answer, poll); + final answer = event.pollVote.toModel(); + return state.onPollAnswerCasted(poll, answer); } if (event is api.PollVoteCastedFeedEvent) { - final vote = event.pollVote.toModel(); final poll = event.poll.toModel(); - return state.onPollVoteCasted(vote, poll); + final vote = event.pollVote.toModel(); + return state.onPollVoteCasted(poll, vote); } if (event is api.PollVoteChangedFeedEvent) { - // Only handle events for this specific feed - if (event.fid != fid.rawValue) return; - final vote = event.pollVote.toModel(); final poll = event.poll.toModel(); - return state.onPollVoteChanged(vote, poll); + final vote = event.pollVote.toModel(); + return state.onPollVoteChanged(poll, vote); } if (event is PollAnswerRemovedFeedEvent) { - final vote = event.pollVote.toModel(); final poll = event.poll.toModel(); - return state.onPollAnswerRemoved(vote, poll); + final answer = event.pollVote.toModel(); + return state.onPollAnswerRemoved(poll, answer); } if (event is api.PollVoteRemovedFeedEvent) { - final vote = event.pollVote.toModel(); final poll = event.poll.toModel(); - return state.onPollVoteRemoved(vote, poll); - } - - if (event is api.ActivityFeedbackEvent) { - final payload = event.activityFeedback; - - // Only process events for this activity and current user - if (payload.activityId != activityId) return; - if (payload.user.id != currentUserId) return; - - // Only handle hide action for now - if (payload.action == api.ActivityFeedbackEventPayloadAction.hide) { - return state.onActivityHidden( - hidden: payload.value == 'true', - ); - } + final vote = event.pollVote.toModel(); + return state.onPollVoteRemoved(poll, vote); } // Handle other activity events here as needed diff --git a/packages/stream_feeds/lib/src/state/event/handler/activity_list_event_handler.dart b/packages/stream_feeds/lib/src/state/event/handler/activity_list_event_handler.dart index 97f02a2d..70ce8dd7 100644 --- a/packages/stream_feeds/lib/src/state/event/handler/activity_list_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/handler/activity_list_event_handler.dart @@ -5,7 +5,11 @@ import '../../../models/activity_data.dart'; import '../../../models/bookmark_data.dart'; import '../../../models/comment_data.dart'; import '../../../models/feeds_reaction_data.dart'; +import '../../../models/poll_data.dart'; +import '../../../models/poll_vote_data.dart'; import '../../../repository/capabilities_repository.dart'; +import '../../../resolvers/resolvers.dart'; +import '../../../utils/filter.dart'; import '../../activity_list_state.dart'; import '../../query/activities_query.dart'; import 'feed_capabilities_mixin.dart'; @@ -34,15 +38,9 @@ class ActivityListEventHandler @override Future handleEvent(WsEvent event) async { - bool matchesQueryFilter(ActivityData activity) { - final filter = query.filter; - if (filter == null) return true; - return filter.matches(activity); - } - if (event is api.ActivityAddedEvent) { final activity = event.activity.toModel(); - if (!matchesQueryFilter(activity)) return; + if (!activity.matches(query.filter)) return; state.onActivityUpdated(activity); @@ -54,9 +52,9 @@ class ActivityListEventHandler if (event is api.ActivityUpdatedEvent) { final activity = event.activity.toModel(); - if (!matchesQueryFilter(activity)) { + if (!activity.matches(query.filter)) { // If the updated activity no longer matches the filter, remove it - return state.onActivityRemoved(activity); + return state.onActivityRemoved(activity.id); } state.onActivityUpdated(activity); @@ -68,95 +66,131 @@ class ActivityListEventHandler } if (event is api.ActivityDeletedEvent) { - return state.onActivityRemoved(event.activity.toModel()); + return state.onActivityRemoved(event.activity.id); } - if (event is api.ActivityReactionAddedEvent) { - final activity = event.activity.toModel(); - if (!matchesQueryFilter(activity)) { - // If the reaction's activity no longer matches the filter, remove it - return state.onActivityRemoved(activity); + if (event is api.ActivityFeedbackEvent) { + final payload = event.activityFeedback; + + // Only process events for the current user + if (payload.user.id != currentUserId) return; + + // Only handle hide action for now + if (payload.action == api.ActivityFeedbackEventPayloadAction.hide) { + return state.onActivityHidden( + activityId: payload.activityId, + hidden: payload.value == 'true', + ); } + } + if (event is api.ActivityReactionAddedEvent) { + final activity = event.activity.toModel(); final reaction = event.reaction.toModel(); return state.onReactionAdded(activity, reaction); } if (event is api.ActivityReactionUpdatedEvent) { final activity = event.activity.toModel(); - if (!matchesQueryFilter(activity)) { - // If the reaction's activity no longer matches the filter, remove it - return state.onActivityRemoved(activity); - } - final reaction = event.reaction.toModel(); return state.onReactionUpdated(activity, reaction); } if (event is api.ActivityReactionDeletedEvent) { final activity = event.activity.toModel(); - if (!matchesQueryFilter(activity)) { - // If the reaction's activity no longer matches the filter, remove it - return state.onActivityRemoved(activity); - } - final reaction = event.reaction.toModel(); return state.onReactionRemoved(activity, reaction); } if (event is api.BookmarkAddedEvent) { - final activity = event.bookmark.activity.toModel(); - if (!matchesQueryFilter(activity)) { - // If the bookmark's activity no longer matches the filter, remove it - return state.onActivityRemoved(activity); - } + final bookmark = event.bookmark.toModel(); + return state.onBookmarkAdded(bookmark); + } - return state.onBookmarkAdded(event.bookmark.toModel()); + if (event is api.BookmarkUpdatedEvent) { + final bookmark = event.bookmark.toModel(); + return state.onBookmarkUpdated(bookmark); } if (event is api.BookmarkDeletedEvent) { - final activity = event.bookmark.activity.toModel(); - if (!matchesQueryFilter(activity)) { - // If the bookmark's activity no longer matches the filter, remove it - return state.onActivityRemoved(activity); - } - - return state.onBookmarkRemoved(event.bookmark.toModel()); + final bookmark = event.bookmark.toModel(); + return state.onBookmarkRemoved(bookmark); } if (event is api.CommentAddedEvent) { - final activity = event.activity.toModel(); - if (!matchesQueryFilter(activity)) { - // If the comment's activity no longer matches the filter, remove it - return state.onActivityRemoved(activity); - } - - return state.onCommentAdded(event.comment.toModel()); + final comment = event.comment.toModel(); + return state.onCommentAdded(comment); } if (event is api.CommentUpdatedEvent) { - // TODO: Match event activity against filter once available in the event - return state.onCommentUpdated(event.comment.toModel()); + final comment = event.comment.toModel(); + return state.onCommentUpdated(comment); } if (event is api.CommentDeletedEvent) { - // TODO: Match event activity against filter once available in the event - return state.onCommentRemoved(event.comment.toModel()); + final comment = event.comment.toModel(); + return state.onCommentRemoved(comment); } - if (event is api.ActivityFeedbackEvent) { - final payload = event.activityFeedback; + if (event is api.CommentReactionAddedEvent) { + final comment = event.comment.toModel(); + final reaction = event.reaction.toModel(); + return state.onCommentReactionAdded(comment, reaction); + } - // Only process events for the current user - if (payload.user.id != currentUserId) return; + if (event is api.CommentReactionUpdatedEvent) { + final comment = event.comment.toModel(); + final reaction = event.reaction.toModel(); + return state.onCommentReactionUpdated(comment, reaction); + } - // Only handle hide action for now - if (payload.action == api.ActivityFeedbackEventPayloadAction.hide) { - return state.onActivityHidden( - activityId: payload.activityId, - hidden: payload.value == 'true', - ); - } + if (event is api.CommentReactionDeletedEvent) { + final comment = event.comment.toModel(); + final reaction = event.reaction.toModel(); + return state.onCommentReactionRemoved(comment, reaction); + } + + if (event is api.PollClosedFeedEvent) { + return state.onPollClosed(event.poll.id); + } + + if (event is api.PollDeletedFeedEvent) { + return state.onPollDeleted(event.poll.id); + } + + if (event is api.PollUpdatedFeedEvent) { + final poll = event.poll.toModel(); + return state.onPollUpdated(poll); + } + + if (event is PollAnswerCastedFeedEvent) { + final poll = event.poll.toModel(); + final answer = event.pollVote.toModel(); + return state.onPollAnswerCasted(poll, answer); + } + + if (event is api.PollVoteCastedFeedEvent) { + final poll = event.poll.toModel(); + final vote = event.pollVote.toModel(); + return state.onPollVoteCasted(poll, vote); + } + + if (event is api.PollVoteChangedFeedEvent) { + final poll = event.poll.toModel(); + final vote = event.pollVote.toModel(); + return state.onPollVoteChanged(poll, vote); + } + + if (event is PollAnswerRemovedFeedEvent) { + final poll = event.poll.toModel(); + final answer = event.pollVote.toModel(); + return state.onPollAnswerRemoved(poll, answer); + } + + if (event is api.PollVoteRemovedFeedEvent) { + final poll = event.poll.toModel(); + final vote = event.pollVote.toModel(); + return state.onPollVoteRemoved(poll, vote); } // Handle other activity list events here as needed diff --git a/packages/stream_feeds/lib/src/state/event/handler/activity_reaction_list_event_handler.dart b/packages/stream_feeds/lib/src/state/event/handler/activity_reaction_list_event_handler.dart index 82de4482..92f50d08 100644 --- a/packages/stream_feeds/lib/src/state/event/handler/activity_reaction_list_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/handler/activity_reaction_list_event_handler.dart @@ -20,6 +20,24 @@ class ActivityReactionListEventHandler implements StateEventHandler { @override void handleEvent(WsEvent event) { + if (event is api.ActivityDeletedEvent) { + // Only handle deletion for this specific activity + if (event.activity.id != activityId) return; + return state.onActivityDeleted(); + } + + if (event is api.ActivityReactionAddedEvent) { + // Only handle reactions for this specific activity + if (event.activity.id != activityId) return; + return state.onReactionAdded(event.reaction.toModel()); + } + + if (event is api.ActivityReactionUpdatedEvent) { + // Only handle reactions for this specific activity + if (event.activity.id != activityId) return; + return state.onReactionUpdated(event.reaction.toModel()); + } + if (event is api.ActivityReactionDeletedEvent) { // Only handle reactions for this specific activity if (event.activity.id != activityId) return; diff --git a/packages/stream_feeds/lib/src/state/event/handler/bookmark_folder_list_event_handler.dart b/packages/stream_feeds/lib/src/state/event/handler/bookmark_folder_list_event_handler.dart index ed2d6b63..f4c48b8b 100644 --- a/packages/stream_feeds/lib/src/state/event/handler/bookmark_folder_list_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/handler/bookmark_folder_list_event_handler.dart @@ -2,6 +2,7 @@ import 'package:stream_core/stream_core.dart'; import '../../../generated/api/models.dart' as api; import '../../../models/bookmark_folder_data.dart'; +import '../../../utils/filter.dart'; import '../../bookmark_folder_list_state.dart'; import '../../query/bookmark_folders_query.dart'; import 'state_event_handler.dart'; @@ -21,19 +22,9 @@ class BookmarkFolderListEventHandler implements StateEventHandler { @override void handleEvent(WsEvent event) { - bool matchesQueryFilter(BookmarkFolderData bookmarkFolder) { - final filter = query.filter; - if (filter == null) return true; - return filter.matches(bookmarkFolder); - } - - if (event is api.BookmarkFolderDeletedEvent) { - return state.onBookmarkFolderRemoved(event.bookmarkFolder.id); - } - if (event is api.BookmarkFolderUpdatedEvent) { final bookmarkFolder = event.bookmarkFolder.toModel(); - if (!matchesQueryFilter(bookmarkFolder)) { + if (!bookmarkFolder.matches(query.filter)) { // If the updated bookmark folder no longer matches the filter, remove it return state.onBookmarkFolderRemoved(bookmarkFolder.id); } @@ -41,6 +32,10 @@ class BookmarkFolderListEventHandler implements StateEventHandler { return state.onBookmarkFolderUpdated(bookmarkFolder); } + if (event is api.BookmarkFolderDeletedEvent) { + return state.onBookmarkFolderRemoved(event.bookmarkFolder.id); + } + // Handle other bookmark folder list events if needed } } diff --git a/packages/stream_feeds/lib/src/state/event/handler/bookmark_list_event_handler.dart b/packages/stream_feeds/lib/src/state/event/handler/bookmark_list_event_handler.dart index 1e3d0c07..7deb87f6 100644 --- a/packages/stream_feeds/lib/src/state/event/handler/bookmark_list_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/handler/bookmark_list_event_handler.dart @@ -3,6 +3,7 @@ import 'package:stream_core/stream_core.dart'; import '../../../generated/api/models.dart' as api; import '../../../models/bookmark_data.dart'; import '../../../models/bookmark_folder_data.dart'; +import '../../../utils/filter.dart'; import '../../bookmark_list_state.dart'; import '../../query/bookmarks_query.dart'; import 'state_event_handler.dart'; @@ -22,12 +23,6 @@ class BookmarkListEventHandler implements StateEventHandler { @override void handleEvent(WsEvent event) { - bool matchesQueryFilter(BookmarkData bookmark) { - final filter = query.filter; - if (filter == null) return true; - return filter.matches(bookmark); - } - if (event is api.BookmarkFolderDeletedEvent) { return state.onBookmarkFolderRemoved(event.bookmarkFolder.id); } @@ -36,9 +31,17 @@ class BookmarkListEventHandler implements StateEventHandler { return state.onBookmarkFolderUpdated(event.bookmarkFolder.toModel()); } + if (event is api.BookmarkAddedEvent) { + final bookmark = event.bookmark.toModel(); + // Check if the new bookmark matches the query filter + if (!bookmark.matches(query.filter)) return; + + return state.onBookmarkAdded(bookmark); + } + if (event is api.BookmarkUpdatedEvent) { final bookmark = event.bookmark.toModel(); - if (!matchesQueryFilter(bookmark)) { + if (!bookmark.matches(query.filter)) { // If the updated bookmark no longer matches the filter, remove it return state.onBookmarkRemoved(bookmark.id); } @@ -46,6 +49,11 @@ class BookmarkListEventHandler implements StateEventHandler { return state.onBookmarkUpdated(bookmark); } + if (event is api.BookmarkDeletedEvent) { + final bookmark = event.bookmark.toModel(); + return state.onBookmarkRemoved(bookmark.id); + } + // Handle other bookmark list events if needed } } diff --git a/packages/stream_feeds/lib/src/state/event/handler/comment_list_event_handler.dart b/packages/stream_feeds/lib/src/state/event/handler/comment_list_event_handler.dart index c83864f6..a9fa1658 100644 --- a/packages/stream_feeds/lib/src/state/event/handler/comment_list_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/handler/comment_list_event_handler.dart @@ -3,6 +3,8 @@ import 'package:stream_core/stream_core.dart'; import '../../../generated/api/models.dart' as api; import '../../../models/comment_data.dart'; +import '../../../models/feeds_reaction_data.dart'; +import '../../../utils/filter.dart'; import '../../comment_list_state.dart'; import '../../query/comments_query.dart'; import 'state_event_handler.dart'; @@ -21,15 +23,17 @@ class CommentListEventHandler implements StateEventHandler { @override void handleEvent(WsEvent event) { - bool matchesQueryFilter(CommentData comment) { - final filter = query.filter; - if (filter == null) return true; - return filter.matches(comment); + if (event is api.CommentAddedEvent) { + final comment = event.comment.toModel(); + // Check if the new comment matches the query filter + if (!comment.matches(query.filter)) return; + + return state.onCommentAdded(comment); } if (event is api.CommentUpdatedEvent) { final comment = event.comment.toModel(); - if (!matchesQueryFilter(comment)) { + if (!comment.matches(query.filter)) { // If the updated comment no longer matches the filter, remove it return state.onCommentRemoved(comment.id); } @@ -41,6 +45,24 @@ class CommentListEventHandler implements StateEventHandler { return state.onCommentRemoved(event.comment.id); } + if (event is api.CommentReactionAddedEvent) { + final comment = event.comment.toModel(); + final reaction = event.reaction.toModel(); + return state.onCommentReactionAdded(comment, reaction); + } + + if (event is api.CommentReactionUpdatedEvent) { + final comment = event.comment.toModel(); + final reaction = event.reaction.toModel(); + return state.onCommentReactionUpdated(comment, reaction); + } + + if (event is api.CommentReactionDeletedEvent) { + final comment = event.comment.toModel(); + final reaction = event.reaction.toModel(); + return state.onCommentReactionRemoved(comment, reaction); + } + // Handle other comment-related events if needed } } diff --git a/packages/stream_feeds/lib/src/state/event/handler/comment_reaction_list_event_handler.dart b/packages/stream_feeds/lib/src/state/event/handler/comment_reaction_list_event_handler.dart index 6f268d85..b77cb340 100644 --- a/packages/stream_feeds/lib/src/state/event/handler/comment_reaction_list_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/handler/comment_reaction_list_event_handler.dart @@ -2,7 +2,9 @@ import 'package:stream_core/stream_core.dart'; import '../../../generated/api/models.dart' as api; import '../../../models/feeds_reaction_data.dart'; +import '../../../utils/filter.dart'; import '../../comment_reaction_list_state.dart'; +import '../../query/comment_reactions_query.dart'; import 'state_event_handler.dart'; /// Event handler for comment reaction list real-time updates. @@ -11,14 +13,41 @@ import 'state_event_handler.dart'; /// and updates the comment reaction list state accordingly. class CommentReactionListEventHandler implements StateEventHandler { const CommentReactionListEventHandler({ + required this.query, required this.state, }); + final CommentReactionsQuery query; final CommentReactionListStateNotifier state; @override void handleEvent(WsEvent event) { + if (event is api.CommentDeletedEvent) { + if (event.comment.id != query.commentId) return; + return state.onCommentDeleted(); + } + + if (event is api.CommentReactionAddedEvent) { + if (event.comment.id != query.commentId) return; + final reaction = event.reaction.toModel(); + if (!reaction.matches(query.filter)) return; + + return state.onReactionAdded(reaction); + } + + if (event is api.CommentReactionUpdatedEvent) { + if (event.comment.id != query.commentId) return; + final reaction = event.reaction.toModel(); + if (!reaction.matches(query.filter)) { + // If the updated reaction no longer matches the filter, remove it + return state.onReactionRemoved(reaction); + } + + return state.onReactionUpdated(reaction); + } + if (event is api.CommentReactionDeletedEvent) { + if (event.comment.id != query.commentId) return; return state.onReactionRemoved(event.reaction.toModel()); } diff --git a/packages/stream_feeds/lib/src/state/event/handler/comment_reply_list_event_handler.dart b/packages/stream_feeds/lib/src/state/event/handler/comment_reply_list_event_handler.dart index fb2175cf..9a3b3633 100644 --- a/packages/stream_feeds/lib/src/state/event/handler/comment_reply_list_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/handler/comment_reply_list_event_handler.dart @@ -4,6 +4,7 @@ import '../../../generated/api/models.dart' as api; import '../../../models/comment_data.dart'; import '../../../models/feeds_reaction_data.dart'; import '../../comment_reply_list_state.dart'; +import '../../query/comment_replies_query.dart'; import 'state_event_handler.dart'; /// Event handler for comment reply list real-time updates. @@ -12,9 +13,11 @@ import 'state_event_handler.dart'; /// and updates the comment reply list state accordingly. class CommentReplyListEventHandler implements StateEventHandler { const CommentReplyListEventHandler({ + required this.query, required this.state, }); + final CommentRepliesQuery query; final CommentReplyListStateNotifier state; @override @@ -28,6 +31,11 @@ class CommentReplyListEventHandler implements StateEventHandler { if (event is api.CommentDeletedEvent) { final comment = event.comment.toModel(); + if (comment.id == query.commentId) { + // If the parent comment is deleted, clear all replies + return state.onParentCommentDeleted(); + } + if (comment.parentId == null) return; return state.onReplyRemoved(comment); diff --git a/packages/stream_feeds/lib/src/state/event/handler/feed_event_handler.dart b/packages/stream_feeds/lib/src/state/event/handler/feed_event_handler.dart index f74c259d..cc7881f3 100644 --- a/packages/stream_feeds/lib/src/state/event/handler/feed_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/handler/feed_event_handler.dart @@ -10,7 +10,11 @@ import '../../../models/feed_data.dart'; import '../../../models/feeds_reaction_data.dart'; import '../../../models/follow_data.dart'; import '../../../models/mark_activity_data.dart'; +import '../../../models/poll_data.dart'; +import '../../../models/poll_vote_data.dart'; import '../../../repository/capabilities_repository.dart'; +import '../../../resolvers/resolvers.dart'; +import '../../../utils/filter.dart'; import '../../feed_state.dart'; import '../../query/feed_query.dart'; @@ -37,16 +41,8 @@ class FeedEventHandler with FeedCapabilitiesMixin implements StateEventHandler { @override Future handleEvent(WsEvent event) async { - final fid = query.fid; - - bool matchesQueryFilter(ActivityData activity) { - final filter = query.activityFilter; - if (filter == null) return true; - return filter.matches(activity); - } - if (event is api.ActivityAddedEvent) { - if (event.fid != fid.rawValue) return; + if (event.fid != query.fid.rawValue) return; final activity = event.activity.toModel(); final insertionAction = onNewActivity(query, activity, currentUserId); @@ -59,10 +55,10 @@ class FeedEventHandler with FeedCapabilitiesMixin implements StateEventHandler { } if (event is api.ActivityUpdatedEvent) { - if (event.fid != fid.rawValue) return; + if (event.fid != query.fid.rawValue) return; final activity = event.activity.toModel(); - if (!matchesQueryFilter(activity)) { + if (!activity.matches(query.activityFilter)) { // If the updated activity no longer matches the filter, remove it return state.onActivityRemoved(activity); } @@ -72,140 +68,143 @@ class FeedEventHandler with FeedCapabilitiesMixin implements StateEventHandler { } if (event is api.ActivityDeletedEvent) { - if (event.fid != fid.rawValue) return; + if (event.fid != query.fid.rawValue) return; return state.onActivityRemoved(event.activity.toModel()); } - if (event is api.ActivityReactionAddedEvent) { - if (event.fid != fid.rawValue) return; + if (event is api.ActivityRemovedFromFeedEvent) { + if (event.fid != query.fid.rawValue) return; + return state.onActivityRemoved(event.activity.toModel()); + } - final activity = event.activity.toModel(); - if (!matchesQueryFilter(activity)) { - // If the reaction's activity no longer matches the filter, remove it - return state.onActivityRemoved(activity); + if (event is api.ActivityPinnedEvent) { + if (event.fid != query.fid.rawValue) return; + return state.onActivityPinned(event.pinnedActivity.toModel()); + } + + if (event is api.ActivityUnpinnedEvent) { + if (event.fid != query.fid.rawValue) return; + return state.onActivityUnpinned(event.pinnedActivity.activity.id); + } + + if (event is api.ActivityFeedbackEvent) { + final payload = event.activityFeedback; + final userId = payload.user.id; + + // Only process events for the current user + if (userId != currentUserId) return; + + // Only handle hide action for now + if (payload.action == api.ActivityFeedbackEventPayloadAction.hide) { + return state.onActivityHidden( + activityId: payload.activityId, + hidden: payload.value == 'true', + ); } + } + if (event is api.ActivityMarkEvent) { + if (event.fid != query.fid.rawValue) return; + return state.onActivityMarked(event.toModel()); + } + + if (event is api.ActivityReactionAddedEvent) { + if (event.fid != query.fid.rawValue) return; + + final activity = event.activity.toModel(); final reaction = event.reaction.toModel(); return state.onReactionAdded(activity, reaction); } if (event is api.ActivityReactionUpdatedEvent) { - if (event.fid != fid.rawValue) return; + if (event.fid != query.fid.rawValue) return; final activity = event.activity.toModel(); - if (!matchesQueryFilter(activity)) { - // If the reaction's activity no longer matches the filter, remove it - return state.onActivityRemoved(activity); - } - final reaction = event.reaction.toModel(); return state.onReactionUpdated(activity, reaction); } if (event is api.ActivityReactionDeletedEvent) { - if (event.fid != fid.rawValue) return; + if (event.fid != query.fid.rawValue) return; final activity = event.activity.toModel(); - if (!matchesQueryFilter(activity)) { - // If the reaction's activity no longer matches the filter, remove it - return state.onActivityRemoved(activity); - } - final reaction = event.reaction.toModel(); return state.onReactionRemoved(activity, reaction); } - if (event is api.ActivityPinnedEvent) { - if (event.fid != fid.rawValue) return; - - final activity = event.pinnedActivity.activity.toModel(); - if (!matchesQueryFilter(activity)) { - // If the pinned activity no longer matches the filter, remove it - return state.onActivityRemoved(activity); - } - - return state.onActivityPinned(event.pinnedActivity.toModel()); - } - - if (event is api.ActivityUnpinnedEvent) { - if (event.fid != fid.rawValue) return; - - final activity = event.pinnedActivity.activity.toModel(); - if (!matchesQueryFilter(activity)) { - // If the unpinned activity no longer matches the filter, remove it - return state.onActivityRemoved(activity); - } - - return state.onActivityUnpinned(event.pinnedActivity.activity.id); - } - - if (event is api.ActivityMarkEvent) { - if (event.fid != fid.rawValue) return; - return state.onActivityMarked(event.toModel()); - } - - if (event is api.NotificationFeedUpdatedEvent) { - if (event.fid != fid.rawValue) return; - return state.onNotificationFeedUpdated( - event.aggregatedActivities?.map((it) => it.toModel()).toList(), - event.notificationStatus, - ); - } - if (event is api.BookmarkAddedEvent) { return state.onBookmarkAdded(event.bookmark.toModel()); } + if (event is api.BookmarkUpdatedEvent) { + return state.onBookmarkUpdated(event.bookmark.toModel()); + } + if (event is api.BookmarkDeletedEvent) { return state.onBookmarkRemoved(event.bookmark.toModel()); } if (event is api.CommentAddedEvent) { - if (event.fid != fid.rawValue) return; - - final activity = event.activity.toModel(); - if (!matchesQueryFilter(activity)) { - // If the comment's activity no longer matches the filter, remove it - return state.onActivityRemoved(activity); - } - + if (event.fid != query.fid.rawValue) return; return state.onCommentAdded(event.comment.toModel()); } if (event is api.CommentUpdatedEvent) { - if (event.fid != fid.rawValue) return; - // TODO: Match event activity against filter once available in the event + if (event.fid != query.fid.rawValue) return; return state.onCommentUpdated(event.comment.toModel()); } if (event is api.CommentDeletedEvent) { - if (event.fid != fid.rawValue) return; - // TODO: Match event activity against filter once available in the event + if (event.fid != query.fid.rawValue) return; return state.onCommentRemoved(event.comment.toModel()); } + if (event is api.CommentReactionAddedEvent) { + if (event.fid != query.fid.rawValue) return; + + final comment = event.comment.toModel(); + final reaction = event.reaction.toModel(); + return state.onCommentReactionAdded(comment, reaction); + } + + if (event is api.CommentReactionUpdatedEvent) { + if (event.fid != query.fid.rawValue) return; + + final comment = event.comment.toModel(); + final reaction = event.reaction.toModel(); + return state.onCommentReactionUpdated(comment, reaction); + } + + if (event is api.CommentReactionDeletedEvent) { + if (event.fid != query.fid.rawValue) return; + + final comment = event.comment.toModel(); + final reaction = event.reaction.toModel(); + return state.onCommentReactionRemoved(comment, reaction); + } + if (event is api.FeedDeletedEvent) { - if (event.fid != fid.rawValue) return; + if (event.fid != query.fid.rawValue) return; return state.onFeedDeleted(); } if (event is api.FeedUpdatedEvent) { - if (event.fid != fid.rawValue) return; + if (event.fid != query.fid.rawValue) return; return state.onFeedUpdated(event.feed.toModel()); } if (event is api.FollowCreatedEvent) { - if (event.fid != fid.rawValue) return; + if (event.fid != query.fid.rawValue) return; return state.onFollowAdded(event.follow.toModel()); } if (event is api.FollowDeletedEvent) { - if (event.fid != fid.rawValue) return; + if (event.fid != query.fid.rawValue) return; return state.onFollowRemoved(event.follow.toModel()); } if (event is api.FollowUpdatedEvent) { - if (event.fid != fid.rawValue) return; + if (event.fid != query.fid.rawValue) return; return state.onFollowUpdated(event.follow.toModel()); } @@ -213,28 +212,62 @@ class FeedEventHandler with FeedCapabilitiesMixin implements StateEventHandler { if (event is api.FeedMemberRemovedEvent) return; if (event is api.FeedMemberUpdatedEvent) return; - if (event is api.StoriesFeedUpdatedEvent) { - if (event.fid != fid.rawValue) return; + if (event is api.NotificationFeedUpdatedEvent) { + if (event.fid != query.fid.rawValue) return; + return state.onNotificationFeedUpdated( + event.notificationStatus, + event.aggregatedActivities?.map((it) => it.toModel()).toList(), + ); + } - return state.onAggregatedActivitiesUpdated( + if (event is api.StoriesFeedUpdatedEvent) { + if (event.fid != query.fid.rawValue) return; + return state.onStoriesFeedUpdated( event.aggregatedActivities?.map((it) => it.toModel()).toList(), ); } - if (event is api.ActivityFeedbackEvent) { - final payload = event.activityFeedback; - final userId = payload.user.id; + if (event is api.PollClosedFeedEvent) { + return state.onPollClosed(event.poll.id); + } - // Only process events for the current user - if (userId != currentUserId) return; + if (event is api.PollDeletedFeedEvent) { + return state.onPollDeleted(event.poll.id); + } - // Only handle hide action for now - if (payload.action == api.ActivityFeedbackEventPayloadAction.hide) { - return state.onActivityHidden( - activityId: payload.activityId, - hidden: payload.value == 'true', - ); - } + if (event is api.PollUpdatedFeedEvent) { + final poll = event.poll.toModel(); + return state.onPollUpdated(poll); + } + + if (event is PollAnswerCastedFeedEvent) { + final poll = event.poll.toModel(); + final answer = event.pollVote.toModel(); + return state.onPollAnswerCasted(poll, answer); + } + + if (event is api.PollVoteCastedFeedEvent) { + final poll = event.poll.toModel(); + final vote = event.pollVote.toModel(); + return state.onPollVoteCasted(poll, vote); + } + + if (event is api.PollVoteChangedFeedEvent) { + final poll = event.poll.toModel(); + final vote = event.pollVote.toModel(); + return state.onPollVoteChanged(poll, vote); + } + + if (event is PollAnswerRemovedFeedEvent) { + final poll = event.poll.toModel(); + final answer = event.pollVote.toModel(); + return state.onPollAnswerRemoved(poll, answer); + } + + if (event is api.PollVoteRemovedFeedEvent) { + final poll = event.poll.toModel(); + final vote = event.pollVote.toModel(); + return state.onPollVoteRemoved(poll, vote); } // Handle other events if necessary diff --git a/packages/stream_feeds/lib/src/state/event/handler/feed_list_event_handler.dart b/packages/stream_feeds/lib/src/state/event/handler/feed_list_event_handler.dart index c106e1a8..17a8faa2 100644 --- a/packages/stream_feeds/lib/src/state/event/handler/feed_list_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/handler/feed_list_event_handler.dart @@ -2,6 +2,7 @@ import 'package:stream_core/stream_core.dart'; import '../../../generated/api/models.dart' as api; import '../../../models/feed_data.dart'; +import '../../../utils/filter.dart'; import '../../feed_list_state.dart'; import '../../query/feeds_query.dart'; @@ -18,15 +19,17 @@ class FeedListEventHandler implements StateEventHandler { @override void handleEvent(WsEvent event) { - bool matchesQueryFilter(FeedData feed) { - final filter = query.filter; - if (filter == null) return true; - return filter.matches(feed); + if (event is api.FeedCreatedEvent) { + final feed = event.feed.toModel(); + // Check if the new feed matches the query filter + if (!feed.matches(query.filter)) return; + + return state.onFeedAdded(feed); } if (event is api.FeedUpdatedEvent) { final feed = event.feed.toModel(); - if (!matchesQueryFilter(feed)) { + if (!feed.matches(query.filter)) { // If the updated feed no longer matches the query filter, remove it return state.onFeedRemoved(feed.fid.rawValue); } @@ -34,6 +37,10 @@ class FeedListEventHandler implements StateEventHandler { return state.onFeedUpdated(feed); } + if (event is api.FeedDeletedEvent) { + return state.onFeedRemoved(event.fid); + } + // Handle other events if needed } } diff --git a/packages/stream_feeds/lib/src/state/event/handler/follow_list_event_handler.dart b/packages/stream_feeds/lib/src/state/event/handler/follow_list_event_handler.dart index a240a052..ff4e53b0 100644 --- a/packages/stream_feeds/lib/src/state/event/handler/follow_list_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/handler/follow_list_event_handler.dart @@ -2,6 +2,7 @@ import 'package:stream_core/stream_core.dart'; import '../../../generated/api/models.dart' as api; import '../../../models/follow_data.dart'; +import '../../../utils/filter.dart'; import '../../follow_list_state.dart'; import '../../query/follows_query.dart'; import 'state_event_handler.dart'; @@ -21,15 +22,17 @@ class FollowListEventHandler implements StateEventHandler { @override void handleEvent(WsEvent event) { - bool matchesQueryFilter(FollowData follow) { - final filter = query.filter; - if (filter == null) return true; - return filter.matches(follow); + if (event is api.FollowCreatedEvent) { + final follow = event.follow.toModel(); + // Check if the new follow matches the query filter + if (!follow.matches(query.filter)) return; + + return state.onFollowAdded(follow); } if (event is api.FollowUpdatedEvent) { final follow = event.follow.toModel(); - if (!matchesQueryFilter(follow)) { + if (!follow.matches(query.filter)) { // If the updated follow no longer matches the query filter, remove it return state.onFollowRemoved(follow.id); } @@ -37,6 +40,11 @@ class FollowListEventHandler implements StateEventHandler { return state.onFollowUpdated(follow); } + if (event is api.FollowDeletedEvent) { + final follow = event.follow.toModel(); + return state.onFollowRemoved(follow.id); + } + // Handle other follow list events here as needed } } diff --git a/packages/stream_feeds/lib/src/state/event/handler/member_list_event_handler.dart b/packages/stream_feeds/lib/src/state/event/handler/member_list_event_handler.dart index 4eae1a6f..12fa8496 100644 --- a/packages/stream_feeds/lib/src/state/event/handler/member_list_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/handler/member_list_event_handler.dart @@ -2,6 +2,7 @@ import 'package:stream_core/stream_core.dart'; import '../../../generated/api/models.dart' as api; import '../../../models/feed_member_data.dart'; +import '../../../utils/filter.dart'; import '../../member_list_state.dart'; import '../../query/members_query.dart'; @@ -20,22 +21,20 @@ class MemberListEventHandler implements StateEventHandler { void handleEvent(WsEvent event) { final fid = query.fid; - bool matchesQueryFilter(FeedMemberData member) { - final filter = query.filter; - if (filter == null) return true; - return filter.matches(member); - } - - if (event is api.FeedMemberRemovedEvent) { + if (event is api.FeedMemberAddedEvent) { if (event.fid != fid.rawValue) return; - return state.onMemberRemoved(event.memberId); + final member = event.member.toModel(); + // Check if the new member matches the query filter + if (!member.matches(query.filter)) return; + + return state.onMemberAdded(member); } if (event is api.FeedMemberUpdatedEvent) { if (event.fid != fid.rawValue) return; final member = event.member.toModel(); - if (!matchesQueryFilter(member)) { + if (!member.matches(query.filter)) { // If the updated member no longer matches the filter, remove it return state.onMemberRemoved(member.id); } @@ -43,6 +42,11 @@ class MemberListEventHandler implements StateEventHandler { return state.onMemberUpdated(member); } + if (event is api.FeedMemberRemovedEvent) { + if (event.fid != fid.rawValue) return; + return state.onMemberRemoved(event.memberId); + } + // Handle other events if needed } } diff --git a/packages/stream_feeds/lib/src/state/event/handler/poll_list_event_handler.dart b/packages/stream_feeds/lib/src/state/event/handler/poll_list_event_handler.dart index 8a9b287f..c6751cd2 100644 --- a/packages/stream_feeds/lib/src/state/event/handler/poll_list_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/handler/poll_list_event_handler.dart @@ -2,6 +2,9 @@ import 'package:stream_core/stream_core.dart'; import '../../../generated/api/models.dart' as api; import '../../../models/poll_data.dart'; +import '../../../models/poll_vote_data.dart'; +import '../../../resolvers/resolvers.dart'; +import '../../../utils/filter.dart'; import '../../poll_list_state.dart'; import '../../query/polls_query.dart'; import 'state_event_handler.dart'; @@ -21,15 +24,13 @@ class PollListEventHandler implements StateEventHandler { @override void handleEvent(WsEvent event) { - bool matchesQueryFilter(PollData poll) { - final filter = query.filter; - if (filter == null) return true; - return filter.matches(poll); + if (event is api.PollDeletedFeedEvent) { + return state.onPollRemoved(event.poll.id); } if (event is api.PollUpdatedFeedEvent) { final poll = event.poll.toModel(); - if (!matchesQueryFilter(poll)) { + if (!poll.matches(query.filter)) { // If the updated poll no longer matches the filter, remove it return state.onPollRemoved(poll.id); } @@ -37,6 +38,46 @@ class PollListEventHandler implements StateEventHandler { return state.onPollUpdated(poll); } + if (event is api.PollClosedFeedEvent) { + final poll = event.poll.toModel(); + if (!poll.matches(query.filter)) { + // If the closed poll no longer matches the filter, remove it + return state.onPollRemoved(poll.id); + } + + return state.onPollClosed(poll.id); + } + + if (event is api.PollVoteCastedFeedEvent) { + final poll = event.poll.toModel(); + final pollVote = event.pollVote.toModel(); + return state.onPollVoteCasted(poll, pollVote); + } + + if (event is PollAnswerCastedFeedEvent) { + final poll = event.poll.toModel(); + final pollVote = event.pollVote.toModel(); + return state.onPollAnswerCasted(poll, pollVote); + } + + if (event is api.PollVoteChangedFeedEvent) { + final poll = event.poll.toModel(); + final pollVote = event.pollVote.toModel(); + return state.onPollVoteChanged(poll, pollVote); + } + + if (event is api.PollVoteRemovedFeedEvent) { + final poll = event.poll.toModel(); + final pollVote = event.pollVote.toModel(); + return state.onPollVoteRemoved(poll, pollVote); + } + + if (event is PollAnswerRemovedFeedEvent) { + final poll = event.poll.toModel(); + final pollVote = event.pollVote.toModel(); + return state.onPollAnswerRemoved(poll, pollVote); + } + // Handle other poll list events here as needed } } diff --git a/packages/stream_feeds/lib/src/state/event/handler/poll_vote_list_event_handler.dart b/packages/stream_feeds/lib/src/state/event/handler/poll_vote_list_event_handler.dart index 97f3916b..0bf0ca0d 100644 --- a/packages/stream_feeds/lib/src/state/event/handler/poll_vote_list_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/handler/poll_vote_list_event_handler.dart @@ -2,6 +2,7 @@ import 'package:stream_core/stream_core.dart'; import '../../../generated/api/models.dart' as api; import '../../../models/poll_vote_data.dart'; +import '../../../utils/filter.dart'; import '../../poll_vote_list_state.dart'; import '../../query/poll_votes_query.dart'; import 'state_event_handler.dart'; @@ -21,31 +22,38 @@ class PollVoteListEventHandler implements StateEventHandler { @override void handleEvent(WsEvent event) { - final pollId = query.pollId; + if (event is api.PollDeletedFeedEvent) { + if (event.poll.id != query.pollId) return; + return state.onPollDeleted(); + } + + if (event is api.PollVoteCastedFeedEvent) { + // Only handle votes for this specific poll + if (event.pollVote.pollId != query.pollId) return; + + final pollVote = event.pollVote.toModel(); + if (!pollVote.matches(query.filter)) return; - bool matchesQueryFilter(PollVoteData pollVote) { - final filter = query.filter; - if (filter == null) return true; - return filter.matches(pollVote); + return state.onPollVoteAdded(pollVote); } if (event is api.PollVoteChangedFeedEvent) { // Only handle votes for this specific poll - if (event.pollVote.pollId != pollId) return; + if (event.pollVote.pollId != query.pollId) return; final pollVote = event.pollVote.toModel(); - if (!matchesQueryFilter(pollVote)) { + if (!pollVote.matches(query.filter)) { // If the updated poll vote no longer matches the filter, remove it - return state.pollVoteRemoved(pollVote.id); + return state.onPollVoteRemoved(pollVote.id); } - return state.pollVoteUpdated(pollVote); + return state.onPollVoteUpdated(pollVote); } if (event is api.PollVoteRemovedFeedEvent) { // Only handle votes for this specific poll - if (event.poll.id != pollId) return; - return state.pollVoteRemoved(event.pollVote.id); + if (event.poll.id != query.pollId) return; + return state.onPollVoteRemoved(event.pollVote.id); } // Handle other poll vote list events here as needed diff --git a/packages/stream_feeds/lib/src/state/feed_list_state.dart b/packages/stream_feeds/lib/src/state/feed_list_state.dart index 6a681e94..d0e1b0e9 100644 --- a/packages/stream_feeds/lib/src/state/feed_list_state.dart +++ b/packages/stream_feeds/lib/src/state/feed_list_state.dart @@ -43,6 +43,17 @@ class FeedListStateNotifier extends StateNotifier { ); } + /// Handles the addition of a new feed. + void onFeedAdded(FeedData feed) { + final updatedFeeds = state.feeds.sortedUpsert( + feed, + key: (it) => it.fid.rawValue, + compare: feedsSort.compare, + ); + + state = state.copyWith(feeds: updatedFeeds); + } + /// Handles updates to a specific feed. void onFeedUpdated(FeedData feed) { final updatedFeeds = state.feeds.sortedUpsert( diff --git a/packages/stream_feeds/lib/src/state/feed_state.dart b/packages/stream_feeds/lib/src/state/feed_state.dart index 1bae68a0..0762c377 100644 --- a/packages/stream_feeds/lib/src/state/feed_state.dart +++ b/packages/stream_feeds/lib/src/state/feed_state.dart @@ -19,6 +19,8 @@ import '../models/follow_data.dart'; import '../models/get_or_create_feed_data.dart'; import '../models/mark_activity_data.dart'; import '../models/pagination_data.dart'; +import '../models/poll_data.dart'; +import '../models/poll_vote_data.dart'; import '../models/query_configuration.dart'; import 'insertion_action.dart'; import 'member_list_state.dart'; @@ -124,20 +126,9 @@ class FeedStateNotifier extends StateNotifier { /// Handles updates to the feed state when an activity is updated. void onActivityUpdated(ActivityData activity) { - final updatedActivities = state.activities.upsert( - activity, - key: (it) => it.id, - update: (existing, updated) => existing.updateWith(updated), - ); - - final updatedPinnedActivities = state.pinnedActivities.map((pin) { - if (pin.activity.id != activity.id) return pin; - return pin.copyWith(activity: pin.activity.updateWith(activity)); - }).toList(); - - state = state.copyWith( - activities: updatedActivities, - pinnedActivities: updatedPinnedActivities, + state = state.updateActivitiesWhere( + (it) => it.id == activity.id, + update: (it) => it.updateWith(activity), ); } @@ -148,20 +139,7 @@ class FeedStateNotifier extends StateNotifier { /// Handles updates to the feed state when an activity is deleted. void onActivityDeleted(String activityId) { - // Remove the activity from the activities list - final updatedActivities = state.activities.where((it) { - return it.id != activityId; - }).toList(); - - // Remove the activity from pinned activities if it exists - final updatedPinnedActivities = state.pinnedActivities.where((pin) { - return pin.activity.id != activityId; - }).toList(); - - state = state.copyWith( - activities: updatedActivities, - pinnedActivities: updatedPinnedActivities, - ); + state = state.removeActivitiesWhere((it) => it.id == activityId); } /// Handles updates to the feed state when an activity is hidden. @@ -169,21 +147,9 @@ class FeedStateNotifier extends StateNotifier { required String activityId, required bool hidden, }) { - // Update the activity to mark it as hidden - final updatedActivities = state.activities.map((activity) { - if (activity.id != activityId) return activity; - return activity.copyWith(hidden: hidden); - }).toList(); - - // Update pinned activities as well - final updatedPinnedActivities = state.pinnedActivities.map((pin) { - if (pin.activity.id != activityId) return pin; - return pin.copyWith(activity: pin.activity.copyWith(hidden: hidden)); - }).toList(); - - state = state.copyWith( - activities: updatedActivities, - pinnedActivities: updatedPinnedActivities, + state = state.updateActivitiesWhere( + (it) => it.id == activityId, + update: (it) => it.copyWith(hidden: hidden), ); } @@ -213,15 +179,15 @@ class FeedStateNotifier extends StateNotifier { // Update the state based on the type of mark operation state = markData.handle( // If markAllRead is true, mark all activities as read - markAllRead: () => _markAllRead(state), + markAllRead: () => state.markAllRead(), // If markAllSeen is true, mark all activities as seen - markAllSeen: () => _markAllSeen(state), + markAllSeen: () => state.markAllSeen(), // If markRead contains specific IDs, mark those as read - markRead: (read) => _markRead(read, state), + markRead: (read) => state.markRead(read), // If markSeen contains specific IDs, mark those as seen - markSeen: (seen) => _markSeen(seen, state), + markSeen: (seen) => state.markSeen(seen), // If markWatched contains specific IDs, mark those as watched - markWatched: (watched) => _markWatched(watched, state), + markWatched: (watched) => state.markWatched(watched), // For other cases, return the current state without changes orElse: (MarkActivityData data) => state, ); @@ -229,8 +195,8 @@ class FeedStateNotifier extends StateNotifier { /// Handles updates to the feed state when the notification feed is updated. void onNotificationFeedUpdated( - List? aggregatedActivities, NotificationStatusResponse? notificationStatus, + List? aggregatedActivities, ) { // Update the aggregated activities and notification status in the state final updatedAggregatedActivities = state.aggregatedActivities.merge( @@ -239,19 +205,24 @@ class FeedStateNotifier extends StateNotifier { ); state = state.copyWith( - aggregatedActivities: updatedAggregatedActivities, notificationStatus: notificationStatus, + aggregatedActivities: updatedAggregatedActivities, ); } - void onAggregatedActivitiesUpdated( + /// Handles updates to the feed state when the stories feed is updated. + void onStoriesFeedUpdated( List? aggregatedActivities, ) { + // Update the aggregated activities in the state final updatedAggregatedActivities = state.aggregatedActivities.merge( aggregatedActivities ?? [], key: (it) => it.group, ); - state = state.copyWith(aggregatedActivities: updatedAggregatedActivities); + + state = state.copyWith( + aggregatedActivities: updatedAggregatedActivities, + ); } /// Handles updates to the feed state when a bookmark is added. @@ -259,13 +230,18 @@ class FeedStateNotifier extends StateNotifier { /// Updates the activity matching [bookmark]'s activity ID by adding or updating /// the bookmark in its own bookmarks list. Only adds bookmarks that belong to /// the current user. - void onBookmarkAdded(BookmarkData bookmark) { - final updatedActivities = state.activities.map((activity) { - if (activity.id != bookmark.activity.id) return activity; - return activity.upsertBookmark(bookmark, currentUserId); - }).toList(); + void onBookmarkAdded(BookmarkData bookmark) => onBookmarkUpdated(bookmark); - state = state.copyWith(activities: updatedActivities); + /// Handles updates to the feed state when a bookmark is updated. + /// + /// Updates the activity matching [bookmark]'s activity ID by adding or updating + /// the bookmark in its own bookmarks list. Only adds bookmarks that belong to + /// the current user. + void onBookmarkUpdated(BookmarkData bookmark) { + state = state.updateActivitiesWhere( + (it) => it.id == bookmark.activity.id, + update: (it) => it.upsertBookmark(bookmark, currentUserId), + ); } /// Handles updates to the feed state when a bookmark is removed. @@ -274,39 +250,73 @@ class FeedStateNotifier extends StateNotifier { /// the bookmark from its own bookmarks list. Only removes bookmarks that /// belong to the current user. void onBookmarkRemoved(BookmarkData bookmark) { - final updatedActivities = state.activities.map((activity) { - if (activity.id != bookmark.activity.id) return activity; - return activity.removeBookmark(bookmark, currentUserId); - }).toList(); - - state = state.copyWith(activities: updatedActivities); + state = state.updateActivitiesWhere( + (it) => it.id == bookmark.activity.id, + update: (it) => it.removeBookmark(bookmark, currentUserId), + ); } /// Handles updates to the feed state when a comment is added or removed. - void onCommentAdded(CommentData comment) { - return onCommentUpdated(comment); - } + void onCommentAdded(CommentData comment) => onCommentUpdated(comment); /// Handles updates to the feed state when a comment is updated. void onCommentUpdated(CommentData comment) { // Add or update the comment in the activity - final updatedActivities = state.activities.map((activity) { - if (activity.id != comment.objectId) return activity; - return activity.upsertComment(comment); - }).toList(); - - state = state.copyWith(activities: updatedActivities); + state = state.updateActivitiesWhere( + (it) => it.id == comment.objectId, + update: (it) => it.upsertComment(comment), + ); } /// Handles updates to the feed state when a comment is removed. void onCommentRemoved(CommentData comment) { // Remove the comment from the activity - final updatedActivities = state.activities.map((activity) { - if (activity.id != comment.objectId) return activity; - return activity.removeComment(comment); - }).toList(); + state = state.updateActivitiesWhere( + (it) => it.id == comment.objectId, + update: (it) => it.removeComment(comment), + ); + } - state = state.copyWith(activities: updatedActivities); + /// Handles updates to the feed state when a comment reaction is added. + void onCommentReactionAdded( + CommentData comment, + FeedsReactionData reaction, + ) { + // Add the reaction to the comment in the activity + state = state.updateActivitiesWhere( + (it) => it.id == comment.objectId, + update: (it) { + return it.upsertCommentReaction(comment, reaction, currentUserId); + }, + ); + } + + /// Handles updates to the feed state when a comment reaction is updated. + void onCommentReactionUpdated( + CommentData comment, + FeedsReactionData reaction, + ) { + // Update the reaction on the comment in the activity + state = state.updateActivitiesWhere( + (it) => it.id == comment.objectId, + update: (it) { + return it.upsertUniqueCommentReaction(comment, reaction, currentUserId); + }, + ); + } + + /// Handles updates to the feed state when a comment reaction is removed. + void onCommentReactionRemoved( + CommentData comment, + FeedsReactionData reaction, + ) { + // Remove the reaction from the comment in the activity + state = state.updateActivitiesWhere( + (it) => it.id == comment.objectId, + update: (it) { + return it.removeCommentReaction(comment, reaction, currentUserId); + }, + ); } /// Handles updates to the feed state when the feed is deleted. @@ -340,19 +350,19 @@ class FeedStateNotifier extends StateNotifier { /// Handles updates to the feed state when a follow is added. void onFollowAdded(FollowData follow) { // Add the follow to the feed state - state = _addFollow(follow, state); + state = state.addFollow(follow); } /// Handles updates to the feed state when a follow is removed. void onFollowRemoved(FollowData follow) { // Remove the follow from the feed state - state = _removeFollow(follow, state); + state = state.removeFollow(follow); } /// Handles updates to the feed state when a follow is updated. void onFollowUpdated(FollowData follow) { // Update the follow in the feed state - state = _updateFollow(follow, state); + state = state.updateFollow(follow); } /// Handles updates to the feed state when an unfollow action occurs. @@ -382,12 +392,10 @@ class FeedStateNotifier extends StateNotifier { FeedsReactionData reaction, ) { // Add or update the reaction in the activity - final updatedActivities = state.activities.map((it) { - if (it.id != reaction.activityId) return it; - return it.upsertReaction(activity, reaction, currentUserId); - }).toList(); - - state = state.copyWith(activities: updatedActivities); + state = state.updateActivitiesWhere( + (it) => it.id == reaction.activityId, + update: (it) => it.upsertReaction(activity, reaction, currentUserId), + ); } /// Handles updates to the feed state when a reaction is updated. @@ -396,12 +404,12 @@ class FeedStateNotifier extends StateNotifier { FeedsReactionData reaction, ) { // Update the reaction in the activity - final updatedActivities = state.activities.map((it) { - if (it.id != reaction.activityId) return it; - return it.upsertUniqueReaction(activity, reaction, currentUserId); - }).toList(); - - state = state.copyWith(activities: updatedActivities); + state = state.updateActivitiesWhere( + (it) => it.id == reaction.activityId, + update: (it) { + return it.upsertUniqueReaction(activity, reaction, currentUserId); + }, + ); } /// Handles updates to the feed state when a reaction is removed. @@ -410,80 +418,246 @@ class FeedStateNotifier extends StateNotifier { FeedsReactionData reaction, ) { // Remove the reaction from the activity - final updatedActivities = state.activities.map((it) { - if (it.id != reaction.activityId) return it; - return it.removeReaction(activity, reaction, currentUserId); - }).toList(); + state = state.updateActivitiesWhere( + (it) => it.id == reaction.activityId, + update: (it) => it.removeReaction(activity, reaction, currentUserId), + ); + } - state = state.copyWith(activities: updatedActivities); + /// Handles when a poll is closed. + void onPollClosed(String pollId) { + state = state.updateActivitiesWhere( + (it) => it.poll?.id == pollId, + update: (it) => it.copyWith(poll: it.poll?.copyWith(isClosed: true)), + ); + } + + /// Handles when a poll is deleted. + void onPollDeleted(String pollId) { + state = state.updateActivitiesWhere( + (it) => it.poll?.id == pollId, + update: (it) => it.copyWith(poll: null), + ); + } + + /// Handles when a poll is updated. + void onPollUpdated(PollData poll) { + state = state.updateActivitiesWhere( + (it) => it.poll?.id == poll.id, + update: (it) => it.copyWith(poll: it.poll?.updateWith(poll)), + ); + } + + /// Handles when a poll answer is casted. + void onPollAnswerCasted(PollData poll, PollVoteData answer) { + state = state.updateActivitiesWhere( + (it) => it.poll?.id == poll.id, + update: (it) => it.copyWith( + poll: it.poll?.upsertAnswer(poll, answer, currentUserId), + ), + ); + } + + /// Handles when a poll vote is casted (with poll data). + void onPollVoteCasted(PollData poll, PollVoteData vote) { + return onPollVoteChanged(poll, vote); + } + + /// Handles when a poll vote is changed. + void onPollVoteChanged(PollData poll, PollVoteData vote) { + state = state.updateActivitiesWhere( + (it) => it.poll?.id == poll.id, + update: (it) => it.copyWith( + poll: it.poll?.upsertVote(poll, vote, currentUserId), + ), + ); + } + + /// Handles when a poll answer is removed (with poll data). + void onPollAnswerRemoved(PollData poll, PollVoteData answer) { + state = state.updateActivitiesWhere( + (it) => it.poll?.id == poll.id, + update: (it) => it.copyWith( + poll: it.poll?.removeAnswer(poll, answer, currentUserId), + ), + ); + } + + /// Handles when a poll vote is removed (with poll data). + void onPollVoteRemoved(PollData poll, PollVoteData vote) { + state = state.updateActivitiesWhere( + (it) => it.poll?.id == poll.id, + update: (it) => it.copyWith( + poll: it.poll?.removeVote(poll, vote, currentUserId), + ), + ); } - FeedState _addFollow(FollowData follow, FeedState state) { + @override + void dispose() { + _removeMemberListListener?.call(); + super.dispose(); + } +} + +/// Represents the current state of a feed. +/// +/// Contains activities, feed metadata, followers, members, and pagination information. +/// Automatically updates when WebSocket events are received. +@freezed +class FeedState with _$FeedState { + const FeedState({ + required this.fid, + required this.feedQuery, + this.activities = const [], + this.aggregatedActivities = const [], + this.feed, + this.followers = const [], + this.following = const [], + this.followRequests = const [], + this.members = const [], + this.pinnedActivities = const [], + this.notificationStatus, + this.activitiesPagination, + }); + + /// The unique identifier for the feed. + @override + final FeedId fid; + + /// The query used to create the feed. + @override + final FeedQuery feedQuery; + + /// The list of activities in the feed, sorted by default sorting criteria. + @override + final List activities; + + /// The list of aggregated activities in the feed. + @override + final List aggregatedActivities; + + /// The feed data containing feed metadata and configuration. + @override + final FeedData? feed; + + /// The list of followers for this feed. + @override + final List followers; + + /// The list of feeds that this feed is following. + @override + final List following; + + /// The list of pending follow requests for this feed. + @override + final List followRequests; + + /// The list of members in this feed. + @override + final List members; + + /// The list of pinned activities and its pinning state. + @override + final List pinnedActivities; + + /// Returns information about the notification status (read / seen activities). + @override + final NotificationStatusResponse? notificationStatus; + + /// Pagination information for [activities] and [aggregatedActivities] queries. + @override + final PaginationData? activitiesPagination; + + /// Indicates whether there are more activities available to load. + /// + /// Returns true if there is a next page available for pagination. + bool get canLoadMoreActivities => activitiesPagination?.next != null; +} + +/// Extension providing helper methods for updating feed state. +/// +/// This extension adds convenience methods for common feed state mutations including +/// follow management, activity marking (read, seen, watched), and activity filtering. +extension on FeedState { + /// Adds a follow to this feed state. + /// + /// Updates the appropriate follow list (follow requests, following, or followers) based on + /// the type of [follow]. Also updates the feed's follower or following count when applicable. + /// + /// Returns a new [FeedState] instance with the updated follow data. + FeedState addFollow(FollowData follow) { if (follow.isFollowRequest) { - final updatedFollowRequests = state.followRequests.upsert( + final updatedFollowRequests = followRequests.upsert( follow, key: (it) => it.id, ); - return state.copyWith(followRequests: updatedFollowRequests); + return copyWith(followRequests: updatedFollowRequests); } - if (follow.isFollowingFeed(state.fid)) { + if (follow.isFollowingFeed(fid)) { final updatedCount = follow.sourceFeed.followingCount; - final updatedFollowing = state.following.upsert( + final updatedFollowing = following.upsert( follow, key: (it) => it.id, ); - return state.copyWith( + return copyWith( following: updatedFollowing, - feed: state.feed?.copyWith(followingCount: updatedCount), + feed: feed?.copyWith(followingCount: updatedCount), ); } - if (follow.isFollowerOf(state.fid)) { + if (follow.isFollowerOf(fid)) { final updatedCount = follow.targetFeed.followerCount; - final updatedFollowers = state.followers.upsert( + final updatedFollowers = followers.upsert( follow, key: (it) => it.id, ); - return state.copyWith( + return copyWith( followers: updatedFollowers, - feed: state.feed?.copyWith(followerCount: updatedCount), + feed: feed?.copyWith(followerCount: updatedCount), ); } // If the follow doesn't match any known categories, // we can simply return the current state without changes. - return state; + return this; } - FeedState _removeFollow(FollowData follow, FeedState state) { - var feed = state.feed; + /// Removes a follow from this feed state. + /// + /// Removes [follow] from all follow lists (following, followers, and follow requests) and + /// updates the feed's follower or following count when applicable. + /// + /// Returns a new [FeedState] instance with the updated follow data. + FeedState removeFollow(FollowData follow) { + var feed = this.feed; - if (follow.isFollowerOf(state.fid)) { + if (follow.isFollowerOf(fid)) { final followerCount = follow.targetFeed.followerCount; feed = feed?.copyWith(followerCount: followerCount); } - if (follow.isFollowingFeed(state.fid)) { + if (follow.isFollowingFeed(fid)) { final followingCount = follow.sourceFeed.followingCount; feed = feed?.copyWith(followingCount: followingCount); } - final updatedFollowing = state.following.where((it) { + final updatedFollowing = following.where((it) { return it.id != follow.id; }).toList(); - final updatedFollowers = state.followers.where((it) { + final updatedFollowers = followers.where((it) { return it.id != follow.id; }).toList(); - final updatedFollowRequests = state.followRequests.where((it) { + final updatedFollowRequests = followRequests.where((it) { return it.id != follow.id; }).toList(); - return state.copyWith( + return copyWith( feed: feed, following: updatedFollowing, followers: updatedFollowers, @@ -491,182 +665,185 @@ class FeedStateNotifier extends StateNotifier { ); } - FeedState _updateFollow(FollowData follow, FeedState state) { - final removedFollowState = _removeFollow(follow, state); - return _addFollow(follow, removedFollowState); + /// Updates a follow in this feed state. + /// + /// Removes the existing [follow] and then adds it again, effectively updating the follow + /// data with the latest information. + /// + /// Returns a new [FeedState] instance with the updated follow data. + FeedState updateFollow(FollowData follow) { + final removedFollowState = removeFollow(follow); + return removedFollowState.addFollow(follow); } - FeedState _markAllRead(FeedState state) { - final aggregatedActivities = state.aggregatedActivities; + /// Marks all activities in this feed state as read. + /// + /// Sets the unread count to 0 and marks all aggregated activity groups as read. + /// Updates the last read timestamp to the current time. + /// + /// Returns a new [FeedState] instance with the updated notification status. + FeedState markAllRead() { + final aggregatedActivities = [...this.aggregatedActivities]; final readActivities = aggregatedActivities.map((it) => it.group).toList(); // Set unread count to 0 and update read activities - final updatedNotificationStatus = state.notificationStatus?.copyWith( + final updatedNotificationStatus = notificationStatus?.copyWith( unread: 0, readActivities: readActivities, lastReadAt: DateTime.timestamp(), ); - return state.copyWith(notificationStatus: updatedNotificationStatus); + return copyWith(notificationStatus: updatedNotificationStatus); } - FeedState _markAllSeen(FeedState state) { - final aggregatedActivities = state.aggregatedActivities; + /// Marks all activities in this feed state as seen. + /// + /// Sets the unseen count to 0 and marks all aggregated activity groups as seen. + /// Updates the last seen timestamp to the current time. + /// + /// Returns a new [FeedState] instance with the updated notification status. + FeedState markAllSeen() { + final aggregatedActivities = [...this.aggregatedActivities]; final seenActivities = aggregatedActivities.map((it) => it.group).toList(); // Set unseen count to 0 and update seen activities - final updatedNotificationStatus = state.notificationStatus?.copyWith( + final updatedNotificationStatus = notificationStatus?.copyWith( unseen: 0, seenActivities: seenActivities, lastSeenAt: DateTime.timestamp(), ); - return state.copyWith(notificationStatus: updatedNotificationStatus); + return copyWith(notificationStatus: updatedNotificationStatus); } - FeedState _markRead(Set readIds, FeedState state) { - final readActivities = state.notificationStatus?.readActivities?.toSet(); + /// Marks specific activities as read in this feed state. + /// + /// Adds the activity IDs in [readIds] to the read activities set and decreases the unread + /// count by the number of newly read activities. Updates the last read timestamp to the + /// current time. + /// + /// Returns a new [FeedState] instance with the updated notification status. + FeedState markRead(Set readIds) { + final readActivities = notificationStatus?.readActivities?.toSet(); final updatedReadActivities = readActivities?.union(readIds).toList(); // Decrease unread count by the number of newly read activities - final unreadCount = state.notificationStatus?.unread ?? 0; + final unreadCount = notificationStatus?.unread ?? 0; final updatedUnreadCount = max(unreadCount - readIds.length, 0); - final updatedNotificationStatus = state.notificationStatus?.copyWith( + final updatedNotificationStatus = notificationStatus?.copyWith( unread: updatedUnreadCount, readActivities: updatedReadActivities, lastReadAt: DateTime.timestamp(), ); - return state.copyWith(notificationStatus: updatedNotificationStatus); + return copyWith(notificationStatus: updatedNotificationStatus); } - FeedState _markSeen(Set seenIds, FeedState state) { - final seenActivities = state.notificationStatus?.seenActivities?.toSet(); + /// Marks specific activities as seen in this feed state. + /// + /// Adds the activity IDs in [seenIds] to the seen activities set and decreases the unseen + /// count by the number of newly seen activities. Updates the last seen timestamp to the + /// current time. + /// + /// Returns a new [FeedState] instance with the updated notification status. + FeedState markSeen(Set seenIds) { + final seenActivities = notificationStatus?.seenActivities?.toSet(); final updatedSeenActivities = seenActivities?.union(seenIds).toList(); // Decrease unseen count by the number of newly seen activities - final unseenCount = state.notificationStatus?.unseen ?? 0; + final unseenCount = notificationStatus?.unseen ?? 0; final updatedUnseenCount = max(unseenCount - seenIds.length, 0); - final updatedNotificationStatus = state.notificationStatus?.copyWith( + final updatedNotificationStatus = notificationStatus?.copyWith( unseen: updatedUnseenCount, seenActivities: updatedSeenActivities, lastSeenAt: DateTime.timestamp(), ); - return state.copyWith(notificationStatus: updatedNotificationStatus); + return copyWith(notificationStatus: updatedNotificationStatus); } - FeedState _markWatched(Set watchedIds, FeedState state) { - final activities = _markWatchedActivities(watchedIds, state.activities); - - final aggregatedActivities = - state.aggregatedActivities.map((aggregatedActivity) { - return aggregatedActivity.copyWith( - activities: _markWatchedActivities( - watchedIds, - aggregatedActivity.activities, - ), - ); - }).toList(); - - return state.copyWith( - activities: activities, - aggregatedActivities: aggregatedActivities, + /// Marks specific activities as watched in this feed state. + /// + /// Updates activities with IDs in [watchedIds] to set their watched status to true. + /// Updates both the main activities list and aggregated activities. + /// + /// Returns a new [FeedState] instance with the updated activities. + FeedState markWatched(Set watchedIds) { + return updateActivitiesWhere( + (it) => watchedIds.contains(it.id), + update: (it) => it.copyWith(isWatched: true), + updateAggregatedActivities: true, ); } - List _markWatchedActivities( - Set watchedIds, - List activities, - ) { - return activities.map((activity) { - if (watchedIds.contains(activity.id)) { - return activity.copyWith(isWatched: true); - } - return activity; - }).toList(); - } - - @override - void dispose() { - _removeMemberListListener?.call(); - super.dispose(); - } -} - -/// Represents the current state of a feed. -/// -/// Contains activities, feed metadata, followers, members, and pagination information. -/// Automatically updates when WebSocket events are received. -@freezed -class FeedState with _$FeedState { - const FeedState({ - required this.fid, - required this.feedQuery, - this.activities = const [], - this.aggregatedActivities = const [], - this.feed, - this.followers = const [], - this.following = const [], - this.followRequests = const [], - this.members = const [], - this.pinnedActivities = const [], - this.notificationStatus, - this.activitiesPagination, - }); - - /// The unique identifier for the feed. - @override - final FeedId fid; - - /// The query used to create the feed. - @override - final FeedQuery feedQuery; - - /// The list of activities in the feed, sorted by default sorting criteria. - @override - final List activities; - - /// The list of aggregated activities in the feed. - @override - final List aggregatedActivities; - - /// The feed data containing feed metadata and configuration. - @override - final FeedData? feed; - - /// The list of followers for this feed. - @override - final List followers; - - /// The list of feeds that this feed is following. - @override - final List following; + /// Updates activities in this feed state that match the provided filter. + /// + /// Applies [update] to all activities where [filter] returns true. Updates both the main + /// activities list and pinned activities. When [compare] is provided, maintains the + /// sort order after updates. + /// + /// Returns a new [FeedState] instance with the updated activities. + FeedState updateActivitiesWhere( + bool Function(ActivityData) filter, { + required ActivityData Function(ActivityData) update, + bool updateAggregatedActivities = false, + Comparator? compare, + }) { + final updatedActivities = activities.updateWhere( + filter, + update: update, + compare: compare, + ); - /// The list of pending follow requests for this feed. - @override - final List followRequests; + final updatedPinnedActivities = pinnedActivities.updateWhere( + (it) => filter(it.activity), + update: (it) => it.copyWith(activity: update(it.activity)), + ); - /// The list of members in this feed. - @override - final List members; + var updatedAggregatedActivities = aggregatedActivities; + if (updateAggregatedActivities) { + updatedAggregatedActivities = updatedAggregatedActivities.map((it) { + final updated = it.activities.updateWhere(filter, update: update); + return it.copyWith(activities: updated); + }).toList(); + } - /// The list of pinned activities and its pinning state. - @override - final List pinnedActivities; + return copyWith( + activities: updatedActivities, + pinnedActivities: updatedPinnedActivities, + aggregatedActivities: updatedAggregatedActivities, + ); + } - /// Returns information about the notification status (read / seen activities). - @override - final NotificationStatusResponse? notificationStatus; + /// Removes activities from this feed state that match the provided filter. + /// + /// Removes all activities where [filter] returns true from both the main activities list + /// and pinned activities. + /// + /// Returns a new [FeedState] instance with the filtered activities. + FeedState removeActivitiesWhere( + bool Function(ActivityData) filter, { + bool removeFromAggregatedActivities = false, + }) { + final updatedActivities = activities.whereNot(filter).toList(); + final updatedPinnedActivities = pinnedActivities.whereNot((it) { + return filter(it.activity); + }).toList(); - /// Pagination information for [activities] and [aggregatedActivities] queries. - @override - final PaginationData? activitiesPagination; + var updatedAggregatedActivities = aggregatedActivities; + if (removeFromAggregatedActivities) { + updatedAggregatedActivities = updatedAggregatedActivities.map((it) { + final updated = it.activities.whereNot(filter).toList(); + return it.copyWith(activities: updated); + }).toList(); + } - /// Indicates whether there are more activities available to load. - /// - /// Returns true if there is a next page available for pagination. - bool get canLoadMoreActivities => activitiesPagination?.next != null; + return copyWith( + activities: updatedActivities, + pinnedActivities: updatedPinnedActivities, + aggregatedActivities: updatedAggregatedActivities, + ); + } } diff --git a/packages/stream_feeds/lib/src/state/follow_list_state.dart b/packages/stream_feeds/lib/src/state/follow_list_state.dart index 06157cff..3b39ca26 100644 --- a/packages/stream_feeds/lib/src/state/follow_list_state.dart +++ b/packages/stream_feeds/lib/src/state/follow_list_state.dart @@ -43,12 +43,24 @@ class FollowListStateNotifier extends StateNotifier { ); } + /// Handles the addition of a new follow. + void onFollowAdded(FollowData follow) { + final updatedFollows = state.follows.sortedUpsert( + follow, + key: (it) => it.id, + compare: followsSort.compare, + ); + + state = state.copyWith(follows: updatedFollows); + } + /// Handles the update of a follow data. void onFollowUpdated(FollowData follow) { - final updatedFollows = state.follows.map((it) { - if (it.id != follow.id) return it; - return follow; - }).toList(); + final updatedFollows = state.follows.sortedUpsert( + follow, + key: (it) => it.id, + compare: followsSort.compare, + ); state = state.copyWith(follows: updatedFollows); } diff --git a/packages/stream_feeds/lib/src/state/member_list_state.dart b/packages/stream_feeds/lib/src/state/member_list_state.dart index 8eac7c56..51b5818b 100644 --- a/packages/stream_feeds/lib/src/state/member_list_state.dart +++ b/packages/stream_feeds/lib/src/state/member_list_state.dart @@ -45,20 +45,32 @@ class MemberListStateNotifier extends StateNotifier { ); } - /// Handles the removal of a member by their ID. - void onMemberRemoved(String memberId) { - final updatedMembers = state.members.where((it) { - return it.id != memberId; - }).toList(); + /// Handles the addition of a new member. + void onMemberAdded(FeedMemberData member) { + final updatedMembers = state.members.sortedUpsert( + member, + key: (it) => it.id, + compare: membersSort.compare, + ); state = state.copyWith(members: updatedMembers); } /// Handles the update of a member's data. void onMemberUpdated(FeedMemberData member) { - final updatedMembers = state.members.map((it) { - if (it.id != member.id) return it; - return member; + final updatedMembers = state.members.sortedUpsert( + member, + key: (it) => it.id, + compare: membersSort.compare, + ); + + state = state.copyWith(members: updatedMembers); + } + + /// Handles the removal of a member by their ID. + void onMemberRemoved(String memberId) { + final updatedMembers = state.members.where((it) { + return it.id != memberId; }).toList(); state = state.copyWith(members: updatedMembers); @@ -75,7 +87,7 @@ class MemberListStateNotifier extends StateNotifier { // Remove members by their IDs updatedMembers = updatedMembers.whereNot((it) { return updates.removedIds.contains(it.id); - }).toList(); + }).sorted(membersSort.compare); state = state.copyWith(members: updatedMembers); } diff --git a/packages/stream_feeds/lib/src/state/poll_list.dart b/packages/stream_feeds/lib/src/state/poll_list.dart index e2b5c6c2..f56885d3 100644 --- a/packages/stream_feeds/lib/src/state/poll_list.dart +++ b/packages/stream_feeds/lib/src/state/poll_list.dart @@ -26,9 +26,11 @@ class PollList with Disposable { required this.query, required this.pollsRepository, required this.eventsEmitter, + required this.currentUserId, }) { _stateNotifier = PollListStateNotifier( initialState: const PollListState(), + currentUserId: currentUserId, ); // Attach event handlers for real-time updates @@ -42,6 +44,7 @@ class PollList with Disposable { final PollsQuery query; final PollsRepository pollsRepository; + final String currentUserId; late final PollListStateNotifier _stateNotifier; diff --git a/packages/stream_feeds/lib/src/state/poll_list_state.dart b/packages/stream_feeds/lib/src/state/poll_list_state.dart index 9620aa4f..6da4f271 100644 --- a/packages/stream_feeds/lib/src/state/poll_list_state.dart +++ b/packages/stream_feeds/lib/src/state/poll_list_state.dart @@ -4,6 +4,7 @@ import 'package:stream_core/stream_core.dart'; import '../models/pagination_data.dart'; import '../models/poll_data.dart'; +import '../models/poll_vote_data.dart'; import '../models/query_configuration.dart'; import 'query/polls_query.dart'; @@ -16,8 +17,11 @@ part 'poll_list_state.freezed.dart'; class PollListStateNotifier extends StateNotifier { PollListStateNotifier({ required PollListState initialState, + required this.currentUserId, }) : super(initialState); + final String currentUserId; + QueryConfiguration? queryConfig; List> get pollsSort { return queryConfig?.sort ?? PollsSort.defaultSort; @@ -45,10 +49,23 @@ class PollListStateNotifier extends StateNotifier { /// Handles the update of a poll. void onPollUpdated(PollData poll) { - final updatedPolls = state.polls.map((it) { - if (it.id != poll.id) return it; - return poll; - }).toList(); + final updatedPolls = state.polls.sortedUpsert( + poll, + key: (it) => it.id, + compare: pollsSort.compare, + update: (existing, updated) => existing.updateWith(updated), + ); + + state = state.copyWith(polls: updatedPolls); + } + + /// Handles the closure of a poll by ID. + void onPollClosed(String pollId) { + final updatedPolls = state.polls.updateWhere( + (it) => it.id == pollId, + update: (it) => it.copyWith(isClosed: true), + compare: pollsSort.compare, + ); state = state.copyWith(polls: updatedPolls); } @@ -61,6 +78,55 @@ class PollListStateNotifier extends StateNotifier { state = state.copyWith(polls: updatedPolls); } + + /// Handles the casting of a vote in a poll. + void onPollVoteCasted(PollData poll, PollVoteData vote) { + return onPollVoteChanged(poll, vote); + } + + /// Handles the change of a vote in a poll. + void onPollVoteChanged(PollData poll, PollVoteData vote) { + final updatedPolls = state.polls.updateWhere( + (it) => it.id == poll.id, + update: (it) => it.upsertVote(poll, vote, currentUserId), + compare: pollsSort.compare, + ); + + state = state.copyWith(polls: updatedPolls); + } + + /// Handles the casting of an answer in a poll. + void onPollAnswerCasted(PollData poll, PollVoteData answer) { + final updatedPolls = state.polls.updateWhere( + (it) => it.id == poll.id, + update: (it) => it.upsertAnswer(poll, answer, currentUserId), + compare: pollsSort.compare, + ); + + state = state.copyWith(polls: updatedPolls); + } + + /// Handles the removal of a vote in a poll. + void onPollVoteRemoved(PollData poll, PollVoteData vote) { + final updatedPolls = state.polls.updateWhere( + (it) => it.id == poll.id, + update: (it) => it.removeVote(poll, vote, currentUserId), + compare: pollsSort.compare, + ); + + state = state.copyWith(polls: updatedPolls); + } + + /// Handles the removal of an answer in a poll. + void onPollAnswerRemoved(PollData poll, PollVoteData answer) { + final updatedPolls = state.polls.updateWhere( + (it) => it.id == poll.id, + update: (it) => it.removeAnswer(poll, answer, currentUserId), + compare: pollsSort.compare, + ); + + state = state.copyWith(polls: updatedPolls); + } } /// An observable state object that manages the current state of a poll list. diff --git a/packages/stream_feeds/lib/src/state/poll_vote_list_state.dart b/packages/stream_feeds/lib/src/state/poll_vote_list_state.dart index 4a6ce615..e416aa95 100644 --- a/packages/stream_feeds/lib/src/state/poll_vote_list_state.dart +++ b/packages/stream_feeds/lib/src/state/poll_vote_list_state.dart @@ -43,20 +43,40 @@ class PollVoteListStateNotifier extends StateNotifier { ); } - /// Handles the removal of a poll vote. - void pollVoteRemoved(String voteId) { - final updatedVotes = state.votes.where((it) { - return it.id != voteId; - }).toList(); + /// Handles the deletion of the poll. + void onPollDeleted() { + state = state.copyWith( + votes: [], // Clear all votes when the poll is deleted + pagination: null, + ); + } + + /// Handles the addition of a new poll vote. + void onPollVoteAdded(PollVoteData vote) { + final updatedVotes = state.votes.sortedUpsert( + vote, + key: (it) => it.id, + compare: votesSort.compare, + ); state = state.copyWith(votes: updatedVotes); } /// Handles the update of a poll vote. - void pollVoteUpdated(PollVoteData vote) { - final updatedVotes = state.votes.map((it) { - if (it.id != vote.id) return it; - return vote; + void onPollVoteUpdated(PollVoteData vote) { + final updatedVotes = state.votes.sortedUpsert( + vote, + key: (it) => it.id, + compare: votesSort.compare, + ); + + state = state.copyWith(votes: updatedVotes); + } + + /// Handles the removal of a poll vote. + void onPollVoteRemoved(String voteId) { + final updatedVotes = state.votes.where((it) { + return it.id != voteId; }).toList(); state = state.copyWith(votes: updatedVotes); diff --git a/packages/stream_feeds/lib/stream_feeds.dart b/packages/stream_feeds/lib/stream_feeds.dart index 5f032618..7ca64223 100644 --- a/packages/stream_feeds/lib/stream_feeds.dart +++ b/packages/stream_feeds/lib/stream_feeds.dart @@ -4,3 +4,4 @@ export 'src/feeds_client.dart'; export 'src/generated/api/api.dart' hide User; export 'src/models.dart'; export 'src/state.dart' hide defaultOnNewActivity; +export 'src/utils/filter.dart' show MatchesExtensions; diff --git a/packages/stream_feeds/pubspec.yaml b/packages/stream_feeds/pubspec.yaml index 2eaafce6..d8dfa98c 100644 --- a/packages/stream_feeds/pubspec.yaml +++ b/packages/stream_feeds/pubspec.yaml @@ -30,7 +30,7 @@ dependencies: retrofit: ">=4.6.0 <=4.9.0" rxdart: ^0.28.0 state_notifier: ^1.0.0 - stream_core: ^0.3.2 + stream_core: ^0.3.3 uuid: ^4.5.1 dev_dependencies: diff --git a/packages/stream_feeds/test/state/activity_comment_list_test.dart b/packages/stream_feeds/test/state/activity_comment_list_test.dart index 83192280..b6b457e3 100644 --- a/packages/stream_feeds/test/state/activity_comment_list_test.dart +++ b/packages/stream_feeds/test/state/activity_comment_list_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_redundant_argument_values + import 'package:stream_feeds/stream_feeds.dart'; import 'package:stream_feeds_test/stream_feeds_test.dart'; @@ -55,6 +57,7 @@ void main() { body: (tester) async { // Initial state - has comment expect(tester.activityCommentListState.comments, hasLength(1)); + expect(tester.activityCommentListState.canLoadMore, isTrue); final nextPageQuery = tester.activityCommentList.query.copyWith( next: tester.activityCommentListState.pagination?.next, @@ -68,10 +71,8 @@ void main() { sort: nextPageQuery.sort, limit: nextPageQuery.limit, next: nextPageQuery.next, - prev: nextPageQuery.previous, ), result: createDefaultCommentsResponse( - prev: 'prev-cursor', comments: [ createDefaultThreadedCommentResponse( id: 'comment-test-2', @@ -94,16 +95,7 @@ void main() { // Verify state was updated with merged comments expect(tester.activityCommentListState.comments, hasLength(2)); - expect(tester.activityCommentListState.pagination?.next, isNull); - expect( - tester.activityCommentListState.pagination?.previous, - 'prev-cursor', - ); - }, - verify: (tester) { - final nextPageQuery = tester.activityCommentList.query.copyWith( - next: tester.activityCommentListState.pagination?.next, - ); + expect(tester.activityCommentListState.canLoadMore, isFalse); tester.verifyApi( (api) => api.getComments( @@ -113,7 +105,6 @@ void main() { sort: nextPageQuery.sort, limit: nextPageQuery.limit, next: nextPageQuery.next, - prev: nextPageQuery.previous, ), ); }, @@ -138,7 +129,7 @@ void main() { body: (tester) async { // Initial state - has comment but no pagination expect(tester.activityCommentListState.comments, hasLength(1)); - expect(tester.activityCommentListState.pagination?.next, isNull); + expect(tester.activityCommentListState.canLoadMore, isFalse); // Query more comments (should return empty immediately) final result = await tester.activityCommentList.queryMoreComments(); @@ -550,13 +541,10 @@ void main() { objectType: 'activity', userId: userId, ), - reaction: FeedsReactionResponse( - activityId: activityId, + reaction: createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, commentId: commentId, - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ), ); @@ -570,7 +558,7 @@ void main() { ); activityCommentListTest( - 'should handle CommentReactionUpdatedEvent and update reaction', + 'CommentReactionUpdatedEvent - should replace user reaction', build: (client) => client.activityCommentList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( @@ -582,13 +570,10 @@ void main() { text: 'Test comment', userId: userId, ownReactions: [ - FeedsReactionResponse( - activityId: activityId, + createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, commentId: commentId, - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ], ), @@ -614,14 +599,18 @@ void main() { objectId: activityId, objectType: 'activity', userId: userId, + latestReactions: [ + createDefaultReactionResponse( + reactionType: 'fire', + userId: userId, + commentId: commentId, + ), + ], ), - reaction: FeedsReactionResponse( - activityId: activityId, + reaction: createDefaultReactionResponse( + reactionType: 'fire', + userId: userId, commentId: commentId, - type: 'fire', - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ), ); @@ -646,13 +635,10 @@ void main() { text: 'Test comment', userId: userId, ownReactions: [ - FeedsReactionResponse( - activityId: activityId, + createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, commentId: commentId, - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ], ), @@ -677,13 +663,10 @@ void main() { objectType: 'activity', userId: userId, ), - reaction: FeedsReactionResponse( - activityId: activityId, + reaction: createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, commentId: commentId, - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ), ); @@ -732,13 +715,11 @@ void main() { objectType: 'activity', userId: userId, ), - reaction: FeedsReactionResponse( + reaction: createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, activityId: 'different-activity-id', commentId: 'different-comment-id', - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ), ); @@ -751,4 +732,151 @@ void main() { }, ); }); + + // ============================================================ + // FEATURE: Activity Comment List - Activity Deletion + // ============================================================ + + group('Activity Comment List - Activity Deletion', () { + activityCommentListTest( + 'should clear all comments when activity is deleted', + build: (client) => client.activityCommentList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + next: 'next-cursor', + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + createDefaultThreadedCommentResponse( + id: 'comment-test-2', + objectId: activityId, + objectType: 'activity', + text: 'Another comment', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has comments and pagination + expect(tester.activityCommentListState.comments, hasLength(2)); + expect(tester.activityCommentListState.pagination?.next, 'next-cursor'); + + // Emit ActivityDeletedEvent + await tester.emitEvent( + ActivityDeletedEvent( + type: 'feeds.activity.deleted', + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: activityId), + ), + ); + + // Verify state has no comments and no pagination + expect(tester.activityCommentListState.comments, isEmpty); + expect(tester.activityCommentListState.pagination, isNull); + expect(tester.activityCommentListState.canLoadMore, isFalse); + }, + ); + + activityCommentListTest( + 'should clear nested replies when activity is deleted', + build: (client) => client.activityCommentList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Top-level comment', + userId: userId, + replies: [ + createDefaultThreadedCommentResponse( + id: 'nested-reply-1', + objectId: activityId, + objectType: 'activity', + text: 'Nested reply', + userId: userId, + ), + createDefaultThreadedCommentResponse( + id: 'nested-reply-2', + objectId: activityId, + objectType: 'activity', + text: 'Another nested reply', + userId: userId, + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has comment with nested replies + expect(tester.activityCommentListState.comments, hasLength(1)); + final initialComment = tester.activityCommentListState.comments.first; + expect(initialComment.replies, hasLength(2)); + + // Emit ActivityDeletedEvent + await tester.emitEvent( + ActivityDeletedEvent( + type: 'feeds.activity.deleted', + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: activityId), + ), + ); + + // Verify all comments including nested replies are cleared + expect(tester.activityCommentListState.comments, isEmpty); + }, + ); + + activityCommentListTest( + 'should not clear comments when different activity is deleted', + build: (client) => client.activityCommentList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has comment + expect(tester.activityCommentListState.comments, hasLength(1)); + expect(tester.activityCommentListState.comments.first.id, commentId); + + // Emit ActivityDeletedEvent for different activity + await tester.emitEvent( + ActivityDeletedEvent( + type: 'feeds.activity.deleted', + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse( + id: 'different-activity-id', + ), + ), + ); + + // Verify state was not changed (still has comment) + expect(tester.activityCommentListState.comments, hasLength(1)); + expect(tester.activityCommentListState.comments.first.id, commentId); + }, + ); + }); } diff --git a/packages/stream_feeds/test/state/activity_list_test.dart b/packages/stream_feeds/test/state/activity_list_test.dart index 485cb38e..ae10fe35 100644 --- a/packages/stream_feeds/test/state/activity_list_test.dart +++ b/packages/stream_feeds/test/state/activity_list_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_redundant_argument_values + import 'package:stream_feeds/stream_feeds.dart'; import 'package:stream_feeds_test/stream_feeds_test.dart'; @@ -8,668 +10,812 @@ void main() { const reactionType = 'like'; const query = ActivitiesQuery(); + // ============================================================ - // FEATURE: Local Filtering + // FEATURE: Activity Feedback // ============================================================ - group('Local filtering with real-time events', () { - final defaultActivities = [ - createDefaultActivityResponse(id: 'activity-1'), - createDefaultActivityResponse(id: 'activity-2'), - createDefaultActivityResponse(id: 'activity-3'), - ]; + group('Activity feedback', () { + const activityId = 'activity-1'; activityListTest( - 'ActivityUpdatedEvent - should remove activity when updated to non-matching type', - build: (client) => client.activityList( - ActivitiesQuery( - filter: Filter.equal(ActivitiesFilterField.activityType, 'post'), - ), - ), + 'marks activity hidden on ActivityFeedbackEvent', + build: (client) => client.activityList(const ActivitiesQuery()), setUp: (tester) => tester.get( - modifyResponse: (it) => it.copyWith(activities: defaultActivities), + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: activityId), + ], + ), ), body: (tester) async { - expect(tester.activityListState.activities, hasLength(3)); + expect(tester.activityListState.activities, hasLength(1)); + expect(tester.activityListState.activities.first.hidden, false); await tester.emitEvent( - ActivityUpdatedEvent( - type: EventTypes.activityUpdated, + ActivityFeedbackEvent( + type: EventTypes.activityFeedback, createdAt: DateTime.timestamp(), custom: const {}, - fid: 'fid', - activity: createDefaultActivityResponse( - id: 'activity-1', - type: 'comment', + activityFeedback: ActivityFeedbackEventPayload( + activityId: activityId, + action: ActivityFeedbackEventPayloadAction.hide, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: 'luke_skywalker'), + value: 'true', ), ), ); - expect(tester.activityListState.activities, hasLength(2)); + expect(tester.activityListState.activities, hasLength(1)); + expect(tester.activityListState.activities.first.hidden, true); }, ); activityListTest( - 'ActivityReactionAddedEvent - should remove activity when reaction causes filter mismatch', - build: (client) => client.activityList( - ActivitiesQuery( - filter: Filter.equal(ActivitiesFilterField.activityType, 'post'), - ), - ), + 'marks activity unhidden on ActivityFeedbackEvent', + build: (client) => client.activityList(const ActivitiesQuery()), setUp: (tester) => tester.get( - modifyResponse: (it) => it.copyWith(activities: defaultActivities), + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: activityId, hidden: true), + ], + ), ), body: (tester) async { - expect(tester.activityListState.activities, hasLength(3)); + expect(tester.activityListState.activities, hasLength(1)); + expect(tester.activityListState.activities.first.hidden, true); await tester.emitEvent( - ActivityReactionAddedEvent( - type: EventTypes.activityReactionAdded, + ActivityFeedbackEvent( + type: EventTypes.activityFeedback, createdAt: DateTime.timestamp(), custom: const {}, - fid: 'fid', - activity: createDefaultActivityResponse( - id: 'activity-2', - type: 'comment', - ), - reaction: FeedsReactionResponse( - activityId: 'activity-2', - type: 'like', + activityFeedback: ActivityFeedbackEventPayload( + activityId: activityId, + action: ActivityFeedbackEventPayloadAction.hide, createdAt: DateTime.timestamp(), updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(), + user: createDefaultUserResponse(id: 'luke_skywalker'), + value: 'false', ), ), ); - expect(tester.activityListState.activities, hasLength(2)); + expect(tester.activityListState.activities, hasLength(1)); + expect(tester.activityListState.activities.first.hidden, false); }, ); + }); + + // ============================================================ + // FEATURE: Activity List - Query Operations + // ============================================================ + group('Activity List - Query Operations', () { activityListTest( - 'ActivityReactionDeletedEvent - should remove activity when reaction deletion causes filter mismatch', - build: (client) => client.activityList( - ActivitiesQuery( - filter: Filter.equal(ActivitiesFilterField.activityType, 'post'), - ), - ), - setUp: (tester) => tester.get( - modifyResponse: (it) => it.copyWith(activities: defaultActivities), - ), + 'get - should query initial activities via API', + build: (client) => client.activityList(query), body: (tester) async { - expect(tester.activityListState.activities, hasLength(3)); + final result = await tester.get(); - await tester.emitEvent( - ActivityReactionDeletedEvent( - type: EventTypes.activityReactionDeleted, - createdAt: DateTime.timestamp(), - custom: const {}, - fid: 'fid', - activity: createDefaultActivityResponse( - id: 'activity-3', - type: 'share', - ), - reaction: FeedsReactionResponse( - activityId: 'activity-3', - type: 'like', - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(), - ), - ), - ); + expect(result, isA>>()); + final activities = result.getOrThrow(); - expect(tester.activityListState.activities, hasLength(2)); + expect(activities, isA>()); + expect(activities, hasLength(3)); + expect(activities[0].id, 'activity-1'); + expect(activities[1].id, 'activity-2'); + expect(activities[2].id, 'activity-3'); }, ); activityListTest( - 'BookmarkAddedEvent - should remove activity when bookmark causes filter mismatch', - build: (client) => client.activityList( - ActivitiesQuery( - filter: Filter.equal(ActivitiesFilterField.activityType, 'post'), - ), - ), + 'queryMoreActivities - should load more activities via API', + build: (client) => client.activityList(query), setUp: (tester) => tester.get( - modifyResponse: (it) => it.copyWith(activities: defaultActivities), + modifyResponse: (response) => response.copyWith( + next: 'next-cursor', + activities: [ + createDefaultActivityResponse( + id: activityId, + ), + ], + ), ), body: (tester) async { - expect(tester.activityListState.activities, hasLength(3)); + // Initial state - has activity + expect(tester.activityListState.activities, hasLength(1)); + expect(tester.activityListState.canLoadMore, isTrue); - await tester.emitEvent( - BookmarkAddedEvent( - type: EventTypes.bookmarkAdded, - createdAt: DateTime.timestamp(), - custom: const {}, - bookmark: createDefaultBookmarkResponse( - activityId: 'activity-1', - activityType: 'comment', - ), + final nextPageQuery = tester.activityList.query.copyWith( + next: tester.activityListState.pagination?.next, + ); + + tester.mockApi( + (api) => api.queryActivities( + queryActivitiesRequest: nextPageQuery.toRequest(), + ), + result: createDefaultQueryActivitiesResponse( + activities: [ + createDefaultActivityResponse(id: 'activity-2'), + ], ), ); + // Query more activities + final result = await tester.activityList.queryMoreActivities(); + + expect(result.isSuccess, isTrue); + final activities = result.getOrNull(); + expect(activities, isNotNull); + expect(activities, hasLength(1)); + + // Verify state was updated with merged activities expect(tester.activityListState.activities, hasLength(2)); + expect(tester.activityListState.canLoadMore, isFalse); + + tester.verifyApi( + (api) => api.queryActivities( + queryActivitiesRequest: nextPageQuery.toRequest(), + ), + ); }, ); activityListTest( - 'BookmarkDeletedEvent - should remove activity when bookmark deletion causes filter mismatch', - build: (client) => client.activityList( - ActivitiesQuery( - filter: Filter.equal(ActivitiesFilterField.activityType, 'post'), + 'queryMoreActivities - should return empty list when no more activities', + build: (client) => client.activityList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: activityId), + ], ), ), + body: (tester) async { + // Initial state - has activity but no pagination + expect(tester.activityListState.activities, hasLength(1)); + expect(tester.activityListState.canLoadMore, isFalse); + // Query more activities (should return empty immediately) + final result = await tester.activityList.queryMoreActivities(); + + expect(result.isSuccess, isTrue); + final activities = result.getOrNull(); + expect(activities, isEmpty); + + // Verify state was not updated (no new activities, pagination remains null) + expect(tester.activityListState.activities, hasLength(1)); + expect(tester.activityListState.canLoadMore, isFalse); + }, + ); + }); + + // ============================================================ + // FEATURE: Activity List - Event Handling + // ============================================================ + + group('Activity List - Event Handling', () { + activityListTest( + 'should handle ActivityAddedEvent and add activity', + build: (client) => client.activityList(query), setUp: (tester) => tester.get( - modifyResponse: (it) => it.copyWith(activities: defaultActivities), + modifyResponse: (response) => response.copyWith(activities: const []), ), body: (tester) async { - expect(tester.activityListState.activities, hasLength(3)); + // Initial state - no activities + expect(tester.activityListState.activities, isEmpty); + // Emit event await tester.emitEvent( - BookmarkDeletedEvent( - type: EventTypes.bookmarkDeleted, + ActivityAddedEvent( + type: EventTypes.activityAdded, createdAt: DateTime.timestamp(), custom: const {}, - bookmark: createDefaultBookmarkResponse( - activityId: 'activity-2', - activityType: 'share', - ), + fid: 'user:john', + activity: createDefaultActivityResponse(id: activityId), ), ); - expect(tester.activityListState.activities, hasLength(2)); + // Verify state has activity + expect(tester.activityListState.activities, hasLength(1)); + final addedActivity = tester.activityListState.activities.first; + expect(addedActivity.id, activityId); + expect(addedActivity.type, 'post'); }, ); activityListTest( - 'CommentAddedEvent - should remove activity when comment causes filter mismatch', - build: (client) => client.activityList( - ActivitiesQuery( - filter: Filter.equal(ActivitiesFilterField.activityType, 'post'), - ), - ), + 'should handle ActivityUpdatedEvent and update activity', + build: (client) => client.activityList(query), setUp: (tester) => tester.get( - modifyResponse: (it) => it.copyWith(activities: defaultActivities), + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: activityId), + ], + ), ), body: (tester) async { - expect(tester.activityListState.activities, hasLength(3)); + // Initial state - has activity + expect(tester.activityListState.activities, hasLength(1)); + expect(tester.activityListState.activities.first.type, 'post'); + // Emit event await tester.emitEvent( - CommentAddedEvent( - type: EventTypes.commentAdded, + ActivityUpdatedEvent( + type: EventTypes.activityUpdated, createdAt: DateTime.timestamp(), custom: const {}, - fid: 'fid', + fid: 'user:john', activity: createDefaultActivityResponse( - id: 'activity-3', - type: 'comment', - ), - comment: createDefaultCommentResponse( - objectId: 'activity-3', + id: activityId, + type: 'share', ), ), ); - expect(tester.activityListState.activities, hasLength(2)); - }, - ); + // Verify state has updated activity + expect(tester.activityListState.activities, hasLength(1)); + expect(tester.activityListState.activities.first.type, 'share'); + }, + ); activityListTest( - 'Complex filter with AND - should filter correctly', - build: (client) => client.activityList( - ActivitiesQuery( - filter: Filter.and([ - Filter.equal(ActivitiesFilterField.activityType, 'post'), - Filter.equal(ActivitiesFilterField.filterTags, ['featured']), - ]), - ), - ), + 'should handle ActivityDeletedEvent and remove activity', + build: (client) => client.activityList(query), setUp: (tester) => tester.get( - modifyResponse: (it) => it.copyWith(activities: defaultActivities), + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: activityId), + ], + ), ), body: (tester) async { - expect(tester.activityListState.activities, hasLength(3)); + // Initial state - has activity + expect(tester.activityListState.activities, hasLength(1)); + expect(tester.activityListState.activities.first.id, activityId); + // Emit event await tester.emitEvent( - ActivityUpdatedEvent( - type: EventTypes.activityUpdated, + ActivityDeletedEvent( + type: 'feeds.activity.deleted', createdAt: DateTime.timestamp(), custom: const {}, - fid: 'fid', + fid: 'user:john', activity: createDefaultActivityResponse( - id: 'activity-1', - ).copyWith( - filterTags: ['general'], // Doesn't match second condition + id: activityId, ), ), ); - expect(tester.activityListState.activities, hasLength(2)); + // Verify state has no activities + expect(tester.activityListState.activities, isEmpty); }, ); + }); + // ============================================================ + // FEATURE: Activity List - Reactions + // ============================================================ + + group('Activity List - Reactions', () { activityListTest( - 'Complex filter with OR - should only keep activities matching any condition', - build: (client) => client.activityList( - ActivitiesQuery( - filter: Filter.or([ - Filter.equal(ActivitiesFilterField.activityType, 'post'), - Filter.equal(ActivitiesFilterField.filterTags, ['featured']), - ]), - ), - ), + 'should handle ActivityReactionAddedEvent and update activity', + build: (client) => client.activityList(query), setUp: (tester) => tester.get( - modifyResponse: (it) => it.copyWith(activities: defaultActivities), + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: activityId), + ], + ), ), body: (tester) async { - expect(tester.activityListState.activities, hasLength(3)); + // Initial state - no reactions + final initialActivity = tester.activityListState.activities.first; + expect(initialActivity.ownReactions, isEmpty); + // Emit event await tester.emitEvent( - ActivityUpdatedEvent( - type: EventTypes.activityUpdated, + ActivityReactionAddedEvent( + type: EventTypes.activityReactionAdded, createdAt: DateTime.timestamp(), custom: const {}, - fid: 'fid', + fid: 'user:john', activity: createDefaultActivityResponse( - id: 'activity-1', - ).copyWith( - filterTags: ['general'], // Doesn't match second condition + id: activityId, + ), + reaction: createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, + activityId: activityId, ), ), ); - expect(tester.activityListState.activities, hasLength(3)); - - final updatedActivity = tester.activityListState.activities.firstWhere( - (activity) => activity.id == 'activity-1', - ); - - expect(updatedActivity.filterTags, ['general']); + // Verify state has reaction + final updatedActivity = tester.activityListState.activities.first; + expect(updatedActivity.ownReactions, hasLength(1)); + expect(updatedActivity.ownReactions.first.type, reactionType); }, ); activityListTest( - 'No filter - filtering is disabled when no filter specified', - build: (client) => client.activityList(const ActivitiesQuery()), + 'ActivityReactionUpdatedEvent - should replace user reaction', + build: (client) => client.activityList(query), setUp: (tester) => tester.get( - modifyResponse: (it) => it.copyWith(activities: defaultActivities), + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + ownReactions: [ + createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, + activityId: activityId, + ), + ], + ), + ], + ), ), body: (tester) async { - expect(tester.activityListState.activities, hasLength(3)); + // Initial state - has reaction + final initialActivity = tester.activityListState.activities.first; + expect(initialActivity.ownReactions, hasLength(1)); + expect(initialActivity.ownReactions.first.type, reactionType); + // Emit event await tester.emitEvent( - ActivityUpdatedEvent( - type: EventTypes.activityUpdated, + ActivityReactionUpdatedEvent( + type: EventTypes.activityReactionUpdated, createdAt: DateTime.timestamp(), custom: const {}, - fid: 'fid', + fid: 'user:john', activity: createDefaultActivityResponse( - id: 'activity-1', - type: 'share', + id: activityId, + latestReactions: [ + createDefaultReactionResponse( + reactionType: 'love', + userId: userId, + activityId: activityId, + ), + ], + ), + reaction: createDefaultReactionResponse( + reactionType: 'love', + userId: userId, + activityId: activityId, ), ), ); - expect(tester.activityListState.activities, hasLength(3)); + // Verify state has updated reaction + final updatedActivity = tester.activityListState.activities.first; + expect(updatedActivity.ownReactions, hasLength(1)); + expect(updatedActivity.ownReactions.first.type, 'love'); }, ); - }); - - // ============================================================ - // FEATURE: Activity Feedback - // ============================================================ - - group('Activity feedback', () { - const activityId = 'activity-1'; activityListTest( - 'marks activity hidden on ActivityFeedbackEvent', - build: (client) => client.activityList(const ActivitiesQuery()), + 'should handle ActivityReactionDeletedEvent and remove reaction', + build: (client) => client.activityList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( activities: [ - createDefaultActivityResponse(id: activityId), + createDefaultActivityResponse( + id: activityId, + ownReactions: [ + createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, + activityId: activityId, + ), + ], + ), ], ), ), body: (tester) async { - expect(tester.activityListState.activities, hasLength(1)); - expect(tester.activityListState.activities.first.hidden, false); + // Initial state - has reaction + final initialActivity = tester.activityListState.activities.first; + expect(initialActivity.ownReactions, hasLength(1)); + // Emit event await tester.emitEvent( - ActivityFeedbackEvent( - type: EventTypes.activityFeedback, + ActivityReactionDeletedEvent( + type: EventTypes.activityReactionDeleted, createdAt: DateTime.timestamp(), custom: const {}, - activityFeedback: ActivityFeedbackEventPayload( + fid: 'user:john', + activity: createDefaultActivityResponse( + id: activityId, + ), + reaction: createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, activityId: activityId, - action: ActivityFeedbackEventPayloadAction.hide, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: 'luke_skywalker'), - value: 'true', ), ), ); - expect(tester.activityListState.activities, hasLength(1)); - expect(tester.activityListState.activities.first.hidden, true); + // Verify state has no reactions + final updatedActivity = tester.activityListState.activities.first; + expect(updatedActivity.ownReactions, isEmpty); }, ); + }); + + // ============================================================ + // FEATURE: Activity List - Bookmarks + // ============================================================ + group('Activity List - Bookmarks', () { activityListTest( - 'marks activity unhidden on ActivityFeedbackEvent', - build: (client) => client.activityList(const ActivitiesQuery()), + 'should handle BookmarkAddedEvent and update activity', + build: (client) => client.activityList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( activities: [ - createDefaultActivityResponse(id: activityId, hidden: true), + createDefaultActivityResponse(id: activityId), ], ), ), body: (tester) async { - expect(tester.activityListState.activities, hasLength(1)); - expect(tester.activityListState.activities.first.hidden, true); + // Initial state - no bookmarks + final initialActivity = tester.activityListState.activities.first; + expect(initialActivity.ownBookmarks, isEmpty); + // Emit event await tester.emitEvent( - ActivityFeedbackEvent( - type: EventTypes.activityFeedback, + BookmarkAddedEvent( + type: EventTypes.bookmarkAdded, createdAt: DateTime.timestamp(), custom: const {}, - activityFeedback: ActivityFeedbackEventPayload( + bookmark: createDefaultBookmarkResponse( activityId: activityId, - action: ActivityFeedbackEventPayloadAction.hide, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: 'luke_skywalker'), - value: 'false', + userId: userId, ), ), ); - expect(tester.activityListState.activities, hasLength(1)); - expect(tester.activityListState.activities.first.hidden, false); - }, - ); - }); - - // ============================================================ - // FEATURE: Activity List - Query Operations - // ============================================================ - - group('Activity List - Query Operations', () { - activityListTest( - 'get - should query initial activities via API', - build: (client) => client.activityList(query), - body: (tester) async { - final result = await tester.get(); - - expect(result, isA>>()); - final activities = result.getOrThrow(); - - expect(activities, isA>()); - expect(activities, hasLength(3)); - expect(activities[0].id, 'activity-1'); - expect(activities[1].id, 'activity-2'); - expect(activities[2].id, 'activity-3'); + // Verify state has bookmark + final updatedActivity = tester.activityListState.activities.first; + expect(updatedActivity.ownBookmarks, hasLength(1)); + expect(updatedActivity.ownBookmarks.first.user.id, userId); }, ); activityListTest( - 'queryMoreActivities - should load more activities via API', + 'should handle BookmarkUpdatedEvent and update bookmark', build: (client) => client.activityList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( - next: 'next-cursor', activities: [ createDefaultActivityResponse( id: activityId, + ownBookmarks: [ + createDefaultBookmarkResponse( + activityId: activityId, + userId: userId, + ), + ], ), ], ), ), body: (tester) async { - // Initial state - has activity - expect(tester.activityListState.activities, hasLength(1)); + // Initial state - has bookmark + final initialActivity = tester.activityListState.activities.first; + expect(initialActivity.ownBookmarks, hasLength(1)); - final nextPageQuery = tester.activityList.query.copyWith( - next: tester.activityListState.pagination?.next, + final existingBookmark = initialActivity.ownBookmarks.first; + + // Emit event with updated bookmark + final updatedBookmarkResponse = createDefaultBookmarkResponse( + activityId: activityId, + userId: userId, + ).copyWith( + custom: const {'updated': true}, ); - tester.mockApi( - (api) => api.queryActivities( - queryActivitiesRequest: nextPageQuery.toRequest(), - ), - result: createDefaultQueryActivitiesResponse( - prev: 'prev-cursor', - activities: [ - createDefaultActivityResponse(id: 'activity-2'), - ], + await tester.emitEvent( + BookmarkUpdatedEvent( + type: EventTypes.bookmarkUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: updatedBookmarkResponse, ), ); - // Query more activities - final result = await tester.activityList.queryMoreActivities(); - - expect(result.isSuccess, isTrue); - final activities = result.getOrNull(); - expect(activities, isNotNull); - expect(activities, hasLength(1)); - - // Verify state was updated with merged activities - expect(tester.activityListState.activities, hasLength(2)); - expect(tester.activityListState.pagination?.next, isNull); - expect(tester.activityListState.pagination?.previous, 'prev-cursor'); - }, - verify: (tester) { - final nextPageQuery = tester.activityList.query.copyWith( - next: tester.activityListState.pagination?.next, - ); + // Verify state has updated bookmark + final updatedActivity = tester.activityListState.activities.first; + expect(updatedActivity.ownBookmarks, hasLength(1)); - tester.verifyApi( - (api) => api.queryActivities( - queryActivitiesRequest: nextPageQuery.toRequest(), - ), - ); + final updatedBookmark = updatedActivity.ownBookmarks.first; + expect(updatedBookmark.id, existingBookmark.id); + expect(updatedBookmark.custom?['updated'], isTrue); }, ); activityListTest( - 'queryMoreActivities - should return empty list when no more activities', + 'should handle BookmarkDeletedEvent and remove bookmark', build: (client) => client.activityList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( activities: [ - createDefaultActivityResponse(id: activityId), + createDefaultActivityResponse( + id: activityId, + ownBookmarks: [ + createDefaultBookmarkResponse( + activityId: activityId, + userId: userId, + ), + ], + ), ], ), ), body: (tester) async { - // Initial state - has activity but no pagination - expect(tester.activityListState.activities, hasLength(1)); - expect(tester.activityListState.pagination?.next, isNull); - expect(tester.activityListState.pagination?.previous, isNull); - - // Query more activities (should return empty immediately) - final result = await tester.activityList.queryMoreActivities(); + // Initial state - has bookmark + final initialActivity = tester.activityListState.activities.first; + expect(initialActivity.ownBookmarks, hasLength(1)); - expect(result.isSuccess, isTrue); - final activities = result.getOrNull(); - expect(activities, isEmpty); + // Emit event + await tester.emitEvent( + BookmarkDeletedEvent( + type: EventTypes.bookmarkDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + activityId: activityId, + userId: userId, + ), + ), + ); - // Verify state was not updated (no new activities, pagination remains null) - expect(tester.activityListState.activities, hasLength(1)); - expect(tester.activityListState.pagination?.next, isNull); - expect(tester.activityListState.pagination?.previous, isNull); + // Verify state has no bookmarks + final updatedActivity = tester.activityListState.activities.first; + expect(updatedActivity.ownBookmarks, isEmpty); }, ); }); // ============================================================ - // FEATURE: Activity List - Event Handling + // FEATURE: Activity List - Comments // ============================================================ - group('Activity List - Event Handling', () { + group('Activity List - Comments', () { activityListTest( - 'should handle ActivityAddedEvent and add activity', + 'should handle CommentAddedEvent and update activity', build: (client) => client.activityList(query), setUp: (tester) => tester.get( - modifyResponse: (response) => response.copyWith(activities: const []), + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: activityId), + ], + ), ), body: (tester) async { - // Initial state - no activities - expect(tester.activityListState.activities, isEmpty); + // Initial state - no comments + final initialActivity = tester.activityListState.activities.first; + expect(initialActivity.comments, isEmpty); // Emit event await tester.emitEvent( - ActivityAddedEvent( - type: EventTypes.activityAdded, + CommentAddedEvent( + type: EventTypes.commentAdded, createdAt: DateTime.timestamp(), custom: const {}, fid: 'user:john', - activity: createDefaultActivityResponse(id: activityId), + activity: createDefaultActivityResponse( + id: activityId, + ), + comment: createDefaultCommentResponse( + id: 'comment-1', + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), ), ); - // Verify state has activity - expect(tester.activityListState.activities, hasLength(1)); - final addedActivity = tester.activityListState.activities.first; - expect(addedActivity.id, activityId); - expect(addedActivity.type, 'post'); + // Verify state has comment + final updatedActivity = tester.activityListState.activities.first; + expect(updatedActivity.comments, hasLength(1)); + expect(updatedActivity.comments.first.text, 'Test comment'); }, ); activityListTest( - 'should handle ActivityUpdatedEvent and update activity', + 'should handle CommentUpdatedEvent and update comment', build: (client) => client.activityList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( activities: [ - createDefaultActivityResponse(id: activityId), + createDefaultActivityResponse( + id: activityId, + comments: [ + createDefaultCommentResponse( + id: 'comment-1', + objectId: activityId, + objectType: 'activity', + text: 'Original comment', + userId: userId, + ), + ], + ), ], ), ), body: (tester) async { - // Initial state - has activity - expect(tester.activityListState.activities, hasLength(1)); - expect(tester.activityListState.activities.first.type, 'post'); + // Initial state - has comment + final initialActivity = tester.activityListState.activities.first; + expect(initialActivity.comments, hasLength(1)); + expect(initialActivity.comments.first.text, 'Original comment'); // Emit event await tester.emitEvent( - ActivityUpdatedEvent( - type: EventTypes.activityUpdated, + CommentUpdatedEvent( + type: EventTypes.commentUpdated, createdAt: DateTime.timestamp(), custom: const {}, fid: 'user:john', - activity: createDefaultActivityResponse( - id: activityId, - type: 'share', + comment: createDefaultCommentResponse( + id: 'comment-1', + objectId: activityId, + objectType: 'activity', + text: 'Updated comment', + userId: userId, ), ), ); - // Verify state has updated activity - expect(tester.activityListState.activities, hasLength(1)); - expect(tester.activityListState.activities.first.type, 'share'); + // Verify state has updated comment + final updatedActivity = tester.activityListState.activities.first; + expect(updatedActivity.comments, hasLength(1)); + expect(updatedActivity.comments.first.text, 'Updated comment'); }, ); activityListTest( - 'should handle ActivityDeletedEvent and remove activity', + 'should handle CommentDeletedEvent and remove comment', build: (client) => client.activityList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( activities: [ - createDefaultActivityResponse(id: activityId), + createDefaultActivityResponse( + id: activityId, + comments: [ + createDefaultCommentResponse( + id: 'comment-1', + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ], + ), ], ), ), body: (tester) async { - // Initial state - has activity - expect(tester.activityListState.activities, hasLength(1)); - expect(tester.activityListState.activities.first.id, activityId); + // Initial state - has comment + final initialActivity = tester.activityListState.activities.first; + expect(initialActivity.comments, hasLength(1)); + expect(initialActivity.comments.first.id, 'comment-1'); + expect(initialActivity.commentCount, 1); // Emit event await tester.emitEvent( - ActivityDeletedEvent( - type: 'feeds.activity.deleted', + CommentDeletedEvent( + type: EventTypes.commentDeleted, createdAt: DateTime.timestamp(), custom: const {}, fid: 'user:john', - activity: createDefaultActivityResponse( - id: activityId, + comment: createDefaultCommentResponse( + id: 'comment-1', + objectId: activityId, + objectType: 'activity', + userId: userId, ), ), ); - // Verify state has no activities - expect(tester.activityListState.activities, isEmpty); + // Verify state has no comments + final updatedActivity = tester.activityListState.activities.first; + expect(updatedActivity.comments, isEmpty); + expect(updatedActivity.commentCount, 0); }, ); }); // ============================================================ - // FEATURE: Activity List - Reactions + // FEATURE: Activity List - Comment Reactions // ============================================================ - group('Activity List - Reactions', () { + group('Activity List - Comment Reactions', () { activityListTest( - 'should handle ActivityReactionAddedEvent and update activity', + 'should handle CommentReactionAddedEvent and update comment', build: (client) => client.activityList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( activities: [ - createDefaultActivityResponse(id: activityId), + createDefaultActivityResponse( + id: activityId, + comments: [ + createDefaultCommentResponse( + id: 'comment-1', + objectId: activityId, + objectType: 'activity', + userId: userId, + ), + ], + ), ], ), ), body: (tester) async { - // Initial state - no reactions + // Initial state - has comment with no reactions final initialActivity = tester.activityListState.activities.first; - expect(initialActivity.ownReactions, isEmpty); + expect(initialActivity.comments, hasLength(1)); + expect(initialActivity.comments.first.ownReactions, isEmpty); // Emit event await tester.emitEvent( - ActivityReactionAddedEvent( - type: EventTypes.activityReactionAdded, + CommentReactionAddedEvent( + type: EventTypes.commentReactionAdded, createdAt: DateTime.timestamp(), custom: const {}, fid: 'user:john', - activity: createDefaultActivityResponse( - id: activityId, + activity: createDefaultActivityResponse(id: activityId), + comment: createDefaultCommentResponse( + id: 'comment-1', + objectId: activityId, + objectType: 'activity', + userId: userId, ), - reaction: FeedsReactionResponse( + reaction: createDefaultReactionResponse( + reactionType: 'love', activityId: activityId, - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), + commentId: 'comment-1', + userId: userId, ), ), ); - // Verify state has reaction + // Verify state has reaction on comment final updatedActivity = tester.activityListState.activities.first; - expect(updatedActivity.ownReactions, hasLength(1)); - expect(updatedActivity.ownReactions.first.type, reactionType); + expect(updatedActivity.comments, hasLength(1)); + + final comment = updatedActivity.comments.first; + expect(comment.ownReactions, hasLength(1)); + expect(comment.ownReactions.first.type, 'love'); }, ); activityListTest( - 'should handle ActivityReactionDeletedEvent and remove reaction', + 'CommentReactionUpdatedEvent - should replace user reaction', build: (client) => client.activityList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( activities: [ createDefaultActivityResponse( id: activityId, - ownReactions: [ - FeedsReactionResponse( - activityId: activityId, - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), + comments: [ + createDefaultCommentResponse( + id: 'comment-1', + objectId: activityId, + objectType: 'activity', + userId: userId, + ownReactions: [ + createDefaultReactionResponse( + reactionType: 'wow', + userId: userId, + commentId: 'comment-1', + ), + ], ), ], ), @@ -677,266 +823,699 @@ void main() { ), ), body: (tester) async { - // Initial state - has reaction + // Initial state - has comment with 'wow' reaction final initialActivity = tester.activityListState.activities.first; - expect(initialActivity.ownReactions, hasLength(1)); + expect(initialActivity.comments, hasLength(1)); + expect(initialActivity.comments.first.ownReactions, hasLength(1)); + expect(initialActivity.comments.first.ownReactions.first.type, 'wow'); - // Emit event + // Emit CommentReactionUpdatedEvent - replaces 'wow' with 'love' await tester.emitEvent( - ActivityReactionDeletedEvent( - type: EventTypes.activityReactionDeleted, + CommentReactionUpdatedEvent( + type: EventTypes.commentReactionUpdated, createdAt: DateTime.timestamp(), custom: const {}, fid: 'user:john', - activity: createDefaultActivityResponse( - id: activityId, + activity: createDefaultActivityResponse(id: activityId), + comment: createDefaultCommentResponse( + id: 'comment-1', + objectId: activityId, + objectType: 'activity', + latestReactions: [ + createDefaultReactionResponse( + reactionType: 'love', + userId: userId, + commentId: 'comment-1', + ), + ], ), - reaction: FeedsReactionResponse( - activityId: activityId, - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), + reaction: createDefaultReactionResponse( + reactionType: 'love', + userId: userId, + commentId: 'comment-1', ), ), ); - // Verify state has no reactions + // Verify 'wow' was replaced with 'love' final updatedActivity = tester.activityListState.activities.first; - expect(updatedActivity.ownReactions, isEmpty); + expect(updatedActivity.comments, hasLength(1)); + expect(updatedActivity.comments.first.ownReactions, hasLength(1)); + expect(updatedActivity.comments.first.ownReactions.first.type, 'love'); }, ); - }); - - // ============================================================ - // FEATURE: Activity List - Bookmarks - // ============================================================ - group('Activity List - Bookmarks', () { activityListTest( - 'should handle BookmarkAddedEvent and update activity', + 'should handle CommentReactionDeletedEvent and remove reaction', build: (client) => client.activityList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( activities: [ - createDefaultActivityResponse(id: activityId), + createDefaultActivityResponse( + id: activityId, + comments: [ + createDefaultCommentResponse( + id: 'comment-1', + objectId: activityId, + objectType: 'activity', + userId: userId, + ownReactions: [ + createDefaultReactionResponse( + reactionType: 'love', + userId: userId, + commentId: 'comment-1', + ), + ], + ), + ], + ), ], ), ), body: (tester) async { - // Initial state - no bookmarks + // Initial state - has comment with 'love' reaction final initialActivity = tester.activityListState.activities.first; - expect(initialActivity.ownBookmarks, isEmpty); + expect(initialActivity.comments, hasLength(1)); + expect(initialActivity.comments.first.ownReactions, hasLength(1)); + expect(initialActivity.comments.first.ownReactions.first.type, 'love'); - // Emit event + // Emit CommentReactionDeletedEvent await tester.emitEvent( - BookmarkAddedEvent( - type: EventTypes.bookmarkAdded, + CommentReactionDeletedEvent( + type: EventTypes.commentReactionDeleted, createdAt: DateTime.timestamp(), custom: const {}, - bookmark: createDefaultBookmarkResponse( - activityId: activityId, + fid: 'user:john', + comment: createDefaultCommentResponse( + id: 'comment-1', + objectId: activityId, + objectType: 'activity', + ), + reaction: createDefaultReactionResponse( + reactionType: 'love', userId: userId, + commentId: 'comment-1', ), ), ); - // Verify state has bookmark + // Verify reaction was removed final updatedActivity = tester.activityListState.activities.first; - expect(updatedActivity.ownBookmarks, hasLength(1)); - expect(updatedActivity.ownBookmarks.first.user.id, userId); + expect(updatedActivity.comments, hasLength(1)); + expect(updatedActivity.comments.first.ownReactions, isEmpty); }, ); + }); + + // ============================================================ + // FEATURE: Activity List - Polls + // ============================================================ + + group('Activity List - Polls', () { + final defaultPoll = createDefaultPollResponse(); activityListTest( - 'should handle BookmarkDeletedEvent and remove bookmark', + 'should handle PollClosedFeedEvent and update poll', build: (client) => client.activityList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( activities: [ createDefaultActivityResponse( id: activityId, - ownBookmarks: [ - createDefaultBookmarkResponse( - activityId: activityId, - userId: userId, - ), - ], + poll: defaultPoll, ), ], ), ), body: (tester) async { - // Initial state - has bookmark + // Initial state - has poll final initialActivity = tester.activityListState.activities.first; - expect(initialActivity.ownBookmarks, hasLength(1)); + expect(initialActivity.poll, isNotNull); + expect(initialActivity.poll!.isClosed, false); // Emit event await tester.emitEvent( - BookmarkDeletedEvent( - type: EventTypes.bookmarkDeleted, + PollClosedFeedEvent( + type: EventTypes.pollClosed, createdAt: DateTime.timestamp(), custom: const {}, - bookmark: createDefaultBookmarkResponse( - activityId: activityId, - userId: userId, - ), + fid: 'user:john', + poll: defaultPoll.copyWith(isClosed: true), ), ); - // Verify state has no bookmarks + // Verify state has closed poll final updatedActivity = tester.activityListState.activities.first; - expect(updatedActivity.ownBookmarks, isEmpty); + expect(updatedActivity.poll, isNotNull); + expect(updatedActivity.poll!.isClosed, true); }, ); - }); - // ============================================================ - // FEATURE: Activity List - Comments - // ============================================================ - - group('Activity List - Comments', () { activityListTest( - 'should handle CommentAddedEvent and update activity', + 'should handle PollDeletedFeedEvent and remove poll', build: (client) => client.activityList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( activities: [ - createDefaultActivityResponse(id: activityId), + createDefaultActivityResponse( + id: activityId, + poll: defaultPoll, + ), ], ), ), body: (tester) async { - // Initial state - no comments + // Initial state - has poll final initialActivity = tester.activityListState.activities.first; - expect(initialActivity.comments, isEmpty); + expect(initialActivity.poll, isNotNull); // Emit event await tester.emitEvent( - CommentAddedEvent( - type: EventTypes.commentAdded, + PollDeletedFeedEvent( + type: EventTypes.pollDeleted, createdAt: DateTime.timestamp(), custom: const {}, fid: 'user:john', - activity: createDefaultActivityResponse( - id: activityId, - ), - comment: createDefaultCommentResponse( - id: 'comment-1', - objectId: activityId, - objectType: 'activity', - text: 'Test comment', - userId: userId, - ), + poll: defaultPoll, ), ); - // Verify state has comment + // Verify state has no poll final updatedActivity = tester.activityListState.activities.first; - expect(updatedActivity.comments, hasLength(1)); - expect(updatedActivity.comments.first.text, 'Test comment'); + expect(updatedActivity.poll, isNull); }, ); activityListTest( - 'should handle CommentUpdatedEvent and update comment', + 'should handle PollUpdatedFeedEvent and update poll', build: (client) => client.activityList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( activities: [ createDefaultActivityResponse( id: activityId, - comments: [ - createDefaultCommentResponse( - id: 'comment-1', - objectId: activityId, - objectType: 'activity', - text: 'Original comment', - userId: userId, - ), - ], + poll: defaultPoll, ), ], ), ), body: (tester) async { - // Initial state - has comment + // Initial state - has poll final initialActivity = tester.activityListState.activities.first; - expect(initialActivity.comments, hasLength(1)); - expect(initialActivity.comments.first.text, 'Original comment'); + expect(initialActivity.poll, isNotNull); + expect(initialActivity.poll!.name, 'name'); // Emit event await tester.emitEvent( - CommentUpdatedEvent( - type: EventTypes.commentUpdated, + PollUpdatedFeedEvent( + type: EventTypes.pollUpdated, createdAt: DateTime.timestamp(), custom: const {}, fid: 'user:john', - comment: createDefaultCommentResponse( - id: 'comment-1', - objectId: activityId, - objectType: 'activity', - text: 'Updated comment', - userId: userId, - ), + poll: defaultPoll.copyWith(name: 'Updated Poll Name'), ), ); - // Verify state has updated comment + // Verify state has updated poll final updatedActivity = tester.activityListState.activities.first; - expect(updatedActivity.comments, hasLength(1)); - expect(updatedActivity.comments.first.text, 'Updated comment'); + expect(updatedActivity.poll, isNotNull); + expect(updatedActivity.poll!.name, 'Updated Poll Name'); }, ); + group('Vote operations', () { + final pollWithVotes = createDefaultPollResponse( + options: [ + createDefaultPollOptionResponse(id: 'option-1', text: 'Option 1'), + createDefaultPollOptionResponse(id: 'option-2', text: 'Option 2'), + ], + ownVotesAndAnswers: [ + createDefaultPollVoteResponse(id: 'vote-1', optionId: 'option-1'), + ], + ); + + activityListTest( + 'should handle PollVoteCastedFeedEvent and update poll with vote', + build: (client) => client.activityList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + poll: pollWithVotes, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has poll with votes + final initialActivity = tester.activityListState.activities.first; + expect(initialActivity.poll, isNotNull); + expect(initialActivity.poll!.voteCount, 1); + + final newVote = createDefaultPollVoteResponse( + id: 'vote-2', + pollId: pollWithVotes.id, + optionId: 'option-2', + ); + + // Emit PollVoteCastedFeedEvent + await tester.emitEvent( + PollVoteCastedFeedEvent( + type: EventTypes.pollVoteCasted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + pollVote: newVote, + poll: createDefaultPollResponse( + options: pollWithVotes.options, + ownVotesAndAnswers: [ + createDefaultPollVoteResponse( + id: 'vote-1', + optionId: 'option-1', + ), + newVote, + ], + ), + ), + ); + + // Verify state has updated poll + final updatedActivity = tester.activityListState.activities.first; + expect(updatedActivity.poll, isNotNull); + expect(updatedActivity.poll!.voteCount, 2); + }, + ); + + activityListTest( + 'should handle PollVoteChangedFeedEvent and update poll with changed vote', + build: (client) => client.activityList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + poll: pollWithVotes, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has poll with vote on option-1 + final initialActivity = tester.activityListState.activities.first; + expect(initialActivity.poll, isNotNull); + final votesInOption1 = + initialActivity.poll!.latestVotesByOption['option-1']; + expect(votesInOption1, hasLength(1)); + + final changedVote = createDefaultPollVoteResponse( + id: 'vote-1', + pollId: pollWithVotes.id, + optionId: 'option-2', + ); + + // Emit PollVoteChangedFeedEvent + await tester.emitEvent( + PollVoteChangedFeedEvent( + type: EventTypes.pollVoteChanged, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + pollVote: changedVote, + poll: createDefaultPollResponse( + options: pollWithVotes.options, + ownVotesAndAnswers: [changedVote], + ), + ), + ); + + // Verify state has updated poll + final updatedActivity = tester.activityListState.activities.first; + expect(updatedActivity.poll, isNotNull); + expect(updatedActivity.poll!.voteCount, 1); + expect( + updatedActivity.poll!.latestVotesByOption['option-2'], + hasLength(1), + ); + }, + ); + + activityListTest( + 'should handle PollVoteRemovedFeedEvent and update poll when vote is removed', + build: (client) => client.activityList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + poll: pollWithVotes, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has poll with vote + final initialActivity = tester.activityListState.activities.first; + expect(initialActivity.poll, isNotNull); + expect(initialActivity.poll!.voteCount, 1); + + final voteToRemove = createDefaultPollVoteResponse( + id: 'vote-1', + pollId: pollWithVotes.id, + optionId: 'option-1', + ); + + // Emit PollVoteRemovedFeedEvent + await tester.emitEvent( + PollVoteRemovedFeedEvent( + type: EventTypes.pollVoteRemoved, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + pollVote: voteToRemove, + poll: createDefaultPollResponse( + options: pollWithVotes.options, + ownVotesAndAnswers: [], + ), + ), + ); + + // Verify state has updated poll + final updatedActivity = tester.activityListState.activities.first; + expect(updatedActivity.poll, isNotNull); + expect(updatedActivity.poll!.voteCount, 0); + }, + ); + }); + + group('Answer operations', () { + final pollWithAnswers = createDefaultPollResponse( + ownVotesAndAnswers: [ + createDefaultPollAnswerResponse(id: 'answer-1'), + ], + ); + + activityListTest( + 'should handle PollAnswerCastedFeedEvent and update poll with answer', + build: (client) => client.activityList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + poll: pollWithAnswers, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has poll with answers + final initialActivity = tester.activityListState.activities.first; + expect(initialActivity.poll, isNotNull); + expect(initialActivity.poll!.answersCount, 1); + + final newAnswer = createDefaultPollAnswerResponse( + id: 'answer-2', + pollId: pollWithAnswers.id, + answerText: 'Answer 2', + ); + + // Emit event using PollVoteCastedFeedEvent with isAnswer: true + // This will be resolved to PollAnswerCastedFeedEvent by the resolver + await tester.emitEvent( + PollVoteCastedFeedEvent( + type: EventTypes.pollVoteCasted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + pollVote: newAnswer, + poll: createDefaultPollResponse( + ownVotesAndAnswers: [ + createDefaultPollAnswerResponse(id: 'answer-1'), + newAnswer, + ], + ), + ), + ); + + // Verify state has updated poll + final updatedActivity = tester.activityListState.activities.first; + expect(updatedActivity.poll, isNotNull); + expect(updatedActivity.poll!.answersCount, 2); + }, + ); + + activityListTest( + 'should handle PollAnswerRemovedFeedEvent and update poll when answer is removed', + build: (client) => client.activityList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + poll: pollWithAnswers, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has poll with answer + final initialActivity = tester.activityListState.activities.first; + expect(initialActivity.poll, isNotNull); + expect(initialActivity.poll!.answersCount, 1); + + final answerToRemove = createDefaultPollAnswerResponse( + id: 'answer-1', + pollId: pollWithAnswers.id, + ); + + // Emit event using PollVoteRemovedFeedEvent with isAnswer: true + // This will be resolved to PollAnswerRemovedFeedEvent by the resolver + await tester.emitEvent( + PollVoteRemovedFeedEvent( + type: EventTypes.pollVoteRemoved, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + pollVote: answerToRemove, + poll: createDefaultPollResponse( + ownVotesAndAnswers: [], + ), + ), + ); + + // Verify state has updated poll + final updatedActivity = tester.activityListState.activities.first; + expect(updatedActivity.poll, isNotNull); + expect(updatedActivity.poll!.answersCount, 0); + }, + ); + }); + }); + + // ============================================================ + // FEATURE: Local Filtering + // ============================================================ + + group('ActivityListEventHandler - Local filtering', () { + final defaultActivities = [ + createDefaultActivityResponse(id: 'activity-1'), + createDefaultActivityResponse(id: 'activity-2'), + createDefaultActivityResponse(id: 'activity-3'), + ]; + activityListTest( - 'should handle CommentDeletedEvent and remove comment', - build: (client) => client.activityList(query), + 'ActivityAddedEvent - should not add activity when it does not match filter', + build: (client) => client.activityList( + ActivitiesQuery( + filter: Filter.equal(ActivitiesFilterField.activityType, 'post'), + ), + ), setUp: (tester) => tester.get( - modifyResponse: (response) => response.copyWith( - activities: [ - createDefaultActivityResponse( - id: activityId, - comments: [ - createDefaultCommentResponse( - id: 'comment-1', - objectId: activityId, - objectType: 'activity', - text: 'Test comment', - userId: userId, - ), - ], + modifyResponse: (it) => it.copyWith(activities: defaultActivities), + ), + body: (tester) async { + expect(tester.activityListState.activities, hasLength(3)); + + await tester.emitEvent( + ActivityAddedEvent( + type: EventTypes.activityAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse( + id: 'activity-4', + type: 'comment', ), - ], + ), + ); + + // Activity should not be added because it doesn't match the filter + expect(tester.activityListState.activities, hasLength(3)); + }, + ); + + activityListTest( + 'ActivityAddedEvent - should add activity when it matches filter', + build: (client) => client.activityList( + ActivitiesQuery( + filter: Filter.equal(ActivitiesFilterField.activityType, 'activity'), ), ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(activities: defaultActivities), + ), body: (tester) async { - // Initial state - has comment - final initialActivity = tester.activityListState.activities.first; - expect(initialActivity.comments, hasLength(1)); - expect(initialActivity.comments.first.id, 'comment-1'); - expect(initialActivity.commentCount, 1); + expect(tester.activityListState.activities, hasLength(3)); - // Emit event await tester.emitEvent( - CommentDeletedEvent( - type: EventTypes.commentDeleted, + ActivityAddedEvent( + type: EventTypes.activityAdded, createdAt: DateTime.timestamp(), custom: const {}, fid: 'user:john', - comment: createDefaultCommentResponse( - id: 'comment-1', - objectId: activityId, - objectType: 'activity', - userId: userId, + activity: createDefaultActivityResponse( + id: 'activity-4', + type: 'activity', ), ), ); - // Verify state has no comments - final updatedActivity = tester.activityListState.activities.first; - expect(updatedActivity.comments, isEmpty); - expect(updatedActivity.commentCount, 0); + // Activity should be added because it matches the filter + final activities = tester.activityListState.activities; + + expect(activities, hasLength(4)); + expect(activities.any((a) => a.id == 'activity-4'), isTrue); + }, + ); + + activityListTest( + 'ActivityUpdatedEvent - should remove activity when updated to non-matching filter', + build: (client) => client.activityList( + ActivitiesQuery( + filter: Filter.equal(ActivitiesFilterField.activityType, 'post'), + ), + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(activities: defaultActivities), + ), + body: (tester) async { + expect(tester.activityListState.activities, hasLength(3)); + + await tester.emitEvent( + ActivityUpdatedEvent( + type: EventTypes.activityUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + activity: createDefaultActivityResponse( + id: 'activity-1', + type: 'comment', + ), + ), + ); + + expect(tester.activityListState.activities, hasLength(2)); + }, + ); + + activityListTest( + 'ActivityUpdatedEvent - should remove activity when updated to non-matching AND filter', + build: (client) => client.activityList( + ActivitiesQuery( + filter: Filter.and([ + Filter.equal(ActivitiesFilterField.activityType, 'post'), + Filter.equal(ActivitiesFilterField.filterTags, ['featured']), + ]), + ), + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(activities: defaultActivities), + ), + body: (tester) async { + expect(tester.activityListState.activities, hasLength(3)); + + await tester.emitEvent( + ActivityUpdatedEvent( + type: EventTypes.activityUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + activity: createDefaultActivityResponse( + id: 'activity-1', + ).copyWith( + filterTags: ['general'], // Doesn't match second condition + ), + ), + ); + + expect(tester.activityListState.activities, hasLength(2)); + }, + ); + + activityListTest( + 'ActivityUpdatedEvent - should keep activity when updated to match OR filter', + build: (client) => client.activityList( + ActivitiesQuery( + filter: Filter.or([ + Filter.equal(ActivitiesFilterField.activityType, 'post'), + Filter.equal(ActivitiesFilterField.filterTags, ['featured']), + ]), + ), + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(activities: defaultActivities), + ), + body: (tester) async { + expect(tester.activityListState.activities, hasLength(3)); + + await tester.emitEvent( + ActivityUpdatedEvent( + type: EventTypes.activityUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + activity: createDefaultActivityResponse( + id: 'activity-1', + ).copyWith( + filterTags: ['general'], // Doesn't match second condition + ), + ), + ); + + expect(tester.activityListState.activities, hasLength(3)); + + final updatedActivity = tester.activityListState.activities.firstWhere( + (activity) => activity.id == 'activity-1', + ); + + expect(updatedActivity.filterTags, ['general']); + }, + ); + + activityListTest( + 'No filter - should not remove activity when no filter specified', + build: (client) => client.activityList(const ActivitiesQuery()), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(activities: defaultActivities), + ), + body: (tester) async { + expect(tester.activityListState.activities, hasLength(3)); + + await tester.emitEvent( + ActivityUpdatedEvent( + type: EventTypes.activityUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + activity: createDefaultActivityResponse( + id: 'activity-1', + type: 'share', + ), + ), + ); + + expect(tester.activityListState.activities, hasLength(3)); }, ); }); diff --git a/packages/stream_feeds/test/state/activity_reaction_list_test.dart b/packages/stream_feeds/test/state/activity_reaction_list_test.dart new file mode 100644 index 00000000..ea378a1c --- /dev/null +++ b/packages/stream_feeds/test/state/activity_reaction_list_test.dart @@ -0,0 +1,400 @@ +import 'package:stream_feeds/stream_feeds.dart'; + +import 'package:stream_feeds_test/stream_feeds_test.dart'; + +void main() { + const currentUser = User(id: 'test_user'); + const activityId = 'activity-1'; + const reactionType = 'love'; + + const query = ActivityReactionsQuery(activityId: activityId); + + // Computed reaction ID based on FeedsReactionData.id getter + // id = '$type-$userReactionsGroupId' where userReactionsGroupId = '${currentUser.id}-$activityId' + final reactionId = '$reactionType-${currentUser.id}-$activityId'; + + // ============================================================ + // FEATURE: Activity Reaction List - Query Operations + // ============================================================ + + group('Activity Reaction List - Query Operations', () { + activityReactionListTest( + 'get - should query initial reactions via API', + user: currentUser, + build: (client) => client.activityReactionList(query), + body: (tester) async { + final result = await tester.get(); + + expect(result, isA>>()); + final reactions = result.getOrThrow(); + + expect(reactions, isA>()); + expect(reactions, hasLength(3)); + }, + ); + + activityReactionListTest( + 'queryMoreReactions - should load more reactions via API', + user: currentUser, + build: (client) => client.activityReactionList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + next: 'next-cursor', + reactions: [ + createDefaultReactionResponse( + activityId: activityId, + reactionType: reactionType, + userId: currentUser.id, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reaction + expect(tester.activityReactionListState.reactions, hasLength(1)); + expect(tester.activityReactionListState.canLoadMore, isTrue); + + final nextPageQuery = tester.activityReactionList.query.copyWith( + next: tester.activityReactionListState.pagination?.next, + ); + + tester.mockApi( + (api) => api.queryActivityReactions( + activityId: activityId, + queryActivityReactionsRequest: nextPageQuery.toRequest(), + ), + result: createDefaultQueryActivityReactionsResponse( + reactions: [ + createDefaultReactionResponse( + activityId: activityId, + reactionType: reactionType, + userId: 'user-2', // Different user to ensure unique reaction + ), + ], + ), + ); + + // Query more reactions + final result = await tester.activityReactionList.queryMoreReactions(); + + expect(result.isSuccess, isTrue); + final reactions = result.getOrNull(); + expect(reactions, isNotNull); + expect(reactions, hasLength(1)); + + // Verify state was updated with merged reactions + expect(tester.activityReactionListState.reactions, hasLength(2)); + expect(tester.activityReactionListState.canLoadMore, isFalse); + + tester.verifyApi( + (api) => api.queryActivityReactions( + activityId: activityId, + queryActivityReactionsRequest: nextPageQuery.toRequest(), + ), + ); + }, + ); + + activityReactionListTest( + 'queryMoreReactions - should return empty list when no more reactions', + user: currentUser, + build: (client) => client.activityReactionList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + reactions: [ + createDefaultReactionResponse( + activityId: activityId, + reactionType: reactionType, + userId: currentUser.id, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reaction but no pagination + expect(tester.activityReactionListState.reactions, hasLength(1)); + expect(tester.activityReactionListState.canLoadMore, isFalse); + + // Query more reactions (should return empty immediately) + final result = await tester.activityReactionList.queryMoreReactions(); + + expect(result.isSuccess, isTrue); + final reactions = result.getOrNull(); + expect(reactions, isNotNull); + expect(reactions, isEmpty); + + // State should remain unchanged + expect(tester.activityReactionListState.reactions, hasLength(1)); + }, + ); + }); + + // ============================================================ + // FEATURE: Activity Reaction List - Event Handling + // ============================================================ + + group('Activity Reaction List - Event Handling', () { + activityReactionListTest( + 'should handle ActivityReactionAddedEvent and add reaction', + user: currentUser, + build: (client) => client.activityReactionList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith(reactions: const []), + ), + body: (tester) async { + // Initial state - no reactions + expect(tester.activityReactionListState.reactions, isEmpty); + + // Emit event + await tester.emitEvent( + ActivityReactionAddedEvent( + type: 'feeds.activity.reaction.added', + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: activityId), + reaction: createDefaultReactionResponse( + activityId: activityId, + reactionType: reactionType, + userId: currentUser.id, + ), + ), + ); + + // Verify state has reaction + expect(tester.activityReactionListState.reactions, hasLength(1)); + final addedReaction = tester.activityReactionListState.reactions.first; + expect(addedReaction.id, reactionId); + expect(addedReaction.type, reactionType); + }, + ); + + activityReactionListTest( + 'ActivityReactionUpdatedEvent - should replace user reaction', + user: currentUser, + build: (client) => client.activityReactionList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + reactions: [ + createDefaultReactionResponse( + activityId: activityId, + reactionType: reactionType, + userId: currentUser.id, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reaction + expect(tester.activityReactionListState.reactions, hasLength(1)); + expect( + tester.activityReactionListState.reactions.first.type, + reactionType, + ); + + // Emit event with updated reaction type + await tester.emitEvent( + ActivityReactionUpdatedEvent( + type: 'feeds.activity.reaction.updated', + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse( + id: activityId, + latestReactions: [ + createDefaultReactionResponse( + activityId: activityId, + reactionType: 'fire', + userId: currentUser.id, + ), + ], + ), + reaction: createDefaultReactionResponse( + activityId: activityId, + reactionType: 'fire', + userId: currentUser.id, + ), + ), + ); + + // Verify state has updated reaction + expect(tester.activityReactionListState.reactions, hasLength(1)); + expect( + tester.activityReactionListState.reactions.first.type, + 'fire', + ); + }, + ); + + activityReactionListTest( + 'should handle ActivityReactionDeletedEvent and remove reaction', + user: currentUser, + build: (client) => client.activityReactionList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + reactions: [ + createDefaultReactionResponse( + activityId: activityId, + reactionType: reactionType, + userId: currentUser.id, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reaction + expect(tester.activityReactionListState.reactions, hasLength(1)); + expect(tester.activityReactionListState.reactions.first.id, reactionId); + + // Emit event + await tester.emitEvent( + ActivityReactionDeletedEvent( + type: 'feeds.activity.reaction.deleted', + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: activityId), + reaction: createDefaultReactionResponse( + activityId: activityId, + reactionType: reactionType, + userId: currentUser.id, + ), + ), + ); + + // Verify state has no reactions + expect(tester.activityReactionListState.reactions, isEmpty); + }, + ); + + activityReactionListTest( + 'should not update reactions if activityId does not match', + user: currentUser, + build: (client) => client.activityReactionList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith(reactions: const []), + ), + body: (tester) async { + // Initial state - no reactions + expect(tester.activityReactionListState.reactions, isEmpty); + + // Emit ActivityReactionAddedEvent for different activity + await tester.emitEvent( + ActivityReactionAddedEvent( + type: 'feeds.activity.reaction.added', + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: + createDefaultActivityResponse(id: 'different-activity-id'), + reaction: createDefaultReactionResponse( + activityId: 'different-activity-id', + reactionType: reactionType, + userId: currentUser.id, + ), + ), + ); + + // Verify state was not updated + expect(tester.activityReactionListState.reactions, isEmpty); + }, + ); + }); + + // ============================================================ + // FEATURE: Activity Reaction List - Activity Deletion + // ============================================================ + + group('Activity Reaction List - Activity Deletion', () { + activityReactionListTest( + 'should clear all reactions when activity is deleted', + user: currentUser, + build: (client) => client.activityReactionList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + next: 'next-cursor', + reactions: [ + createDefaultReactionResponse( + activityId: activityId, + reactionType: reactionType, + userId: currentUser.id, + ), + createDefaultReactionResponse( + activityId: activityId, + reactionType: 'fire', + userId: 'user-2', // Different user to ensure unique reaction + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reactions and pagination + expect(tester.activityReactionListState.reactions, hasLength(2)); + expect( + tester.activityReactionListState.pagination?.next, + 'next-cursor', + ); + + // Emit ActivityDeletedEvent + await tester.emitEvent( + ActivityDeletedEvent( + type: 'feeds.activity.deleted', + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: activityId), + ), + ); + + // Verify state has no reactions and no pagination + expect(tester.activityReactionListState.reactions, isEmpty); + expect(tester.activityReactionListState.pagination, isNull); + expect(tester.activityReactionListState.canLoadMore, isFalse); + }, + ); + + activityReactionListTest( + 'should not clear reactions when different activity is deleted', + user: currentUser, + build: (client) => client.activityReactionList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + reactions: [ + createDefaultReactionResponse( + activityId: activityId, + reactionType: reactionType, + userId: currentUser.id, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reaction + expect(tester.activityReactionListState.reactions, hasLength(1)); + expect( + tester.activityReactionListState.reactions.first.id, + reactionId, + ); + + // Emit ActivityDeletedEvent for different activity + await tester.emitEvent( + ActivityDeletedEvent( + type: 'feeds.activity.deleted', + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: + createDefaultActivityResponse(id: 'different-activity-id'), + ), + ); + + // Verify state was not changed (still has reaction) + expect(tester.activityReactionListState.reactions, hasLength(1)); + expect( + tester.activityReactionListState.reactions.first.id, + reactionId, + ); + }, + ); + }); +} diff --git a/packages/stream_feeds/test/state/activity_test.dart b/packages/stream_feeds/test/state/activity_test.dart index 18d5785a..aed6388f 100644 --- a/packages/stream_feeds/test/state/activity_test.dart +++ b/packages/stream_feeds/test/state/activity_test.dart @@ -4,16 +4,16 @@ import 'package:stream_feeds_test/stream_feeds_test.dart'; void main() { const activityId = 'activity-1'; - const feedId = FeedId(group: 'user', id: 'john'); + const fid = FeedId(group: 'user', id: 'john'); // ============================================================ - // FEATURE: Activity Retrieval + // FEATURE: Query Operations // ============================================================ - group('Getting an activity', () { + group('Activity - Query Operations', () { activityTest( 'fetch activity and comments', - build: (client) => client.activity(activityId: activityId, fid: feedId), + build: (client) => client.activity(activityId: activityId, fid: fid), body: (tester) async { final result = await tester.get(); @@ -24,368 +24,440 @@ void main() { (api) => api.getActivity(id: activityId), ), ); - }); - - // ============================================================ - // FEATURE: Activity Feedback - // ============================================================ - group('Activity feedback', () { activityTest( - 'submits feedback via API', - build: (client) => client.activity(activityId: activityId, fid: feedId), - setUp: (tester) => tester.mockApi( - (api) => api.activityFeedback( - activityId: activityId, - activityFeedbackRequest: const ActivityFeedbackRequest(hide: true), + 'queryMoreComments - should load more comments', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get( + modifyCommentsResponse: (response) => response.copyWith( + next: 'next-cursor', + comments: [ + createDefaultThreadedCommentResponse( + id: 'comment-1', + objectId: activityId, + objectType: 'activity', + ), + ], ), - result: createDefaultActivityFeedbackResponse(activityId: activityId), ), body: (tester) async { - const activityFeedbackRequest = ActivityFeedbackRequest(hide: true); + // Initial state - has comment + expect(tester.activityState.comments, hasLength(1)); + expect(tester.activityState.canLoadMoreComments, isTrue); - final result = await tester.activity.activityFeedback( - activityFeedbackRequest: activityFeedbackRequest, + tester.mockApi( + (api) => api.getComments( + next: 'next-cursor', + objectId: activityId, + objectType: 'activity', + depth: 3, + ), + result: createDefaultCommentsResponse( + comments: [ + createDefaultThreadedCommentResponse( + id: 'comment-2', + objectId: activityId, + objectType: 'activity', + ), + ], + ), + ); + + final result = await tester.activity.queryMoreComments(); + + expect(result.isSuccess, isTrue); + final comments = result.getOrNull(); + expect(comments, isNotNull); + expect(comments, hasLength(1)); + + // Verify state was updated + expect(tester.activityState.comments, hasLength(2)); + expect(tester.activityState.canLoadMoreComments, isFalse); + + tester.verifyApi( + (api) => api.getComments( + next: 'next-cursor', + objectId: activityId, + objectType: 'activity', + depth: 3, + ), + ); + }, + ); + + activityTest( + 'getComment - should get specific comment by ID', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get(), + body: (tester) async { + const commentId = 'comment-1'; + + tester.mockApi( + (api) => api.getComment(id: commentId), + result: GetCommentResponse( + duration: DateTime.timestamp().toIso8601String(), + comment: createDefaultCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + ), + ), ); + final result = await tester.activity.getComment(commentId); + expect(result.isSuccess, isTrue); + final comment = result.getOrNull(); + expect(comment, isNotNull); + expect(comment!.id, commentId); }, verify: (tester) => tester.verifyApi( - (api) => api.activityFeedback( - activityId: activityId, - activityFeedbackRequest: const ActivityFeedbackRequest(hide: true), - ), + (api) => api.getComment(id: 'comment-1'), ), ); activityTest( - 'marks activity hidden on ActivityFeedbackEvent', - build: (client) => client.activity(activityId: activityId, fid: feedId), + 'getPoll - should get poll for activity', + build: (client) => client.activity(activityId: activityId, fid: fid), setUp: (tester) => tester.get( - modifyResponse: (response) => response.copyWith(hidden: false), + modifyResponse: (response) => response.copyWith( + poll: createDefaultPollResponse(), + ), ), body: (tester) async { - expect(tester.activityState.activity?.hidden, false); + final pollId = tester.activityState.poll!.id; - await tester.emitEvent( - ActivityFeedbackEvent( - type: EventTypes.activityFeedback, - createdAt: DateTime.timestamp(), - custom: const {}, - activityFeedback: ActivityFeedbackEventPayload( - activityId: activityId, - action: ActivityFeedbackEventPayloadAction.hide, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: 'luke_skywalker'), - value: 'true', - ), + tester.mockApi( + (api) => api.getPoll(pollId: pollId), + result: PollResponse( + poll: createDefaultPollResponse(id: pollId), + duration: DateTime.timestamp().toIso8601String(), ), ); - expect(tester.activityState.activity?.hidden, true); + final result = await tester.activity.getPoll(); + + expect(result.isSuccess, isTrue); + final poll = result.getOrNull(); + expect(poll, isNotNull); + expect(poll!.id, pollId); + }, + verify: (tester) { + final pollId = tester.activityState.poll!.id; + tester.verifyApi((api) => api.getPoll(pollId: pollId)); }, ); activityTest( - 'marks activity unhidden on ActivityFeedbackEvent', - build: (client) => client.activity(activityId: activityId, fid: feedId), + 'getPollOption - should get poll option by ID', + build: (client) => client.activity(activityId: activityId, fid: fid), setUp: (tester) => tester.get( - modifyResponse: (response) => response.copyWith(hidden: true), + modifyResponse: (response) => response.copyWith( + poll: createDefaultPollResponse( + options: [ + createDefaultPollOptionResponse( + id: 'option-1', + text: 'Option 1', + ), + ], + ), + ), ), body: (tester) async { - expect(tester.activityState.activity?.hidden, true); + const optionId = 'option-1'; + final pollId = tester.activityState.poll!.id; - await tester.emitEvent( - ActivityFeedbackEvent( - type: EventTypes.activityFeedback, - createdAt: DateTime.timestamp(), - custom: const {}, - activityFeedback: ActivityFeedbackEventPayload( - activityId: activityId, - action: ActivityFeedbackEventPayloadAction.hide, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: 'luke_skywalker'), - value: 'false', + tester.mockApi( + (api) => api.getPollOption(pollId: pollId, optionId: optionId), + result: PollOptionResponse( + duration: DateTime.timestamp().toIso8601String(), + pollOption: createDefaultPollOptionResponse( + id: optionId, + text: 'Option 1', ), ), ); - expect(tester.activityState.activity?.hidden, false); + final result = await tester.activity.getPollOption(optionId); + + expect(result.isSuccess, isTrue); + final option = result.getOrNull(); + expect(option, isNotNull); + expect(option!.id, optionId); + }, + verify: (tester) { + final pollId = tester.activityState.poll!.id; + tester.verifyApi( + (api) => api.getPollOption(pollId: pollId, optionId: 'option-1'), + ); }, ); }); // ============================================================ - // FEATURE: Poll Events + // FEATURE: Activity Methods // ============================================================ - group('Poll events', () { - group('Vote operations', () { - final pollWithVotes = createDefaultPollResponse( - options: [ - createDefaultPollOptionResponse(id: 'option-1', text: 'Option 1'), - createDefaultPollOptionResponse(id: 'option-2', text: 'Option 2'), - ], - latestVotesByOption: { - 'option-1': [ - createDefaultPollVoteResponse(id: 'vote-1', optionId: 'option-1'), - createDefaultPollVoteResponse(id: 'vote-2', optionId: 'option-1'), - ], - 'option-2': [ - createDefaultPollVoteResponse(id: 'vote-3', optionId: 'option-2'), - ], - }, - ); + group('Activity - Activity Methods', () { + activityTest( + 'activityFeedback - should submit feedback via API', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.mockApi( + (api) => api.activityFeedback( + activityId: activityId, + activityFeedbackRequest: const ActivityFeedbackRequest(hide: true), + ), + result: createDefaultActivityFeedbackResponse(activityId: activityId), + ), + body: (tester) async { + const activityFeedbackRequest = ActivityFeedbackRequest(hide: true); + + final result = await tester.activity.activityFeedback( + activityFeedbackRequest: activityFeedbackRequest, + ); + + expect(result.isSuccess, isTrue); + }, + verify: (tester) => tester.verifyApi( + (api) => api.activityFeedback( + activityId: activityId, + activityFeedbackRequest: const ActivityFeedbackRequest(hide: true), + ), + ), + ); + group('ActivityFeedbackEvent', () { activityTest( - 'poll vote casted', - build: (client) => client.activity(activityId: activityId, fid: feedId), + 'ActivityFeedbackEvent - should mark activity hidden', + build: (client) => client.activity(activityId: activityId, fid: fid), setUp: (tester) => tester.get( - modifyResponse: (it) => it.copyWith(poll: pollWithVotes), + modifyResponse: (response) => response.copyWith(hidden: false), ), body: (tester) async { - final pollData = tester.activityState.poll; - expect(pollData!.voteCount, 3); - - expect(pollData.latestVotesByOption, hasLength(2)); - expect(pollData.latestVotesByOption['option-2'], hasLength(1)); - - final pollVote = createDefaultPollVoteResponse( - id: 'vote-4', - pollId: pollData.id, - optionId: 'option-2', - ); + expect(tester.activityState.activity?.hidden, false); await tester.emitEvent( - PollVoteCastedFeedEvent( - type: EventTypes.pollVoteCasted, + ActivityFeedbackEvent( + type: EventTypes.activityFeedback, createdAt: DateTime.timestamp(), custom: const {}, - fid: feedId.rawValue, - pollVote: pollVote, - poll: pollWithVotes.copyWith( - voteCount: 4, - latestVotesByOption: { - ...pollWithVotes.latestVotesByOption, - pollVote.optionId: List.from( - pollWithVotes.latestVotesByOption[pollVote.optionId]!, - )..add(pollVote), - }, + activityFeedback: ActivityFeedbackEventPayload( + activityId: activityId, + action: ActivityFeedbackEventPayloadAction.hide, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: 'luke_skywalker'), + value: 'true', ), ), ); - final updatedPollData = tester.activityState.poll; - expect(updatedPollData!.voteCount, 4); - - expect(updatedPollData.latestVotesByOption, hasLength(2)); - expect(updatedPollData.latestVotesByOption['option-2'], hasLength(2)); + expect(tester.activityState.activity?.hidden, true); }, ); activityTest( - 'poll vote removed', - build: (client) => client.activity(activityId: activityId, fid: feedId), + 'ActivityFeedbackEvent - should mark activity unhidden', + build: (client) => client.activity(activityId: activityId, fid: fid), setUp: (tester) => tester.get( - modifyResponse: (it) => it.copyWith(poll: pollWithVotes), + modifyResponse: (response) => response.copyWith(hidden: true), ), body: (tester) async { - final pollData = tester.activityState.poll; - expect(pollData!.voteCount, 3); - - expect(pollData.latestVotesByOption, hasLength(2)); - expect(pollData.latestVotesByOption['option-1'], hasLength(2)); - - final voteToRemove = createDefaultPollVoteResponse( - id: 'vote-1', - pollId: pollData.id, - optionId: 'option-1', - ); + expect(tester.activityState.activity?.hidden, true); await tester.emitEvent( - PollVoteRemovedFeedEvent( - type: EventTypes.pollVoteRemoved, + ActivityFeedbackEvent( + type: EventTypes.activityFeedback, createdAt: DateTime.timestamp(), custom: const {}, - fid: feedId.rawValue, - pollVote: voteToRemove, - poll: pollWithVotes.copyWith( - voteCount: 2, - latestVotesByOption: { - ...pollWithVotes.latestVotesByOption, - voteToRemove.optionId: List.from( - pollWithVotes.latestVotesByOption[voteToRemove.optionId]!, - )..removeWhere((vote) => vote.id == voteToRemove.id), - }, + activityFeedback: ActivityFeedbackEventPayload( + activityId: activityId, + action: ActivityFeedbackEventPayloadAction.hide, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: 'luke_skywalker'), + value: 'false', ), ), ); - final updatedPollData = tester.activityState.poll; - expect(updatedPollData!.voteCount, 2); - - expect(updatedPollData.latestVotesByOption, hasLength(2)); - expect(updatedPollData.latestVotesByOption['option-1'], hasLength(1)); + expect(tester.activityState.activity?.hidden, false); }, ); }); - group('Answer operations', () { - final pollWithAnswers = createDefaultPollResponse( - latestAnswers: [ - createDefaultPollAnswerResponse(id: 'answer-1'), - createDefaultPollAnswerResponse(id: 'answer-2'), - createDefaultPollAnswerResponse(id: 'answer-3'), - ], - ); - + group('Activity Events', () { activityTest( - 'poll answer casted', - build: (client) => client.activity(activityId: activityId, fid: feedId), - setUp: (tester) => tester.get( - modifyResponse: (it) => it.copyWith(poll: pollWithAnswers), - ), + 'ActivityUpdatedEvent - should update activity', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get(), body: (tester) async { - final pollData = tester.activityState.poll; - expect(pollData!.answersCount, 3); - - expect(pollData.latestAnswers, hasLength(3)); - - final newAnswer = createDefaultPollAnswerResponse( - id: 'answer-4', - pollId: pollData.id, - answerText: 'Answer 4', - ); + final activity = tester.activityState.activity; + expect(activity, isNotNull); + expect(activity!.type, 'post'); await tester.emitEvent( - PollVoteCastedFeedEvent( - type: EventTypes.pollVoteCasted, + ActivityUpdatedEvent( + type: EventTypes.activityUpdated, createdAt: DateTime.timestamp(), custom: const {}, - fid: feedId.rawValue, - pollVote: newAnswer, - poll: pollWithAnswers.copyWith( - answersCount: 4, - latestAnswers: List.from( - pollWithAnswers.latestAnswers, - )..add(newAnswer), + fid: fid.rawValue, + activity: createDefaultActivityResponse( + id: activityId, + type: 'share', ), ), ); - final updatedPollData = tester.activityState.poll; - expect(updatedPollData!.answersCount, 4); - - expect(updatedPollData.latestAnswers, hasLength(4)); + final updatedActivity = tester.activityState.activity; + expect(updatedActivity, isNotNull); + expect(updatedActivity!.type, 'share'); }, ); activityTest( - 'poll answer removed', - build: (client) => client.activity(activityId: activityId, fid: feedId), + 'ActivityDeletedEvent - should delete activity', + build: (client) => client.activity(activityId: activityId, fid: fid), setUp: (tester) => tester.get( - modifyResponse: (it) => it.copyWith(poll: pollWithAnswers), + modifyCommentsResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: 'comment-1', + objectId: activityId, + objectType: 'activity', + ), + ], + ), ), body: (tester) async { - final pollData = tester.activityState.poll; - expect(pollData!.answersCount, 3); - - expect(pollData.latestAnswers, hasLength(3)); - - final answerToRemove = createDefaultPollAnswerResponse( - id: 'answer-1', - pollId: pollData.id, - answerText: 'Answer 1', - ); + final activity = tester.activityState.activity; + expect(activity, isNotNull); + expect(tester.activityState.comments, hasLength(1)); await tester.emitEvent( - PollVoteRemovedFeedEvent( - type: EventTypes.pollVoteRemoved, + ActivityDeletedEvent( + type: EventTypes.activityDeleted, createdAt: DateTime.timestamp(), custom: const {}, - fid: feedId.rawValue, - pollVote: answerToRemove, - poll: pollWithAnswers.copyWith( - answersCount: 2, - latestAnswers: List.from( - pollWithAnswers.latestAnswers, - )..removeWhere((answer) => answer.id == answerToRemove.id), - ), + fid: fid.rawValue, + activity: createDefaultActivityResponse(id: activityId), ), ); - final updatedPollData = tester.activityState.poll; - expect(updatedPollData!.answersCount, 2); - - expect(updatedPollData.latestAnswers, hasLength(2)); + final deletedActivity = tester.activityState.activity; + expect(deletedActivity, isNull); + expect(tester.activityState.comments, isEmpty); }, ); - }); - - group('State changes', () { - final defaultPoll = createDefaultPollResponse(); activityTest( - 'poll closed', - build: (client) => client.activity(activityId: activityId, fid: feedId), - setUp: (tester) => tester.get( - modifyResponse: (it) => it.copyWith(poll: defaultPoll), - ), + 'ActivityDeletedEvent - should not delete activity for different activity', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get(), body: (tester) async { - expect(tester.activityState.poll?.isClosed, false); + final activity = tester.activityState.activity; + expect(activity, isNotNull); await tester.emitEvent( - PollClosedFeedEvent( - type: EventTypes.pollClosed, + ActivityDeletedEvent( + type: EventTypes.activityDeleted, createdAt: DateTime.timestamp(), custom: const {}, - fid: feedId.rawValue, - poll: defaultPoll.copyWith(isClosed: true), + fid: fid.rawValue, + activity: createDefaultActivityResponse(id: 'different-activity'), ), ); - expect(tester.activityState.poll?.isClosed, true); + final updatedActivity = tester.activityState.activity; + expect(updatedActivity, isNotNull); + expect(updatedActivity!.id, activityId); }, ); + }); - activityTest( - 'poll deleted', - build: (client) => client.activity(activityId: activityId, fid: feedId), - setUp: (tester) => tester.get( - modifyResponse: (it) => it.copyWith(poll: defaultPoll), + activityTest( + 'pin - should pin activity via API', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get(), + body: (tester) async { + tester.mockApi( + (api) => api.pinActivity( + activityId: activityId, + feedGroupId: fid.group, + feedId: fid.id, + ), + result: PinActivityResponse( + activity: createDefaultActivityResponse(id: activityId), + createdAt: DateTime.timestamp(), + duration: DateTime.timestamp().toIso8601String(), + feed: fid.rawValue, + userId: 'user-id', + ), + ); + + final result = await tester.activity.pin(); + + expect(result.isSuccess, isTrue); + }, + verify: (tester) => tester.verifyApi( + (api) => api.pinActivity( + activityId: activityId, + feedGroupId: fid.group, + feedId: fid.id, ), - body: (tester) async { - expect(tester.activityState.poll, isNotNull); + ), + ); - await tester.emitEvent( - PollDeletedFeedEvent( - type: EventTypes.pollDeleted, - createdAt: DateTime.timestamp(), - custom: const {}, - fid: feedId.rawValue, - poll: defaultPoll, - ), - ); + activityTest( + 'unpin - should unpin activity via API', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get(), + body: (tester) async { + tester.mockApi( + (api) => api.unpinActivity( + activityId: activityId, + feedGroupId: fid.group, + feedId: fid.id, + ), + result: UnpinActivityResponse( + activity: createDefaultActivityResponse(id: activityId), + duration: DateTime.timestamp().toIso8601String(), + feed: fid.rawValue, + userId: 'user-id', + ), + ); - expect(tester.activityState.poll, isNull); - }, - ); - }); + final result = await tester.activity.unpin(); + + expect(result.isSuccess, isTrue); + }, + verify: (tester) => tester.verifyApi( + (api) => api.unpinActivity( + activityId: activityId, + feedGroupId: fid.group, + feedId: fid.id, + ), + ), + ); }); // ============================================================ // FEATURE: Comments // ============================================================ - group('Comments', () { + group('Activity - Comments', () { const commentId = 'comment-test-1'; const userId = 'luke_skywalker'; + setUpAll(() { + registerFallbackValue( + const AddCommentsBatchRequest(comments: []), + ); + }); + activityTest( 'addComment - should add comment to activity via API', - build: (client) => client.activity(activityId: activityId, fid: feedId), + build: (client) => client.activity(activityId: activityId, fid: fid), setUp: (tester) => tester.get(), body: (tester) async { // Mock API call that will be used @@ -430,43 +502,9 @@ void main() { ), ); - activityTest( - 'addComment - should handle CommentAddedEvent and update comments', - build: (client) => client.activity(activityId: activityId, fid: feedId), - setUp: (tester) => tester.get(), - body: (tester) async { - // Initial state - no comments - expect(tester.activityState.comments, isEmpty); - - // Emit event - await tester.emitEvent( - CommentAddedEvent( - type: EventTypes.commentAdded, - createdAt: DateTime.timestamp(), - custom: const {}, - fid: feedId.rawValue, - activity: createDefaultActivityResponse(id: activityId), - comment: createDefaultCommentResponse( - id: commentId, - objectId: activityId, - objectType: 'activity', - text: 'Test comment', - userId: userId, - ), - ), - ); - - // Verify state has comment - expect(tester.activityState.comments, hasLength(1)); - expect(tester.activityState.comments.first.id, commentId); - expect(tester.activityState.comments.first.text, 'Test comment'); - expect(tester.activityState.comments.first.user.id, userId); - }, - ); - activityTest( 'addComment - should handle both API call and event together', - build: (client) => client.activity(activityId: activityId, fid: feedId), + build: (client) => client.activity(activityId: activityId, fid: fid), setUp: (tester) => tester.get(), body: (tester) async { // Initial state - no comments @@ -500,7 +538,7 @@ void main() { type: EventTypes.commentAdded, createdAt: DateTime.timestamp(), custom: const {}, - fid: feedId.rawValue, + fid: fid.rawValue, activity: createDefaultActivityResponse(id: activityId), comment: createDefaultCommentResponse( id: commentId, @@ -519,46 +557,14 @@ void main() { ); activityTest( - 'addComment - should not update comments if objectId does not match', - build: (client) => client.activity(activityId: activityId, fid: feedId), - setUp: (tester) => tester.get(), - body: (tester) async { - // Initial state - no comments - expect(tester.activityState.comments, isEmpty); - - // Emit CommentAddedEvent for different activity - await tester.emitEvent( - CommentAddedEvent( - type: EventTypes.commentAdded, - createdAt: DateTime.timestamp(), - custom: const {}, - fid: feedId.rawValue, - activity: - createDefaultActivityResponse(id: 'different-activity-id'), - comment: createDefaultCommentResponse( + 'deleteComment - should delete comment via API', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get( + modifyCommentsResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( id: commentId, - objectId: 'different-activity-id', - objectType: 'activity', - text: 'Test comment', - userId: userId, - ), - ), - ); - - // Verify state was not updated - expect(tester.activityState.comments, isEmpty); - }, - ); - - activityTest( - 'deleteComment - should delete comment via API', - build: (client) => client.activity(activityId: activityId, fid: feedId), - setUp: (tester) => tester.get( - modifyCommentsResponse: (response) => response.copyWith( - comments: [ - createDefaultThreadedCommentResponse( - id: commentId, - objectId: activityId, + objectId: activityId, objectType: 'activity', text: 'Test comment', userId: userId, @@ -601,94 +607,9 @@ void main() { ), ); - activityTest( - 'deleteComment - should handle CommentDeletedEvent and update comments', - build: (client) => client.activity(activityId: activityId, fid: feedId), - setUp: (tester) => tester.get( - modifyCommentsResponse: (response) => response.copyWith( - comments: [ - createDefaultThreadedCommentResponse( - id: commentId, - objectId: activityId, - objectType: 'activity', - text: 'Test comment', - userId: userId, - ), - ], - ), - ), - body: (tester) async { - // Initial state - has comment - expect(tester.activityState.comments, hasLength(1)); - expect(tester.activityState.comments.first.id, commentId); - - // Emit event - await tester.emitEvent( - CommentDeletedEvent( - type: EventTypes.commentDeleted, - createdAt: DateTime.timestamp(), - custom: const {}, - fid: feedId.rawValue, - comment: createDefaultCommentResponse( - id: commentId, - objectId: activityId, - objectType: 'activity', - userId: userId, - ), - ), - ); - - // Verify state has no comments - expect(tester.activityState.comments, isEmpty); - }, - ); - - activityTest( - 'deleteComment - should not update comments if objectId does not match', - build: (client) => client.activity(activityId: activityId, fid: feedId), - setUp: (tester) => tester.get( - modifyCommentsResponse: (response) => response.copyWith( - comments: [ - createDefaultThreadedCommentResponse( - id: commentId, - objectId: activityId, - objectType: 'activity', - text: 'Test comment', - userId: userId, - ), - ], - ), - ), - body: (tester) async { - // Initial state - has comment - expect(tester.activityState.comments, hasLength(1)); - expect(tester.activityState.comments.first.id, commentId); - - // Emit CommentDeletedEvent for different activity - await tester.emitEvent( - CommentDeletedEvent( - type: EventTypes.commentDeleted, - createdAt: DateTime.timestamp(), - custom: const {}, - fid: feedId.rawValue, - comment: createDefaultCommentResponse( - id: 'different-comment-id', - objectId: 'different-activity-id', - objectType: 'activity', - userId: userId, - ), - ), - ); - - // Verify state was not updated - expect(tester.activityState.comments, hasLength(1)); - expect(tester.activityState.comments.first.id, commentId); - }, - ); - activityTest( 'updateComment - should update comment via API', - build: (client) => client.activity(activityId: activityId, fid: feedId), + build: (client) => client.activity(activityId: activityId, fid: fid), setUp: (tester) => tester.get( modifyCommentsResponse: (response) => response.copyWith( comments: [ @@ -747,91 +668,64 @@ void main() { ); activityTest( - 'updateComment - should handle CommentUpdatedEvent and update comments', - build: (client) => client.activity(activityId: activityId, fid: feedId), - setUp: (tester) => tester.get( - modifyCommentsResponse: (response) => response.copyWith( - comments: [ - createDefaultThreadedCommentResponse( - id: commentId, - objectId: activityId, - objectType: 'activity', - text: 'Original comment', - userId: userId, - ), - ], - ), - ), + 'addCommentsBatch - should add multiple comments via API', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get(), body: (tester) async { - // Initial state - has comment - expect(tester.activityState.comments, hasLength(1)); - expect(tester.activityState.comments.first.text, 'Original comment'); + const commentId1 = 'comment-1'; + const commentId2 = 'comment-2'; - // Emit event - await tester.emitEvent( - CommentUpdatedEvent( - type: EventTypes.commentUpdated, - createdAt: DateTime.timestamp(), - custom: const {}, - fid: feedId.rawValue, - comment: createDefaultCommentResponse( - id: commentId, - objectId: activityId, - objectType: 'activity', - text: 'Updated comment', - userId: userId, - ), + tester.mockApi( + (api) => api.addCommentsBatch( + addCommentsBatchRequest: any(named: 'addCommentsBatchRequest'), + ), + result: AddCommentsBatchResponse( + duration: DateTime.timestamp().toIso8601String(), + comments: [ + createDefaultCommentResponse( + id: commentId1, + objectId: activityId, + objectType: 'activity', + userId: userId, + text: 'First comment', + ), + createDefaultCommentResponse( + id: commentId2, + objectId: activityId, + objectType: 'activity', + userId: userId, + text: 'Second comment', + ), + ], ), ); - // Verify state has updated comment - expect(tester.activityState.comments, hasLength(1)); - expect(tester.activityState.comments.first.text, 'Updated comment'); - }, - ); - - activityTest( - 'updateComment - should not update comments if objectId does not match', - build: (client) => client.activity(activityId: activityId, fid: feedId), - setUp: (tester) => tester.get( - modifyCommentsResponse: (response) => response.copyWith( - comments: [ - createDefaultThreadedCommentResponse( - id: commentId, - objectId: activityId, - objectType: 'activity', - text: 'Original comment', - userId: userId, - ), - ], - ), - ), - body: (tester) async { - // Initial state - has comment - expect(tester.activityState.comments, hasLength(1)); - expect(tester.activityState.comments.first.text, 'Original comment'); - - // Emit CommentUpdatedEvent for different activity - await tester.emitEvent( - CommentUpdatedEvent( - type: EventTypes.commentUpdated, - createdAt: DateTime.timestamp(), - custom: const {}, - fid: feedId.rawValue, - comment: createDefaultCommentResponse( - id: 'different-comment-id', - objectId: 'different-activity-id', - objectType: 'activity', - text: 'Updated comment', - userId: userId, - ), + final result = await tester.activity.addCommentsBatch([ + const ActivityAddCommentRequest( + activityId: activityId, + comment: 'First comment', ), - ); + const ActivityAddCommentRequest( + activityId: activityId, + comment: 'Second comment', + ), + ]); - // Verify state was not updated - expect(tester.activityState.comments, hasLength(1)); - expect(tester.activityState.comments.first.text, 'Original comment'); + expect(result.isSuccess, isTrue); + final comments = result.getOrNull(); + expect(comments, isNotNull); + expect(comments, hasLength(2)); + expect(comments!.first.id, commentId1); + expect(comments.last.id, commentId2); + + // Verify state was updated via onCommentAdded + expect(tester.activityState.comments, hasLength(2)); }, + verify: (tester) => tester.verifyApi( + (api) => api.addCommentsBatch( + addCommentsBatchRequest: any(named: 'addCommentsBatchRequest'), + ), + ), ); }); @@ -839,7 +733,7 @@ void main() { // FEATURE: Comment Reactions // ============================================================ - group('Comment Reactions', () { + group('Activity - Comment Reactions', () { const commentId = 'comment-test-1'; const userId = 'luke_skywalker'; const reactionType = 'heart'; @@ -850,7 +744,7 @@ void main() { activityTest( 'addCommentReaction - should add reaction to comment via API', - build: (client) => client.activity(activityId: activityId, fid: feedId), + build: (client) => client.activity(activityId: activityId, fid: fid), setUp: (tester) => tester.get( modifyCommentsResponse: (response) => response.copyWith( comments: [ @@ -909,63 +803,9 @@ void main() { ), ); - activityTest( - 'addCommentReaction - should handle CommentReactionAddedEvent and update comment', - build: (client) => client.activity(activityId: activityId, fid: feedId), - setUp: (tester) => tester.get( - modifyCommentsResponse: (response) => response.copyWith( - comments: [ - createDefaultThreadedCommentResponse( - id: commentId, - objectId: activityId, - objectType: 'activity', - text: 'Test comment', - userId: userId, - ), - ], - ), - ), - body: (tester) async { - // Initial state - comment has no reactions - final initialComment = tester.activityState.comments.first; - expect(initialComment.ownReactions, isEmpty); - - // Emit event - await tester.emitEvent( - CommentReactionAddedEvent( - type: EventTypes.commentReactionAdded, - createdAt: DateTime.timestamp(), - custom: const {}, - fid: feedId.rawValue, - activity: createDefaultActivityResponse(id: activityId), - comment: createDefaultCommentResponse( - id: commentId, - objectId: activityId, - objectType: 'activity', - userId: userId, - ), - reaction: FeedsReactionResponse( - activityId: activityId, - commentId: commentId, - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), - ), - ), - ); - - // Verify state has reaction - final updatedComment = tester.activityState.comments.first; - expect(updatedComment.ownReactions, hasLength(1)); - expect(updatedComment.ownReactions.first.type, reactionType); - expect(updatedComment.ownReactions.first.user.id, userId); - }, - ); - activityTest( 'addCommentReaction - should handle both API call and event together', - build: (client) => client.activity(activityId: activityId, fid: feedId), + build: (client) => client.activity(activityId: activityId, fid: fid), setUp: (tester) => tester.get( modifyCommentsResponse: (response) => response.copyWith( comments: [ @@ -1011,7 +851,7 @@ void main() { type: EventTypes.commentReactionAdded, createdAt: DateTime.timestamp(), custom: const {}, - fid: feedId.rawValue, + fid: fid.rawValue, activity: createDefaultActivityResponse(id: activityId), comment: createDefaultCommentResponse( id: commentId, @@ -1019,13 +859,11 @@ void main() { objectType: 'activity', userId: userId, ), - reaction: FeedsReactionResponse( + reaction: createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, activityId: activityId, commentId: commentId, - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ), ); @@ -1037,62 +875,9 @@ void main() { }, ); - activityTest( - 'addCommentReaction - should not update comment if objectId does not match', - build: (client) => client.activity(activityId: activityId, fid: feedId), - setUp: (tester) => tester.get( - modifyCommentsResponse: (response) => response.copyWith( - comments: [ - createDefaultThreadedCommentResponse( - id: commentId, - objectId: activityId, - objectType: 'activity', - text: 'Test comment', - userId: userId, - ), - ], - ), - ), - body: (tester) async { - // Initial state - comment has no reactions - final initialComment = tester.activityState.comments.first; - expect(initialComment.ownReactions, isEmpty); - - // Emit CommentReactionAddedEvent for different activity - await tester.emitEvent( - CommentReactionAddedEvent( - type: EventTypes.commentReactionAdded, - createdAt: DateTime.timestamp(), - custom: const {}, - fid: feedId.rawValue, - activity: - createDefaultActivityResponse(id: 'different-activity-id'), - comment: createDefaultCommentResponse( - id: 'different-comment-id', - objectId: 'different-activity-id', - objectType: 'activity', - userId: userId, - ), - reaction: FeedsReactionResponse( - activityId: 'different-activity-id', - commentId: 'different-comment-id', - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), - ), - ), - ); - - // Verify state was not updated - final comment = tester.activityState.comments.first; - expect(comment.ownReactions, isEmpty); - }, - ); - activityTest( 'deleteCommentReaction - should delete reaction from comment via API', - build: (client) => client.activity(activityId: activityId, fid: feedId), + build: (client) => client.activity(activityId: activityId, fid: fid), setUp: (tester) => tester.get( modifyCommentsResponse: (response) => response.copyWith( comments: [ @@ -1103,13 +888,11 @@ void main() { text: 'Test comment', userId: userId, ownReactions: [ - FeedsReactionResponse( + createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, activityId: activityId, commentId: commentId, - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ], ), @@ -1155,128 +938,1132 @@ void main() { ), ), ); + }); - activityTest( - 'deleteCommentReaction - should handle CommentReactionDeletedEvent and update comment', - build: (client) => client.activity(activityId: activityId, fid: feedId), - setUp: (tester) => tester.get( - modifyCommentsResponse: (response) => response.copyWith( - comments: [ - createDefaultThreadedCommentResponse( - id: commentId, - objectId: activityId, - objectType: 'activity', - text: 'Test comment', - userId: userId, - ownReactions: [ - FeedsReactionResponse( - activityId: activityId, - commentId: commentId, - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), - ), - ], - ), - ], - ), - ), - body: (tester) async { - // Initial state - comment has reaction - final initialComment = tester.activityState.comments.first; - expect(initialComment.ownReactions, hasLength(1)); + // ============================================================ + // FEATURE: Activity Reactions + // ============================================================ - // Emit event - await tester.emitEvent( - CommentReactionDeletedEvent( - type: EventTypes.commentReactionDeleted, - createdAt: DateTime.timestamp(), - custom: const {}, - fid: feedId.rawValue, - comment: createDefaultCommentResponse( - id: commentId, - objectId: activityId, - objectType: 'activity', - userId: userId, + group('Activity - Reactions', () { + const currentUser = User(id: 'test_user'); + + group('Activity Reactions', () { + activityTest( + 'ActivityReactionAddedEvent - should add reaction to activity', + user: currentUser, + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get(), + body: (tester) async { + final activity = tester.activityState.activity; + expect(activity, isNotNull); + expect(activity!.ownReactions, isEmpty); + + await tester.emitEvent( + ActivityReactionAddedEvent( + type: EventTypes.activityReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: fid.rawValue, + activity: createDefaultActivityResponse(id: activityId), + reaction: createDefaultReactionResponse( + reactionType: 'love', + userId: currentUser.id, + activityId: activityId, + ), ), - reaction: FeedsReactionResponse( - activityId: activityId, - commentId: commentId, - type: reactionType, + ); + + final updatedActivity = tester.activityState.activity; + expect(updatedActivity, isNotNull); + expect(updatedActivity!.ownReactions, hasLength(1)); + + expect(updatedActivity.ownReactions.first.type, 'love'); + }, + ); + + activityTest( + 'ActivityReactionUpdatedEvent - should replace user reaction', + user: currentUser, + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + ownReactions: [ + createDefaultReactionResponse( + reactionType: 'wow', + userId: currentUser.id, + activityId: activityId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has 'wow' reaction + final activity = tester.activityState.activity; + expect(activity, isNotNull); + expect(activity!.ownReactions, hasLength(1)); + expect(activity.ownReactions.first.type, 'wow'); + + // Emit ActivityReactionUpdatedEvent - replaces 'wow' with 'fire' + await tester.emitEvent( + ActivityReactionUpdatedEvent( + type: EventTypes.activityReactionUpdated, createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), + custom: const {}, + fid: fid.rawValue, + activity: createDefaultActivityResponse( + id: activityId, + latestReactions: [ + createDefaultReactionResponse( + reactionType: 'fire', + userId: currentUser.id, + activityId: activityId, + ), + ], + ), + reaction: createDefaultReactionResponse( + reactionType: 'fire', + userId: currentUser.id, + activityId: activityId, + ), ), + ); + + // Verify 'wow' was replaced with 'fire' + final updatedActivity = tester.activityState.activity; + expect(updatedActivity, isNotNull); + expect(updatedActivity!.ownReactions, hasLength(1)); + expect(updatedActivity.ownReactions.first.type, 'fire'); + }, + ); + + activityTest( + 'ActivityReactionDeletedEvent - should remove reaction from activity', + user: currentUser, + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + ownReactions: [ + createDefaultReactionResponse( + reactionType: 'love', + userId: currentUser.id, + activityId: activityId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has 'love' reaction + final activity = tester.activityState.activity; + expect(activity, isNotNull); + expect(activity!.ownReactions, hasLength(1)); + expect(activity.ownReactions.first.type, 'love'); + + // Emit ActivityReactionDeletedEvent + await tester.emitEvent( + ActivityReactionDeletedEvent( + type: EventTypes.activityReactionDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: fid.rawValue, + activity: createDefaultActivityResponse(id: activityId), + reaction: createDefaultReactionResponse( + reactionType: 'love', + userId: currentUser.id, + activityId: activityId, + ), + ), + ); + + // Verify reaction was removed + final updatedActivity = tester.activityState.activity; + expect(updatedActivity, isNotNull); + expect(updatedActivity!.ownReactions, isEmpty); + }, + ); + + activityTest( + 'ActivityReactionAddedEvent - should not add reaction for different activity', + user: currentUser, + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get(), + body: (tester) async { + final activity = tester.activityState.activity; + expect(activity, isNotNull); + expect(activity!.ownReactions, isEmpty); + + await tester.emitEvent( + ActivityReactionAddedEvent( + type: EventTypes.activityReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: fid.rawValue, + activity: createDefaultActivityResponse(id: 'different-activity'), + reaction: createDefaultReactionResponse( + reactionType: 'love', + userId: currentUser.id, + activityId: 'different-activity', + ), + ), + ); + + final updatedActivity = tester.activityState.activity; + expect(updatedActivity, isNotNull); + expect(updatedActivity!.ownReactions, isEmpty); + }, + ); + }); + }); + + // ============================================================ + // FEATURE: Bookmarks + // ============================================================ + + group('Activity - Bookmarks', () { + const currentUser = User(id: 'test_user'); + + group('Activity Bookmarks', () { + activityTest( + 'BookmarkAddedEvent - should add bookmark to activity', + user: currentUser, + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get(), + body: (tester) async { + final activity = tester.activityState.activity; + expect(activity, isNotNull); + expect(activity!.ownBookmarks, isEmpty); + + await tester.emitEvent( + BookmarkAddedEvent( + type: EventTypes.bookmarkAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + activityId: activityId, + userId: currentUser.id, + ), + ), + ); + + final updatedActivity = tester.activityState.activity; + expect(updatedActivity, isNotNull); + expect(updatedActivity!.ownBookmarks, hasLength(1)); + }, + ); + + activityTest( + 'BookmarkUpdatedEvent - should update bookmark on activity', + user: currentUser, + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get(), + body: (tester) async { + final activity = tester.activityState.activity; + expect(activity, isNotNull); + + final existingBookmark = createDefaultBookmarkResponse( + activityId: activityId, + userId: currentUser.id, + ); + + // First add a bookmark + await tester.emitEvent( + BookmarkAddedEvent( + type: EventTypes.bookmarkAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: existingBookmark, + ), + ); + + // Then update it + final updatedBookmark = existingBookmark.copyWith( + custom: const {'updated': true}, + ); + + await tester.emitEvent( + BookmarkUpdatedEvent( + type: EventTypes.bookmarkUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: updatedBookmark, + ), + ); + + final updatedActivity = tester.activityState.activity; + expect(updatedActivity, isNotNull); + expect(updatedActivity!.ownBookmarks, hasLength(1)); + + final bookmarkModel = updatedBookmark.toModel(); + final foundBookmark = updatedActivity.ownBookmarks.firstWhere( + (b) => b.id == bookmarkModel.id, + ); + expect(foundBookmark.custom?['updated'], isTrue); + }, + ); + + activityTest( + 'BookmarkDeletedEvent - should remove bookmark from activity', + user: currentUser, + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get(), + body: (tester) async { + final activity = tester.activityState.activity; + expect(activity, isNotNull); + + final bookmark = createDefaultBookmarkResponse( + activityId: activityId, + userId: currentUser.id, + ); + + // First add a bookmark + await tester.emitEvent( + BookmarkAddedEvent( + type: EventTypes.bookmarkAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: bookmark, + ), + ); + + // Verify bookmark was added + var updatedActivity = tester.activityState.activity; + expect(updatedActivity, isNotNull); + expect(updatedActivity!.ownBookmarks, hasLength(1)); + + // Then remove it + await tester.emitEvent( + BookmarkDeletedEvent( + type: EventTypes.bookmarkDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: bookmark, + ), + ); + + updatedActivity = tester.activityState.activity; + expect(updatedActivity, isNotNull); + expect(updatedActivity!.ownBookmarks, isEmpty); + }, + ); + + activityTest( + 'BookmarkAddedEvent - should not add bookmark for different activity', + user: currentUser, + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get(), + body: (tester) async { + final activity = tester.activityState.activity; + expect(activity, isNotNull); + expect(activity!.ownBookmarks, isEmpty); + + await tester.emitEvent( + BookmarkAddedEvent( + type: EventTypes.bookmarkAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + activityId: 'different-activity', + userId: currentUser.id, + ), + ), + ); + + final updatedActivity = tester.activityState.activity; + expect(updatedActivity, isNotNull); + expect(updatedActivity!.ownBookmarks, isEmpty); + }, + ); + }); + }); + + // ============================================================ + // FEATURE: Polls + // ============================================================ + + group('Activity - Polls', () { + activityTest( + 'closePoll - should close poll via API', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + poll: createDefaultPollResponse(), + ), + ), + body: (tester) async { + final pollId = tester.activityState.poll!.id; + + tester.mockApi( + (api) => api.updatePollPartial( + pollId: pollId, + updatePollPartialRequest: const UpdatePollPartialRequest( + set: {'is_closed': true}, + ), + ), + result: PollResponse( + poll: createDefaultPollResponse( + id: pollId, + ).copyWith(isClosed: true), + duration: DateTime.timestamp().toIso8601String(), ), ); - // Verify state has no reactions - final updatedComment = tester.activityState.comments.first; - expect(updatedComment.ownReactions, isEmpty); + final result = await tester.activity.closePoll(); + + expect(result.isSuccess, isTrue); + final poll = result.getOrNull(); + expect(poll, isNotNull); + expect(poll!.isClosed, isTrue); + }, + verify: (tester) { + final pollId = tester.activityState.poll!.id; + tester.verifyApi( + (api) => api.updatePollPartial( + pollId: pollId, + updatePollPartialRequest: const UpdatePollPartialRequest( + set: {'is_closed': true}, + ), + ), + ); }, ); activityTest( - 'deleteCommentReaction - should not update comment if objectId does not match', - build: (client) => client.activity(activityId: activityId, fid: feedId), + 'deletePoll - should delete poll via API', + build: (client) => client.activity(activityId: activityId, fid: fid), setUp: (tester) => tester.get( - modifyCommentsResponse: (response) => response.copyWith( - comments: [ - createDefaultThreadedCommentResponse( - id: commentId, - objectId: activityId, - objectType: 'activity', - text: 'Test comment', - userId: userId, - ownReactions: [ - FeedsReactionResponse( - activityId: activityId, - commentId: commentId, - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), - ), - ], + modifyResponse: (response) => response.copyWith( + poll: createDefaultPollResponse(), + ), + ), + body: (tester) async { + final pollId = tester.activityState.poll!.id; + + tester.mockApi( + (api) => api.deletePoll(pollId: pollId), + result: DurationResponse( + duration: DateTime.timestamp().toIso8601String(), + ), + ); + + final result = await tester.activity.deletePoll(); + + expect(result.isSuccess, isTrue); + }, + verify: (tester) { + final pollId = tester.activityState.poll!.id; + tester.verifyApi((api) => api.deletePoll(pollId: pollId)); + }, + ); + + activityTest( + 'updatePollPartial - should update poll partially via API', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + poll: createDefaultPollResponse(), + ), + ), + body: (tester) async { + final pollId = tester.activityState.poll!.id; + + tester.mockApi( + (api) => api.updatePollPartial( + pollId: pollId, + updatePollPartialRequest: const UpdatePollPartialRequest( + set: {'name': 'Updated Poll Name'}, ), - ], + ), + result: PollResponse( + poll: createDefaultPollResponse( + id: pollId, + ).copyWith(name: 'Updated Poll Name'), + duration: DateTime.timestamp().toIso8601String(), + ), + ); + + final result = await tester.activity.updatePollPartial( + const UpdatePollPartialRequest(set: {'name': 'Updated Poll Name'}), + ); + + expect(result.isSuccess, isTrue); + final poll = result.getOrNull(); + expect(poll, isNotNull); + expect(poll!.name, 'Updated Poll Name'); + }, + verify: (tester) { + final pollId = tester.activityState.poll!.id; + tester.verifyApi( + (api) => api.updatePollPartial( + pollId: pollId, + updatePollPartialRequest: const UpdatePollPartialRequest( + set: {'name': 'Updated Poll Name'}, + ), + ), + ); + }, + ); + + activityTest( + 'updatePoll - should update poll via API', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + poll: createDefaultPollResponse(), ), ), body: (tester) async { - // Initial state - comment has reaction - final initialComment = tester.activityState.comments.first; - expect(initialComment.ownReactions, hasLength(1)); + final pollId = tester.activityState.poll!.id; - // Emit CommentReactionDeletedEvent for different activity - await tester.emitEvent( - CommentReactionDeletedEvent( - type: EventTypes.commentReactionDeleted, - createdAt: DateTime.timestamp(), - custom: const {}, - fid: feedId.rawValue, - comment: createDefaultCommentResponse( - id: 'different-comment-id', - objectId: 'different-activity-id', - objectType: 'activity', - userId: userId, + tester.mockApi( + (api) => api.updatePoll( + updatePollRequest: UpdatePollRequest( + id: pollId, + name: 'Updated Poll Name', ), - reaction: FeedsReactionResponse( - activityId: 'different-activity-id', - commentId: 'different-comment-id', - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), + ), + result: PollResponse( + poll: createDefaultPollResponse( + id: pollId, + ).copyWith(name: 'Updated Poll Name'), + duration: DateTime.timestamp().toIso8601String(), + ), + ); + + final result = await tester.activity.updatePoll( + UpdatePollRequest( + id: pollId, + name: 'Updated Poll Name', + ), + ); + + expect(result.isSuccess, isTrue); + final poll = result.getOrNull(); + expect(poll, isNotNull); + expect(poll!.name, 'Updated Poll Name'); + }, + verify: (tester) { + final pollId = tester.activityState.poll!.id; + tester.verifyApi( + (api) => api.updatePoll( + updatePollRequest: UpdatePollRequest( + id: pollId, + name: 'Updated Poll Name', + ), + ), + ); + }, + ); + + activityTest( + 'createPollOption - should create poll option via API', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + poll: createDefaultPollResponse(), + ), + ), + body: (tester) async { + final pollId = tester.activityState.poll!.id; + + const optionId = 'option-new'; + const optionText = 'New Option'; + + tester.mockApi( + (api) => api.createPollOption( + pollId: pollId, + createPollOptionRequest: const CreatePollOptionRequest( + text: optionText, + ), + ), + result: PollOptionResponse( + pollOption: createDefaultPollOptionResponse( + id: optionId, + text: optionText, + ), + duration: DateTime.timestamp().toIso8601String(), + ), + ); + + final result = await tester.activity.createPollOption( + const CreatePollOptionRequest(text: optionText), + ); + + expect(result.isSuccess, isTrue); + final option = result.getOrNull(); + expect(option, isNotNull); + expect(option!.text, optionText); + }, + verify: (tester) { + final pollId = tester.activityState.poll!.id; + tester.verifyApi( + (api) => api.createPollOption( + pollId: pollId, + createPollOptionRequest: const CreatePollOptionRequest( + text: 'New Option', + ), + ), + ); + }, + ); + + activityTest( + 'deletePollOption - should delete poll option via API', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + poll: createDefaultPollResponse( + options: [ + createDefaultPollOptionResponse( + id: 'option-1', + text: 'Option 1', + ), + ], + ), + ), + ), + body: (tester) async { + const optionId = 'option-1'; + final pollId = tester.activityState.poll!.id; + + tester.mockApi( + (api) => api.deletePollOption( + pollId: pollId, + optionId: optionId, + ), + result: DurationResponse( + duration: DateTime.timestamp().toIso8601String(), + ), + ); + + final result = await tester.activity.deletePollOption( + optionId: optionId, + ); + + expect(result.isSuccess, isTrue); + }, + verify: (tester) { + final pollId = tester.activityState.poll!.id; + tester.verifyApi( + (api) => api.deletePollOption( + pollId: pollId, + optionId: 'option-1', + ), + ); + }, + ); + + activityTest( + 'updatePollOption - should update poll option via API', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + poll: createDefaultPollResponse( + options: [ + createDefaultPollOptionResponse( + id: 'option-1', + text: 'Option 1', + ), + ], + ), + ), + ), + body: (tester) async { + const optionId = 'option-1'; + final pollId = tester.activityState.poll?.id; + expect(pollId, isNotNull); + + tester.mockApi( + (api) => api.updatePollOption( + pollId: pollId!, + updatePollOptionRequest: const UpdatePollOptionRequest( + id: optionId, + text: 'Updated Option Text', + ), + ), + result: PollOptionResponse( + pollOption: createDefaultPollOptionResponse( + id: optionId, + text: 'Updated Option Text', + ), + duration: DateTime.timestamp().toIso8601String(), + ), + ); + + final result = await tester.activity.updatePollOption( + const UpdatePollOptionRequest( + id: optionId, + text: 'Updated Option Text', + ), + ); + + expect(result.isSuccess, isTrue); + final option = result.getOrNull(); + expect(option, isNotNull); + expect(option!.text, 'Updated Option Text'); + }, + verify: (tester) { + final pollId = tester.activityState.poll!.id; + tester.verifyApi( + (api) => api.updatePollOption( + pollId: pollId, + updatePollOptionRequest: const UpdatePollOptionRequest( + id: 'option-1', + text: 'Updated Option Text', + ), + ), + ); + }, + ); + + activityTest( + 'castPollVote - should cast poll vote via API', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + poll: createDefaultPollResponse( + options: [ + createDefaultPollOptionResponse( + id: 'option-1', + text: 'Option 1', + ), + ], + ), + ), + ), + body: (tester) async { + final pollId = tester.activityState.poll!.id; + + const optionId = 'option-1'; + + tester.mockApi( + (api) => api.castPollVote( + pollId: pollId, + activityId: activityId, + castPollVoteRequest: const CastPollVoteRequest( + vote: VoteData(optionId: optionId), + ), + ), + result: PollVoteResponse( + vote: createDefaultPollVoteResponse( + id: 'vote-1', + pollId: pollId, + optionId: optionId, + ), + duration: DateTime.timestamp().toIso8601String(), + ), + ); + + final result = await tester.activity.castPollVote( + const CastPollVoteRequest(vote: VoteData(optionId: optionId)), + ); + + expect(result.isSuccess, isTrue); + final vote = result.getOrNull(); + expect(vote, isNotNull); + expect(vote!.id, 'vote-1'); + }, + verify: (tester) { + final pollId = tester.activityState.poll!.id; + tester.verifyApi( + (api) => api.castPollVote( + pollId: pollId, + activityId: activityId, + castPollVoteRequest: const CastPollVoteRequest( + vote: VoteData(optionId: 'option-1'), + ), + ), + ); + }, + ); + + activityTest( + 'deletePollVote - should delete poll vote via API', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + poll: createDefaultPollResponse(), + ), + ), + body: (tester) async { + const voteId = 'vote-1'; + final pollId = tester.activityState.poll!.id; + + tester.mockApi( + (api) => api.deletePollVote( + activityId: activityId, + pollId: pollId, + voteId: voteId, + ), + result: PollVoteResponse( + vote: createDefaultPollVoteResponse( + id: voteId, + pollId: pollId, ), + duration: DateTime.timestamp().toIso8601String(), ), ); - // Verify state was not updated - final comment = tester.activityState.comments.first; - expect(comment.ownReactions, hasLength(1)); - expect(comment.ownReactions.first.type, reactionType); + final result = await tester.activity.deletePollVote(voteId: voteId); + + expect(result.isSuccess, isTrue); + final vote = result.getOrNull(); + expect(vote, isNotNull); + expect(vote!.id, voteId); + }, + verify: (tester) { + final pollId = tester.activityState.poll!.id; + tester.verifyApi( + (api) => api.deletePollVote( + activityId: activityId, + pollId: pollId, + voteId: 'vote-1', + ), + ); }, ); + + group('Poll Events', () { + final defaultPoll = createDefaultPollResponse(); + + activityTest( + 'poll closed', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(poll: defaultPoll), + ), + body: (tester) async { + expect(tester.activityState.poll?.isClosed, false); + + await tester.emitEvent( + PollClosedFeedEvent( + type: EventTypes.pollClosed, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: fid.rawValue, + poll: defaultPoll.copyWith(isClosed: true), + ), + ); + + expect(tester.activityState.poll?.isClosed, true); + }, + ); + + activityTest( + 'poll deleted', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(poll: defaultPoll), + ), + body: (tester) async { + expect(tester.activityState.poll, isNotNull); + + await tester.emitEvent( + PollDeletedFeedEvent( + type: EventTypes.pollDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: fid.rawValue, + poll: defaultPoll, + ), + ); + + expect(tester.activityState.poll, isNull); + }, + ); + + activityTest( + 'poll updated', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(poll: defaultPoll), + ), + body: (tester) async { + final poll = tester.activityState.poll!; + expect(poll.name, 'name'); + + await tester.emitEvent( + PollUpdatedFeedEvent( + type: EventTypes.pollUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: fid.rawValue, + poll: defaultPoll.copyWith(name: 'Updated Poll Name'), + ), + ); + + final updatedPoll = tester.activityState.poll; + expect(updatedPoll, isNotNull); + expect(updatedPoll!.name, 'Updated Poll Name'); + }, + ); + + group('Vote operations', () { + final pollWithVotes = createDefaultPollResponse( + options: [ + createDefaultPollOptionResponse(id: 'option-1', text: 'Option 1'), + createDefaultPollOptionResponse(id: 'option-2', text: 'Option 2'), + ], + ownVotesAndAnswers: [ + createDefaultPollVoteResponse(id: 'vote-1', optionId: 'option-1'), + createDefaultPollVoteResponse(id: 'vote-2', optionId: 'option-1'), + createDefaultPollVoteResponse(id: 'vote-3', optionId: 'option-2'), + ], + ); + + activityTest( + 'poll vote casted', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(poll: pollWithVotes), + ), + body: (tester) async { + // Initial state - has 3 votes + final initialPoll = tester.activityState.poll!; + expect(initialPoll.voteCount, 3); + expect(initialPoll.latestVotesByOption, hasLength(2)); + expect(initialPoll.latestVotesByOption['option-2'], hasLength(1)); + + final newVote = createDefaultPollVoteResponse( + id: 'vote-4', + pollId: initialPoll.id, + optionId: 'option-2', + ); + + // Emit PollVoteCastedFeedEvent + await tester.emitEvent( + PollVoteCastedFeedEvent( + type: EventTypes.pollVoteCasted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: fid.rawValue, + pollVote: newVote, + poll: createDefaultPollResponse( + options: pollWithVotes.options, + ownVotesAndAnswers: [ + createDefaultPollVoteResponse( + id: 'vote-1', + optionId: 'option-1', + ), + createDefaultPollVoteResponse( + id: 'vote-2', + optionId: 'option-1', + ), + createDefaultPollVoteResponse( + id: 'vote-3', + optionId: 'option-2', + ), + newVote, + ], + ), + ), + ); + + // Verify vote was added + final updatedPoll = tester.activityState.poll!; + expect(updatedPoll.voteCount, 4); + expect(updatedPoll.latestVotesByOption, hasLength(2)); + expect(updatedPoll.latestVotesByOption['option-2'], hasLength(2)); + }, + ); + + activityTest( + 'poll vote changed', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith( + poll: createDefaultPollResponse( + options: pollWithVotes.options, + ownVotesAndAnswers: [ + createDefaultPollVoteResponse( + id: 'vote-1', + optionId: 'option-1', + ), + ], + ), + ), + ), + body: (tester) async { + // Initial state - has one vote on option-1 + final initialPoll = tester.activityState.poll!; + expect(initialPoll.voteCount, 1); + expect(initialPoll.latestVotesByOption['option-1'], hasLength(1)); + + final changedVote = createDefaultPollVoteResponse( + id: 'vote-1', + pollId: initialPoll.id, + optionId: 'option-2', + ); + + // Emit PollVoteChangedFeedEvent + await tester.emitEvent( + PollVoteChangedFeedEvent( + type: EventTypes.pollVoteChanged, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: fid.rawValue, + pollVote: changedVote, + poll: createDefaultPollResponse( + options: pollWithVotes.options, + ownVotesAndAnswers: [changedVote], + ), + ), + ); + + // Verify vote was changed + final updatedPoll = tester.activityState.poll!; + expect(updatedPoll.voteCount, 1); + expect(updatedPoll.latestVotesByOption['option-1'], isNull); + expect(updatedPoll.latestVotesByOption['option-2'], hasLength(1)); + }, + ); + + activityTest( + 'poll vote removed', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith( + poll: createDefaultPollResponse( + options: pollWithVotes.options, + ownVotesAndAnswers: [ + createDefaultPollVoteResponse( + id: 'vote-1', + optionId: 'option-1', + ), + ], + ), + ), + ), + body: (tester) async { + // Initial state - has one vote on option-1 + final initialPoll = tester.activityState.poll!; + expect(initialPoll.voteCount, 1); + expect(initialPoll.latestVotesByOption['option-1'], hasLength(1)); + + final voteToRemove = createDefaultPollVoteResponse( + id: 'vote-1', + pollId: initialPoll.id, + optionId: 'option-1', + ); + + // Emit PollVoteRemovedFeedEvent + await tester.emitEvent( + PollVoteRemovedFeedEvent( + type: EventTypes.pollVoteRemoved, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: fid.rawValue, + pollVote: voteToRemove, + poll: createDefaultPollResponse( + options: pollWithVotes.options, + ownVotesAndAnswers: [], + ), + ), + ); + + // Verify vote was removed + final updatedPoll = tester.activityState.poll!; + expect(updatedPoll.voteCount, 0); + expect(updatedPoll.latestVotesByOption['option-1'], isNull); + }, + ); + }); + + group('Answer operations', () { + final pollWithAnswers = createDefaultPollResponse( + ownVotesAndAnswers: [ + createDefaultPollAnswerResponse(id: 'answer-1'), + createDefaultPollAnswerResponse(id: 'answer-2'), + createDefaultPollAnswerResponse(id: 'answer-3'), + ], + ); + + activityTest( + 'poll answer casted', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(poll: pollWithAnswers), + ), + body: (tester) async { + // Initial state - has 3 answers + final initialPoll = tester.activityState.poll!; + expect(initialPoll.answersCount, 3); + expect(initialPoll.latestAnswers, hasLength(3)); + + final newAnswer = createDefaultPollAnswerResponse( + id: 'answer-4', + pollId: initialPoll.id, + answerText: 'Answer 4', + ); + + // Emit PollVoteCastedFeedEvent (resolved to PollAnswerCastedFeedEvent) + await tester.emitEvent( + PollVoteCastedFeedEvent( + type: EventTypes.pollVoteCasted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: fid.rawValue, + pollVote: newAnswer, + poll: createDefaultPollResponse( + ownVotesAndAnswers: [ + createDefaultPollAnswerResponse(id: 'answer-1'), + createDefaultPollAnswerResponse(id: 'answer-2'), + createDefaultPollAnswerResponse(id: 'answer-3'), + newAnswer, + ], + ), + ), + ); + + // Verify answer was added + final updatedPoll = tester.activityState.poll!; + expect(updatedPoll.answersCount, 4); + expect(updatedPoll.latestAnswers, hasLength(4)); + }, + ); + + activityTest( + 'poll answer removed', + build: (client) => client.activity(activityId: activityId, fid: fid), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith( + poll: createDefaultPollResponse( + ownVotesAndAnswers: [ + createDefaultPollAnswerResponse(id: 'answer-1'), + ], + ), + ), + ), + body: (tester) async { + // Initial state - has one answer + final initialPoll = tester.activityState.poll!; + expect(initialPoll.answersCount, 1); + expect(initialPoll.latestAnswers, hasLength(1)); + + final answerToRemove = createDefaultPollAnswerResponse( + id: 'answer-1', + pollId: initialPoll.id, + ); + + // Emit PollVoteRemovedFeedEvent (resolved to PollAnswerRemovedFeedEvent) + await tester.emitEvent( + PollVoteRemovedFeedEvent( + type: EventTypes.pollVoteRemoved, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: fid.rawValue, + pollVote: answerToRemove, + poll: createDefaultPollResponse( + ownVotesAndAnswers: [], + ), + ), + ); + + // Verify answer was removed + final updatedPoll = tester.activityState.poll!; + expect(updatedPoll.answersCount, 0); + expect(updatedPoll.latestAnswers, isEmpty); + }, + ); + }); + }); }); } diff --git a/packages/stream_feeds/test/state/bookmark_folder_list_test.dart b/packages/stream_feeds/test/state/bookmark_folder_list_test.dart index a9177673..d05d2094 100644 --- a/packages/stream_feeds/test/state/bookmark_folder_list_test.dart +++ b/packages/stream_feeds/test/state/bookmark_folder_list_test.dart @@ -3,6 +3,190 @@ import 'package:stream_feeds/stream_feeds.dart'; import 'package:stream_feeds_test/stream_feeds_test.dart'; void main() { + const folderId = 'folder-1'; + const query = BookmarkFoldersQuery(); + + // ============================================================ + // FEATURE: Query Operations + // ============================================================ + + group('Bookmark Folder List - Query Operations', () { + bookmarkFolderListTest( + 'get - should query initial bookmark folders via API', + build: (client) => client.bookmarkFolderList(query), + body: (tester) async { + final result = await tester.get(); + + expect(result, isA>>()); + final folders = result.getOrThrow(); + + expect(folders, isA>()); + expect(folders, hasLength(3)); + expect(folders[0].id, 'folder-1'); + expect(folders[1].id, 'folder-2'); + expect(folders[2].id, 'folder-3'); + }, + ); + + bookmarkFolderListTest( + 'queryMoreBookmarkFolders - should load more folders via API', + build: (client) => client.bookmarkFolderList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + next: 'next-cursor', + bookmarkFolders: [ + createDefaultBookmarkFolderResponse(id: folderId), + ], + ), + ), + body: (tester) async { + // Initial state - has folder + expect(tester.bookmarkFolderListState.bookmarkFolders, hasLength(1)); + expect(tester.bookmarkFolderListState.canLoadMore, isTrue); + + final nextPageQuery = tester.bookmarkFolderList.query.copyWith( + next: tester.bookmarkFolderListState.pagination?.next, + ); + + tester.mockApi( + (api) => api.queryBookmarkFolders( + queryBookmarkFoldersRequest: nextPageQuery.toRequest(), + ), + result: createDefaultQueryBookmarkFoldersResponse( + bookmarkFolders: [ + createDefaultBookmarkFolderResponse(id: 'folder-2'), + ], + ), + ); + + // Query more folders + final result = + await tester.bookmarkFolderList.queryMoreBookmarkFolders(); + + expect(result.isSuccess, isTrue); + final folders = result.getOrNull(); + expect(folders, isNotNull); + expect(folders, hasLength(1)); + + // Verify state was updated with merged folders + expect(tester.bookmarkFolderListState.bookmarkFolders, hasLength(2)); + expect(tester.bookmarkFolderListState.canLoadMore, isFalse); + + tester.verifyApi( + (api) => api.queryBookmarkFolders( + queryBookmarkFoldersRequest: nextPageQuery.toRequest(), + ), + ); + }, + ); + + bookmarkFolderListTest( + 'queryMoreBookmarkFolders - should return empty list when no more folders', + build: (client) => client.bookmarkFolderList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + bookmarkFolders: [ + createDefaultBookmarkFolderResponse(id: folderId), + ], + ), + ), + body: (tester) async { + // Initial state - has folder but no pagination + expect(tester.bookmarkFolderListState.bookmarkFolders, hasLength(1)); + expect(tester.bookmarkFolderListState.canLoadMore, isFalse); + // Query more folders (should return empty immediately) + final result = + await tester.bookmarkFolderList.queryMoreBookmarkFolders(); + + expect(result.isSuccess, isTrue); + final folders = result.getOrNull(); + expect(folders, isEmpty); + + // Verify state was not updated (no new folders, pagination remains null) + expect(tester.bookmarkFolderListState.bookmarkFolders, hasLength(1)); + expect(tester.bookmarkFolderListState.canLoadMore, isFalse); + }, + ); + }); + + // ============================================================ + // FEATURE: Event Handling + // ============================================================ + + group('Bookmark Folder List - Event Handling', () { + bookmarkFolderListTest( + 'should handle BookmarkFolderUpdatedEvent and update folder', + build: (client) => client.bookmarkFolderList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + bookmarkFolders: [ + createDefaultBookmarkFolderResponse(id: folderId), + ], + ), + ), + body: (tester) async { + // Initial state - has folder + expect(tester.bookmarkFolderListState.bookmarkFolders, hasLength(1)); + expect( + tester.bookmarkFolderListState.bookmarkFolders.first.name, + 'My Folder', + ); + + // Emit event + await tester.emitEvent( + BookmarkFolderUpdatedEvent( + type: EventTypes.bookmarkFolderUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmarkFolder: createDefaultBookmarkFolderResponse( + id: folderId, + ).copyWith(name: 'Updated Folder'), + ), + ); + + // Verify state has updated folder + expect(tester.bookmarkFolderListState.bookmarkFolders, hasLength(1)); + expect( + tester.bookmarkFolderListState.bookmarkFolders.first.name, + 'Updated Folder', + ); + }, + ); + + bookmarkFolderListTest( + 'should handle BookmarkFolderDeletedEvent and remove folder', + build: (client) => client.bookmarkFolderList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + bookmarkFolders: [ + createDefaultBookmarkFolderResponse(id: folderId), + ], + ), + ), + body: (tester) async { + // Initial state - has folder + expect(tester.bookmarkFolderListState.bookmarkFolders, hasLength(1)); + expect( + tester.bookmarkFolderListState.bookmarkFolders.first.id, + folderId, + ); + + // Emit event + await tester.emitEvent( + BookmarkFolderDeletedEvent( + type: EventTypes.bookmarkFolderDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmarkFolder: createDefaultBookmarkFolderResponse(id: folderId), + ), + ); + + // Verify state has no folders + expect(tester.bookmarkFolderListState.bookmarkFolders, isEmpty); + }, + ); + }); + // ============================================================ // FEATURE: Local Filtering // ============================================================ diff --git a/packages/stream_feeds/test/state/bookmark_list_test.dart b/packages/stream_feeds/test/state/bookmark_list_test.dart index 56154008..71e06b36 100644 --- a/packages/stream_feeds/test/state/bookmark_list_test.dart +++ b/packages/stream_feeds/test/state/bookmark_list_test.dart @@ -3,6 +3,338 @@ import 'package:stream_feeds/stream_feeds.dart'; import 'package:stream_feeds_test/stream_feeds_test.dart'; void main() { + const folderId = 'folder-1'; + const activityId = 'activity-1'; + const query = BookmarksQuery(); + + // ============================================================ + // FEATURE: Query Operations + // ============================================================ + + group('Bookmark List - Query Operations', () { + bookmarkListTest( + 'get - should query initial bookmarks via API', + build: (client) => client.bookmarkList(query), + body: (tester) async { + final result = await tester.get(); + + expect(result, isA>>()); + final bookmarks = result.getOrThrow(); + + expect(bookmarks, isA>()); + expect(bookmarks, hasLength(3)); + expect(bookmarks[0].activity.id, 'activity-1'); + expect(bookmarks[1].activity.id, 'activity-2'); + expect(bookmarks[2].activity.id, 'activity-3'); + }, + ); + + bookmarkListTest( + 'queryMoreBookmarks - should load more bookmarks via API', + build: (client) => client.bookmarkList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + next: 'next-cursor', + bookmarks: [ + createDefaultBookmarkResponse( + folderId: folderId, + activityId: activityId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has bookmark + expect(tester.bookmarkListState.bookmarks, hasLength(1)); + expect(tester.bookmarkListState.canLoadMore, isTrue); + + final nextPageQuery = tester.bookmarkList.query.copyWith( + next: tester.bookmarkListState.pagination?.next, + ); + + tester.mockApi( + (api) => api.queryBookmarks( + queryBookmarksRequest: nextPageQuery.toRequest(), + ), + result: createDefaultQueryBookmarksResponse( + bookmarks: [ + createDefaultBookmarkResponse( + folderId: folderId, + activityId: 'activity-2', + ), + ], + ), + ); + + // Query more bookmarks + final result = await tester.bookmarkList.queryMoreBookmarks(); + + expect(result.isSuccess, isTrue); + final bookmarks = result.getOrNull(); + expect(bookmarks, isNotNull); + expect(bookmarks, hasLength(1)); + + // Verify state was updated with merged bookmarks + expect(tester.bookmarkListState.bookmarks, hasLength(2)); + expect(tester.bookmarkListState.canLoadMore, isFalse); + + tester.verifyApi( + (api) => api.queryBookmarks( + queryBookmarksRequest: nextPageQuery.toRequest(), + ), + ); + }, + ); + + bookmarkListTest( + 'queryMoreBookmarks - should return empty list when no more bookmarks', + build: (client) => client.bookmarkList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + bookmarks: [ + createDefaultBookmarkResponse( + folderId: folderId, + activityId: activityId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has bookmark but no pagination + expect(tester.bookmarkListState.bookmarks, hasLength(1)); + expect(tester.bookmarkListState.canLoadMore, isFalse); + // Query more bookmarks (should return empty immediately) + final result = await tester.bookmarkList.queryMoreBookmarks(); + + expect(result.isSuccess, isTrue); + final bookmarks = result.getOrNull(); + expect(bookmarks, isEmpty); + + // Verify state was not updated (no new bookmarks, pagination remains null) + expect(tester.bookmarkListState.bookmarks, hasLength(1)); + expect(tester.bookmarkListState.canLoadMore, isFalse); + }, + ); + }); + + // ============================================================ + // FEATURE: Event Handling - Bookmark Events + // ============================================================ + + group('Bookmark List - Bookmark Events', () { + bookmarkListTest( + 'should handle BookmarkAddedEvent and add bookmark', + build: (client) => client.bookmarkList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith(bookmarks: const []), + ), + body: (tester) async { + // Initial state - no bookmarks + expect(tester.bookmarkListState.bookmarks, isEmpty); + + // Emit event + await tester.emitEvent( + BookmarkAddedEvent( + type: EventTypes.bookmarkAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + folderId: folderId, + activityId: activityId, + ), + ), + ); + + // Verify state has bookmark + expect(tester.bookmarkListState.bookmarks, hasLength(1)); + expect( + tester.bookmarkListState.bookmarks.first.activity.id, + activityId, + ); + }, + ); + + bookmarkListTest( + 'should handle BookmarkUpdatedEvent and update bookmark', + build: (client) => client.bookmarkList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + bookmarks: [ + createDefaultBookmarkResponse( + folderId: folderId, + activityId: activityId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has bookmark + expect(tester.bookmarkListState.bookmarks, hasLength(1)); + expect(tester.bookmarkListState.bookmarks.first.folder!.id, folderId); + + // Emit event with updated folder + await tester.emitEvent( + BookmarkUpdatedEvent( + type: EventTypes.bookmarkUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + folderId: 'folder-2', + activityId: activityId, + ), + ), + ); + + // Verify state has updated bookmark + expect(tester.bookmarkListState.bookmarks, hasLength(1)); + expect(tester.bookmarkListState.bookmarks.first.folder!.id, 'folder-2'); + }, + ); + + bookmarkListTest( + 'should handle BookmarkDeletedEvent and remove bookmark', + build: (client) => client.bookmarkList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + bookmarks: [ + createDefaultBookmarkResponse( + folderId: folderId, + activityId: activityId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has bookmark + expect(tester.bookmarkListState.bookmarks, hasLength(1)); + + // Emit event + await tester.emitEvent( + BookmarkDeletedEvent( + type: EventTypes.bookmarkDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + folderId: folderId, + activityId: activityId, + ), + ), + ); + + // Verify state has no bookmarks + expect(tester.bookmarkListState.bookmarks, isEmpty); + }, + ); + }); + + // ============================================================ + // FEATURE: Event Handling - Bookmark Folder Events + // ============================================================ + + group('Bookmark List - Bookmark Folder Events', () { + bookmarkListTest( + 'should handle BookmarkFolderUpdatedEvent and update folder in bookmarks', + build: (client) => client.bookmarkList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + bookmarks: [ + createDefaultBookmarkResponse( + folderId: folderId, + activityId: 'activity-1', + ), + createDefaultBookmarkResponse( + folderId: folderId, + activityId: 'activity-2', + ), + createDefaultBookmarkResponse( + folderId: 'folder-2', + activityId: 'activity-3', + ), + ], + ), + ), + body: (tester) async { + // Initial state - has bookmarks with folders + expect(tester.bookmarkListState.bookmarks, hasLength(3)); + expect(tester.bookmarkListState.bookmarks[0].folder!.name, 'My Folder'); + expect(tester.bookmarkListState.bookmarks[1].folder!.name, 'My Folder'); + + // Emit event + await tester.emitEvent( + BookmarkFolderUpdatedEvent( + type: EventTypes.bookmarkFolderUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmarkFolder: createDefaultBookmarkFolderResponse( + id: folderId, + name: 'Updated Folder', + ), + ), + ); + + // Verify bookmarks with matching folder ID were updated + expect(tester.bookmarkListState.bookmarks, hasLength(3)); + expect( + tester.bookmarkListState.bookmarks[0].folder!.name, + 'Updated Folder', + ); + expect( + tester.bookmarkListState.bookmarks[1].folder!.name, + 'Updated Folder', + ); + // Bookmark with different folder should not be affected + expect(tester.bookmarkListState.bookmarks[2].folder!.name, 'My Folder'); + }, + ); + + bookmarkListTest( + 'should handle BookmarkFolderDeletedEvent and remove folder from bookmarks', + build: (client) => client.bookmarkList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + bookmarks: [ + createDefaultBookmarkResponse( + folderId: folderId, + activityId: 'activity-1', + ), + createDefaultBookmarkResponse( + folderId: folderId, + activityId: 'activity-2', + ), + createDefaultBookmarkResponse( + folderId: 'folder-2', + activityId: 'activity-3', + ), + ], + ), + ), + body: (tester) async { + // Initial state - has bookmarks with folders + expect(tester.bookmarkListState.bookmarks, hasLength(3)); + expect(tester.bookmarkListState.bookmarks[0].folder, isNotNull); + expect(tester.bookmarkListState.bookmarks[1].folder, isNotNull); + expect(tester.bookmarkListState.bookmarks[2].folder, isNotNull); + + // Emit event + await tester.emitEvent( + BookmarkFolderDeletedEvent( + type: EventTypes.bookmarkFolderDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmarkFolder: createDefaultBookmarkFolderResponse(id: folderId), + ), + ); + + // Verify bookmarks with matching folder ID have folder removed + expect(tester.bookmarkListState.bookmarks, hasLength(3)); + expect(tester.bookmarkListState.bookmarks[0].folder, isNull); + expect(tester.bookmarkListState.bookmarks[1].folder, isNull); + // Bookmark with different folder should still have folder + expect(tester.bookmarkListState.bookmarks[2].folder, isNotNull); + }, + ); + }); + // ============================================================ // FEATURE: Local Filtering // ============================================================ diff --git a/packages/stream_feeds/test/state/comment_list_test.dart b/packages/stream_feeds/test/state/comment_list_test.dart index 510eecc9..9932ff7f 100644 --- a/packages/stream_feeds/test/state/comment_list_test.dart +++ b/packages/stream_feeds/test/state/comment_list_test.dart @@ -1,8 +1,389 @@ +import 'package:collection/collection.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:stream_feeds_test/stream_feeds_test.dart'; void main() { + const query = CommentsQuery(); + + // ============================================================ + // FEATURE: Query Operations + // ============================================================ + + group('Comment List - Query Operations', () { + commentListTest( + 'get - should query initial comments via API', + build: (client) => client.commentList(query), + body: (tester) async { + final result = await tester.get(); + + expect(result, isA>>()); + final comments = result.getOrThrow(); + + expect(comments, isA>()); + expect(comments, hasLength(3)); + }, + ); + + commentListTest( + 'queryMoreComments - should load more comments via API', + build: (client) => client.commentList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + next: 'next-cursor', + comments: [ + createDefaultCommentResponse(id: 'comment-1', objectId: 'obj-1'), + createDefaultCommentResponse(id: 'comment-2', objectId: 'obj-1'), + createDefaultCommentResponse(id: 'comment-3', objectId: 'obj-1'), + ], + ), + ), + body: (tester) async { + // Initial state - has comments + expect(tester.commentListState.comments, hasLength(3)); + expect(tester.commentListState.canLoadMore, isTrue); + + final nextPageQuery = tester.commentList.query.copyWith( + next: tester.commentListState.pagination?.next, + ); + + tester.mockApi( + (api) => api.queryComments( + queryCommentsRequest: nextPageQuery.toRequest(), + ), + result: createDefaultQueryCommentsResponse( + comments: [ + createDefaultCommentResponse(id: 'comment-4', objectId: 'obj-1'), + ], + ), + ); + + // Query more comments + final result = await tester.commentList.queryMoreComments(); + + expect(result.isSuccess, isTrue); + final comments = result.getOrNull(); + expect(comments, isNotNull); + expect(comments, hasLength(1)); + + // Verify state was updated with merged comments + expect(tester.commentListState.comments, hasLength(4)); + expect(tester.commentListState.canLoadMore, isFalse); + + tester.verifyApi( + (api) => api.queryComments( + queryCommentsRequest: nextPageQuery.toRequest(), + ), + ); + }, + ); + + commentListTest( + 'queryMoreComments - should return empty list when no more comments', + build: (client) => client.commentList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultCommentResponse(id: 'comment-1', objectId: 'obj-1'), + createDefaultCommentResponse(id: 'comment-2', objectId: 'obj-1'), + createDefaultCommentResponse(id: 'comment-3', objectId: 'obj-1'), + ], + ), + ), + body: (tester) async { + // Initial state - has comments, no next cursor + expect(tester.commentListState.comments, hasLength(3)); + expect(tester.commentListState.canLoadMore, isFalse); + + // Query more comments when no next cursor + final result = await tester.commentList.queryMoreComments(); + + expect(result.isSuccess, isTrue); + final comments = result.getOrNull(); + expect(comments, isNotNull); + expect(comments, isEmpty); + }, + ); + }); + + // ============================================================ + // FEATURE: Event Handling + // ============================================================ + + group('Comment List - Event Handling', () { + const currentUser = User(id: 'test_user'); + + final initialComments = [ + createDefaultCommentResponse(id: 'comment-1', objectId: 'obj-1'), + createDefaultCommentResponse(id: 'comment-2', objectId: 'obj-1'), + createDefaultCommentResponse(id: 'comment-3', objectId: 'obj-1'), + ]; + + commentListTest( + 'CommentAddedEvent - should add new comment', + build: (client) => client.commentList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(comments: initialComments), + ), + body: (tester) async { + expect(tester.commentListState.comments, hasLength(3)); + + const newCommentId = 'comment-4'; + final newComment = createDefaultCommentResponse( + id: newCommentId, + objectId: 'obj-1', + ); + + await tester.emitEvent( + CommentAddedEvent( + type: EventTypes.commentAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:source', + activity: createDefaultActivityResponse(id: 'obj-1'), + comment: newComment, + ), + ); + + final comments = tester.commentListState.comments; + expect(comments, hasLength(4)); + + final addedComment = comments.firstWhereOrNull( + (c) => c.id == newCommentId, + ); + + expect(addedComment, isNotNull); + expect(addedComment!.id, equals(newCommentId)); + }, + ); + + commentListTest( + 'CommentUpdatedEvent - should update existing comment', + build: (client) => client.commentList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(comments: initialComments), + ), + body: (tester) async { + expect(tester.commentListState.comments, hasLength(3)); + + const updatedText = 'Updated comment text'; + await tester.emitEvent( + CommentUpdatedEvent( + type: EventTypes.commentUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:source', + comment: createDefaultCommentResponse( + id: 'comment-1', + objectId: 'obj-1', + ).copyWith(text: updatedText), + ), + ); + + final comments = tester.commentListState.comments; + expect(comments, hasLength(3)); + + final updatedComment = comments.firstWhereOrNull( + (c) => c.id == 'comment-1', + ); + + expect(updatedComment, isNotNull); + expect(updatedComment!.text, equals(updatedText)); + }, + ); + + commentListTest( + 'CommentDeletedEvent - should remove comment', + build: (client) => client.commentList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(comments: initialComments), + ), + body: (tester) async { + expect(tester.commentListState.comments, hasLength(3)); + + await tester.emitEvent( + CommentDeletedEvent( + type: EventTypes.commentDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:source', + comment: createDefaultCommentResponse( + id: 'comment-1', + objectId: 'obj-1', + ), + ), + ); + + final comments = tester.commentListState.comments; + expect(comments, hasLength(2)); + + final deletedComment = comments.firstWhereOrNull( + (c) => c.id == 'comment-1', + ); + + expect(deletedComment, isNull); + }, + ); + + commentListTest( + 'CommentReactionAddedEvent - should add reaction to comment', + user: currentUser, + build: (client) => client.commentList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith( + comments: [ + createDefaultCommentResponse( + id: 'comment-1', + objectId: 'obj-1', + ), + ], + ), + ), + body: (tester) async { + // Initial state - no reactions + var comment = tester.commentListState.comments.first; + expect(comment.ownReactions, isEmpty); + + await tester.emitEvent( + CommentReactionAddedEvent( + type: EventTypes.commentReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:source', + activity: createDefaultActivityResponse(id: 'obj-1'), + comment: createDefaultCommentResponse( + id: 'comment-1', + objectId: 'obj-1', + ), + reaction: createDefaultReactionResponse( + reactionType: 'love', + userId: currentUser.id, + commentId: 'comment-1', + ), + ), + ); + + // Verify reaction was added + comment = tester.commentListState.comments.first; + expect(comment.ownReactions, hasLength(1)); + expect(comment.ownReactions.first.type, 'love'); + }, + ); + + commentListTest( + 'CommentReactionUpdatedEvent - should replace user reaction', + user: currentUser, + build: (client) => client.commentList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith( + comments: [ + createDefaultCommentResponse( + id: 'comment-1', + objectId: 'obj-1', + ownReactions: [ + createDefaultReactionResponse( + reactionType: 'wow', + userId: currentUser.id, + commentId: 'comment-1', + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has 'wow' reaction + var comment = tester.commentListState.comments.first; + expect(comment.ownReactions, hasLength(1)); + expect(comment.ownReactions.first.type, 'wow'); + + // Emit CommentReactionUpdatedEvent - replaces 'wow' with 'fire' + await tester.emitEvent( + CommentReactionUpdatedEvent( + type: EventTypes.commentReactionUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:source', + activity: createDefaultActivityResponse(id: 'obj-1'), + comment: createDefaultCommentResponse( + id: 'comment-1', + objectId: 'obj-1', + latestReactions: [ + createDefaultReactionResponse( + reactionType: 'fire', + userId: currentUser.id, + commentId: 'comment-1', + ), + ], + ), + reaction: createDefaultReactionResponse( + reactionType: 'fire', + userId: currentUser.id, + commentId: 'comment-1', + ), + ), + ); + + // Verify 'wow' was replaced with 'fire' + comment = tester.commentListState.comments.first; + expect(comment.ownReactions, hasLength(1)); + expect(comment.ownReactions.first.type, 'fire'); + }, + ); + + commentListTest( + 'CommentReactionDeletedEvent - should remove reaction from comment', + user: currentUser, + build: (client) => client.commentList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith( + comments: [ + createDefaultCommentResponse( + id: 'comment-1', + objectId: 'obj-1', + ownReactions: [ + createDefaultReactionResponse( + reactionType: 'love', + userId: currentUser.id, + commentId: 'comment-1', + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has 'love' reaction + var comment = tester.commentListState.comments.first; + expect(comment.ownReactions, hasLength(1)); + expect(comment.ownReactions.first.type, 'love'); + + // Emit CommentReactionDeletedEvent + await tester.emitEvent( + CommentReactionDeletedEvent( + type: EventTypes.commentReactionDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:source', + comment: createDefaultCommentResponse( + id: 'comment-1', + objectId: 'obj-1', + ), + reaction: createDefaultReactionResponse( + reactionType: 'love', + userId: currentUser.id, + commentId: 'comment-1', + ), + ), + ); + + // Verify reaction was removed + comment = tester.commentListState.comments.first; + expect(comment.ownReactions, isEmpty); + }, + ); + }); + // ============================================================ // FEATURE: Local Filtering // ============================================================ diff --git a/packages/stream_feeds/test/state/comment_reaction_list_test.dart b/packages/stream_feeds/test/state/comment_reaction_list_test.dart new file mode 100644 index 00000000..4bed6677 --- /dev/null +++ b/packages/stream_feeds/test/state/comment_reaction_list_test.dart @@ -0,0 +1,429 @@ +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:stream_feeds_test/stream_feeds_test.dart'; + +void main() { + const fid = 'user:john'; + const activityId = 'activity-1'; + const reactionType = 'love'; + const query = CommentReactionsQuery(commentId: 'comment-1'); + + // ============================================================ + // FEATURE: Query Operations + // ============================================================ + + group('Comment Reaction List - Query Operations', () { + commentReactionListTest( + 'get - should query initial comment reactions via API', + build: (client) => client.commentReactionList(query), + body: (tester) async { + final result = await tester.get(); + + expect(result, isA>>()); + final reactions = result.getOrThrow(); + + expect(reactions, isA>()); + expect(reactions, hasLength(3)); + }, + ); + + commentReactionListTest( + 'queryMoreReactions - should load more reactions via API', + build: (client) => client.commentReactionList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + next: 'next-cursor', + reactions: [ + createDefaultReactionResponse( + commentId: query.commentId, + reactionType: reactionType, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reaction + expect(tester.commentReactionListState.reactions, hasLength(1)); + expect(tester.commentReactionListState.canLoadMore, isTrue); + + final nextPageQuery = tester.commentReactionList.query.copyWith( + next: tester.commentReactionListState.pagination?.next, + ); + + tester.mockApi( + (api) => api.queryCommentReactions( + id: query.commentId, + queryCommentReactionsRequest: nextPageQuery.toRequest(), + ), + result: createDefaultQueryCommentReactionsResponse( + reactions: [ + createDefaultReactionResponse( + commentId: query.commentId, + reactionType: reactionType, + userId: 'user-2', + ), + ], + ), + ); + + // Query more reactions + final result = await tester.commentReactionList.queryMoreReactions(); + + expect(result.isSuccess, isTrue); + final reactions = result.getOrNull(); + expect(reactions, isNotNull); + expect(reactions, hasLength(1)); + + // Verify state was updated with merged reactions + expect(tester.commentReactionListState.reactions, hasLength(2)); + expect(tester.commentReactionListState.canLoadMore, isFalse); + + tester.verifyApi( + (api) => api.queryCommentReactions( + id: query.commentId, + queryCommentReactionsRequest: nextPageQuery.toRequest(), + ), + ); + }, + ); + + commentReactionListTest( + 'queryMoreReactions - should return empty list when no more reactions', + build: (client) => client.commentReactionList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + reactions: [ + createDefaultReactionResponse( + commentId: query.commentId, + reactionType: reactionType, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reaction but no pagination + expect(tester.commentReactionListState.reactions, hasLength(1)); + expect(tester.commentReactionListState.canLoadMore, isFalse); + // Query more reactions (should return empty immediately) + final result = await tester.commentReactionList.queryMoreReactions(); + + expect(result.isSuccess, isTrue); + final reactions = result.getOrNull(); + expect(reactions, isEmpty); + + // State should remain unchanged + expect(tester.commentReactionListState.reactions, hasLength(1)); + expect(tester.commentReactionListState.canLoadMore, isFalse); + }, + ); + }); + + // ============================================================ + // FEATURE: Comment Deletion + // ============================================================ + + group('Comment Reaction List - Comment Deletion', () { + commentReactionListTest( + 'CommentDeletedEvent - should clear all reactions when comment is deleted', + build: (client) => client.commentReactionList(query), + setUp: (tester) => tester.get(), + body: (tester) async { + // Verify reactions are loaded + expect(tester.commentReactionListState.reactions, hasLength(3)); + + // Emit event + await tester.emitEvent( + CommentDeletedEvent( + type: EventTypes.commentDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: fid, + comment: createDefaultCommentResponse( + id: query.commentId, + objectId: activityId, + ), + ), + ); + + // Verify state was cleared + expect(tester.commentReactionListState.reactions, isEmpty); + expect(tester.commentReactionListState.pagination, isNull); + }, + ); + + commentReactionListTest( + 'CommentDeletedEvent - should not clear reactions for different comment', + build: (client) => client.commentReactionList(query), + setUp: (tester) => tester.get(), + body: (tester) async { + // Verify reactions are loaded + expect(tester.commentReactionListState.reactions, hasLength(3)); + + // Emit event for different comment + await tester.emitEvent( + CommentDeletedEvent( + type: EventTypes.commentDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: fid, + comment: createDefaultCommentResponse( + id: 'different-comment-id', + objectId: activityId, + ), + ), + ); + + // Verify state was not cleared + expect(tester.commentReactionListState.reactions, hasLength(3)); + }, + ); + }); + + // ============================================================ + // FEATURE: Event Handling + // ============================================================ + + group('Comment Reaction List - Event Handling', () { + commentReactionListTest( + 'CommentReactionAddedEvent - should add reaction', + build: (client) => client.commentReactionList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + reactions: [ + createDefaultReactionResponse( + commentId: query.commentId, + reactionType: reactionType, + ), + createDefaultReactionResponse( + commentId: query.commentId, + reactionType: reactionType, + userId: 'user-2', + ), + createDefaultReactionResponse( + commentId: query.commentId, + reactionType: reactionType, + userId: 'user-3', + ), + ], + ), + ), + body: (tester) async { + // Verify initial state + expect(tester.commentReactionListState.reactions, hasLength(3)); + + // Emit event + await tester.emitEvent( + CommentReactionAddedEvent( + type: EventTypes.commentReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: fid, + activity: createDefaultActivityResponse(id: activityId), + comment: createDefaultCommentResponse( + id: query.commentId, + objectId: activityId, + ), + reaction: createDefaultReactionResponse( + commentId: query.commentId, + reactionType: reactionType, + userId: 'user-4', + ), + ), + ); + + // Verify reaction was added + expect(tester.commentReactionListState.reactions, hasLength(4)); + expect( + tester.commentReactionListState.reactions.first.user.id, + 'user-4', + ); + }, + ); + + commentReactionListTest( + 'CommentReactionAddedEvent - should not add reaction for different comment', + build: (client) => client.commentReactionList(query), + setUp: (tester) => tester.get(), + body: (tester) async { + // Verify initial state + expect(tester.commentReactionListState.reactions, hasLength(3)); + + // Emit event for different comment + await tester.emitEvent( + CommentReactionAddedEvent( + type: EventTypes.commentReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: fid, + activity: createDefaultActivityResponse(id: activityId), + comment: createDefaultCommentResponse( + id: 'different-comment-id', + objectId: activityId, + ), + reaction: createDefaultReactionResponse( + commentId: 'different-comment-id', + reactionType: reactionType, + userId: 'user-4', + ), + ), + ); + + // Verify reaction was not added + expect(tester.commentReactionListState.reactions, hasLength(3)); + }, + ); + + commentReactionListTest( + 'CommentReactionUpdatedEvent - should replace user reaction', + build: (client) => client.commentReactionList(query), + setUp: (tester) => tester.get(), + body: (tester) async { + // Initial state - has 'like' reaction + final existingReaction = + tester.commentReactionListState.reactions.first; + expect(existingReaction.type, 'like'); + + // Emit event to replace 'like' with 'fire' + await tester.emitEvent( + CommentReactionUpdatedEvent( + type: EventTypes.commentReactionUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: fid, + activity: createDefaultActivityResponse(id: activityId), + comment: createDefaultCommentResponse( + id: query.commentId, + objectId: activityId, + latestReactions: [ + createDefaultReactionResponse( + commentId: query.commentId, + reactionType: 'fire', + userId: existingReaction.user.id, + ), + ], + ), + reaction: createDefaultReactionResponse( + commentId: query.commentId, + reactionType: 'fire', + userId: existingReaction.user.id, + ), + ), + ); + + // Verify 'like' was replaced with 'fire' + final updatedReaction = tester.commentReactionListState.reactions.first; + expect(updatedReaction.type, 'fire'); + }, + ); + + commentReactionListTest( + 'CommentReactionUpdatedEvent - should not update reaction for different comment', + build: (client) => client.commentReactionList(query), + setUp: (tester) => tester.get(), + body: (tester) async { + final existingReaction = + tester.commentReactionListState.reactions.first; + + // Emit event for different comment + await tester.emitEvent( + CommentReactionUpdatedEvent( + type: EventTypes.commentReactionUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: fid, + activity: createDefaultActivityResponse(id: activityId), + comment: createDefaultCommentResponse( + id: 'different-comment-id', + objectId: activityId, + ), + reaction: createDefaultReactionResponse( + commentId: 'different-comment-id', + reactionType: reactionType, + userId: existingReaction.user.id, + ).copyWith( + custom: const {'updated': true}, + ), + ), + ); + + // Verify reaction was not updated + final updatedReaction = tester.commentReactionListState.reactions.first; + expect(updatedReaction.custom, isNull); + }, + ); + + commentReactionListTest( + 'CommentReactionDeletedEvent - should remove reaction', + build: (client) => client.commentReactionList(query), + setUp: (tester) => tester.get(), + body: (tester) async { + // Verify initial state + expect(tester.commentReactionListState.reactions, hasLength(3)); + + final reactionToDelete = + tester.commentReactionListState.reactions.first; + + // Emit event + await tester.emitEvent( + CommentReactionDeletedEvent( + type: EventTypes.commentReactionDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: fid, + comment: createDefaultCommentResponse( + id: query.commentId, + objectId: activityId, + ), + reaction: createDefaultReactionResponse( + commentId: query.commentId, + reactionType: reactionToDelete.type, + userId: reactionToDelete.user.id, + ), + ), + ); + + // Verify reaction was removed + expect(tester.commentReactionListState.reactions, hasLength(2)); + expect( + tester.commentReactionListState.reactions + .any((r) => r.id == reactionToDelete.id), + isFalse, + ); + }, + ); + + commentReactionListTest( + 'CommentReactionDeletedEvent - should not remove reaction for different comment', + build: (client) => client.commentReactionList(query), + setUp: (tester) => tester.get(), + body: (tester) async { + // Verify initial state + expect(tester.commentReactionListState.reactions, hasLength(3)); + + final reactionToDelete = + tester.commentReactionListState.reactions.first; + + // Emit event for different comment + await tester.emitEvent( + CommentReactionDeletedEvent( + type: EventTypes.commentReactionDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: fid, + comment: createDefaultCommentResponse( + id: 'different-comment-id', + objectId: activityId, + ), + reaction: createDefaultReactionResponse( + commentId: 'different-comment-id', + reactionType: reactionToDelete.type, + userId: reactionToDelete.user.id, + ), + ), + ); + + // Verify reaction was not removed + expect(tester.commentReactionListState.reactions, hasLength(3)); + }, + ); + }); +} diff --git a/packages/stream_feeds/test/state/comment_reply_list_test.dart b/packages/stream_feeds/test/state/comment_reply_list_test.dart index 34595ddb..54dd49d2 100644 --- a/packages/stream_feeds/test/state/comment_reply_list_test.dart +++ b/packages/stream_feeds/test/state/comment_reply_list_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_redundant_argument_values + import 'package:stream_feeds/stream_feeds.dart'; import 'package:stream_feeds_test/stream_feeds_test.dart'; @@ -55,6 +57,7 @@ void main() { body: (tester) async { // Initial state - has reply expect(tester.commentReplyListState.replies, hasLength(1)); + expect(tester.commentReplyListState.canLoadMore, isTrue); final nextPageQuery = tester.commentReplyList.query.copyWith( next: tester.commentReplyListState.pagination?.next, @@ -66,12 +69,10 @@ void main() { depth: nextPageQuery.depth, limit: nextPageQuery.limit, next: nextPageQuery.next, - prev: nextPageQuery.previous, repliesLimit: nextPageQuery.repliesLimit, sort: nextPageQuery.sort, ), result: createDefaultCommentRepliesResponse( - prev: 'prev-cursor', comments: [ createDefaultThreadedCommentResponse( id: 'reply-test-2', @@ -94,16 +95,7 @@ void main() { // Verify state was updated with merged replies expect(tester.commentReplyListState.replies, hasLength(2)); - expect(tester.commentReplyListState.pagination?.next, isNull); - expect( - tester.commentReplyListState.pagination?.previous, - 'prev-cursor', - ); - }, - verify: (tester) { - final nextPageQuery = tester.commentReplyList.query.copyWith( - next: tester.commentReplyListState.pagination?.next, - ); + expect(tester.commentReplyListState.canLoadMore, isFalse); tester.verifyApi( (api) => api.getCommentReplies( @@ -111,7 +103,6 @@ void main() { depth: nextPageQuery.depth, limit: nextPageQuery.limit, next: nextPageQuery.next, - prev: nextPageQuery.previous, repliesLimit: nextPageQuery.repliesLimit, sort: nextPageQuery.sort, ), @@ -138,9 +129,7 @@ void main() { body: (tester) async { // Initial state - has reply but no pagination expect(tester.commentReplyListState.replies, hasLength(1)); - expect(tester.commentReplyListState.pagination?.next, isNull); - expect(tester.commentReplyListState.pagination?.previous, isNull); - + expect(tester.commentReplyListState.canLoadMore, isFalse); // Query more replies (should return empty immediately) final result = await tester.commentReplyList.queryMoreReplies(); @@ -150,8 +139,7 @@ void main() { // Verify state was not updated (no new replies, pagination remains null) expect(tester.commentReplyListState.replies, hasLength(1)); - expect(tester.commentReplyListState.pagination?.next, isNull); - expect(tester.commentReplyListState.pagination?.previous, isNull); + expect(tester.commentReplyListState.canLoadMore, isFalse); }, ); }); @@ -291,6 +279,34 @@ void main() { }, ); + commentReplyListTest( + 'should clear all replies when parent comment is deleted', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get(), + body: (tester) async { + // Verify replies are loaded + expect(tester.commentReplyListState.replies, hasLength(3)); + + // Emit event for parent comment deletion + await tester.emitEvent( + CommentDeletedEvent( + type: EventTypes.commentDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + comment: createDefaultCommentResponse( + id: query.commentId, + objectId: 'activity-1', + ), + ), + ); + + // Verify state was cleared + expect(tester.commentReplyListState.replies, isEmpty); + expect(tester.commentReplyListState.pagination, isNull); + }, + ); + commentReplyListTest( 'should not add reply if parentId does not match query commentId', build: (client) => client.commentReplyList(query), @@ -925,13 +941,10 @@ void main() { userId: userId, parentId: parentCommentId, ), - reaction: FeedsReactionResponse( - activityId: 'activity-1', + reaction: createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, commentId: replyId, - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ), ); @@ -956,13 +969,10 @@ void main() { text: 'Test reply', userId: userId, ownReactions: [ - FeedsReactionResponse( - activityId: 'activity-1', + createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, commentId: replyId, - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ], ), @@ -988,13 +998,10 @@ void main() { userId: userId, parentId: parentCommentId, ), - reaction: FeedsReactionResponse( - activityId: 'activity-1', + reaction: createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, commentId: replyId, - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ), ); @@ -1006,7 +1013,7 @@ void main() { ); commentReplyListTest( - 'should handle CommentReactionUpdatedEvent and update reaction', + 'CommentReactionUpdatedEvent - should replace user reaction', build: (client) => client.commentReplyList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( @@ -1018,13 +1025,10 @@ void main() { text: 'Test reply', userId: userId, ownReactions: [ - FeedsReactionResponse( - activityId: 'activity-1', + createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, commentId: replyId, - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ], ), @@ -1051,14 +1055,18 @@ void main() { objectType: 'activity', userId: userId, parentId: parentCommentId, + latestReactions: [ + createDefaultReactionResponse( + reactionType: 'fire', + userId: userId, + commentId: replyId, + ), + ], ), - reaction: FeedsReactionResponse( - activityId: 'activity-1', + reaction: createDefaultReactionResponse( + reactionType: 'fire', + userId: userId, commentId: replyId, - type: 'fire', - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ), ); @@ -1116,13 +1124,10 @@ void main() { userId: userId, parentId: replyId, ), - reaction: FeedsReactionResponse( - activityId: 'activity-1', + reaction: createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, commentId: 'nested-reply-1', - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ), ); @@ -1194,13 +1199,10 @@ void main() { userId: userId, parentId: 'nested-reply-1', ), - reaction: FeedsReactionResponse( - activityId: 'activity-1', + reaction: createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, commentId: 'deep-nested-reply-1', - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ), ); @@ -1247,13 +1249,10 @@ void main() { text: 'Deep nested reply', userId: userId, ownReactions: [ - FeedsReactionResponse( - activityId: 'activity-1', + createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, commentId: 'deep-nested-reply-1', - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ], ), @@ -1285,13 +1284,10 @@ void main() { userId: userId, parentId: 'nested-reply-1', ), - reaction: FeedsReactionResponse( - activityId: 'activity-1', + reaction: createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, commentId: 'deep-nested-reply-1', - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ), ); @@ -1339,13 +1335,10 @@ void main() { objectType: 'activity', userId: userId, ), - reaction: FeedsReactionResponse( - activityId: 'activity-1', + reaction: createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, commentId: replyId, - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ), ); @@ -1369,13 +1362,10 @@ void main() { text: 'Test reply', userId: userId, ownReactions: [ - FeedsReactionResponse( - activityId: 'activity-1', + createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, commentId: replyId, - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ], ), @@ -1402,13 +1392,10 @@ void main() { objectType: 'activity', userId: userId, ), - reaction: FeedsReactionResponse( - activityId: 'activity-1', + reaction: createDefaultReactionResponse( + reactionType: 'fire', + userId: userId, commentId: replyId, - type: 'fire', - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ), ); @@ -1433,13 +1420,10 @@ void main() { text: 'Test reply', userId: userId, ownReactions: [ - FeedsReactionResponse( - activityId: 'activity-1', + createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, commentId: replyId, - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ], ), @@ -1464,13 +1448,10 @@ void main() { objectType: 'activity', userId: userId, ), - reaction: FeedsReactionResponse( - activityId: 'activity-1', + reaction: createDefaultReactionResponse( + reactionType: reactionType, + userId: userId, commentId: replyId, - type: reactionType, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), ), ), ); diff --git a/packages/stream_feeds/test/state/feed_list_test.dart b/packages/stream_feeds/test/state/feed_list_test.dart index 7bdbd112..a4ccacc0 100644 --- a/packages/stream_feeds/test/state/feed_list_test.dart +++ b/packages/stream_feeds/test/state/feed_list_test.dart @@ -3,11 +3,211 @@ import 'package:stream_feeds/stream_feeds.dart'; import 'package:stream_feeds_test/stream_feeds_test.dart'; void main() { + // ============================================================ + // FEATURE: Query Operations + // ============================================================ + + group('Feed List - Query Operations', () { + feedListTest( + 'get - should query initial feeds via API', + build: (client) => client.feedList(const FeedsQuery()), + body: (tester) async { + final result = await tester.get(); + + expect(result, isA>>()); + final feeds = result.getOrThrow(); + + expect(feeds, isA>()); + expect(feeds, hasLength(3)); + }, + ); + + feedListTest( + 'queryMoreFeeds - should load more feeds via API', + build: (client) => client.feedList(const FeedsQuery()), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + next: 'next-cursor', + feeds: [createDefaultFeedResponse(id: 'feed-1')], + ), + ), + body: (tester) async { + // Initial state - has feed + expect(tester.feedListState.feeds, hasLength(1)); + expect(tester.feedListState.canLoadMore, isTrue); + + final nextPageQuery = tester.feedList.query.copyWith( + next: tester.feedListState.pagination?.next, + ); + + tester.mockApi( + (api) => api.queryFeeds( + queryFeedsRequest: nextPageQuery.toRequest(), + ), + result: createDefaultQueryFeedsResponse( + feeds: [createDefaultFeedResponse(id: 'feed-2')], + ), + ); + + // Query more feeds + final result = await tester.feedList.queryMoreFeeds(); + + expect(result.isSuccess, isTrue); + final feeds = result.getOrNull(); + expect(feeds, isNotNull); + expect(feeds, hasLength(1)); + + // Verify state was updated with merged feeds + expect(tester.feedListState.feeds, hasLength(2)); + expect(tester.feedListState.canLoadMore, isFalse); + + tester.verifyApi( + (api) => api.queryFeeds( + queryFeedsRequest: nextPageQuery.toRequest(), + ), + ); + }, + ); + + feedListTest( + 'queryMoreFeeds - should return empty list when no more feeds', + build: (client) => client.feedList(const FeedsQuery()), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + feeds: [createDefaultFeedResponse(id: 'feed-1')], + ), + ), + body: (tester) async { + // Initial state - has feed but no pagination + expect(tester.feedListState.feeds, hasLength(1)); + expect(tester.feedListState.canLoadMore, isFalse); + // Query more feeds (should return empty immediately) + final result = await tester.feedList.queryMoreFeeds(); + + expect(result.isSuccess, isTrue); + final feeds = result.getOrNull(); + expect(feeds, isEmpty); + + // State should remain unchanged + expect(tester.feedListState.feeds, hasLength(1)); + expect(tester.feedListState.canLoadMore, isFalse); + }, + ); + }); + + // ============================================================ + // FEATURE: Event Handling + // ============================================================ + + group('Feed List - Event Handling', () { + feedListTest( + 'FeedCreatedEvent - should add feed', + build: (client) => client.feedList(const FeedsQuery()), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith(feeds: const []), + ), + body: (tester) async { + // Initial state - no feeds + expect(tester.feedListState.feeds, isEmpty); + + const feedId = FeedId(group: 'user', id: 'john'); + + // Emit event + await tester.emitEvent( + FeedCreatedEvent( + type: EventTypes.feedCreated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + feed: + createDefaultFeedResponse(id: feedId.id, groupId: feedId.group), + members: const [], + user: UserResponseCommonFields( + id: 'user-id', + banned: false, + blockedUserIds: const [], + createdAt: DateTime(2021), + custom: const {}, + language: 'en', + online: false, + role: 'user', + teams: const [], + updatedAt: DateTime(2021, 2), + ), + ), + ); + + // Verify feed was added + expect(tester.feedListState.feeds, hasLength(1)); + expect(tester.feedListState.feeds.first.fid, feedId); + }, + ); + + feedListTest( + 'FeedUpdatedEvent - should update feed', + build: (client) => client.feedList(const FeedsQuery()), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + feeds: [createDefaultFeedResponse(id: 'feed-1')], + ), + ), + body: (tester) async { + final existingFeed = tester.feedListState.feeds.first; + + // Emit event + await tester.emitEvent( + FeedUpdatedEvent( + type: EventTypes.feedUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: existingFeed.fid.rawValue, + feed: createDefaultFeedResponse( + id: 'feed-1', + name: 'Updated Feed Name', + ), + ), + ); + + // Verify feed was updated + final updatedFeed = tester.feedListState.feeds.first; + expect(updatedFeed.name, 'Updated Feed Name'); + }, + ); + + feedListTest( + 'FeedDeletedEvent - should remove feed', + build: (client) => client.feedList(const FeedsQuery()), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + feeds: [createDefaultFeedResponse(id: 'feed-1')], + ), + ), + body: (tester) async { + // Verify initial state + expect(tester.feedListState.feeds, hasLength(1)); + final feedToDelete = tester.feedListState.feeds.first; + + // Emit event + await tester.emitEvent( + FeedDeletedEvent( + type: EventTypes.feedDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedToDelete.fid.rawValue, + ), + ); + + // Verify feed was removed + expect(tester.feedListState.feeds, isEmpty); + }, + ); + }); + // ============================================================ // FEATURE: Local Filtering // ============================================================ - group('FeedListEventHandler - Local filtering', () { + group('Feed List - Local filtering', () { final initialFeeds = [ createDefaultFeedResponse(id: 'feed-1'), createDefaultFeedResponse(id: 'feed-2'), diff --git a/packages/stream_feeds/test/state/feed_test.dart b/packages/stream_feeds/test/state/feed_test.dart index 0edec576..d9f31d48 100644 --- a/packages/stream_feeds/test/state/feed_test.dart +++ b/packages/stream_feeds/test/state/feed_test.dart @@ -1,18 +1,18 @@ // ignore_for_file: avoid_redundant_argument_values -import 'package:stream_feeds/src/utils/filter.dart'; import 'package:stream_feeds/stream_feeds.dart'; - import 'package:stream_feeds_test/stream_feeds_test.dart'; void main() { - // ============================================================ - // FEATURE: Feed Operations - // ============================================================ + // ========================================================================== + // Feed - Feed Operations + // ========================================================================== + + group('Feed - Feed Operations', () { + const feedId = FeedId(group: 'user', id: 'john'); - group('Get a Feed', () { feedTest( - 'get feed', + 'getOrCreate() - should fetch or create feed', build: (client) => client.feed(group: 'group', id: 'id'), body: (tester) async { final result = await tester.getOrCreate(); @@ -25,1793 +25,3865 @@ void main() { expect(feedData.groupId, 'group'); }, ); - }); - - group('Query follow suggestions', () { - const feedId = FeedId(group: 'user', id: 'john'); feedTest( - 'should return list of FeedSuggestionData', - build: (client) => client.feedFromId(feedId), - setUp: (tester) => tester.mockApi( - (api) => api.getFollowSuggestions( - feedGroupId: feedId.group, - limit: any(named: 'limit'), - ), - result: createDefaultGetFollowSuggestionsResponse( - suggestions: [ - createDefaultFeedSuggestionResponse( - id: 'suggestion-1', - reason: 'Based on your interests', - recommendationScore: 0.95, - algorithmScores: {'relevance': 0.9, 'popularity': 0.85}, - ), - createDefaultFeedSuggestionResponse( - id: 'suggestion-2', - reason: 'Popular in your network', - recommendationScore: 0.88, - ), - ], - ), - ), + 'stopWatching() - should stop watching feed', + build: (client) => client.feed(group: 'user', id: 'john'), + setUp: (tester) => tester.getOrCreate(), body: (tester) async { - final result = await tester.feed.queryFollowSuggestions(limit: 10); - - expect(result, isA>>()); - - final suggestions = result.getOrThrow(); - expect(suggestions.length, 2); + tester.mockApi( + (api) => api.stopWatchingFeed(feedGroupId: 'user', feedId: 'john'), + result: const DurationResponse(duration: '0ms'), + ); - final firstSuggestion = suggestions[0]; - expect(firstSuggestion.feed.id, 'suggestion-1'); - expect(firstSuggestion.reason, 'Based on your interests'); - expect(firstSuggestion.recommendationScore, 0.95); - expect(firstSuggestion.algorithmScores, isNotNull); - expect(firstSuggestion.algorithmScores!['relevance'], 0.9); - expect(firstSuggestion.algorithmScores!['popularity'], 0.85); + final result = await tester.feed.stopWatching(); - final secondSuggestion = suggestions[1]; - expect(secondSuggestion.feed.id, 'suggestion-2'); - expect(secondSuggestion.reason, 'Popular in your network'); - expect(secondSuggestion.recommendationScore, 0.88); + expect(result.isSuccess, isTrue); }, verify: (tester) => tester.verifyApi( - (api) => api.getFollowSuggestions( - feedGroupId: feedId.group, - limit: any(named: 'limit'), - ), + (api) => api.stopWatchingFeed(feedGroupId: 'user', feedId: 'john'), ), ); - }); - - // ============================================================ - // FEATURE: Activity Feedback - // ============================================================ - - group('Activity feedback', () { - const activityId = 'activity-1'; - const feedId = FeedId(group: 'user', id: 'john'); feedTest( - 'submits feedback via API', - build: (client) => client.feedFromId(feedId), - setUp: (tester) { - const activityFeedbackRequest = ActivityFeedbackRequest(hide: true); + 'updateFeed() - should update feed data', + build: (client) => client.feed(group: 'user', id: 'john'), + setUp: (tester) => tester.getOrCreate(), + body: (tester) async { tester.mockApi( - (api) => api.activityFeedback( - activityId: activityId, - activityFeedbackRequest: activityFeedbackRequest, + (api) => api.updateFeed( + feedGroupId: 'user', + feedId: 'john', + updateFeedRequest: any(named: 'updateFeedRequest'), + ), + result: UpdateFeedResponse( + duration: '0ms', + feed: createDefaultFeedResponse( + id: 'john', + groupId: 'user', + name: 'updated-name', + ), ), - result: createDefaultActivityFeedbackResponse(activityId: activityId), ); - }, - body: (tester) async { - const activityFeedbackRequest = ActivityFeedbackRequest(hide: true); - final result = await tester.feed.activityFeedback( - activityId: activityId, - activityFeedbackRequest: activityFeedbackRequest, + + final result = await tester.feed.updateFeed( + request: const UpdateFeedRequest( + custom: {'updated': true}, + ), ); expect(result.isSuccess, isTrue); + final feedData = result.getOrThrow(); + expect(feedData.name, 'updated-name'); + expect(tester.feedState.feed?.name, 'updated-name'); }, - verify: (tester) { - const activityFeedbackRequest = ActivityFeedbackRequest(hide: true); - tester.verifyApi( - (api) => api.activityFeedback( - activityId: activityId, - activityFeedbackRequest: activityFeedbackRequest, + verify: (tester) => tester.verifyApi( + (api) => api.updateFeed( + feedGroupId: 'user', + feedId: 'john', + updateFeedRequest: any(named: 'updateFeedRequest'), + ), + ), + ); + + feedTest( + 'deleteFeed() - should delete feed', + build: (client) => client.feed(group: 'user', id: 'john'), + setUp: (tester) => tester.getOrCreate(), + body: (tester) async { + tester.mockApi( + (api) => api.deleteFeed( + feedGroupId: 'user', + feedId: 'john', + hardDelete: any(named: 'hardDelete'), + ), + result: const DeleteFeedResponse( + taskId: 'task-1', + duration: '0ms', ), ); + + final result = await tester.feed.deleteFeed(); + + expect(result.isSuccess, isTrue); + expect(tester.feedState.feed, isNull); }, + verify: (tester) => tester.verifyApi( + (api) => api.deleteFeed( + feedGroupId: 'user', + feedId: 'john', + hardDelete: any(named: 'hardDelete'), + ), + ), ); feedTest( - 'marks activity hidden on ActivityFeedbackEvent', - build: (client) => client.feedFromId(feedId), - setUp: (tester) => tester.getOrCreate( - modifyResponse: (response) => response.copyWith( - activities: [ - createDefaultActivityResponse(id: activityId, hidden: false), - ], - ), - ), + 'FeedUpdatedEvent - should update feed data', + build: (client) => client.feed(group: 'user', id: 'john'), + setUp: (tester) => tester.getOrCreate(), body: (tester) async { - expect(tester.feedState.activities, hasLength(1)); - expect(tester.feedState.activities.first.hidden, false); + expect(tester.feedState.feed, isNotNull); + expect(tester.feedState.feed!.name, 'name'); await tester.emitEvent( - ActivityFeedbackEvent( - type: EventTypes.activityFeedback, + FeedUpdatedEvent( + type: EventTypes.feedUpdated, createdAt: DateTime.timestamp(), custom: const {}, - activityFeedback: ActivityFeedbackEventPayload( - activityId: activityId, - action: ActivityFeedbackEventPayloadAction.hide, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: 'luke_skywalker'), - value: 'true', + fid: 'user:john', + feed: createDefaultFeedResponse( + id: 'john', + groupId: 'user', + name: 'updated-name', ), ), ); - expect(tester.feedState.activities, hasLength(1)); - expect(tester.feedState.activities.first.hidden, true); + expect(tester.feedState.feed, isNotNull); + expect(tester.feedState.feed!.name, 'updated-name'); }, ); feedTest( - 'marks activity unhidden on ActivityFeedbackEvent', + 'FeedDeletedEvent - should clear feed data', build: (client) => client.feedFromId(feedId), - setUp: (tester) => tester.getOrCreate( - modifyResponse: (response) => response.copyWith( - activities: [ - createDefaultActivityResponse(id: activityId, hidden: true), - ], - ), - ), + setUp: (tester) => tester.getOrCreate(), body: (tester) async { - expect(tester.feedState.activities, hasLength(1)); - expect(tester.feedState.activities.first.hidden, true); + expect(tester.feedState.feed, isNotNull); await tester.emitEvent( - ActivityFeedbackEvent( - type: EventTypes.activityFeedback, + FeedDeletedEvent( + type: EventTypes.feedDeleted, createdAt: DateTime.timestamp(), custom: const {}, - activityFeedback: ActivityFeedbackEventPayload( - activityId: activityId, - action: ActivityFeedbackEventPayloadAction.hide, - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: 'luke_skywalker'), - value: 'false', - ), + fid: feedId.rawValue, ), ); - expect(tester.feedState.activities, hasLength(1)); - expect(tester.feedState.activities.first.hidden, false); + expect(tester.feedState.feed, isNull); }, ); }); - group('Follow events', () { - const targetFeedId = FeedId(group: 'group', id: 'target'); - const sourceFeedId = FeedId(group: 'group', id: 'source'); + // ========================================================================== + // Feed - Activities + // ========================================================================== + + group('Feed - Activities', () { + const feedId = FeedId(group: 'user', id: 'john'); + + setUpAll(() { + registerFallbackValue( + const AddActivityRequest(type: 'post', feeds: []), + ); + }); feedTest( - 'follow target feed should update follower count', - build: (client) => client.feedFromId(targetFeedId), + 'addActivity() - should add activity to feed', + build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate(), body: (tester) async { - expect(tester.feedState.feed?.followerCount, 0); - expect(tester.feedState.feed?.followingCount, 0); - - await tester.emitEvent( - FollowCreatedEvent( - type: EventTypes.followCreated, - createdAt: DateTime.timestamp(), - custom: const {}, - fid: targetFeedId.toString(), - follow: FollowResponse( - createdAt: DateTime.timestamp(), - custom: const {}, - followerRole: 'followerRole', - pushPreference: FollowResponsePushPreference.none, - requestAcceptedAt: DateTime.timestamp(), - requestRejectedAt: DateTime.timestamp(), - sourceFeed: createDefaultFeedResponse( - id: sourceFeedId.id, - groupId: sourceFeedId.group, - followingCount: 1, - ), - status: FollowResponseStatus.accepted, - targetFeed: createDefaultFeedResponse( - id: targetFeedId.id, - groupId: targetFeedId.group, - followerCount: 1, - ), - updatedAt: DateTime.timestamp(), + tester.mockApi( + (api) => api.addActivity( + addActivityRequest: any(named: 'addActivityRequest'), + ), + result: AddActivityResponse( + duration: '0ms', + activity: createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], ), ), ); - expect(tester.feedState.feed?.followerCount, 1); - expect(tester.feedState.feed?.followingCount, 0); + final result = await tester.feed.addActivity( + request: const FeedAddActivityRequest(type: 'post'), + ); + + expect(result.isSuccess, isTrue); + final activity = result.getOrThrow(); + expect(activity.id, 'activity-1'); }, + verify: (tester) => tester.verifyApi( + (api) => api.addActivity( + addActivityRequest: any(named: 'addActivityRequest'), + ), + ), ); feedTest( - 'follow source feed should update following count', - build: (client) => client.feedFromId(sourceFeedId), - setUp: (tester) => tester.getOrCreate(), - body: (tester) async { - expect(tester.feedState.feed?.followerCount, 0); - expect(tester.feedState.feed?.followingCount, 0); - - await tester.emitEvent( - FollowCreatedEvent( - type: EventTypes.followCreated, - createdAt: DateTime.timestamp(), - custom: const {}, - fid: sourceFeedId.toString(), - follow: FollowResponse( - createdAt: DateTime.timestamp(), - custom: const {}, - followerRole: 'followerRole', - pushPreference: FollowResponsePushPreference.none, - requestAcceptedAt: DateTime.timestamp(), - requestRejectedAt: DateTime.timestamp(), - sourceFeed: createDefaultFeedResponse( - id: sourceFeedId.id, - groupId: sourceFeedId.group, - followingCount: 1, - ), - status: FollowResponseStatus.accepted, - targetFeed: createDefaultFeedResponse( - id: targetFeedId.id, - groupId: targetFeedId.group, - followerCount: 1, - ), - updatedAt: DateTime.timestamp(), + 'updateActivity() - should update activity', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], ), + ], + ), + ), + body: (tester) async { + tester.mockApi( + (api) => api.updateActivity( + id: 'activity-1', + updateActivityRequest: any(named: 'updateActivityRequest'), + ), + result: UpdateActivityResponse( + duration: '0ms', + activity: createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ).copyWith(custom: {'updated': true}), ), ); - expect(tester.feedState.feed?.followerCount, 0); - expect(tester.feedState.feed?.followingCount, 1); + final result = await tester.feed.updateActivity( + id: 'activity-1', + request: const UpdateActivityRequest(custom: {'updated': true}), + ); + + expect(result.isSuccess, isTrue); + final activity = result.getOrThrow(); + expect(activity.custom?['updated'], true); }, + verify: (tester) => tester.verifyApi( + (api) => api.updateActivity( + id: 'activity-1', + updateActivityRequest: any(named: 'updateActivityRequest'), + ), + ), ); feedTest( - 'follow deleted target feed should update follower count', - build: (client) => client.feedFromId(targetFeedId), + 'deleteActivity() - should delete activity from feed', + build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( modifyResponse: (it) => it.copyWith( - feed: createDefaultFeedResponse( - id: targetFeedId.id, - groupId: targetFeedId.group, - followerCount: 1, - followingCount: 1, - ), + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ), + ], ), ), body: (tester) async { - expect(tester.feedState.feed?.followerCount, 1); - expect(tester.feedState.feed?.followingCount, 1); + expect(tester.feedState.activities, hasLength(1)); - await tester.emitEvent( - FollowDeletedEvent( - type: EventTypes.followDeleted, - createdAt: DateTime.timestamp(), - custom: const {}, - fid: targetFeedId.toString(), - follow: FollowResponse( - createdAt: DateTime.timestamp(), - custom: const {}, - followerRole: 'followerRole', - pushPreference: FollowResponsePushPreference.none, - requestAcceptedAt: DateTime.timestamp(), - requestRejectedAt: DateTime.timestamp(), - sourceFeed: createDefaultFeedResponse( - id: sourceFeedId.id, - groupId: sourceFeedId.group, - followingCount: 0, - ), - status: FollowResponseStatus.accepted, - targetFeed: createDefaultFeedResponse( - id: targetFeedId.id, - groupId: targetFeedId.group, - followerCount: 0, - ), - updatedAt: DateTime.timestamp(), - ), + tester.mockApi( + (api) => api.deleteActivity( + id: 'activity-1', + hardDelete: any(named: 'hardDelete'), ), + result: const DeleteActivityResponse(duration: '0ms'), ); - expect(tester.feedState.feed?.followerCount, 0); - expect(tester.feedState.feed?.followingCount, 1); + final result = await tester.feed.deleteActivity(id: 'activity-1'); + + expect(result.isSuccess, isTrue); + expect(tester.feedState.activities, isEmpty); }, + verify: (tester) => tester.verifyApi( + (api) => api.deleteActivity( + id: 'activity-1', + hardDelete: any(named: 'hardDelete'), + ), + ), ); feedTest( - 'follow deleted source feed should update following count', - build: (client) => client.feedFromId(sourceFeedId), - setUp: (tester) => tester.getOrCreate( - modifyResponse: (it) => it.copyWith( - feed: createDefaultFeedResponse( - id: sourceFeedId.id, - groupId: sourceFeedId.group, - followerCount: 1, - followingCount: 1, - ), - ), - ), + 'activityFeedback() - should submit feedback via API', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate(), body: (tester) async { - expect(tester.feedState.feed?.followerCount, 1); - expect(tester.feedState.feed?.followingCount, 1); - - await tester.emitEvent( - FollowDeletedEvent( - type: EventTypes.followDeleted, - createdAt: DateTime.timestamp(), - custom: const {}, - fid: sourceFeedId.toString(), - follow: FollowResponse( - createdAt: DateTime.timestamp(), - custom: const {}, - followerRole: 'followerRole', - pushPreference: FollowResponsePushPreference.none, - requestAcceptedAt: DateTime.timestamp(), - requestRejectedAt: DateTime.timestamp(), - sourceFeed: createDefaultFeedResponse( - id: sourceFeedId.id, - groupId: sourceFeedId.group, - followingCount: 0, - ), - status: FollowResponseStatus.accepted, - targetFeed: createDefaultFeedResponse( - id: targetFeedId.id, - groupId: targetFeedId.group, - followerCount: 0, - ), - updatedAt: DateTime.timestamp(), - ), + tester.mockApi( + (api) => api.activityFeedback( + activityId: 'activity-1', + activityFeedbackRequest: any(named: 'activityFeedbackRequest'), ), + result: + createDefaultActivityFeedbackResponse(activityId: 'activity-1'), ); - expect(tester.feedState.feed?.followerCount, 1); - expect(tester.feedState.feed?.followingCount, 0); - }, - ); - }); - - // ============================================================ - // FEATURE: Local Filtering - // ============================================================ - - group('Local filtering with real-time events', () { - const feedId = FeedId(group: 'user', id: 'test'); - - final initialActivities = [ - createDefaultActivityResponse(id: 'activity-1'), - createDefaultActivityResponse(id: 'activity-2'), - createDefaultActivityResponse(id: 'activity-3'), - ]; + final result = await tester.feed.activityFeedback( + activityId: 'activity-1', + activityFeedbackRequest: const ActivityFeedbackRequest(hide: true), + ); - final initialPinnedActivities = [ - ActivityPinResponse( - feed: feedId.rawValue, - activity: createDefaultActivityResponse(id: 'activity-1'), - createdAt: DateTime(2022, 1, 1), - updatedAt: DateTime(2022, 1, 1), - user: createDefaultUserResponse(id: 'user-1'), + expect(result.isSuccess, isTrue); + }, + verify: (tester) => tester.verifyApi( + (api) => api.activityFeedback( + activityId: 'activity-1', + activityFeedbackRequest: any(named: 'activityFeedbackRequest'), + ), ), - ]; + ); feedTest( - 'ActivityAddedEvent - should not add activity that does not match filter', - build: (client) => client.feedFromQuery( - FeedQuery( - fid: feedId, - activityFilter: Filter.equal( - ActivitiesFilterField.activityType, - 'post', - ), - ), - ), - setUp: (tester) => tester.getOrCreate( - modifyResponse: (it) => it.copyWith(activities: initialActivities), - ), + 'markActivity() - should mark activity as read', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate(), body: (tester) async { - expect(tester.feedState.activities, hasLength(3)); + tester.mockApi( + (api) => api.markActivity( + feedGroupId: feedId.group, + feedId: feedId.id, + markActivityRequest: any(named: 'markActivityRequest'), + ), + result: const DurationResponse(duration: '0ms'), + ); - // Send ActivityAddedEvent with type 'comment' (doesn't match filter) - await tester.emitEvent( - ActivityAddedEvent( - type: EventTypes.activityAdded, - createdAt: DateTime.timestamp(), - custom: const {}, - fid: feedId.rawValue, - activity: createDefaultActivityResponse( - id: 'activity-4', - // Doesn't match 'post' filter - ).copyWith(type: 'comment'), + final result = await tester.feed.markActivity( + request: const MarkActivityRequest( + markRead: ['activity-1'], ), ); - expect(tester.feedState.activities, hasLength(3)); + expect(result.isSuccess, isTrue); }, + verify: (tester) => tester.verifyApi( + (api) => api.markActivity( + feedGroupId: feedId.group, + feedId: feedId.id, + markActivityRequest: any(named: 'markActivityRequest'), + ), + ), ); feedTest( - 'ActivityUpdatedEvent - should remove activity when updated to non-matching type', - build: (client) => client.feedFromQuery( - FeedQuery( - fid: feedId, - activityFilter: Filter.equal( - ActivitiesFilterField.activityType, - 'post', - ), - ), - ), + 'queryMoreActivities() - should load more activities', + build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( - modifyResponse: (it) => it.copyWith(activities: initialActivities), + modifyResponse: (it) => it.copyWith( + next: 'next-cursor', + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ), + ], + ), ), body: (tester) async { - expect(tester.feedState.activities, hasLength(3)); + expect(tester.feedState.activities, hasLength(1)); + expect(tester.feedState.canLoadMoreActivities, isTrue); - // Send ActivityUpdatedEvent with type that doesn't match filter - await tester.emitEvent( - ActivityUpdatedEvent( - type: EventTypes.activityUpdated, - createdAt: DateTime.timestamp(), - custom: const {}, - fid: feedId.rawValue, - activity: createDefaultActivityResponse( - id: 'activity-1', - // Doesn't match 'post' filter - ).copyWith(type: 'comment'), + tester.mockApi( + (api) => api.getOrCreateFeed( + feedGroupId: feedId.group, + feedId: feedId.id, + getOrCreateFeedRequest: any(named: 'getOrCreateFeedRequest'), + ), + result: createDefaultGetOrCreateFeedResponse( + prevPagination: 'prev-cursor', + activities: [ + createDefaultActivityResponse( + id: 'activity-2', + feeds: [feedId.rawValue], + ), + ], ), ); + final result = await tester.feed.queryMoreActivities(); + + expect(result.isSuccess, isTrue); + final activities = result.getOrThrow(); + expect(activities, hasLength(1)); + expect(activities.first.id, 'activity-2'); expect(tester.feedState.activities, hasLength(2)); }, ); feedTest( - 'ActivityReactionAddedEvent - should remove activity when reaction causes filter mismatch', - build: (client) => client.feedFromQuery( - FeedQuery( - fid: feedId, - activityFilter: Filter.equal( - ActivitiesFilterField.activityType, - 'post', + 'repost() - should repost activity', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate(), + body: (tester) async { + tester.mockApi( + (api) => api.addActivity( + addActivityRequest: any(named: 'addActivityRequest'), ), + result: AddActivityResponse( + duration: '0ms', + activity: createDefaultActivityResponse( + id: 'repost-1', + feeds: [feedId.rawValue], + type: 'repost', + ), + ), + ); + + final result = await tester.feed.repost( + activityId: 'activity-1', + text: 'Check this out!', + ); + + expect(result.isSuccess, isTrue); + final activity = result.getOrThrow(); + expect(activity.type, 'repost'); + }, + verify: (tester) => tester.verifyApi( + (api) => api.addActivity( + addActivityRequest: any(named: 'addActivityRequest'), ), ), + ); + + // Activity Events + + feedTest( + 'ActivityFeedbackEvent - should mark activity hidden', + build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( - modifyResponse: (it) => it.copyWith(activities: initialActivities), + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: 'activity-1', hidden: false), + ], + ), ), body: (tester) async { - expect(tester.feedState.activities, hasLength(3)); + expect(tester.feedState.activities, hasLength(1)); + expect(tester.feedState.activities.first.hidden, false); - // Send ActivityReactionAddedEvent with activity that doesn't match filter await tester.emitEvent( - ActivityReactionAddedEvent( - type: EventTypes.activityReactionAdded, + ActivityFeedbackEvent( + type: EventTypes.activityFeedback, createdAt: DateTime.timestamp(), custom: const {}, - fid: feedId.rawValue, - activity: createDefaultActivityResponse( - id: 'activity-1', - // Doesn't match 'post' filter - ).copyWith(type: 'comment'), - reaction: FeedsReactionResponse( + activityFeedback: ActivityFeedbackEventPayload( activityId: 'activity-1', - type: 'like', + action: ActivityFeedbackEventPayloadAction.hide, createdAt: DateTime.timestamp(), updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(), + user: createDefaultUserResponse(id: 'luke_skywalker'), + value: 'true', ), ), ); - expect(tester.feedState.activities, hasLength(2)); + expect(tester.feedState.activities, hasLength(1)); + expect(tester.feedState.activities.first.hidden, true); }, ); feedTest( - 'CommentAddedEvent - should remove activity when comment causes filter mismatch', - build: (client) => client.feedFromQuery( - FeedQuery( - fid: feedId, - activityFilter: Filter.in_( - ActivitiesFilterField.filterTags, - ['important'], - ), - ), - ), + 'ActivityFeedbackEvent - should mark activity unhidden', + build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( - modifyResponse: (it) => it.copyWith(activities: initialActivities), + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: 'activity-1', hidden: true), + ], + ), ), body: (tester) async { - expect(tester.feedState.activities, hasLength(3)); + expect(tester.feedState.activities, hasLength(1)); + expect(tester.feedState.activities.first.hidden, true); - // Send CommentAddedEvent with activity that doesn't have 'important' tag await tester.emitEvent( - CommentAddedEvent( - type: EventTypes.commentAdded, + ActivityFeedbackEvent( + type: EventTypes.activityFeedback, createdAt: DateTime.timestamp(), custom: const {}, - fid: feedId.rawValue, - activity: createDefaultActivityResponse( - id: 'activity-1', - ).copyWith( - filterTags: ['general'], // Doesn't have 'important' tag - ), - comment: createDefaultCommentResponse( - objectId: 'activity-1', + activityFeedback: ActivityFeedbackEventPayload( + activityId: 'activity-1', + action: ActivityFeedbackEventPayloadAction.hide, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: 'luke_skywalker'), + value: 'false', ), ), ); - expect(tester.feedState.activities, hasLength(2)); + expect(tester.feedState.activities, hasLength(1)); + expect(tester.feedState.activities.first.hidden, false); }, ); feedTest( - 'ActivityReactionDeletedEvent - should remove activity when reaction deletion causes filter mismatch', - build: (client) => client.feedFromQuery( - FeedQuery( - fid: feedId, - activityFilter: Filter.equal( - ActivitiesFilterField.activityType, - 'post', - ), - ), - ), + 'ActivityAddedEvent - should add activity to feed', + user: const User(id: 'user-1'), + build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( - modifyResponse: (it) => it.copyWith(activities: initialActivities), + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: 'activity-1', userId: 'user-1'), + createDefaultActivityResponse(id: 'activity-2', userId: 'user-1'), + createDefaultActivityResponse(id: 'activity-3', userId: 'user-1'), + ], + ), ), body: (tester) async { expect(tester.feedState.activities, hasLength(3)); - // Send ActivityReactionDeletedEvent with activity that doesn't match filter await tester.emitEvent( - ActivityReactionDeletedEvent( - type: EventTypes.activityReactionDeleted, + ActivityAddedEvent( + type: EventTypes.activityAdded, createdAt: DateTime.timestamp(), custom: const {}, fid: feedId.rawValue, activity: createDefaultActivityResponse( - id: 'activity-2', - // Doesn't match 'post' filter - ).copyWith(type: 'comment'), - reaction: FeedsReactionResponse( - activityId: 'activity-2', - type: 'like', - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(), + id: 'new-activity', + userId: 'user-1', ), ), ); - expect(tester.feedState.activities, hasLength(2)); + final activities = tester.feedState.activities; + + expect(activities, hasLength(4)); + expect(activities.any((a) => a.id == 'new-activity'), isTrue); }, ); feedTest( - 'ActivityPinnedEvent - should remove activity when pinned activity does not match filter', - build: (client) => client.feedFromQuery( - FeedQuery( - fid: feedId, - activityFilter: Filter.equal( - ActivitiesFilterField.activityType, - 'post', - ), - ), - ), + 'ActivityUpdatedEvent - should update existing activity', + build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( - modifyResponse: (it) => it.copyWith( - activities: initialActivities, - pinnedActivities: initialPinnedActivities, + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: 'activity-1', type: 'post'), + ], ), ), body: (tester) async { - expect(tester.feedState.activities, hasLength(3)); - expect(tester.feedState.pinnedActivities, hasLength(1)); + expect(tester.feedState.activities, hasLength(1)); + expect(tester.feedState.activities.first.type, 'post'); - // Send ActivityPinnedEvent with activity that doesn't match filter await tester.emitEvent( - ActivityPinnedEvent( - type: EventTypes.activityPinned, + ActivityUpdatedEvent( + type: EventTypes.activityUpdated, createdAt: DateTime.timestamp(), custom: const {}, fid: feedId.rawValue, - pinnedActivity: createDefaultPinActivityResponse( - activityId: 'activity-1', - type: 'comment', // Doesn't match 'post' filter + activity: createDefaultActivityResponse( + id: 'activity-1', + type: 'share', ), ), ); - expect(tester.feedState.activities, hasLength(2)); - expect(tester.feedState.pinnedActivities, isEmpty); + expect(tester.feedState.activities, hasLength(1)); + expect(tester.feedState.activities.first.type, 'share'); }, ); feedTest( - 'ActivityUnpinnedEvent - should remove activity when unpinned activity does not match filter', - build: (client) => client.feedFromQuery( - FeedQuery( - fid: feedId, - activityFilter: Filter.equal( - ActivitiesFilterField.activityType, - 'post', - ), - ), - ), + 'ActivityDeletedEvent - should remove activity from feed', + build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( - modifyResponse: (it) => it.copyWith( - activities: initialActivities, - pinnedActivities: initialPinnedActivities, + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: 'activity-1'), + ], ), ), body: (tester) async { - expect(tester.feedState.activities, hasLength(3)); - expect(tester.feedState.pinnedActivities, hasLength(1)); + expect(tester.feedState.activities, hasLength(1)); - // Send ActivityUnpinnedEvent with activity that doesn't match filter await tester.emitEvent( - ActivityUnpinnedEvent( - type: EventTypes.activityUnpinned, + ActivityDeletedEvent( + type: EventTypes.activityDeleted, createdAt: DateTime.timestamp(), custom: const {}, fid: feedId.rawValue, - pinnedActivity: createDefaultPinActivityResponse( - activityId: 'activity-1', - type: 'comment', // Doesn't match 'post' filter - ), + activity: createDefaultActivityResponse(id: 'activity-1'), ), ); - expect(tester.feedState.activities, hasLength(2)); - expect(tester.feedState.pinnedActivities, isEmpty); + expect(tester.feedState.activities, isEmpty); }, ); feedTest( - 'Complex filter with AND - should filter correctly', - build: (client) => client.feedFromQuery( - FeedQuery( - fid: feedId, - activityFilter: Filter.and([ - Filter.equal(ActivitiesFilterField.activityType, 'post'), - Filter.in_(ActivitiesFilterField.filterTags, ['featured']), - ]), - ), - ), - setUp: (tester) => tester.getOrCreate( - modifyResponse: (it) => it.copyWith( - activities: initialActivities, - pinnedActivities: initialPinnedActivities, - ), - ), + 'ActivityPinnedEvent - should add pinned activity', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate(), body: (tester) async { - expect(tester.feedState.activities, hasLength(3)); + expect(tester.feedState.pinnedActivities, isEmpty); await tester.emitEvent( - ActivityAddedEvent( - type: EventTypes.activityAdded, + ActivityPinnedEvent( + type: EventTypes.activityPinned, createdAt: DateTime.timestamp(), custom: const {}, fid: feedId.rawValue, - activity: createDefaultActivityResponse( - id: 'activity-4', - ).copyWith( - type: 'post', // Matches first condition - filterTags: ['general'], // Doesn't match second condition + pinnedActivity: createDefaultPinActivityResponse( + activityId: 'activity-1', ), ), ); - expect(tester.feedState.activities, hasLength(3)); + final pinnedActivities = tester.feedState.pinnedActivities; + expect(pinnedActivities, hasLength(1)); + expect(pinnedActivities.first.activity.id, 'activity-1'); }, ); feedTest( - 'Complex filter with OR - should add activities matching any condition', - build: (client) => client.feedFromQuery( - FeedQuery( - fid: feedId, - activityFilter: Filter.or([ - Filter.equal(ActivitiesFilterField.activityType, 'post'), - Filter.in_(ActivitiesFilterField.filterTags, ['featured']), - ]), - ), - ), + 'ActivityUnpinnedEvent - should remove pinned activity', + build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( - modifyResponse: (it) => it.copyWith( - activities: initialActivities, - pinnedActivities: initialPinnedActivities, + modifyResponse: (response) => response.copyWith( + pinnedActivities: [ + createDefaultActivityPinResponse( + activityId: 'activity-1', + ), + ], ), ), body: (tester) async { - expect(tester.feedState.activities, hasLength(3)); + expect(tester.feedState.pinnedActivities, hasLength(1)); await tester.emitEvent( - ActivityAddedEvent( - type: EventTypes.activityAdded, + ActivityUnpinnedEvent( + type: EventTypes.activityUnpinned, createdAt: DateTime.timestamp(), custom: const {}, fid: feedId.rawValue, - activity: createDefaultActivityResponse( - id: 'activity-4', - userId: 'luke_skywalker', - ).copyWith( - type: 'post', // Matches first condition - filterTags: ['general'], // Doesn't match second condition + pinnedActivity: createDefaultPinActivityResponse( + activityId: 'activity-1', ), ), ); - expect(tester.feedState.activities, hasLength(4)); + expect(tester.feedState.pinnedActivities, isEmpty); }, ); feedTest( - 'No filter - filtering is disabled when no filter specified', - build: (client) => client.feedFromQuery( - const FeedQuery( - fid: feedId, - // No activityFilter - all activities should be accepted - ), - ), + 'ActivityRemovedFromFeedEvent - should remove activity from feed', + build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( - modifyResponse: (it) => it.copyWith( - activities: initialActivities, - pinnedActivities: initialPinnedActivities, + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: 'activity-1'), + ], ), ), body: (tester) async { - // Verify the feed has no filter - expect(tester.feed.query.activityFilter, isNull); - expect(tester.feedState.activities, hasLength(3)); + expect(tester.feedState.activities, hasLength(1)); - // Send ActivityAddedEvent that matches only one condition await tester.emitEvent( - ActivityAddedEvent( - type: EventTypes.activityAdded, + ActivityRemovedFromFeedEvent( + type: EventTypes.activityRemovedFromFeed, createdAt: DateTime.timestamp(), custom: const {}, fid: feedId.rawValue, - activity: createDefaultActivityResponse( - id: 'activity-4', - userId: 'luke_skywalker', - // Doesn't match 'post' activity type - ).copyWith(type: 'post'), + activity: createDefaultActivityResponse(id: 'activity-1'), ), ); - expect(tester.feedState.activities, hasLength(4)); + expect(tester.feedState.activities, isEmpty); }, ); + + // Local filtering tests + + group('ActivityListEventHandler - Local filtering', () { + const feedId = FeedId(group: 'user', id: 'test'); + + final initialActivities = [ + createDefaultActivityResponse(id: 'activity-1'), + createDefaultActivityResponse(id: 'activity-2'), + createDefaultActivityResponse(id: 'activity-3'), + ]; + + final initialPinnedActivities = [ + ActivityPinResponse( + feed: feedId.rawValue, + activity: createDefaultActivityResponse(id: 'activity-1'), + createdAt: DateTime(2022, 1, 1), + updatedAt: DateTime(2022, 1, 1), + user: createDefaultUserResponse(id: 'user-1'), + ), + ]; + + feedTest( + 'ActivityAddedEvent - should not add activity that does not match filter', + build: (client) => client.feedFromQuery( + FeedQuery( + fid: const FeedId(group: 'user', id: 'test'), + activityFilter: Filter.equal( + ActivitiesFilterField.activityType, + 'post', + ), + ), + ), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: 'activity-1', type: 'post'), + ], + ), + ), + body: (tester) async { + expect(tester.feedState.activities, hasLength(1)); + + // Send ActivityAddedEvent with type 'comment' (doesn't match filter) + await tester.emitEvent( + ActivityAddedEvent( + type: EventTypes.activityAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:test', + activity: createDefaultActivityResponse( + id: 'activity-2', + type: 'comment', + ), + ), + ); + + // Activity should not be added because it doesn't match filter + expect(tester.feedState.activities, hasLength(1)); + }, + ); + + feedTest( + 'ActivityUpdatedEvent - should remove activity when updated to non-matching type', + build: (client) => client.feedFromQuery( + FeedQuery( + fid: const FeedId(group: 'user', id: 'test'), + activityFilter: Filter.equal( + ActivitiesFilterField.activityType, + 'post', + ), + ), + ), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: 'activity-1', type: 'post'), + ], + ), + ), + body: (tester) async { + expect(tester.feedState.activities, hasLength(1)); + + // Send ActivityUpdatedEvent with type 'comment' (doesn't match filter) + await tester.emitEvent( + ActivityUpdatedEvent( + type: EventTypes.activityUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:test', + activity: createDefaultActivityResponse( + id: 'activity-1', + type: 'comment', + ), + ), + ); + + // Activity should be removed because it no longer matches filter + expect(tester.feedState.activities, isEmpty); + }, + ); + + feedTest( + 'Complex filter with AND - should filter correctly', + build: (client) => client.feedFromQuery( + FeedQuery( + fid: feedId, + activityFilter: Filter.and([ + Filter.equal(ActivitiesFilterField.activityType, 'post'), + Filter.in_(ActivitiesFilterField.filterTags, ['featured']), + ]), + ), + ), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: initialActivities, + pinnedActivities: initialPinnedActivities, + ), + ), + body: (tester) async { + expect(tester.feedState.activities, hasLength(3)); + + await tester.emitEvent( + ActivityAddedEvent( + type: EventTypes.activityAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse( + id: 'activity-4', + ).copyWith( + type: 'post', // Matches first condition + filterTags: ['general'], // Doesn't match second condition + ), + ), + ); + + expect(tester.feedState.activities, hasLength(3)); + }, + ); + + feedTest( + 'Complex filter with OR - should add activities matching any condition', + build: (client) => client.feedFromQuery( + FeedQuery( + fid: feedId, + activityFilter: Filter.or([ + Filter.equal(ActivitiesFilterField.activityType, 'post'), + Filter.in_(ActivitiesFilterField.filterTags, ['featured']), + ]), + ), + ), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: initialActivities, + pinnedActivities: initialPinnedActivities, + ), + ), + body: (tester) async { + expect(tester.feedState.activities, hasLength(3)); + + await tester.emitEvent( + ActivityAddedEvent( + type: EventTypes.activityAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse( + id: 'activity-4', + userId: 'luke_skywalker', + ).copyWith( + type: 'post', // Matches first condition + filterTags: ['general'], // Doesn't match second condition + ), + ), + ); + + expect(tester.feedState.activities, hasLength(4)); + }, + ); + + feedTest( + 'No filter - filtering is disabled when no filter specified', + build: (client) => client.feedFromQuery( + const FeedQuery( + fid: feedId, + // No activityFilter - all activities should be accepted + ), + ), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: initialActivities, + pinnedActivities: initialPinnedActivities, + ), + ), + body: (tester) async { + // Verify the feed has no filter + expect(tester.feed.query.activityFilter, isNull); + expect(tester.feedState.activities, hasLength(3)); + + // Send ActivityAddedEvent that matches only one condition + await tester.emitEvent( + ActivityAddedEvent( + type: EventTypes.activityAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse( + id: 'activity-4', + userId: 'luke_skywalker', + // Doesn't match 'post' activity type + ).copyWith(type: 'post'), + ), + ); + + expect(tester.feedState.activities, hasLength(4)); + }, + ); + }); }); - // ============================================================ - // FEATURE: Story Events - // ============================================================ + // ========================================================================== + // Feed - Bookmarks + // ========================================================================== - group('Story events', () { - const feedId = FeedId(group: 'stories', id: 'target'); - final initialStories = [ - createDefaultActivityResponse(id: 'storyActivityId1'), - createDefaultActivityResponse(id: 'storyActivityId2'), - ]; + group('Feed - Bookmarks', () { + const feedId = FeedId(group: 'user', id: 'john'); + const userId = 'luke_skywalker'; feedTest( - 'Watch story should update isWatched', + 'addBookmark() - should add bookmark to activity via API', build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( modifyResponse: (it) => it.copyWith( - aggregatedActivities: [ - createDefaultAggregatedActivityResponse( - group: 'group1', - activities: initialStories, + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], ), ], ), ), body: (tester) async { - final userStories = tester.feedState.aggregatedActivities; - expect(userStories, hasLength(1)); - - final firstUserStories = userStories.first.activities; - expect(firstUserStories, hasLength(2)); - expect(firstUserStories[0].isWatched ?? false, isFalse); - expect(firstUserStories[1].isWatched ?? false, isFalse); - - await tester.emitEvent( - ActivityMarkEvent( - type: EventTypes.activityMarked, - createdAt: DateTime.timestamp(), - custom: const {}, - fid: feedId.rawValue, - markWatched: const ['storyActivityId1'], + // Mock API call that will be used + tester.mockApi( + (api) => api.addBookmark( + activityId: 'activity-1', + addBookmarkRequest: any(named: 'addBookmarkRequest'), + ), + result: createDefaultAddBookmarkResponse( + userId: userId, + activityId: 'activity-1', ), ); - final updatedAllUserStories = tester.feedState.aggregatedActivities; - expect(updatedAllUserStories, hasLength(1)); + // Initial state - no bookmarks + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.id, 'activity-1'); + expect(initialActivity.ownBookmarks, isEmpty); - final updatedFirstUserStories = updatedAllUserStories.first.activities; - expect(updatedFirstUserStories, hasLength(2)); - expect(updatedFirstUserStories[0].isWatched ?? false, isTrue); - expect(updatedFirstUserStories[1].isWatched ?? false, isFalse); + // Add bookmark + final result = await tester.feed.addBookmark(activityId: 'activity-1'); + + expect(result, isA>()); + final bookmark = result.getOrThrow(); + expect(bookmark.activity.id, 'activity-1'); + expect(bookmark.user.id, userId); + + // Verify state was updated + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.ownBookmarks, hasLength(1)); + expect(updatedActivity.ownBookmarks.first.id, bookmark.id); + expect(updatedActivity.ownBookmarks.first.user.id, userId); }, + verify: (tester) => tester.verifyApi( + (api) => api.addBookmark( + activityId: 'activity-1', + addBookmarkRequest: any(named: 'addBookmarkRequest'), + ), + ), ); feedTest( - 'Pagination should load more aggregated activities', + 'updateBookmark() - should update bookmark via API', build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( modifyResponse: (it) => it.copyWith( - next: 'nextPageToken', - aggregatedActivities: [ - createDefaultAggregatedActivityResponse( - group: 'group1', - activities: initialStories, + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ownBookmarks: [ + createDefaultBookmarkResponse( + userId: userId, + activityId: 'activity-1', + folderId: 'folder-id', + ), + ], ), ], ), ), body: (tester) async { - final userStories = tester.feedState.aggregatedActivities; - expect(userStories, hasLength(1)); + // Initial state - has bookmark + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownBookmarks, hasLength(1)); + expect(initialActivity.ownBookmarks.first.folder?.id, 'folder-id'); + + // Mock API call that will be used + tester.mockApi( + (api) => api.updateBookmark( + activityId: 'activity-1', + updateBookmarkRequest: any(named: 'updateBookmarkRequest'), + ), + result: createDefaultUpdateBookmarkResponse( + userId: userId, + activityId: 'activity-1', + folderId: 'new-folder-id', + ), + ); + + final result = await tester.feed.updateBookmark( + activityId: 'activity-1', + request: const UpdateBookmarkRequest(folderId: 'new-folder-id'), + ); + + expect(result, isA>()); + final bookmark = result.getOrThrow(); + expect(bookmark.activity.id, 'activity-1'); + expect(bookmark.folder?.id, 'new-folder-id'); + }, + verify: (tester) => tester.verifyApi( + (api) => api.updateBookmark( + activityId: 'activity-1', + updateBookmarkRequest: any(named: 'updateBookmarkRequest'), + ), + ), + ); + + feedTest( + 'deleteBookmark() - should delete bookmark via API', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ownBookmarks: [ + createDefaultBookmarkResponse( + userId: userId, + activityId: 'activity-1', + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has bookmark + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownBookmarks, hasLength(1)); + + // Mock API call that will be used + tester.mockApi( + (api) => api.deleteBookmark( + activityId: 'activity-1', + folderId: any(named: 'folderId'), + ), + result: createDefaultDeleteBookmarkResponse( + userId: userId, + activityId: 'activity-1', + ), + ); + + // Delete bookmark + final result = await tester.feed.deleteBookmark( + activityId: 'activity-1', + ); + + expect(result, isA>()); + final bookmark = result.getOrThrow(); + expect(bookmark.activity.id, 'activity-1'); + expect(bookmark.user.id, userId); + + // Verify state was updated + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.ownBookmarks, isEmpty); + }, + verify: (tester) => tester.verifyApi( + (api) => api.deleteBookmark( + activityId: 'activity-1', + folderId: any(named: 'folderId'), + ), + ), + ); + + // Bookmark Events + + feedTest( + 'BookmarkAddedEvent - should add bookmark and update activity', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ), + ], + ), + ), + body: (tester) async { + // Initial state - no bookmarks + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownBookmarks, isEmpty); + + // Emit BookmarkAddedEvent + await tester.emitEvent( + BookmarkAddedEvent( + type: EventTypes.bookmarkAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + userId: userId, + activityId: 'activity-1', + ), + ), + ); + + // Verify state was updated + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.ownBookmarks, hasLength(1)); + expect(updatedActivity.ownBookmarks.first.user.id, userId); + expect(updatedActivity.ownBookmarks.first.activity.id, 'activity-1'); + }, + ); + + feedTest( + 'BookmarkAddedEvent - should not update activity if activity ID does not match', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ), + ], + ), + ), + body: (tester) async { + // Initial state - no bookmarks + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownBookmarks, isEmpty); + + // Emit BookmarkAddedEvent for different activity + await tester.emitEvent( + BookmarkAddedEvent( + type: EventTypes.bookmarkAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + userId: userId, + activityId: 'different-activity-id', + ), + ), + ); + + // Verify state was not updated + final activity = tester.feedState.activities.first; + expect(activity.ownBookmarks, isEmpty); + }, + ); + + feedTest( + 'BookmarkUpdatedEvent - should update bookmark and activity', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ownBookmarks: [ + createDefaultBookmarkResponse( + userId: userId, + activityId: 'activity-1', + folderId: 'folder-1', + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has bookmark in folder-1 + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownBookmarks, hasLength(1)); + expect(initialActivity.ownBookmarks.first.folder?.id, 'folder-1'); + expect(initialActivity.ownBookmarks.first.custom, isEmpty); + + // Emit BookmarkUpdatedEvent with updated folder + await tester.emitEvent( + BookmarkUpdatedEvent( + type: EventTypes.bookmarkUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + userId: userId, + activityId: 'activity-1', + folderId: 'folder-2', + ).copyWith(custom: const {'updated': true}), + ), + ); + + // Verify state was updated + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.ownBookmarks, hasLength(1)); + expect(updatedActivity.ownBookmarks.first.folder?.id, 'folder-2'); + expect(updatedActivity.ownBookmarks.first.custom, isNotEmpty); + expect(updatedActivity.ownBookmarks.first.custom?['updated'], true); + }, + ); + + feedTest( + 'BookmarkDeletedEvent - should delete bookmark and update activity', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ownBookmarks: [ + createDefaultBookmarkResponse( + userId: userId, + activityId: 'activity-1', + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has bookmark + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownBookmarks, hasLength(1)); + + // Emit BookmarkDeletedEvent + await tester.emitEvent( + BookmarkDeletedEvent( + type: EventTypes.bookmarkDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + userId: userId, + activityId: 'activity-1', + ), + ), + ); + + // Verify state was updated + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.ownBookmarks, isEmpty); + }, + ); + }); + + // ========================================================================== + // Feed - Comments + // ========================================================================== + + group('Feed - Comments', () { + const currentUser = User(id: 'test_user'); + const feedId = FeedId(group: 'user', id: 'john'); + const commentId = 'comment-1'; + + setUpAll(() { + registerFallbackValue(const AddCommentReactionRequest(type: 'like')); + }); + + feedTest( + 'getComment() - should fetch comment by ID', + user: currentUser, + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ), + ], + ), + ), + body: (tester) async { + tester.mockApi( + (api) => api.getComment(id: commentId), + result: GetCommentResponse( + comment: createDefaultCommentResponse( + id: commentId, + objectId: 'activity-1', + ), + duration: '0ms', + ), + ); + + final result = await tester.feed.getComment(commentId: commentId); + + expect(result.isSuccess, isTrue); + final comment = result.getOrThrow(); + expect(comment.id, commentId); + expect(comment.objectId, 'activity-1'); + }, + verify: (tester) => tester.verifyApi( + (api) => api.getComment(id: commentId), + ), + ); + + feedTest( + 'addComment() - should add comment to activity', + user: currentUser, + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ), + ], + ), + ), + body: (tester) async { + tester.mockApi( + (api) => api.addComment( + addCommentRequest: any(named: 'addCommentRequest'), + ), + result: AddCommentResponse( + comment: createDefaultCommentResponse( + id: commentId, + objectId: 'activity-1', + userId: currentUser.id, + ), + duration: '0ms', + ), + ); + + final result = await tester.feed.addComment( + request: const ActivityAddCommentRequest( + activityId: 'activity-1', + activityType: 'activity', + comment: 'Test comment', + ), + ); + + expect(result.isSuccess, isTrue); + final comment = result.getOrThrow(); + expect(comment.id, commentId); + expect(comment.objectId, 'activity-1'); + expect(comment.user.id, currentUser.id); + }, + verify: (tester) => tester.verifyApi( + (api) => api.addComment( + addCommentRequest: any(named: 'addCommentRequest'), + ), + ), + ); + + feedTest( + 'updateComment() - should update comment', + user: currentUser, + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ), + ], + ), + ), + body: (tester) async { + const updatedText = 'Updated comment text'; + + tester.mockApi( + (api) => api.updateComment( + id: commentId, + updateCommentRequest: any(named: 'updateCommentRequest'), + ), + result: UpdateCommentResponse( + comment: createDefaultCommentResponse( + id: commentId, + objectId: 'activity-1', + text: updatedText, + ), + duration: '0ms', + ), + ); + + final result = await tester.feed.updateComment( + commentId: commentId, + request: const UpdateCommentRequest(comment: updatedText), + ); + + expect(result.isSuccess, isTrue); + final comment = result.getOrThrow(); + expect(comment.id, commentId); + expect(comment.text, updatedText); + }, + verify: (tester) => tester.verifyApi( + (api) => api.updateComment( + id: commentId, + updateCommentRequest: any(named: 'updateCommentRequest'), + ), + ), + ); + + feedTest( + 'deleteComment() - should delete comment', + user: currentUser, + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ), + ], + ), + ), + body: (tester) async { + tester.mockApi( + (api) => api.deleteComment( + id: commentId, + hardDelete: any(named: 'hardDelete'), + ), + result: DeleteCommentResponse( + activity: createDefaultActivityResponse(id: 'activity-1'), + comment: createDefaultCommentResponse( + id: commentId, + objectId: 'activity-1', + ), + duration: '0ms', + ), + ); + + final result = await tester.feed.deleteComment(commentId: commentId); + + expect(result.isSuccess, isTrue); + }, + verify: (tester) => tester.verifyApi( + (api) => api.deleteComment( + id: commentId, + hardDelete: any(named: 'hardDelete'), + ), + ), + ); + + feedTest( + 'addCommentReaction() - should add reaction to comment', + user: currentUser, + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ), + ], + ), + ), + body: (tester) async { + tester.mockApi( + (api) => api.addCommentReaction( + id: commentId, + addCommentReactionRequest: any(named: 'addCommentReactionRequest'), + ), + result: createDefaultAddCommentReactionResponse( + commentId: commentId, + objectId: 'activity-1', + userId: currentUser.id, + reactionType: 'like', + ), + ); + + final result = await tester.feed.addCommentReaction( + commentId: commentId, + request: const AddCommentReactionRequest(type: 'like'), + ); + + expect(result.isSuccess, isTrue); + final reaction = result.getOrThrow(); + expect(reaction.type, 'like'); + expect(reaction.user.id, currentUser.id); + }, + verify: (tester) => tester.verifyApi( + (api) => api.addCommentReaction( + id: commentId, + addCommentReactionRequest: any(named: 'addCommentReactionRequest'), + ), + ), + ); + + feedTest( + 'deleteCommentReaction() - should delete reaction from comment', + user: currentUser, + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ), + ], + ), + ), + body: (tester) async { + tester.mockApi( + (api) => api.deleteCommentReaction( + id: commentId, + type: 'like', + ), + result: createDefaultDeleteCommentReactionResponse( + commentId: commentId, + objectId: 'activity-1', + userId: currentUser.id, + reactionType: 'like', + ), + ); + + final result = await tester.feed.deleteCommentReaction( + commentId: commentId, + type: 'like', + ); + + expect(result.isSuccess, isTrue); + final reaction = result.getOrThrow(); + expect(reaction.type, 'like'); + expect(reaction.user.id, currentUser.id); + }, + verify: (tester) => tester.verifyApi( + (api) => api.deleteCommentReaction( + id: commentId, + type: 'like', + ), + ), + ); + + feedTest( + 'CommentAddedEvent - should add comment to activity', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ), + ], + ), + ), + body: (tester) async { + // Initial state - no comments + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.comments, isEmpty); + + // Emit CommentAddedEvent + await tester.emitEvent( + CommentAddedEvent( + type: EventTypes.commentAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse(id: 'activity-1'), + comment: createDefaultCommentResponse( + id: commentId, + objectId: 'activity-1', + text: 'Test comment', + ), + ), + ); + + // Verify comment was added + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.comments, hasLength(1)); + expect(updatedActivity.comments.first.id, commentId); + expect(updatedActivity.comments.first.text, 'Test comment'); + }, + ); + + feedTest( + 'CommentUpdatedEvent - should update comment in activity', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + comments: [ + createDefaultCommentResponse( + id: commentId, + objectId: 'activity-1', + text: 'Original comment', + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has comment + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.comments, hasLength(1)); + expect(initialActivity.comments.first.text, 'Original comment'); + + // Emit CommentUpdatedEvent + await tester.emitEvent( + CommentUpdatedEvent( + type: EventTypes.commentUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + comment: createDefaultCommentResponse( + id: commentId, + objectId: 'activity-1', + text: 'Updated comment', + ), + ), + ); + + // Verify comment was updated + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.comments, hasLength(1)); + expect(updatedActivity.comments.first.id, commentId); + expect(updatedActivity.comments.first.text, 'Updated comment'); + }, + ); + + feedTest( + 'CommentDeletedEvent - should remove comment from activity', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + comments: [ + createDefaultCommentResponse( + id: commentId, + objectId: 'activity-1', + text: 'Test comment', + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has comment + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.comments, hasLength(1)); + + // Emit CommentDeletedEvent + await tester.emitEvent( + CommentDeletedEvent( + type: EventTypes.commentDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + comment: createDefaultCommentResponse( + id: commentId, + objectId: 'activity-1', + ), + ), + ); + + // Verify comment was removed + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.comments, isEmpty); + }, + ); + + feedTest( + 'CommentReactionAddedEvent - should add reaction to comment', + user: currentUser, + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + comments: [ + createDefaultCommentResponse( + id: commentId, + objectId: 'activity-1', + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - comment has no reactions + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.comments, hasLength(1)); + expect(initialActivity.comments.first.ownReactions, isEmpty); + + // Emit CommentReactionAddedEvent + await tester.emitEvent( + CommentReactionAddedEvent( + type: EventTypes.commentReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse(id: 'activity-1'), + comment: createDefaultCommentResponse( + id: commentId, + objectId: 'activity-1', + ), + reaction: createDefaultReactionResponse( + reactionType: 'love', + userId: currentUser.id, + commentId: commentId, + ), + ), + ); + + // Verify reaction was added + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.comments, hasLength(1)); + expect(updatedActivity.comments.first.ownReactions, hasLength(1)); + expect(updatedActivity.comments.first.ownReactions.first.type, 'love'); + }, + ); + + feedTest( + 'CommentReactionUpdatedEvent - should replace user reaction on comment', + user: currentUser, + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + comments: [ + createDefaultCommentResponse( + id: commentId, + objectId: 'activity-1', + ownReactions: [ + createDefaultReactionResponse( + reactionType: 'wow', + userId: currentUser.id, + commentId: commentId, + ), + ], + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - comment has 'wow' reaction + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.comments, hasLength(1)); + expect(initialActivity.comments.first.ownReactions, hasLength(1)); + expect(initialActivity.comments.first.ownReactions.first.type, 'wow'); + + // Emit CommentReactionUpdatedEvent - replaces 'wow' with 'fire' + await tester.emitEvent( + CommentReactionUpdatedEvent( + type: EventTypes.commentReactionUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse(id: 'activity-1'), + comment: createDefaultCommentResponse( + id: commentId, + objectId: 'activity-1', + latestReactions: [ + createDefaultReactionResponse( + reactionType: 'fire', + userId: currentUser.id, + commentId: commentId, + ), + ], + ), + reaction: createDefaultReactionResponse( + reactionType: 'fire', + userId: currentUser.id, + commentId: commentId, + ), + ), + ); + + // Verify 'wow' was replaced with 'fire' + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.comments, hasLength(1)); + expect(updatedActivity.comments.first.ownReactions, hasLength(1)); + expect(updatedActivity.comments.first.ownReactions.first.type, 'fire'); + }, + ); + + feedTest( + 'CommentReactionDeletedEvent - should remove reaction from comment', + user: currentUser, + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + comments: [ + createDefaultCommentResponse( + id: commentId, + objectId: 'activity-1', + ownReactions: [ + createDefaultReactionResponse( + reactionType: 'love', + userId: currentUser.id, + commentId: commentId, + ), + ], + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - comment has 'love' reaction + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.comments, hasLength(1)); + expect(initialActivity.comments.first.ownReactions, hasLength(1)); + expect(initialActivity.comments.first.ownReactions.first.type, 'love'); + + // Emit CommentReactionDeletedEvent + await tester.emitEvent( + CommentReactionDeletedEvent( + type: EventTypes.commentReactionDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + comment: createDefaultCommentResponse( + id: commentId, + objectId: 'activity-1', + ), + reaction: createDefaultReactionResponse( + reactionType: 'love', + userId: currentUser.id, + commentId: commentId, + ), + ), + ); + + // Verify reaction was removed + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.comments, hasLength(1)); + expect(updatedActivity.comments.first.ownReactions, isEmpty); + }, + ); + }); + + // ========================================================================== + // Feed - Members + // ========================================================================== + + group('Feed - Members', () { + const currentUser = User(id: 'test_user'); + const feedId = FeedId(group: 'team', id: 'developers'); + + setUpAll(() { + registerFallbackValue( + const UpdateFeedMembersRequest( + operation: UpdateFeedMembersRequestOperation.upsert, + ), + ); + }); + + feedTest( + 'queryFeedMembers() - should query initial members', + user: currentUser, + build: (client) => client.feedFromId(feedId), + setUp: (tester) { + tester.getOrCreate(); + + tester.mockApi( + (api) => api.queryFeedMembers( + feedGroupId: feedId.group, + feedId: feedId.id, + queryFeedMembersRequest: any(named: 'queryFeedMembersRequest'), + ), + result: createDefaultQueryFeedMembersResponse( + members: [ + createDefaultFeedMemberResponse(id: currentUser.id), + ], + ), + ); + }, + body: (tester) async { + final result = await tester.feed.queryFeedMembers(); + + expect(result.isSuccess, isTrue); + final members = result.getOrThrow(); + expect(members, hasLength(1)); + expect(members.first.id, currentUser.id); + }, + verify: (tester) => tester.verifyApi( + (api) => api.queryFeedMembers( + feedGroupId: feedId.group, + feedId: feedId.id, + queryFeedMembersRequest: any(named: 'queryFeedMembersRequest'), + ), + ), + ); + + feedTest( + 'queryMoreFeedMembers() - should load more members', + user: currentUser, + build: (client) => client.feedFromId(feedId), + setUp: (tester) { + tester.getOrCreate(); + + // Mock initial query + tester.mockApi( + (api) => api.queryFeedMembers( + feedGroupId: feedId.group, + feedId: feedId.id, + queryFeedMembersRequest: any(named: 'queryFeedMembersRequest'), + ), + result: createDefaultQueryFeedMembersResponse( + members: [ + createDefaultFeedMemberResponse(id: currentUser.id), + ], + next: 'next-cursor', + ), + ); + }, + body: (tester) async { + // Load initial members + await tester.feed.queryFeedMembers(); + + // Mock queryMore + tester.mockApi( + (api) => api.queryFeedMembers( + feedGroupId: feedId.group, + feedId: feedId.id, + queryFeedMembersRequest: any(named: 'queryFeedMembersRequest'), + ), + result: createDefaultQueryFeedMembersResponse( + members: [ + createDefaultFeedMemberResponse(id: 'user-2'), + ], + ), + ); + + final result = await tester.feed.queryMoreFeedMembers(); + + expect(result.isSuccess, isTrue); + final members = result.getOrThrow(); + expect(members, hasLength(1)); + expect(members.first.user.id, 'user-2'); + }, + ); + + feedTest( + 'updateFeedMembers() - should update feed members', + user: currentUser, + build: (client) => client.feedFromId(feedId), + setUp: (tester) { + tester.getOrCreate(); + + tester.mockApi( + (api) => api.updateFeedMembers( + feedGroupId: feedId.group, + feedId: feedId.id, + updateFeedMembersRequest: any(named: 'updateFeedMembersRequest'), + ), + result: UpdateFeedMembersResponse( + added: [createDefaultFeedMemberResponse(id: 'user-new')], + updated: const [], + removedIds: const [], + duration: '0ms', + ), + ); + }, + body: (tester) async { + final result = await tester.feed.updateFeedMembers( + request: const UpdateFeedMembersRequest( + operation: UpdateFeedMembersRequestOperation.upsert, + members: [FeedMemberRequest(userId: 'user-new', role: 'member')], + ), + ); + + expect(result.isSuccess, isTrue); + final updates = result.getOrThrow(); + expect(updates.added, hasLength(1)); + expect(updates.added.first.user.id, 'user-new'); + }, + verify: (tester) => tester.verifyApi( + (api) => api.updateFeedMembers( + feedGroupId: feedId.group, + feedId: feedId.id, + updateFeedMembersRequest: any(named: 'updateFeedMembersRequest'), + ), + ), + ); + + feedTest( + 'acceptFeedMember() - should accept member invitation', + user: currentUser, + build: (client) => client.feedFromId(feedId), + setUp: (tester) { + tester.getOrCreate(); + + tester.mockApi( + (api) => api.acceptFeedMemberInvite( + feedGroupId: feedId.group, + feedId: feedId.id, + ), + result: AcceptFeedMemberInviteResponse( + member: createDefaultFeedMemberResponse( + id: currentUser.id, + status: FeedMemberResponseStatus.member, + ), + duration: '0ms', + ), + ); + }, + body: (tester) async { + final result = await tester.feed.acceptFeedMember(); + + expect(result.isSuccess, isTrue); + final member = result.getOrThrow(); + expect(member.user.id, currentUser.id); + expect(member.status, FeedMemberStatus.member); + }, + verify: (tester) => tester.verifyApi( + (api) => api.acceptFeedMemberInvite( + feedGroupId: feedId.group, + feedId: feedId.id, + ), + ), + ); + + feedTest( + 'rejectFeedMember() - should reject member invitation', + user: currentUser, + build: (client) => client.feedFromId(feedId), + setUp: (tester) { + tester.getOrCreate(); + + tester.mockApi( + (api) => api.rejectFeedMemberInvite( + feedGroupId: feedId.group, + feedId: feedId.id, + ), + result: RejectFeedMemberInviteResponse( + member: createDefaultFeedMemberResponse( + id: currentUser.id, + status: FeedMemberResponseStatus.rejected, + ), + duration: '0ms', + ), + ); + }, + body: (tester) async { + final result = await tester.feed.rejectFeedMember(); + + expect(result.isSuccess, isTrue); + final member = result.getOrThrow(); + expect(member.user.id, currentUser.id); + expect(member.status, FeedMemberStatus.rejected); + }, + verify: (tester) => tester.verifyApi( + (api) => api.rejectFeedMemberInvite( + feedGroupId: feedId.group, + feedId: feedId.id, + ), + ), + ); + }); + + // ========================================================================== + // Feed - Follows + // ========================================================================== + + group('Feed - Follows', () { + const feedId = FeedId(group: 'user', id: 'john'); + const targetFeedId = FeedId(group: 'group', id: 'target'); + + setUpAll(() { + registerFallbackValue( + const FollowRequest(source: 'user:john', target: 'user:target'), + ); + + registerFallbackValue( + const AcceptFollowRequest(source: 'user:john', target: 'user:target'), + ); + + registerFallbackValue( + const RejectFollowRequest(source: 'user:john', target: 'user:target'), + ); + }); + + feedTest( + 'queryFollowSuggestions() - should return list of FeedSuggestionData', + build: (client) => client.feedFromId(feedId), + body: (tester) async { + tester.mockApi( + (api) => api.getFollowSuggestions( + feedGroupId: feedId.group, + limit: any(named: 'limit'), + ), + result: createDefaultGetFollowSuggestionsResponse( + suggestions: [ + createDefaultFeedSuggestionResponse( + id: 'suggestion-1', + reason: 'Based on your interests', + recommendationScore: 0.95, + algorithmScores: {'relevance': 0.9, 'popularity': 0.85}, + ), + createDefaultFeedSuggestionResponse( + id: 'suggestion-2', + reason: 'Popular in your network', + recommendationScore: 0.88, + ), + ], + ), + ); + + final result = await tester.feed.queryFollowSuggestions(limit: 10); + + expect(result, isA>>()); + + final suggestions = result.getOrThrow(); + expect(suggestions.length, 2); + + final firstSuggestion = suggestions[0]; + expect(firstSuggestion.feed.id, 'suggestion-1'); + expect(firstSuggestion.reason, 'Based on your interests'); + expect(firstSuggestion.recommendationScore, 0.95); + expect(firstSuggestion.algorithmScores, isNotNull); + expect(firstSuggestion.algorithmScores!['relevance'], 0.9); + expect(firstSuggestion.algorithmScores!['popularity'], 0.85); + + final secondSuggestion = suggestions[1]; + expect(secondSuggestion.feed.id, 'suggestion-2'); + expect(secondSuggestion.reason, 'Popular in your network'); + expect(secondSuggestion.recommendationScore, 0.88); + }, + verify: (tester) => tester.verifyApi( + (api) => api.getFollowSuggestions( + feedGroupId: feedId.group, + limit: any(named: 'limit'), + ), + ), + ); + + feedTest( + 'follow() - should create follow relationship', + build: (client) => client.feedFromId(feedId), + body: (tester) async { + tester.mockApi( + (api) => api.follow(followRequest: any(named: 'followRequest')), + result: SingleFollowResponse( + duration: '0ms', + follow: FollowResponse( + createdAt: DateTime(2021, 1, 1), + custom: const {}, + followerRole: 'follower', + pushPreference: FollowResponsePushPreference.all, + sourceFeed: createDefaultFeedResponse( + id: feedId.id, + groupId: feedId.group, + ), + status: FollowResponseStatus.accepted, + targetFeed: createDefaultFeedResponse( + id: targetFeedId.id, + groupId: targetFeedId.group, + ), + updatedAt: DateTime(2021, 1, 1), + ), + ), + ); + + final result = await tester.feed.follow(targetFid: targetFeedId); + + expect(result, isA>()); + + final followData = result.getOrThrow(); + expect(followData.sourceFeed.fid.rawValue, feedId.rawValue); + expect(followData.targetFeed.fid.rawValue, targetFeedId.rawValue); + expect(followData.status, FollowStatus.accepted); + }, + verify: (tester) => tester.verifyApi( + (api) => api.follow(followRequest: any(named: 'followRequest')), + ), + ); + + feedTest( + 'unfollow() - should remove follow relationship', + build: (client) => client.feedFromId(feedId), + body: (tester) async { + tester.mockApi( + (api) => api.unfollow( + source: any(named: 'source'), + target: any(named: 'target'), + ), + result: UnfollowResponse( + duration: '0ms', + follow: FollowResponse( + createdAt: DateTime(2021, 1, 1), + custom: const {}, + followerRole: 'follower', + pushPreference: FollowResponsePushPreference.all, + sourceFeed: createDefaultFeedResponse( + id: feedId.id, + groupId: feedId.group, + ), + status: FollowResponseStatus.accepted, + targetFeed: createDefaultFeedResponse( + id: targetFeedId.id, + groupId: targetFeedId.group, + ), + updatedAt: DateTime(2021, 1, 1), + ), + ), + ); + + final result = await tester.feed.unfollow(targetFid: targetFeedId); + + expect(result, isA>()); + expect(result.isSuccess, isTrue); + }, + verify: (tester) => tester.verifyApi( + (api) => api.unfollow( + source: any(named: 'source'), + target: any(named: 'target'), + ), + ), + ); + + feedTest( + 'acceptFollow() - should accept follow request', + build: (client) => client.feedFromId(targetFeedId), + body: (tester) async { + const sourceFeedId = FeedId(group: 'user', id: 'source'); + tester.mockApi( + (api) => api.acceptFollow( + acceptFollowRequest: any(named: 'acceptFollowRequest'), + ), + result: AcceptFollowResponse( + duration: '0ms', + follow: FollowResponse( + createdAt: DateTime(2021, 1, 1), + custom: const {}, + followerRole: 'follower', + pushPreference: FollowResponsePushPreference.all, + sourceFeed: createDefaultFeedResponse( + id: sourceFeedId.id, + groupId: sourceFeedId.group, + ), + status: FollowResponseStatus.accepted, + targetFeed: createDefaultFeedResponse( + id: targetFeedId.id, + groupId: targetFeedId.group, + ), + updatedAt: DateTime(2021, 1, 1), + ), + ), + ); + + final result = await tester.feed.acceptFollow(sourceFid: sourceFeedId); + + expect(result, isA>()); + + final followData = result.getOrThrow(); + expect(followData.sourceFeed.fid.rawValue, sourceFeedId.rawValue); + expect(followData.targetFeed.fid.rawValue, targetFeedId.rawValue); + expect(followData.status, FollowStatus.accepted); + }, + verify: (tester) => tester.verifyApi( + (api) => api.acceptFollow( + acceptFollowRequest: any(named: 'acceptFollowRequest'), + ), + ), + ); + + feedTest( + 'rejectFollow() - should reject follow request', + build: (client) => client.feedFromId(targetFeedId), + body: (tester) async { + const sourceFeedId = FeedId(group: 'user', id: 'source'); + tester.mockApi( + (api) => api.rejectFollow( + rejectFollowRequest: any(named: 'rejectFollowRequest'), + ), + result: RejectFollowResponse( + duration: '0ms', + follow: FollowResponse( + createdAt: DateTime(2021, 1, 1), + custom: const {}, + followerRole: 'follower', + pushPreference: FollowResponsePushPreference.all, + sourceFeed: createDefaultFeedResponse( + id: sourceFeedId.id, + groupId: sourceFeedId.group, + ), + status: FollowResponseStatus.rejected, + targetFeed: createDefaultFeedResponse( + id: targetFeedId.id, + groupId: targetFeedId.group, + ), + updatedAt: DateTime(2021, 1, 1), + ), + ), + ); + + final result = await tester.feed.rejectFollow(sourceFid: sourceFeedId); + + expect(result, isA>()); + + final followData = result.getOrThrow(); + expect(followData.sourceFeed.fid.rawValue, sourceFeedId.rawValue); + expect(followData.targetFeed.fid.rawValue, targetFeedId.rawValue); + expect(followData.status, FollowStatus.rejected); + }, + verify: (tester) => tester.verifyApi( + (api) => api.rejectFollow( + rejectFollowRequest: any(named: 'rejectFollowRequest'), + ), + ), + ); + + // Follow Events + + feedTest( + 'FollowCreatedEvent - follow target feed should update follower count', + build: (client) => client.feedFromId(targetFeedId), + setUp: (tester) => tester.getOrCreate(), + body: (tester) async { + expect(tester.feedState.feed?.followerCount, 0); + expect(tester.feedState.feed?.followingCount, 0); + + await tester.emitEvent( + FollowCreatedEvent( + type: EventTypes.followCreated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: targetFeedId.toString(), + follow: FollowResponse( + createdAt: DateTime.timestamp(), + custom: const {}, + followerRole: 'followerRole', + pushPreference: FollowResponsePushPreference.none, + requestAcceptedAt: DateTime.timestamp(), + requestRejectedAt: DateTime.timestamp(), + sourceFeed: createDefaultFeedResponse( + id: 'john', + groupId: 'user', + followingCount: 1, + ), + status: FollowResponseStatus.accepted, + targetFeed: createDefaultFeedResponse( + id: targetFeedId.id, + groupId: targetFeedId.group, + followerCount: 1, + ), + updatedAt: DateTime.timestamp(), + ), + ), + ); + + expect(tester.feedState.feed?.followerCount, 1); + expect(tester.feedState.feed?.followingCount, 0); + }, + ); + + feedTest( + 'FollowCreatedEvent - follow source feed should update following count', + build: (client) => client.feed(group: 'user', id: 'john'), + setUp: (tester) => tester.getOrCreate(), + body: (tester) async { + expect(tester.feedState.feed?.followerCount, 0); + expect(tester.feedState.feed?.followingCount, 0); + + await tester.emitEvent( + FollowCreatedEvent( + type: EventTypes.followCreated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + follow: FollowResponse( + createdAt: DateTime.timestamp(), + custom: const {}, + followerRole: 'followerRole', + pushPreference: FollowResponsePushPreference.none, + requestAcceptedAt: DateTime.timestamp(), + requestRejectedAt: DateTime.timestamp(), + sourceFeed: createDefaultFeedResponse( + id: 'john', + groupId: 'user', + followingCount: 1, + ), + status: FollowResponseStatus.accepted, + targetFeed: createDefaultFeedResponse( + id: targetFeedId.id, + groupId: targetFeedId.group, + followerCount: 1, + ), + updatedAt: DateTime.timestamp(), + ), + ), + ); + + expect(tester.feedState.feed?.followerCount, 0); + expect(tester.feedState.feed?.followingCount, 1); + }, + ); + + feedTest( + 'FollowDeletedEvent - follow deleted target feed should update follower count', + build: (client) => client.feedFromId(targetFeedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + feed: createDefaultFeedResponse( + id: targetFeedId.id, + groupId: targetFeedId.group, + followerCount: 1, + followingCount: 1, + ), + ), + ), + body: (tester) async { + expect(tester.feedState.feed?.followerCount, 1); + expect(tester.feedState.feed?.followingCount, 1); + + await tester.emitEvent( + FollowDeletedEvent( + type: EventTypes.followDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: targetFeedId.toString(), + follow: FollowResponse( + createdAt: DateTime.timestamp(), + custom: const {}, + followerRole: 'followerRole', + pushPreference: FollowResponsePushPreference.none, + requestAcceptedAt: DateTime.timestamp(), + requestRejectedAt: DateTime.timestamp(), + sourceFeed: createDefaultFeedResponse( + id: 'john', + groupId: 'user', + followingCount: 0, + ), + status: FollowResponseStatus.accepted, + targetFeed: createDefaultFeedResponse( + id: targetFeedId.id, + groupId: targetFeedId.group, + followerCount: 0, + ), + updatedAt: DateTime.timestamp(), + ), + ), + ); + + expect(tester.feedState.feed?.followerCount, 0); + expect(tester.feedState.feed?.followingCount, 1); + }, + ); + + feedTest( + 'FollowDeletedEvent - follow deleted source feed should update following count', + build: (client) => client.feed(group: 'user', id: 'john'), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + feed: createDefaultFeedResponse( + id: 'john', + groupId: 'user', + followerCount: 1, + followingCount: 1, + ), + ), + ), + body: (tester) async { + expect(tester.feedState.feed?.followerCount, 1); + expect(tester.feedState.feed?.followingCount, 1); + + await tester.emitEvent( + FollowDeletedEvent( + type: EventTypes.followDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + follow: FollowResponse( + createdAt: DateTime.timestamp(), + custom: const {}, + followerRole: 'followerRole', + pushPreference: FollowResponsePushPreference.none, + requestAcceptedAt: DateTime.timestamp(), + requestRejectedAt: DateTime.timestamp(), + sourceFeed: createDefaultFeedResponse( + id: 'john', + groupId: 'user', + followingCount: 0, + ), + status: FollowResponseStatus.accepted, + targetFeed: createDefaultFeedResponse( + id: targetFeedId.id, + groupId: targetFeedId.group, + followerCount: 0, + ), + updatedAt: DateTime.timestamp(), + ), + ), + ); + + expect(tester.feedState.feed?.followerCount, 1); + expect(tester.feedState.feed?.followingCount, 0); + }, + ); + + feedTest( + 'FollowUpdatedEvent - should update follow relationship', + build: (client) => client.feedFromQuery( + const FeedQuery(fid: feedId, followerLimit: 10), + ), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + feed: createDefaultFeedResponse( + id: feedId.id, + groupId: feedId.group, + followerCount: 1, + followingCount: 0, + ), + followers: [ + createDefaultFollowResponse( + sourceId: 'source-1', + targetId: feedId.id, + ), + ], + ), + ), + body: (tester) async { + expect(tester.feedState.followers, hasLength(1)); + final initialFollow = tester.feedState.followers.first; + expect(initialFollow.custom, isEmpty); + + await tester.emitEvent( + FollowUpdatedEvent( + type: EventTypes.followUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + follow: FollowResponse( + createdAt: initialFollow.createdAt, + custom: const {'updated': true}, + followerRole: initialFollow.followerRole, + pushPreference: FollowResponsePushPreference.values.firstWhere( + (e) => e.name == initialFollow.pushPreference, + ), + requestAcceptedAt: initialFollow.requestAcceptedAt, + requestRejectedAt: initialFollow.requestRejectedAt, + sourceFeed: createDefaultFeedResponse( + id: initialFollow.sourceFeed.id, + groupId: initialFollow.sourceFeed.groupId, + ), + status: FollowResponseStatus.accepted, + targetFeed: createDefaultFeedResponse( + id: initialFollow.targetFeed.id, + groupId: initialFollow.targetFeed.groupId, + ), + updatedAt: DateTime.timestamp(), + ), + ), + ); + + expect(tester.feedState.followers, hasLength(1)); + final updatedFollow = tester.feedState.followers.first; + expect(updatedFollow.custom, isNotEmpty); + expect(updatedFollow.custom?['updated'], true); + }, + ); + }); + + // ========================================================================== + // Feed - Reactions + // ========================================================================== + + group('Feed - Reactions', () { + const feedId = FeedId(group: 'user', id: 'john'); + const userId = 'luke_skywalker'; + + setUpAll(() { + registerFallbackValue(const AddReactionRequest(type: 'like')); + }); + + feedTest( + 'addActivityReaction() - should add reaction to activity via API', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ), + ], + ), + ), + body: (tester) async { + // Initial state - no reactions + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownReactions, isEmpty); + + // Mock API call that will be used + tester.mockApi( + (api) => api.addActivityReaction( + activityId: 'activity-1', + addReactionRequest: any(named: 'addReactionRequest'), + ), + result: createDefaultAddReactionResponse( + activityId: 'activity-1', + userId: userId, + reactionType: 'heart', + ), + ); + + // Add reaction + final result = await tester.feed.addActivityReaction( + activityId: 'activity-1', + request: const AddReactionRequest(type: 'heart'), + ); + + expect(result.isSuccess, isTrue); + final reaction = result.getOrThrow(); + expect(reaction.activityId, 'activity-1'); + expect(reaction.type, 'heart'); + expect(reaction.user.id, userId); + }, + verify: (tester) => tester.verifyApi( + (api) => api.addActivityReaction( + activityId: 'activity-1', + addReactionRequest: any(named: 'addReactionRequest'), + ), + ), + ); + + feedTest( + 'ActivityReactionAddedEvent - should handle event and update activity', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ), + ], + ), + ), + body: (tester) async { + // Initial state - no reactions + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownReactions, isEmpty); - final firstUserStories = userStories.first.activities; - expect(firstUserStories, hasLength(2)); + // Emit ActivityReactionAddedEvent + await tester.emitEvent( + ActivityReactionAddedEvent( + type: EventTypes.activityReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse( + id: 'activity-1', + ownReactions: [ + createDefaultReactionResponse( + reactionType: 'heart', + userId: userId, + activityId: 'activity-1', + ), + ], + ), + reaction: createDefaultReactionResponse( + reactionType: 'heart', + userId: userId, + activityId: 'activity-1', + ), + ), + ); - final nextPageQuery = tester.feed.query.copyWith( - activityNext: tester.feedState.activitiesPagination?.next, + // Verify state was updated + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.ownReactions, hasLength(1)); + expect(updatedActivity.ownReactions.first.type, 'heart'); + expect(updatedActivity.ownReactions.first.user.id, userId); + expect(updatedActivity.reactionGroups['heart']?.count ?? 0, 1); + }, + ); + + feedTest( + 'ActivityReactionUpdatedEvent - should replace user reaction', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ownReactions: [ + createDefaultReactionResponse( + reactionType: 'heart', + userId: userId, + activityId: 'activity-1', + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has one heart reaction + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownReactions, hasLength(1)); + expect(initialActivity.ownReactions.first.type, 'heart'); + expect(initialActivity.reactionCount, 1); + + // Emit ActivityReactionUpdatedEvent - replaces existing 'heart' with 'fire' + await tester.emitEvent( + ActivityReactionUpdatedEvent( + type: EventTypes.activityReactionUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse( + id: 'activity-1', + latestReactions: [ + createDefaultReactionResponse( + reactionType: 'fire', + userId: userId, + activityId: 'activity-1', + ), + ], + ), + reaction: createDefaultReactionResponse( + reactionType: 'fire', + userId: userId, + activityId: 'activity-1', + ), + ), ); + // Verify state was updated + final updatedActivity = tester.feedState.activities.first; + // "Own" data: user now has 'fire' reaction instead of 'heart' + expect(updatedActivity.ownReactions, hasLength(1)); + expect(updatedActivity.ownReactions.first.type, 'fire'); + // Aggregate counts: still 1 reaction total (replaced, not added) + expect(updatedActivity.reactionCount, 1); + expect(updatedActivity.reactionGroups['fire']?.count ?? 0, 1); + expect(updatedActivity.reactionGroups['heart']?.count ?? 0, 0); + }, + ); + + feedTest( + 'deleteActivityReaction() - should delete reaction via API', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ownReactions: [ + createDefaultReactionResponse( + reactionType: 'heart', + userId: userId, + activityId: 'activity-1', + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reaction + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownReactions, hasLength(1)); + + // Mock API call that will be used tester.mockApi( - (api) => api.getOrCreateFeed( - feedId: feedId.id, - feedGroupId: feedId.group, - getOrCreateFeedRequest: nextPageQuery.toRequest(), + (api) => api.deleteActivityReaction( + activityId: 'activity-1', + type: 'heart', ), - result: createDefaultGetOrCreateFeedResponse( - prevPagination: 'prevPageToken', - aggregatedActivities: [ - createDefaultAggregatedActivityResponse( - group: 'group2', - activities: [ - createDefaultActivityResponse(id: 'storyActivityId3'), - ], - ), - ], + result: createDefaultDeleteReactionResponse( + activityId: 'activity-1', + userId: userId, + reactionType: 'heart', ), ); - // Fetch more activities - await tester.feed.queryMoreActivities(); + // Delete reaction + final result = await tester.feed.deleteActivityReaction( + activityId: 'activity-1', + type: 'heart', + ); - final updatedUserStories = tester.feedState.aggregatedActivities; - expect(updatedUserStories, hasLength(2)); + expect(result.isSuccess, isTrue); + final reaction = result.getOrThrow(); + expect(reaction.activityId, 'activity-1'); + expect(reaction.type, 'heart'); + expect(reaction.user.id, userId); - final lastUserStories = updatedUserStories.last.activities; - expect(lastUserStories, hasLength(1)); + // Note: deleteActivityReaction doesn't update state automatically + // State is only updated via events (ActivityReactionDeletedEvent) }, - verify: (tester) { - final nextPageQuery = tester.feed.query.copyWith( - activityNext: tester.feedState.activitiesPagination?.next, + verify: (tester) => tester.verifyApi( + (api) => api.deleteActivityReaction( + activityId: 'activity-1', + type: 'heart', + ), + ), + ); + + feedTest( + 'ActivityReactionDeletedEvent - should handle event and update activity', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ownReactions: [ + createDefaultReactionResponse( + reactionType: 'heart', + userId: userId, + activityId: 'activity-1', + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reaction + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownReactions, hasLength(1)); + + // Emit ActivityReactionDeletedEvent + await tester.emitEvent( + ActivityReactionDeletedEvent( + type: EventTypes.activityReactionDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse(id: 'activity-1').copyWith( + reactionGroups: const {}, + ), + reaction: createDefaultReactionResponse( + reactionType: 'heart', + userId: userId, + activityId: 'activity-1', + ), + ), + ); + + // Verify state was updated + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.ownReactions, isEmpty); + }, + ); + + feedTest( + 'should handle multiple reaction types (heart and fire) on same activity', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ), + ], + ), + ), + body: (tester) async { + // Initial state - no reactions + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownReactions, isEmpty); + expect(initialActivity.reactionGroups, isEmpty); + + // Add heart reaction via event + await tester.emitEvent( + ActivityReactionAddedEvent( + type: EventTypes.activityReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse( + id: 'activity-1', + ownReactions: [ + createDefaultReactionResponse( + reactionType: 'heart', + userId: userId, + activityId: 'activity-1', + ), + ], + ), + reaction: createDefaultReactionResponse( + reactionType: 'heart', + userId: userId, + activityId: 'activity-1', + ), + ), + ); + + final activityAfterHeart = tester.feedState.activities.first; + expect( + activityAfterHeart.ownReactions.any((r) => r.type == 'heart'), + isTrue, + ); + expect(activityAfterHeart.reactionGroups['heart']?.count ?? 0, 1); + + // Add fire reaction via event (should coexist with heart) + await tester.emitEvent( + ActivityReactionAddedEvent( + type: EventTypes.activityReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse(id: 'activity-1').copyWith( + reactionGroups: { + 'heart': ReactionGroupResponse( + count: 1, + firstReactionAt: DateTime.timestamp(), + lastReactionAt: DateTime.timestamp(), + ), + 'fire': ReactionGroupResponse( + count: 1, + firstReactionAt: DateTime.timestamp(), + lastReactionAt: DateTime.timestamp(), + ), + }, + ), + reaction: createDefaultReactionResponse( + reactionType: 'fire', + userId: userId, + activityId: 'activity-1', + ), + ), ); - tester.verifyApi( - (api) => api.getOrCreateFeed( - feedId: feedId.id, - feedGroupId: feedId.group, - getOrCreateFeedRequest: nextPageQuery.toRequest(), - ), + final activityAfterFire = tester.feedState.activities.first; + expect( + activityAfterFire.ownReactions.any((r) => r.type == 'heart'), + isTrue, + ); + expect( + activityAfterFire.ownReactions.any((r) => r.type == 'fire'), + isTrue, ); + expect(activityAfterFire.reactionGroups['heart']?.count ?? 0, 1); + expect(activityAfterFire.reactionGroups['fire']?.count ?? 0, 1); }, ); feedTest( - 'StoriesFeedUpdatedEvent should update aggregated activities', + 'should handle removing one reaction type while keeping another', build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( modifyResponse: (it) => it.copyWith( - aggregatedActivities: [ - createDefaultAggregatedActivityResponse( - group: 'group1', - activities: initialStories, + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ownReactions: [ + createDefaultReactionResponse( + reactionType: 'heart', + userId: userId, + activityId: 'activity-1', + ), + createDefaultReactionResponse( + reactionType: 'fire', + userId: userId, + activityId: 'activity-1', + ), + ], ), ], ), ), body: (tester) async { - final userStories = tester.feedState.aggregatedActivities; - expect(userStories, hasLength(1)); - - final firstUserStories = userStories.first.activities; - expect(firstUserStories, hasLength(2)); + // Initial state - has both reactions + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownReactions.length, 2); + expect(initialActivity.reactionGroups['heart']?.count ?? 0, 1); + expect(initialActivity.reactionGroups['fire']?.count ?? 0, 1); + // Delete heart reaction via event await tester.emitEvent( - StoriesFeedUpdatedEvent( - type: EventTypes.storiesFeedUpdated, + ActivityReactionDeletedEvent( + type: EventTypes.activityReactionDeleted, createdAt: DateTime.timestamp(), custom: const {}, fid: feedId.rawValue, - aggregatedActivities: [ - createDefaultAggregatedActivityResponse( - group: 'group1', - activities: [ - ...initialStories, - createDefaultActivityResponse(id: 'storyActivityId3'), - ], - ), - ], + activity: createDefaultActivityResponse(id: 'activity-1').copyWith( + reactionGroups: { + 'fire': ReactionGroupResponse( + count: 1, + firstReactionAt: DateTime.timestamp(), + lastReactionAt: DateTime.timestamp(), + ), + }, + ), + reaction: createDefaultReactionResponse( + reactionType: 'heart', + userId: userId, + activityId: 'activity-1', + ), ), ); - final updatedUserStories = tester.feedState.aggregatedActivities; - expect(updatedUserStories, hasLength(1)); - - final updatedFirstUserStories = updatedUserStories.first.activities; - expect(updatedFirstUserStories, hasLength(3)); + final activityAfterDelete = tester.feedState.activities.first; + expect(activityAfterDelete.ownReactions.length, 1); + expect(activityAfterDelete.ownReactions.first.type, 'fire'); + expect(activityAfterDelete.reactionGroups['heart']?.count ?? 0, 0); + expect(activityAfterDelete.reactionGroups['fire']?.count ?? 0, 1); }, ); }); - // ============================================================ - // FEATURE: Bookmarks - // ============================================================ + // ========================================================================== + // Feed - Polls + // ========================================================================== - group('Bookmarks', () { + group('Feed - Polls', () { + const pollId = 'poll-1'; const feedId = FeedId(group: 'user', id: 'john'); - const activityId = 'activity-1'; - const userId = 'luke_skywalker'; + + setUpAll(() { + registerFallbackValue(const CreatePollRequest(name: 'Fallback Poll')); + registerFallbackValue(const AddActivityRequest(type: 'poll', feeds: [])); + }); feedTest( - 'addBookmark - should add bookmark to activity via API', + 'createPoll() - should create poll and add activity', build: (client) => client.feedFromId(feedId), - setUp: (tester) => tester.getOrCreate( - modifyResponse: (it) => it.copyWith( - activities: [ - createDefaultActivityResponse( - id: activityId, - feeds: [feedId.rawValue], - ), - ], - ), - ), body: (tester) async { - // Mock API call that will be used tester.mockApi( - (api) => api.addBookmark( - activityId: activityId, - addBookmarkRequest: any(named: 'addBookmarkRequest'), + (api) => api.createPoll( + createPollRequest: any(named: 'createPollRequest'), ), - result: createDefaultAddBookmarkResponse( - userId: userId, - activityId: activityId, + result: PollResponse( + duration: '0ms', + poll: createDefaultPollResponse(id: pollId), ), ); - // Initial state - no bookmarks - final initialActivity = tester.feedState.activities.first; - expect(initialActivity.id, activityId); - expect(initialActivity.ownBookmarks, isEmpty); + tester.mockApi( + (api) => api.addActivity( + addActivityRequest: any(named: 'addActivityRequest'), + ), + result: AddActivityResponse( + duration: '0ms', + activity: createDefaultActivityResponse( + id: 'activity-1', + poll: createDefaultPollResponse(id: pollId), + ), + ), + ); - // Add bookmark - final result = await tester.feed.addBookmark(activityId: activityId); + final result = await tester.feed.createPoll( + activityType: 'poll', + request: const CreatePollRequest(name: 'Test Poll'), + ); - expect(result, isA>()); - final bookmark = result.getOrThrow(); - expect(bookmark.activity.id, activityId); - expect(bookmark.user.id, userId); + expect(result, isA>()); + final activity = result.getOrThrow(); - // Verify state was updated - final updatedActivity = tester.feedState.activities.first; - expect(updatedActivity.ownBookmarks, hasLength(1)); - expect(updatedActivity.ownBookmarks.first.id, bookmark.id); - expect(updatedActivity.ownBookmarks.first.user.id, userId); + expect(activity.id, 'activity-1'); + expect(activity.poll, isNotNull); + }, + verify: (tester) { + tester.verifyApi( + (api) => api.createPoll( + createPollRequest: any(named: 'createPollRequest'), + ), + ); + tester.verifyApi( + (api) => api.addActivity( + addActivityRequest: any(named: 'addActivityRequest'), + ), + ); }, - verify: (tester) => tester.verifyApi( - (api) => api.addBookmark( - activityId: activityId, - addBookmarkRequest: any(named: 'addBookmarkRequest'), - ), - ), ); feedTest( - 'addBookmark - should handle BookmarkAddedEvent and update activity', + 'PollDeletedFeedEvent - should remove poll from activity', build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( modifyResponse: (it) => it.copyWith( activities: [ createDefaultActivityResponse( - id: activityId, + id: 'activity-1', feeds: [feedId.rawValue], + poll: createDefaultPollResponse(id: pollId), ), ], ), ), body: (tester) async { - // Initial state - no bookmarks + // Initial state - activity has poll final initialActivity = tester.feedState.activities.first; - expect(initialActivity.ownBookmarks, isEmpty); + expect(initialActivity.poll, isNotNull); + expect(initialActivity.poll!.id, pollId); - // Emit BookmarkAddedEvent + // Emit PollDeletedFeedEvent await tester.emitEvent( - BookmarkAddedEvent( - type: EventTypes.bookmarkAdded, + PollDeletedFeedEvent( + type: EventTypes.pollDeleted, createdAt: DateTime.timestamp(), custom: const {}, - bookmark: createDefaultBookmarkResponse( - userId: userId, - activityId: activityId, - ), + fid: feedId.rawValue, + poll: createDefaultPollResponse(id: pollId), ), ); - // Verify state was updated + // Verify poll was removed final updatedActivity = tester.feedState.activities.first; - expect(updatedActivity.ownBookmarks, hasLength(1)); - expect(updatedActivity.ownBookmarks.first.user.id, userId); - expect(updatedActivity.ownBookmarks.first.activity.id, activityId); + expect(updatedActivity.poll, isNull); }, ); feedTest( - 'addBookmark - should handle both API call and event together', + 'PollUpdatedFeedEvent - should update poll in activity', build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( modifyResponse: (it) => it.copyWith( activities: [ createDefaultActivityResponse( - id: activityId, + id: 'activity-1', feeds: [feedId.rawValue], + poll: createDefaultPollResponse( + id: pollId, + ).copyWith(name: 'Original poll name'), ), ], ), ), body: (tester) async { - // Initial state - no bookmarks + // Initial state - poll has original name final initialActivity = tester.feedState.activities.first; - expect(initialActivity.ownBookmarks, isEmpty); + expect(initialActivity.poll, isNotNull); + expect(initialActivity.poll!.name, 'Original poll name'); - // Mock API call that will be used - tester.mockApi( - (api) => api.addBookmark( - activityId: activityId, - addBookmarkRequest: any(named: 'addBookmarkRequest'), - ), - result: createDefaultAddBookmarkResponse( - userId: userId, - activityId: activityId, - ), - ); - - // Add bookmark via API - final result = await tester.feed.addBookmark(activityId: activityId); - expect(result.isSuccess, isTrue); - - // Also emit event (simulating real-time update) + // Emit PollUpdatedFeedEvent await tester.emitEvent( - BookmarkAddedEvent( - type: EventTypes.bookmarkAdded, + PollUpdatedFeedEvent( + type: EventTypes.pollUpdated, createdAt: DateTime.timestamp(), custom: const {}, - bookmark: createDefaultBookmarkResponse( - userId: userId, - activityId: activityId, - ), + fid: feedId.rawValue, + poll: createDefaultPollResponse( + id: pollId, + ).copyWith(name: 'Updated poll name'), ), ); - // Verify state has bookmark (should not duplicate) + // Verify poll was updated final updatedActivity = tester.feedState.activities.first; - expect(updatedActivity.ownBookmarks, hasLength(1)); + expect(updatedActivity.poll, isNotNull); + expect(updatedActivity.poll!.name, 'Updated poll name'); }, ); feedTest( - 'addBookmark - should not update activity if activity ID does not match', + 'PollClosedFeedEvent - should mark poll as closed', build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( modifyResponse: (it) => it.copyWith( activities: [ createDefaultActivityResponse( - id: activityId, + id: 'activity-1', feeds: [feedId.rawValue], + poll: createDefaultPollResponse(id: pollId), ), ], ), ), body: (tester) async { - // Initial state - no bookmarks + // Initial state - poll is not closed final initialActivity = tester.feedState.activities.first; - expect(initialActivity.ownBookmarks, isEmpty); + expect(initialActivity.poll, isNotNull); + expect(initialActivity.poll!.isClosed, false); - // Emit BookmarkAddedEvent for different activity + // Emit PollClosedFeedEvent await tester.emitEvent( - BookmarkAddedEvent( - type: EventTypes.bookmarkAdded, + PollClosedFeedEvent( + type: EventTypes.pollClosed, createdAt: DateTime.timestamp(), custom: const {}, - bookmark: createDefaultBookmarkResponse( - userId: userId, - activityId: 'different-activity-id', - ), + fid: feedId.rawValue, + poll: createDefaultPollResponse( + id: pollId, + ).copyWith(isClosed: true), ), ); - // Verify state was not updated - final activity = tester.feedState.activities.first; - expect(activity.ownBookmarks, isEmpty); + // Verify poll is closed + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.poll, isNotNull); + expect(updatedActivity.poll!.isClosed, true); }, ); feedTest( - 'updateBookmark - should update bookmark via API', + 'PollVoteCastedFeedEvent - should add vote to poll', build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( modifyResponse: (it) => it.copyWith( activities: [ createDefaultActivityResponse( - id: activityId, + id: 'activity-1', feeds: [feedId.rawValue], - ownBookmarks: [ - createDefaultBookmarkResponse( - userId: userId, - activityId: activityId, - folderId: 'folder-id', - ), - ], + poll: createDefaultPollResponse(id: pollId), ), ], ), ), body: (tester) async { - // Initial state - has bookmark + // Initial state - no votes final initialActivity = tester.feedState.activities.first; - expect(initialActivity.ownBookmarks, hasLength(1)); - expect(initialActivity.ownBookmarks.first.folder?.id, 'folder-id'); + expect(initialActivity.poll, isNotNull); + expect(initialActivity.poll!.voteCount, 0); - // Mock API call that will be used - tester.mockApi( - (api) => api.updateBookmark( - activityId: activityId, - updateBookmarkRequest: any(named: 'updateBookmarkRequest'), - ), - result: createDefaultUpdateBookmarkResponse( - userId: userId, - activityId: activityId, - folderId: 'new-folder-id', - ), + final firstOption = initialActivity.poll!.options.first; + + final newVote = createDefaultPollVoteResponse( + id: 'vote-1', + pollId: pollId, + optionId: firstOption.id, ); - final result = await tester.feed.updateBookmark( - activityId: activityId, - request: const UpdateBookmarkRequest(folderId: 'new-folder-id'), + // Emit PollVoteCastedFeedEvent + await tester.emitEvent( + PollVoteCastedFeedEvent( + type: EventTypes.pollVoteCasted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + pollVote: newVote, + poll: createDefaultPollResponse( + id: pollId, + ownVotesAndAnswers: [newVote], + ), + ), ); - expect(result, isA>()); - final bookmark = result.getOrThrow(); - expect(bookmark.activity.id, activityId); - expect(bookmark.folder?.id, 'new-folder-id'); + // Verify vote was added + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.poll, isNotNull); + expect(updatedActivity.poll!.voteCount, 1); + expect(updatedActivity.poll!.voteCountsByOption[firstOption.id], 1); }, - verify: (tester) => tester.verifyApi( - (api) => api.updateBookmark( - activityId: activityId, - updateBookmarkRequest: any(named: 'updateBookmarkRequest'), - ), - ), ); - // Note: BookmarkUpdatedEvent is not currently handled in FeedEventHandler - // It's only handled in BookmarkListEventHandler for bookmark list state - // This test is skipped until BookmarkUpdatedEvent handling is added to FeedEventHandler - feedTest( - 'deleteBookmark - should delete bookmark via API', + 'PollVoteChangedFeedEvent - should update vote on poll', build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( modifyResponse: (it) => it.copyWith( activities: [ createDefaultActivityResponse( - id: activityId, + id: 'activity-1', feeds: [feedId.rawValue], - ownBookmarks: [ - createDefaultBookmarkResponse( - userId: userId, - activityId: activityId, - ), - ], + poll: createDefaultPollResponse( + id: pollId, + ownVotesAndAnswers: [ + createDefaultPollVoteResponse( + id: 'vote-1', + pollId: pollId, + optionId: 'option-1', + ), + ], + ), ), ], ), ), body: (tester) async { - // Initial state - has bookmark + // Initial state - has vote on option-1 final initialActivity = tester.feedState.activities.first; - expect(initialActivity.ownBookmarks, hasLength(1)); + expect(initialActivity.poll, isNotNull); + expect(initialActivity.poll!.options, hasLength(2)); + expect(initialActivity.poll!.voteCountsByOption['option-1'], 1); + + final changedVote = createDefaultPollVoteResponse( + id: 'vote-1', + pollId: pollId, + optionId: 'option-2', + ); - // Mock API call that will be used - tester.mockApi( - (api) => api.deleteBookmark( - activityId: activityId, - folderId: any(named: 'folderId'), - ), - result: createDefaultDeleteBookmarkResponse( - userId: userId, - activityId: activityId, + // Emit PollVoteChangedFeedEvent + await tester.emitEvent( + PollVoteChangedFeedEvent( + type: EventTypes.pollVoteChanged, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + pollVote: changedVote, + poll: createDefaultPollResponse( + id: pollId, + ownVotesAndAnswers: [changedVote], + ), ), ); - // Delete bookmark - final result = await tester.feed.deleteBookmark(activityId: activityId); + // Verify vote was changed to option-2 + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.poll, isNotNull); + expect(updatedActivity.poll!.voteCountsByOption['option-1'], null); + expect(updatedActivity.poll!.voteCountsByOption['option-2'], 1); + }, + ); + + feedTest( + 'PollVoteRemovedFeedEvent - should remove vote from poll', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + poll: createDefaultPollResponse( + id: pollId, + ownVotesAndAnswers: [ + createDefaultPollVoteResponse( + id: 'vote-1', + pollId: pollId, + optionId: 'option-1', + ), + ], + ), + ), + ], + ), + ), + body: (tester) async { + // Initial state - has one vote + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.poll, isNotNull); + expect(initialActivity.poll!.voteCount, 1); + + final pollVoteToRemove = createDefaultPollVoteResponse( + id: 'vote-1', + pollId: pollId, + optionId: 'option-1', + ); - expect(result, isA>()); - final bookmark = result.getOrThrow(); - expect(bookmark.activity.id, activityId); - expect(bookmark.user.id, userId); + // Emit PollVoteRemovedFeedEvent + await tester.emitEvent( + PollVoteRemovedFeedEvent( + type: EventTypes.pollVoteRemoved, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + pollVote: pollVoteToRemove, + poll: createDefaultPollResponse(id: pollId), + ), + ); - // Verify state was updated + // Verify vote was removed final updatedActivity = tester.feedState.activities.first; - expect(updatedActivity.ownBookmarks, isEmpty); + expect(updatedActivity.poll, isNotNull); + expect(updatedActivity.poll!.voteCount, 0); }, - verify: (tester) => tester.verifyApi( - (api) => api.deleteBookmark( - activityId: activityId, - folderId: any(named: 'folderId'), - ), - ), ); feedTest( - 'deleteBookmark - should handle BookmarkDeletedEvent and update activity', + 'PollAnswerCastedFeedEvent - should add answer to poll', build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( modifyResponse: (it) => it.copyWith( activities: [ createDefaultActivityResponse( - id: activityId, + id: 'activity-1', feeds: [feedId.rawValue], - ownBookmarks: [ - createDefaultBookmarkResponse( - userId: userId, - activityId: activityId, - ), - ], + poll: createDefaultPollResponse(id: pollId), ), ], ), ), body: (tester) async { - // Initial state - has bookmark + // Initial state - no answers final initialActivity = tester.feedState.activities.first; - expect(initialActivity.ownBookmarks, hasLength(1)); + expect(initialActivity.poll, isNotNull); + expect(initialActivity.poll!.answersCount, 0); - // Emit BookmarkDeletedEvent + final newAnswer = createDefaultPollAnswerResponse( + id: 'answer-1', + pollId: pollId, + answerText: 'My answer text', + ); + + // Emit PollAnswerCastedFeedEvent (resolved from PollVoteCastedFeedEvent) await tester.emitEvent( - BookmarkDeletedEvent( - type: EventTypes.bookmarkDeleted, + PollVoteCastedFeedEvent( + type: EventTypes.pollVoteCasted, createdAt: DateTime.timestamp(), custom: const {}, - bookmark: createDefaultBookmarkResponse( - userId: userId, - activityId: activityId, + fid: feedId.rawValue, + pollVote: newAnswer, + poll: createDefaultPollResponse( + id: pollId, + ownVotesAndAnswers: [newAnswer], ), ), ); - // Verify state was updated + // Verify answer was added final updatedActivity = tester.feedState.activities.first; - expect(updatedActivity.ownBookmarks, isEmpty); + expect(updatedActivity.poll, isNotNull); + expect(updatedActivity.poll!.answersCount, 1); + expect(updatedActivity.poll!.latestAnswers, hasLength(1)); }, ); feedTest( - 'deleteBookmark - should handle both API call and event together', + 'PollAnswerRemovedFeedEvent - should remove answer from poll', build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( modifyResponse: (it) => it.copyWith( activities: [ createDefaultActivityResponse( - id: activityId, + id: 'activity-1', feeds: [feedId.rawValue], - ownBookmarks: [ - createDefaultBookmarkResponse( - userId: userId, - activityId: activityId, - ), - ], + poll: createDefaultPollResponse( + id: pollId, + ownVotesAndAnswers: [ + createDefaultPollAnswerResponse( + id: 'answer-1', + pollId: pollId, + answerText: 'My answer text', + ), + ], + ), ), ], ), ), body: (tester) async { - // Initial state - has bookmark + // Initial state - has one answer final initialActivity = tester.feedState.activities.first; - expect(initialActivity.ownBookmarks, hasLength(1)); + expect(initialActivity.poll, isNotNull); + expect(initialActivity.poll!.answersCount, 1); - // Mock API call that will be used - tester.mockApi( - (api) => api.deleteBookmark( - activityId: activityId, - folderId: any(named: 'folderId'), - ), - result: createDefaultDeleteBookmarkResponse( - userId: userId, - activityId: activityId, - ), + final answerToRemove = createDefaultPollAnswerResponse( + id: 'answer-1', + pollId: pollId, + answerText: 'My answer text', ); - // Delete bookmark via API - final result = await tester.feed.deleteBookmark(activityId: activityId); - expect(result.isSuccess, isTrue); - - // Also emit event (simulating real-time update) + // Emit PollAnswerRemovedFeedEvent (resolved from PollVoteRemovedFeedEvent) await tester.emitEvent( - BookmarkDeletedEvent( - type: EventTypes.bookmarkDeleted, + PollVoteRemovedFeedEvent( + type: EventTypes.pollVoteRemoved, createdAt: DateTime.timestamp(), custom: const {}, - bookmark: createDefaultBookmarkResponse( - userId: userId, - activityId: activityId, - ), + fid: feedId.rawValue, + pollVote: answerToRemove, + poll: createDefaultPollResponse(id: pollId), ), ); - // Verify state has no bookmarks + // Verify answer was removed final updatedActivity = tester.feedState.activities.first; - expect(updatedActivity.ownBookmarks, isEmpty); + expect(updatedActivity.poll, isNotNull); + expect(updatedActivity.poll!.answersCount, 0); + expect(updatedActivity.poll!.latestAnswers, isEmpty); }, ); }); - // ============================================================ - // FEATURE: Reactions - // ============================================================ + // ========================================================================== + // Feed - Stories + // ========================================================================== - group('Reactions', () { - const feedId = FeedId(group: 'user', id: 'john'); - const activityId = 'activity-1'; - const userId = 'luke_skywalker'; + group('Feed - Stories', () { + const feedId = FeedId(group: 'stories', id: 'target'); + final initialStories = [ + createDefaultActivityResponse(id: 'storyActivityId1'), + createDefaultActivityResponse(id: 'storyActivityId2'), + ]; - setUpAll(() { - registerFallbackValue(const AddReactionRequest(type: 'like')); - }); + feedTest( + 'ActivityMarkEvent - watch story should update isWatched', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + aggregatedActivities: [ + createDefaultAggregatedActivityResponse( + group: 'group1', + activities: initialStories, + ), + ], + ), + ), + body: (tester) async { + final userStories = tester.feedState.aggregatedActivities; + expect(userStories, hasLength(1)); + + final firstUserStories = userStories.first.activities; + expect(firstUserStories, hasLength(2)); + expect(firstUserStories[0].isWatched ?? false, isFalse); + expect(firstUserStories[1].isWatched ?? false, isFalse); + + await tester.emitEvent( + ActivityMarkEvent( + type: EventTypes.activityMarked, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + markWatched: const ['storyActivityId1'], + ), + ); + + final updatedAllUserStories = tester.feedState.aggregatedActivities; + expect(updatedAllUserStories, hasLength(1)); + + final updatedFirstUserStories = updatedAllUserStories.first.activities; + expect(updatedFirstUserStories, hasLength(2)); + expect(updatedFirstUserStories[0].isWatched ?? false, isTrue); + expect(updatedFirstUserStories[1].isWatched ?? false, isFalse); + }, + ); feedTest( - 'addActivityReaction - should add reaction to activity via API', + 'queryMoreActivities() - pagination should load more aggregated activities', build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( modifyResponse: (it) => it.copyWith( - activities: [ - createDefaultActivityResponse( - id: activityId, - feeds: [feedId.rawValue], + next: 'nextPageToken', + aggregatedActivities: [ + createDefaultAggregatedActivityResponse( + group: 'group1', + activities: initialStories, ), ], ), ), body: (tester) async { - // Mock API call that will be used + final userStories = tester.feedState.aggregatedActivities; + expect(userStories, hasLength(1)); + + final firstUserStories = userStories.first.activities; + expect(firstUserStories, hasLength(2)); + + final nextPageQuery = tester.feed.query.copyWith( + activityNext: tester.feedState.activitiesPagination?.next, + ); + tester.mockApi( - (api) => api.addActivityReaction( - activityId: activityId, - addReactionRequest: any(named: 'addReactionRequest'), + (api) => api.getOrCreateFeed( + feedId: feedId.id, + feedGroupId: feedId.group, + getOrCreateFeedRequest: nextPageQuery.toRequest(), ), - result: createDefaultAddReactionResponse( - activityId: activityId, - userId: userId, - reactionType: 'heart', + result: createDefaultGetOrCreateFeedResponse( + prevPagination: 'prevPageToken', + aggregatedActivities: [ + createDefaultAggregatedActivityResponse( + group: 'group2', + activities: [ + createDefaultActivityResponse(id: 'storyActivityId3'), + ], + ), + ], ), ); - // Initial state - no reactions - final initialActivity = tester.feedState.activities.first; - expect(initialActivity.ownReactions, isEmpty); + // Fetch more activities + await tester.feed.queryMoreActivities(); - // Add reaction - final result = await tester.feed.addActivityReaction( - activityId: activityId, - request: const AddReactionRequest(type: 'heart'), - ); + final updatedUserStories = tester.feedState.aggregatedActivities; + expect(updatedUserStories, hasLength(2)); - expect(result.isSuccess, isTrue); - final reaction = result.getOrThrow(); - expect(reaction.activityId, activityId); - expect(reaction.type, 'heart'); - expect(reaction.user.id, userId); + final lastUserStories = updatedUserStories.last.activities; + expect(lastUserStories, hasLength(1)); + + tester.verifyApi( + (api) => api.getOrCreateFeed( + feedId: feedId.id, + feedGroupId: feedId.group, + getOrCreateFeedRequest: nextPageQuery.toRequest(), + ), + ); }, - verify: (tester) => tester.verifyApi( - (api) => api.addActivityReaction( - activityId: activityId, - addReactionRequest: any(named: 'addReactionRequest'), - ), - ), ); feedTest( - 'addActivityReaction - should handle ActivityReactionAddedEvent and update activity', + 'StoriesFeedUpdatedEvent - should update aggregated activities', build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( modifyResponse: (it) => it.copyWith( - activities: [ - createDefaultActivityResponse( - id: activityId, - feeds: [feedId.rawValue], + aggregatedActivities: [ + createDefaultAggregatedActivityResponse( + group: 'group1', + activities: initialStories, ), ], ), ), body: (tester) async { - // Initial state - no reactions - final initialActivity = tester.feedState.activities.first; - expect(initialActivity.ownReactions, isEmpty); + final userStories = tester.feedState.aggregatedActivities; + expect(userStories, hasLength(1)); + + final firstUserStories = userStories.first.activities; + expect(firstUserStories, hasLength(2)); - // Emit ActivityReactionAddedEvent await tester.emitEvent( - ActivityReactionAddedEvent( - type: EventTypes.activityReactionAdded, + StoriesFeedUpdatedEvent( + type: EventTypes.storiesFeedUpdated, createdAt: DateTime.timestamp(), custom: const {}, fid: feedId.rawValue, - activity: createDefaultActivityResponse(id: activityId).copyWith( - reactionGroups: { - 'heart': ReactionGroupResponse( - count: 1, - firstReactionAt: DateTime.timestamp(), - lastReactionAt: DateTime.timestamp(), - ), - }, - ), - reaction: FeedsReactionResponse( - activityId: activityId, - type: 'heart', - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), - ), + aggregatedActivities: [ + createDefaultAggregatedActivityResponse( + group: 'group1', + activities: [ + ...initialStories, + createDefaultActivityResponse(id: 'storyActivityId3'), + ], + ), + ], ), ); - // Verify state was updated - final updatedActivity = tester.feedState.activities.first; - expect(updatedActivity.ownReactions, hasLength(1)); - expect(updatedActivity.ownReactions.first.type, 'heart'); - expect(updatedActivity.ownReactions.first.user.id, userId); - expect(updatedActivity.reactionGroups['heart']?.count ?? 0, 1); + final updatedUserStories = tester.feedState.aggregatedActivities; + expect(updatedUserStories, hasLength(1)); + + final updatedFirstUserStories = updatedUserStories.first.activities; + expect(updatedFirstUserStories, hasLength(3)); }, ); - - feedTest( - 'deleteActivityReaction - should delete reaction via API', - build: (client) => client.feedFromId(feedId), - setUp: (tester) => tester.getOrCreate( - modifyResponse: (it) => it.copyWith( - activities: [ - createDefaultActivityResponse( - id: activityId, - feeds: [feedId.rawValue], - ownReactions: [ - FeedsReactionResponse( - activityId: activityId, - type: 'heart', - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), - ), - ], - reactionGroups: { - 'heart': ReactionGroupResponse( - count: 1, - firstReactionAt: DateTime.timestamp(), - lastReactionAt: DateTime.timestamp(), - ), - }, - ), - ], + }); + + // ========================================================================== + // Feed - Notifications + // ========================================================================== + + group('Feed - Notifications', () { + const feedId = FeedId(group: 'notification', id: 'john'); + final initialAggregatedActivities = [ + createDefaultAggregatedActivityResponse( + group: 'group1', + activities: [ + createDefaultActivityResponse(id: 'notification-1'), + ], + ), + ]; + + feedTest( + 'NotificationFeedUpdatedEvent - should update notification status and aggregated activities', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + aggregatedActivities: initialAggregatedActivities, + notificationStatus: const NotificationStatusResponse( + unseen: 5, + unread: 10, + ), ), ), body: (tester) async { - // Initial state - has reaction - final initialActivity = tester.feedState.activities.first; - expect(initialActivity.ownReactions, hasLength(1)); + expect(tester.feedState.aggregatedActivities, hasLength(1)); + expect(tester.feedState.notificationStatus?.unseen, 5); + expect(tester.feedState.notificationStatus?.unread, 10); - // Mock API call that will be used - tester.mockApi( - (api) => api.deleteActivityReaction( - activityId: activityId, - type: 'heart', - ), - result: createDefaultDeleteReactionResponse( - activityId: activityId, - userId: userId, - reactionType: 'heart', + await tester.emitEvent( + NotificationFeedUpdatedEvent( + type: EventTypes.notificationFeedUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + notificationStatus: const NotificationStatusResponse( + unseen: 2, + unread: 3, + ), + aggregatedActivities: [ + createDefaultAggregatedActivityResponse( + group: 'group2', + activities: [ + createDefaultActivityResponse(id: 'notification-2'), + ], + ), + ], ), ); - // Delete reaction - final result = await tester.feed.deleteActivityReaction( - activityId: activityId, - type: 'heart', + expect(tester.feedState.aggregatedActivities, hasLength(2)); + final updatedGroup = tester.feedState.aggregatedActivities.firstWhere( + (a) => a.group == 'group2', ); - - expect(result.isSuccess, isTrue); - final reaction = result.getOrThrow(); - expect(reaction.activityId, activityId); - expect(reaction.type, 'heart'); - expect(reaction.user.id, userId); - - // Note: deleteActivityReaction doesn't update state automatically - // State is only updated via events (ActivityReactionDeletedEvent) + expect(updatedGroup, isNotNull); + expect(tester.feedState.notificationStatus?.unseen, 2); + expect(tester.feedState.notificationStatus?.unread, 3); }, - verify: (tester) => tester.verifyApi( - (api) => api.deleteActivityReaction( - activityId: activityId, - type: 'heart', - ), - ), ); feedTest( - 'deleteActivityReaction - should handle ActivityReactionDeletedEvent and update activity', + 'ActivityMarkEvent - should mark activity as read', build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( - modifyResponse: (it) => it.copyWith( - activities: [ - createDefaultActivityResponse( - id: activityId, - feeds: [feedId.rawValue], - ownReactions: [ - FeedsReactionResponse( - activityId: activityId, - type: 'heart', - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), - ), - ], - reactionGroups: { - 'heart': ReactionGroupResponse( - count: 1, - firstReactionAt: DateTime.timestamp(), - lastReactionAt: DateTime.timestamp(), - ), - }, - ), - ], + modifyResponse: (response) => response.copyWith( + aggregatedActivities: initialAggregatedActivities, + notificationStatus: NotificationStatusResponse( + unseen: 0, + unread: 1, + seenActivities: const [], + readActivities: const [], + lastSeenAt: DateTime(2021, 1, 1), + lastReadAt: DateTime(2021, 1, 1), + ), ), ), body: (tester) async { - // Initial state - has reaction - final initialActivity = tester.feedState.activities.first; - expect(initialActivity.ownReactions, hasLength(1)); + expect(tester.feedState.aggregatedActivities, hasLength(1)); + expect(tester.feedState.notificationStatus?.unread, 1); + expect(tester.feedState.notificationStatus?.readActivities, isEmpty); - // Emit ActivityReactionDeletedEvent await tester.emitEvent( - ActivityReactionDeletedEvent( - type: EventTypes.activityReactionDeleted, + ActivityMarkEvent( + type: EventTypes.activityMarked, createdAt: DateTime.timestamp(), custom: const {}, fid: feedId.rawValue, - activity: createDefaultActivityResponse(id: activityId).copyWith( - reactionGroups: const {}, - ), - reaction: FeedsReactionResponse( - activityId: activityId, - type: 'heart', - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), - ), + markRead: const ['notification-1'], ), ); - // Verify state was updated - final updatedActivity = tester.feedState.activities.first; - expect(updatedActivity.ownReactions, isEmpty); + // Verify activity was marked as read + final notificationStatus = tester.feedState.notificationStatus; + expect(notificationStatus?.unread, 0); + expect(notificationStatus?.readActivities, contains('notification-1')); + expect(notificationStatus?.lastReadAt, isNotNull); }, ); feedTest( - 'should handle multiple reaction types (heart and fire) on same activity', + 'ActivityMarkEvent - should mark activity as seen', build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( - modifyResponse: (it) => it.copyWith( - activities: [ - createDefaultActivityResponse( - id: activityId, - feeds: [feedId.rawValue], - ), - ], + modifyResponse: (response) => response.copyWith( + aggregatedActivities: initialAggregatedActivities, + notificationStatus: NotificationStatusResponse( + unseen: 1, + unread: 0, + seenActivities: const [], + readActivities: const [], + lastSeenAt: DateTime(2021, 1, 1), + lastReadAt: DateTime(2021, 1, 1), + ), ), ), body: (tester) async { - // Initial state - no reactions - final initialActivity = tester.feedState.activities.first; - expect(initialActivity.ownReactions, isEmpty); - expect(initialActivity.reactionGroups, isEmpty); + expect(tester.feedState.aggregatedActivities, hasLength(1)); + expect(tester.feedState.notificationStatus?.unseen, 1); + expect(tester.feedState.notificationStatus?.seenActivities, isEmpty); - // Add heart reaction via event await tester.emitEvent( - ActivityReactionAddedEvent( - type: EventTypes.activityReactionAdded, + ActivityMarkEvent( + type: EventTypes.activityMarked, createdAt: DateTime.timestamp(), custom: const {}, fid: feedId.rawValue, - activity: createDefaultActivityResponse(id: activityId).copyWith( - reactionGroups: { - 'heart': ReactionGroupResponse( - count: 1, - firstReactionAt: DateTime.timestamp(), - lastReactionAt: DateTime.timestamp(), - ), - }, - ), - reaction: FeedsReactionResponse( - activityId: activityId, - type: 'heart', - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), - ), + markSeen: const ['notification-1'], ), ); - final activityAfterHeart = tester.feedState.activities.first; - expect( - activityAfterHeart.ownReactions.any((r) => r.type == 'heart'), - isTrue, - ); - expect(activityAfterHeart.reactionGroups['heart']?.count ?? 0, 1); + // Verify activity was marked as seen + final notificationStatus = tester.feedState.notificationStatus; + expect(notificationStatus?.unseen, 0); + expect(notificationStatus?.seenActivities, contains('notification-1')); + expect(notificationStatus?.lastSeenAt, isNotNull); + }, + ); + + feedTest( + 'ActivityMarkEvent - should mark all activities as read', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (response) => response.copyWith( + aggregatedActivities: [ + createDefaultAggregatedActivityResponse( + group: 'group1', + activities: [ + createDefaultActivityResponse(id: 'notification-1'), + createDefaultActivityResponse(id: 'notification-2'), + createDefaultActivityResponse(id: 'notification-3'), + ], + ), + ], + notificationStatus: NotificationStatusResponse( + unseen: 0, + unread: 3, + seenActivities: const [], + readActivities: const [], + lastSeenAt: DateTime(2021, 1, 1), + lastReadAt: DateTime(2021, 1, 1), + ), + ), + ), + body: (tester) async { + expect(tester.feedState.aggregatedActivities, hasLength(1)); + expect(tester.feedState.notificationStatus?.unread, 3); + expect(tester.feedState.notificationStatus?.readActivities, isEmpty); - // Add fire reaction via event (should coexist with heart) await tester.emitEvent( - ActivityReactionAddedEvent( - type: EventTypes.activityReactionAdded, + ActivityMarkEvent( + type: EventTypes.activityMarked, createdAt: DateTime.timestamp(), custom: const {}, fid: feedId.rawValue, - activity: createDefaultActivityResponse(id: activityId).copyWith( - reactionGroups: { - 'heart': ReactionGroupResponse( - count: 1, - firstReactionAt: DateTime.timestamp(), - lastReactionAt: DateTime.timestamp(), - ), - 'fire': ReactionGroupResponse( - count: 1, - firstReactionAt: DateTime.timestamp(), - lastReactionAt: DateTime.timestamp(), - ), - }, - ), - reaction: FeedsReactionResponse( - activityId: activityId, - type: 'fire', - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), - ), + markAllRead: true, ), ); - final activityAfterFire = tester.feedState.activities.first; - expect( - activityAfterFire.ownReactions.any((r) => r.type == 'heart'), - isTrue, - ); - expect( - activityAfterFire.ownReactions.any((r) => r.type == 'fire'), - isTrue, - ); - expect(activityAfterFire.reactionGroups['heart']?.count ?? 0, 1); - expect(activityAfterFire.reactionGroups['fire']?.count ?? 0, 1); + // Verify all activities were marked as read + final notificationStatus = tester.feedState.notificationStatus; + expect(notificationStatus?.unread, 0); + expect(notificationStatus?.lastReadAt, isNotNull); }, ); feedTest( - 'should handle removing one reaction type while keeping another', + 'ActivityMarkEvent - should mark all activities as seen', build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( - modifyResponse: (it) => it.copyWith( - activities: [ - createDefaultActivityResponse( - id: activityId, - feeds: [feedId.rawValue], - ownReactions: [ - FeedsReactionResponse( - activityId: activityId, - type: 'heart', - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), - ), - FeedsReactionResponse( - activityId: activityId, - type: 'fire', - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), - ), + modifyResponse: (response) => response.copyWith( + aggregatedActivities: [ + createDefaultAggregatedActivityResponse( + group: 'group1', + activities: [ + createDefaultActivityResponse(id: 'notification-1'), + createDefaultActivityResponse(id: 'notification-2'), + createDefaultActivityResponse(id: 'notification-3'), ], - reactionGroups: { - 'heart': ReactionGroupResponse( - count: 1, - firstReactionAt: DateTime.timestamp(), - lastReactionAt: DateTime.timestamp(), - ), - 'fire': ReactionGroupResponse( - count: 1, - firstReactionAt: DateTime.timestamp(), - lastReactionAt: DateTime.timestamp(), - ), - }, ), ], + notificationStatus: NotificationStatusResponse( + unseen: 3, + unread: 0, + seenActivities: const [], + readActivities: const [], + lastSeenAt: DateTime(2021, 1, 1), + lastReadAt: DateTime(2021, 1, 1), + ), ), ), body: (tester) async { - // Initial state - has both reactions - final initialActivity = tester.feedState.activities.first; - expect(initialActivity.ownReactions.length, 2); - expect(initialActivity.reactionGroups['heart']?.count ?? 0, 1); - expect(initialActivity.reactionGroups['fire']?.count ?? 0, 1); + expect(tester.feedState.aggregatedActivities, hasLength(1)); + expect(tester.feedState.notificationStatus?.unseen, 3); + expect(tester.feedState.notificationStatus?.seenActivities, isEmpty); - // Delete heart reaction via event await tester.emitEvent( - ActivityReactionDeletedEvent( - type: EventTypes.activityReactionDeleted, + ActivityMarkEvent( + type: EventTypes.activityMarked, createdAt: DateTime.timestamp(), custom: const {}, fid: feedId.rawValue, - activity: createDefaultActivityResponse(id: activityId).copyWith( - reactionGroups: { - 'fire': ReactionGroupResponse( - count: 1, - firstReactionAt: DateTime.timestamp(), - lastReactionAt: DateTime.timestamp(), - ), - }, - ), - reaction: FeedsReactionResponse( - activityId: activityId, - type: 'heart', - createdAt: DateTime.timestamp(), - updatedAt: DateTime.timestamp(), - user: createDefaultUserResponse(id: userId), - ), + markAllSeen: true, ), ); - final activityAfterDelete = tester.feedState.activities.first; - expect(activityAfterDelete.ownReactions.length, 1); - expect(activityAfterDelete.ownReactions.first.type, 'fire'); - expect(activityAfterDelete.reactionGroups['heart']?.count ?? 0, 0); - expect(activityAfterDelete.reactionGroups['fire']?.count ?? 0, 1); + // Verify all activities were marked as seen + final notificationStatus = tester.feedState.notificationStatus; + expect(notificationStatus?.unseen, 0); + expect(notificationStatus?.lastSeenAt, isNotNull); }, ); }); - // ============================================================ - // FEATURE: OnNewActivity - // ============================================================ + // ========================================================================== + // Feed - OnNewActivity + // ========================================================================== - group('OnNewActivity', () { + group('Feed - OnNewActivity', () { const feedId = FeedId(group: 'user', id: 'john'); const currentUser = User(id: 'luke_skywalker'); const otherUser = User(id: 'other_user'); @@ -2102,82 +4174,4 @@ void main() { }, ); }); - - // ============================================================ - // FEATURE: Feed Updated Event - // ============================================================ - - group('FeedUpdatedEvent', () { - const feedId = FeedId(group: 'user', id: 'john'); - const currentUser = User(id: 'luke_skywalker'); - - feedTest( - 'should preserve own fields when feed is updated', - user: currentUser, - build: (client) => client.feedFromId(feedId), - setUp: (tester) => tester.getOrCreate( - modifyResponse: (it) => it.copyWith( - feed: createDefaultFeedResponse( - id: feedId.id, - groupId: feedId.group, - ownCapabilities: [ - FeedOwnCapability.createFeed, - FeedOwnCapability.deleteFeed, - ], - ownMembership: createDefaultFeedMemberResponse( - id: currentUser.id, - role: 'admin', - ), - ownFollows: [ - createDefaultFollowResponse(id: 'follow-1'), - createDefaultFollowResponse(id: 'follow-2'), - ], - ), - ), - ), - body: (tester) async { - // Verify initial state has own fields - final initialFeed = tester.feedState.feed; - expect(initialFeed, isNotNull); - expect(initialFeed!.ownCapabilities, hasLength(2)); - expect(initialFeed.ownMembership, isNotNull); - expect(initialFeed.ownFollows, hasLength(2)); - - final originalCapabilities = initialFeed.ownCapabilities; - final originalMembership = initialFeed.ownMembership; - final originalFollows = initialFeed.ownFollows; - - // Emit FeedUpdatedEvent without own fields - await tester.emitEvent( - FeedUpdatedEvent( - type: EventTypes.feedUpdated, - createdAt: DateTime.timestamp(), - custom: const {}, - fid: feedId.rawValue, - feed: createDefaultFeedResponse( - id: feedId.id, - groupId: feedId.group, - ).copyWith( - name: 'Updated Name', - description: 'Updated Description', - followerCount: 100, - // Note: ownCapabilities, ownMembership, ownFollows are not included - ), - ), - ); - - // Verify own fields are preserved - final updatedFeed = tester.feedState.feed; - expect(updatedFeed, isNotNull); - expect(updatedFeed!.name, equals('Updated Name')); - expect(updatedFeed.description, equals('Updated Description')); - expect(updatedFeed.followerCount, equals(100)); - - // Own fields should be preserved - expect(updatedFeed.ownCapabilities, equals(originalCapabilities)); - expect(updatedFeed.ownMembership, equals(originalMembership)); - expect(updatedFeed.ownFollows, equals(originalFollows)); - }, - ); - }); } diff --git a/packages/stream_feeds/test/state/follow_list_test.dart b/packages/stream_feeds/test/state/follow_list_test.dart index ea270d84..aec732a2 100644 --- a/packages/stream_feeds/test/state/follow_list_test.dart +++ b/packages/stream_feeds/test/state/follow_list_test.dart @@ -1,17 +1,268 @@ +import 'package:collection/collection.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:stream_feeds_test/stream_feeds_test.dart'; void main() { + // ============================================================ + // FEATURE: Query Operations + // ============================================================ + + group('Follow List - Query Operations', () { + followListTest( + 'get - should query initial follows via API', + build: (client) => client.followList(const FollowsQuery()), + body: (tester) async { + final result = await tester.get(); + + expect(result, isA>>()); + final follows = result.getOrThrow(); + + expect(follows, isA>()); + expect(follows, hasLength(3)); + }, + ); + + followListTest( + 'queryMoreFollows - should load more follows via API', + build: (client) => client.followList(const FollowsQuery()), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + next: 'next-cursor', + follows: [ + createDefaultFollowResponse( + sourceId: 'source-1', + targetId: 'target-1', + ), + createDefaultFollowResponse( + sourceId: 'source-2', + targetId: 'target-2', + ), + createDefaultFollowResponse( + sourceId: 'source-3', + targetId: 'target-3', + ), + ], + ), + ), + body: (tester) async { + // Initial state - has follows + expect(tester.followListState.follows, hasLength(3)); + expect(tester.followListState.canLoadMore, isTrue); + + final nextPageQuery = tester.followList.query.copyWith( + next: tester.followListState.pagination?.next, + ); + + tester.mockApi( + (api) => api.queryFollows( + queryFollowsRequest: nextPageQuery.toRequest(), + ), + result: QueryFollowsResponse( + duration: DateTime.now().toIso8601String(), + follows: [ + createDefaultFollowResponse( + sourceId: 'source-4', + targetId: 'target-4', + ), + ], + ), + ); + + // Query more follows + final result = await tester.followList.queryMoreFollows(); + + expect(result.isSuccess, isTrue); + final follows = result.getOrNull(); + expect(follows, isNotNull); + expect(follows, hasLength(1)); + + // Verify state was updated with merged follows + expect(tester.followListState.follows, hasLength(4)); + expect(tester.followListState.canLoadMore, isFalse); + + tester.verifyApi( + (api) => api.queryFollows( + queryFollowsRequest: nextPageQuery.toRequest(), + ), + ); + }, + ); + + followListTest( + 'queryMoreFollows - should return empty list when no more follows', + build: (client) => client.followList(const FollowsQuery()), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + follows: [ + createDefaultFollowResponse( + sourceId: 'source-1', + targetId: 'target-1', + ), + createDefaultFollowResponse( + sourceId: 'source-2', + targetId: 'target-2', + ), + createDefaultFollowResponse( + sourceId: 'source-3', + targetId: 'target-3', + ), + ], + ), + ), + body: (tester) async { + // Initial state - has follows but no pagination + expect(tester.followListState.follows, hasLength(3)); + expect(tester.followListState.canLoadMore, isFalse); + // Query more follows (should return empty immediately) + final result = await tester.followList.queryMoreFollows(); + + expect(result.isSuccess, isTrue); + final follows = result.getOrNull(); + expect(follows, isEmpty); + + // State should remain unchanged + expect(tester.followListState.follows, hasLength(3)); + expect(tester.followListState.canLoadMore, isFalse); + }, + ); + }); + + // ============================================================ + // FEATURE: Event Handling + // ============================================================ + + group('Follow List - Event Handling', () { + final initialFollows = [ + createDefaultFollowResponse( + sourceId: 'source-1', + targetId: 'target-1', + ), + createDefaultFollowResponse( + sourceId: 'source-2', + targetId: 'target-2', + ), + createDefaultFollowResponse( + sourceId: 'source-3', + targetId: 'target-3', + ), + ]; + + followListTest( + 'FollowCreatedEvent - should add follow', + build: (client) => client.followList(const FollowsQuery()), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(follows: initialFollows), + ), + body: (tester) async { + // Initial state - has follows + expect(tester.followListState.follows, hasLength(3)); + + // Emit event + await tester.emitEvent( + FollowCreatedEvent( + type: EventTypes.followCreated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + follow: createDefaultFollowResponse( + sourceId: 'source-4', + targetId: 'target-4', + ), + ), + ); + + // Verify follow was added + expect(tester.followListState.follows, hasLength(4)); + }, + ); + + followListTest( + 'FollowUpdatedEvent - should update follow', + build: (client) => client.followList(const FollowsQuery()), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(follows: initialFollows), + ), + body: (tester) async { + // Emit event with same createdAt to match the follow ID + await tester.emitEvent( + FollowUpdatedEvent( + type: EventTypes.followUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + follow: createDefaultFollowResponse( + sourceId: 'source-1', + targetId: 'target-1', + status: FollowResponseStatus.rejected, + ), + ), + ); + + // Verify follow was updated + final updatedFollow = tester.followListState.follows.firstWhereOrNull( + (f) => f.sourceFeed.id == 'source-1' && f.targetFeed.id == 'target-1', + ); + + expect(updatedFollow, isNotNull); + expect(updatedFollow!.status, 'rejected'); + expect(tester.followListState.follows, hasLength(3)); + }, + ); + + followListTest( + 'FollowDeletedEvent - should remove follow', + build: (client) => client.followList(const FollowsQuery()), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(follows: initialFollows), + ), + body: (tester) async { + // Verify initial state + expect(tester.followListState.follows, hasLength(3)); + + // Emit event + await tester.emitEvent( + FollowDeletedEvent( + type: EventTypes.followDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + follow: createDefaultFollowResponse( + sourceId: 'source-1', + targetId: 'target-1', + ), + ), + ); + + // Verify follow was removed + final deletedFollow = tester.followListState.follows.firstWhereOrNull( + (f) => f.sourceFeed.id == 'source-1' && f.targetFeed.id == 'target-1', + ); + + expect(deletedFollow, isNull); + expect(tester.followListState.follows, hasLength(2)); + }, + ); + }); + // ============================================================ // FEATURE: Local Filtering // ============================================================ - group('FollowListEventHandler - Local filtering', () { + group('Follow List - Local filtering', () { final initialFollows = [ - createDefaultFollowResponse(id: 'follow-1'), - createDefaultFollowResponse(id: 'follow-2'), - createDefaultFollowResponse(id: 'follow-3'), + createDefaultFollowResponse( + sourceId: 'source-1', + targetId: 'target-1', + ), + createDefaultFollowResponse( + sourceId: 'source-2', + targetId: 'target-2', + ), + createDefaultFollowResponse( + sourceId: 'source-3', + targetId: 'target-3', + ), ]; followListTest( @@ -37,8 +288,8 @@ void main() { custom: const {}, fid: 'user:follow-1', follow: createDefaultFollowResponse( - id: 'follow-1', - ).copyWith( + sourceId: 'source-1', + targetId: 'target-1', status: FollowResponseStatus.rejected, ), ), @@ -67,8 +318,8 @@ void main() { custom: const {}, fid: 'user:follow-1', follow: createDefaultFollowResponse( - id: 'follow-1', - ).copyWith( + sourceId: 'source-1', + targetId: 'target-1', status: FollowResponseStatus.rejected, ), ), diff --git a/packages/stream_feeds/test/state/member_list_test.dart b/packages/stream_feeds/test/state/member_list_test.dart new file mode 100644 index 00000000..0d075071 --- /dev/null +++ b/packages/stream_feeds/test/state/member_list_test.dart @@ -0,0 +1,318 @@ +import 'package:collection/collection.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import 'package:stream_feeds_test/stream_feeds_test.dart'; + +void main() { + const query = MembersQuery(fid: FeedId.user('john')); + + // ============================================================ + // FEATURE: Query Operations + // ============================================================ + + group('Member List - Query Operations', () { + memberListTest( + 'get - should query initial members via API', + build: (client) => client.memberList(query), + body: (tester) async { + final result = await tester.get(); + + expect(result, isA>>()); + final members = result.getOrThrow(); + + expect(members, isA>()); + expect(members, hasLength(3)); + }, + ); + + memberListTest( + 'queryMoreMembers - should load more members via API', + build: (client) => client.memberList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + next: 'next-cursor', + members: [ + createDefaultFeedMemberResponse(id: 'member-1'), + createDefaultFeedMemberResponse(id: 'member-2'), + createDefaultFeedMemberResponse(id: 'member-3'), + ], + ), + ), + body: (tester) async { + // Initial state - has members + expect(tester.memberListState.members, hasLength(3)); + expect(tester.memberListState.canLoadMore, isTrue); + + final nextPageQuery = tester.memberList.query.copyWith( + next: tester.memberListState.pagination?.next, + ); + + tester.mockApi( + (api) => api.queryFeedMembers( + feedGroupId: nextPageQuery.fid.group, + feedId: nextPageQuery.fid.id, + queryFeedMembersRequest: nextPageQuery.toRequest(), + ), + result: createDefaultQueryFeedMembersResponse( + members: [ + createDefaultFeedMemberResponse(id: 'member-4'), + ], + ), + ); + + // Query more members + final result = await tester.memberList.queryMoreMembers(); + + expect(result.isSuccess, isTrue); + final members = result.getOrNull(); + expect(members, isNotNull); + expect(members, hasLength(1)); + + // Verify state was updated with merged members + expect(tester.memberListState.members, hasLength(4)); + expect(tester.memberListState.canLoadMore, isFalse); + + tester.verifyApi( + (api) => api.queryFeedMembers( + feedGroupId: nextPageQuery.fid.group, + feedId: nextPageQuery.fid.id, + queryFeedMembersRequest: nextPageQuery.toRequest(), + ), + ); + }, + ); + + memberListTest( + 'queryMoreMembers - should return empty list when no more members', + build: (client) => client.memberList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + members: [ + createDefaultFeedMemberResponse(id: 'member-1'), + createDefaultFeedMemberResponse(id: 'member-2'), + createDefaultFeedMemberResponse(id: 'member-3'), + ], + ), + ), + body: (tester) async { + // Initial state - has members but no pagination + expect(tester.memberListState.members, hasLength(3)); + expect(tester.memberListState.canLoadMore, isFalse); + // Query more members (should return empty immediately) + final result = await tester.memberList.queryMoreMembers(); + + expect(result.isSuccess, isTrue); + final members = result.getOrNull(); + expect(members, isEmpty); + + // State should remain unchanged + expect(tester.memberListState.members, hasLength(3)); + expect(tester.memberListState.canLoadMore, isFalse); + }, + ); + }); + + // ============================================================ + // FEATURE: Event Handling + // ============================================================ + + group('Member List - Event Handling', () { + // Note: Member ID is simply user.id + final initialMembers = [ + createDefaultFeedMemberResponse(id: 'member-1'), + createDefaultFeedMemberResponse(id: 'member-2'), + createDefaultFeedMemberResponse(id: 'member-3'), + ]; + + memberListTest( + 'FeedMemberAddedEvent - should add member', + build: (client) => client.memberList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(members: initialMembers), + ), + body: (tester) async { + // Initial state - has members + expect(tester.memberListState.members, hasLength(3)); + + // Emit event + await tester.emitEvent( + FeedMemberAddedEvent( + type: EventTypes.feedMemberAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: query.fid.rawValue, + member: createDefaultFeedMemberResponse(id: 'member-4'), + ), + ); + + // Verify member was added + expect(tester.memberListState.members, hasLength(4)); + expect( + tester.memberListState.members.any((m) => m.user.id == 'member-4'), + isTrue, + ); + }, + ); + + memberListTest( + 'FeedMemberUpdatedEvent - should update member', + build: (client) => client.memberList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(members: initialMembers), + ), + body: (tester) async { + // Emit event + await tester.emitEvent( + FeedMemberUpdatedEvent( + type: EventTypes.feedMemberUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: query.fid.rawValue, + member: createDefaultFeedMemberResponse( + id: 'member-1', + role: 'admin', + ), + ), + ); + + // Verify member was updated + final updatedMember = tester.memberListState.members.firstWhereOrNull( + (m) => m.user.id == 'member-1', + ); + + expect(updatedMember, isNotNull); + expect(updatedMember!.role, 'admin'); + expect(tester.memberListState.members, hasLength(3)); + }, + ); + + memberListTest( + 'FeedMemberRemovedEvent - should remove member', + build: (client) => client.memberList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(members: initialMembers), + ), + body: (tester) async { + // Verify initial state + expect(tester.memberListState.members, hasLength(3)); + final memberToRemove = tester.memberListState.members.first; + + // Emit event + await tester.emitEvent( + FeedMemberRemovedEvent( + type: EventTypes.feedMemberRemoved, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: query.fid.rawValue, + memberId: memberToRemove.id, + ), + ); + + // Verify member was removed + final deletedMember = tester.memberListState.members.firstWhereOrNull( + (m) => m.id == memberToRemove.id, + ); + + expect(deletedMember, isNull); + expect(tester.memberListState.members, hasLength(2)); + }, + ); + + memberListTest( + 'FeedMemberAddedEvent - should not add member for different feed', + build: (client) => client.memberList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(members: initialMembers), + ), + body: (tester) async { + // Initial state - has members + expect(tester.memberListState.members, hasLength(3)); + + // Emit event for different feed + await tester.emitEvent( + FeedMemberAddedEvent( + type: EventTypes.feedMemberAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:different-feed', + member: createDefaultFeedMemberResponse(id: 'member-4'), + ), + ); + + // Verify member was not added + expect(tester.memberListState.members, hasLength(3)); + }, + ); + }); + + // ============================================================ + // FEATURE: Local Filtering + // ============================================================ + + group('Member List - Local filtering', () { + final initialMembers = [ + createDefaultFeedMemberResponse(id: 'member-1'), + createDefaultFeedMemberResponse(id: 'member-2'), + createDefaultFeedMemberResponse(id: 'member-3'), + ]; + + memberListTest( + 'FeedMemberUpdatedEvent - should remove member when updated to non-matching status', + build: (client) => client.memberList( + query.copyWith( + filter: Filter.equal( + MembersFilterField.status, + FeedMemberStatus.member, + ), + ), + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(members: initialMembers), + ), + body: (tester) async { + expect(tester.memberListState.members, hasLength(3)); + + await tester.emitEvent( + FeedMemberUpdatedEvent( + type: EventTypes.feedMemberUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: query.fid.rawValue, + member: createDefaultFeedMemberResponse( + id: 'member-1', + status: FeedMemberResponseStatus.pending, + ), + ), + ); + + expect(tester.memberListState.members, hasLength(2)); + }, + ); + + memberListTest( + 'No filter - should not remove member when no filter specified', + build: (client) => client.memberList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(members: initialMembers), + ), + body: (tester) async { + expect(tester.memberListState.members, hasLength(3)); + + await tester.emitEvent( + FeedMemberUpdatedEvent( + type: EventTypes.feedMemberUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: query.fid.rawValue, + member: createDefaultFeedMemberResponse( + id: 'member-1', + status: FeedMemberResponseStatus.pending, + ), + ), + ); + + expect(tester.memberListState.members, hasLength(3)); + }, + ); + }); +} diff --git a/packages/stream_feeds/test/state/moderation_config_list_test.dart b/packages/stream_feeds/test/state/moderation_config_list_test.dart new file mode 100644 index 00000000..0ab6259d --- /dev/null +++ b/packages/stream_feeds/test/state/moderation_config_list_test.dart @@ -0,0 +1,97 @@ +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:stream_feeds_test/stream_feeds_test.dart'; + +void main() { + // ============================================================ + // FEATURE: Query Operations + // ============================================================ + + group('Moderation Config List - Query Operations', () { + const query = ModerationConfigsQuery(); + + moderationConfigListTest( + 'get - should query initial moderation configs via API', + build: (client) => client.moderationConfigList(query), + body: (tester) async { + final result = await tester.get(); + + expect(result, isA>>()); + final configs = result.getOrThrow(); + + expect(configs, isA>()); + expect(configs, hasLength(3)); + }, + ); + + moderationConfigListTest( + 'queryMoreConfigs - should load more configs via API', + build: (client) => client.moderationConfigList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + next: 'next-cursor', + configs: [createDefaultModerationConfigResponse(key: 'config-1')], + ), + ), + body: (tester) async { + // Initial state - has config + expect(tester.moderationConfigListState.configs, hasLength(1)); + expect(tester.moderationConfigListState.canLoadMore, isTrue); + + final nextPageQuery = tester.moderationConfigList.query.copyWith( + next: tester.moderationConfigListState.pagination?.next, + ); + + tester.mockApi( + (api) => api.queryModerationConfigs( + queryModerationConfigsRequest: nextPageQuery.toRequest(), + ), + result: createDefaultQueryModerationConfigsResponse( + configs: [createDefaultModerationConfigResponse(key: 'config-2')], + ), + ); + + // Query more configs + final result = await tester.moderationConfigList.queryMoreConfigs(); + + expect(result.isSuccess, isTrue); + final configs = result.getOrNull(); + expect(configs, isNotNull); + expect(configs, hasLength(1)); + + // Verify state was updated with merged configs + expect(tester.moderationConfigListState.configs, hasLength(2)); + expect(tester.moderationConfigListState.canLoadMore, isFalse); + + tester.verifyApi( + (api) => api.queryModerationConfigs( + queryModerationConfigsRequest: nextPageQuery.toRequest(), + ), + ); + }, + ); + + moderationConfigListTest( + 'queryMoreConfigs - should return empty list when no more configs', + build: (client) => client.moderationConfigList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + configs: [createDefaultModerationConfigResponse(key: 'config-1')], + ), + ), + body: (tester) async { + // Initial state - has config but no pagination + expect(tester.moderationConfigListState.configs, hasLength(1)); + expect(tester.moderationConfigListState.canLoadMore, isFalse); + // Query more configs (should return empty immediately) + final result = await tester.moderationConfigList.queryMoreConfigs(); + + expect(result.isSuccess, isTrue); + final configs = result.getOrNull(); + expect(configs, isEmpty); + + // State should remain unchanged + expect(tester.moderationConfigListState.configs, hasLength(1)); + }, + ); + }); +} diff --git a/packages/stream_feeds/test/state/poll_list_test.dart b/packages/stream_feeds/test/state/poll_list_test.dart index 5258d76b..af79839b 100644 --- a/packages/stream_feeds/test/state/poll_list_test.dart +++ b/packages/stream_feeds/test/state/poll_list_test.dart @@ -1,13 +1,468 @@ +import 'package:collection/collection.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:stream_feeds_test/stream_feeds_test.dart'; void main() { + const query = PollsQuery(); + + // ============================================================ + // FEATURE: Query Operations + // ============================================================ + + group('Poll List - Query Operations', () { + pollListTest( + 'get - should query initial polls via API', + build: (client) => client.pollList(query), + body: (tester) async { + final result = await tester.get(); + + expect(result, isA>>()); + final polls = result.getOrThrow(); + + expect(polls, isA>()); + expect(polls, hasLength(3)); + }, + ); + + pollListTest( + 'queryMorePolls - should load more polls via API', + build: (client) => client.pollList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + next: 'next-cursor', + polls: [ + createDefaultPollResponse(id: 'poll-1'), + createDefaultPollResponse(id: 'poll-2'), + createDefaultPollResponse(id: 'poll-3'), + ], + ), + ), + body: (tester) async { + // Initial state - has polls + expect(tester.pollListState.polls, hasLength(3)); + expect(tester.pollListState.canLoadMore, isTrue); + + final nextPageQuery = tester.pollList.query.copyWith( + next: tester.pollListState.pagination?.next, + ); + + tester.mockApi( + (api) => api.queryPolls( + queryPollsRequest: nextPageQuery.toRequest(), + ), + result: QueryPollsResponse( + duration: DateTime.now().toIso8601String(), + polls: [ + createDefaultPollResponse(id: 'poll-4'), + ], + ), + ); + + // Query more polls + final result = await tester.pollList.queryMorePolls(); + + expect(result.isSuccess, isTrue); + final polls = result.getOrNull(); + expect(polls, isNotNull); + expect(polls, hasLength(1)); + + // Verify state was updated with merged polls + expect(tester.pollListState.polls, hasLength(4)); + expect(tester.pollListState.canLoadMore, isFalse); + + tester.verifyApi( + (api) => api.queryPolls( + queryPollsRequest: nextPageQuery.toRequest(), + ), + ); + }, + ); + + pollListTest( + 'queryMorePolls - should return empty list when no more polls', + build: (client) => client.pollList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + polls: [ + createDefaultPollResponse(id: 'poll-1'), + createDefaultPollResponse(id: 'poll-2'), + createDefaultPollResponse(id: 'poll-3'), + ], + ), + ), + body: (tester) async { + // Initial state - has polls but no pagination + expect(tester.pollListState.polls, hasLength(3)); + expect(tester.pollListState.canLoadMore, isFalse); + // Query more polls (should return empty immediately) + final result = await tester.pollList.queryMorePolls(); + + expect(result.isSuccess, isTrue); + final polls = result.getOrNull(); + expect(polls, isEmpty); + + // State should remain unchanged + expect(tester.pollListState.polls, hasLength(3)); + expect(tester.pollListState.canLoadMore, isFalse); + }, + ); + }); + + // ============================================================ + // FEATURE: Event Handling + // ============================================================ + + group('Poll List - Event Handling', () { + final initialPolls = [ + createDefaultPollResponse(id: 'poll-1'), + createDefaultPollResponse(id: 'poll-2'), + createDefaultPollResponse(id: 'poll-3'), + ]; + + pollListTest( + 'PollDeletedFeedEvent - should remove poll', + build: (client) => client.pollList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(polls: initialPolls), + ), + body: (tester) async { + // Initial state - has polls + expect(tester.pollListState.polls, hasLength(3)); + + // Emit event + await tester.emitEvent( + PollDeletedFeedEvent( + type: EventTypes.pollDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + poll: createDefaultPollResponse(id: 'poll-1'), + ), + ); + + // Verify poll was removed + expect(tester.pollListState.polls, hasLength(2)); + + final removedPoll = tester.pollListState.polls.firstWhereOrNull( + (p) => p.id == 'poll-1', + ); + + expect(removedPoll, isNull); + }, + ); + + pollListTest( + 'PollUpdatedFeedEvent - should update poll', + build: (client) => client.pollList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(polls: initialPolls), + ), + body: (tester) async { + // Initial state - has polls + expect(tester.pollListState.polls, hasLength(3)); + + // Emit event + await tester.emitEvent( + PollUpdatedFeedEvent( + type: EventTypes.pollUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + poll: createDefaultPollResponse(id: 'poll-1').copyWith( + name: 'Updated Poll Name', + ), + ), + ); + + // Verify poll was updated + expect(tester.pollListState.polls, hasLength(3)); + final updatedPoll = tester.pollListState.polls.firstWhereOrNull( + (p) => p.id == 'poll-1', + ); + + expect(updatedPoll, isNotNull); + expect(updatedPoll!.name, 'Updated Poll Name'); + }, + ); + + pollListTest( + 'PollClosedFeedEvent - should close poll', + build: (client) => client.pollList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(polls: initialPolls), + ), + body: (tester) async { + // Initial state - has polls + expect(tester.pollListState.polls, hasLength(3)); + + // Emit event + await tester.emitEvent( + PollClosedFeedEvent( + type: EventTypes.pollClosed, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + poll: createDefaultPollResponse(id: 'poll-1'), + ), + ); + + // Verify poll was closed + expect(tester.pollListState.polls, hasLength(3)); + final closedPoll = tester.pollListState.polls.firstWhereOrNull( + (p) => p.id == 'poll-1', + ); + + expect(closedPoll, isNotNull); + expect(closedPoll!.isClosed, isTrue); + }, + ); + + pollListTest( + 'PollVoteCastedFeedEvent - should update poll with vote', + build: (client) => client.pollList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(polls: initialPolls), + ), + body: (tester) async { + // Initial state - has 3 polls + expect(tester.pollListState.polls, hasLength(3)); + + final newVote = createDefaultPollVoteResponse( + id: 'vote-1', + pollId: 'poll-1', + optionId: 'option-1', + ); + + // Emit PollVoteCastedFeedEvent + await tester.emitEvent( + PollVoteCastedFeedEvent( + type: EventTypes.pollVoteCasted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + poll: createDefaultPollResponse( + id: 'poll-1', + ownVotesAndAnswers: [newVote], + ), + pollVote: newVote, + ), + ); + + // Verify poll was updated with vote + final updatedPoll = tester.pollListState.polls.firstWhereOrNull( + (p) => p.id == 'poll-1', + ); + expect(updatedPoll, isNotNull); + expect(updatedPoll!.voteCount, 1); + expect(updatedPoll.latestVotesByOption['option-1'], hasLength(1)); + }, + ); + + pollListTest( + 'PollVoteChangedFeedEvent - should update poll with changed vote', + build: (client) => client.pollList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith( + polls: [ + createDefaultPollResponse( + id: 'poll-1', + ownVotesAndAnswers: [ + createDefaultPollVoteResponse( + id: 'vote-1', + optionId: 'option-1', + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has one poll with one vote on option-1 + expect(tester.pollListState.polls, hasLength(1)); + final initialPoll = tester.pollListState.polls.first; + expect(initialPoll.voteCount, 1); + expect(initialPoll.latestVotesByOption['option-1'], hasLength(1)); + + final changedVote = createDefaultPollVoteResponse( + id: 'vote-1', + pollId: 'poll-1', + optionId: 'option-2', + ); + + // Emit PollVoteChangedFeedEvent + await tester.emitEvent( + PollVoteChangedFeedEvent( + type: EventTypes.pollVoteChanged, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + poll: createDefaultPollResponse( + id: 'poll-1', + ownVotesAndAnswers: [changedVote], + ), + pollVote: changedVote, + ), + ); + + // Verify poll was updated with changed vote + final updatedPoll = tester.pollListState.polls.first; + expect(updatedPoll.voteCount, 1); + expect(updatedPoll.latestVotesByOption['option-1'], isNull); + expect(updatedPoll.latestVotesByOption['option-2'], hasLength(1)); + }, + ); + + pollListTest( + 'PollVoteRemovedFeedEvent - should update poll when vote is removed', + build: (client) => client.pollList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith( + polls: [ + createDefaultPollResponse( + id: 'poll-1', + ownVotesAndAnswers: [ + createDefaultPollVoteResponse( + id: 'vote-1', + optionId: 'option-1', + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has one poll with one vote + expect(tester.pollListState.polls, hasLength(1)); + final initialPoll = tester.pollListState.polls.first; + expect(initialPoll.voteCount, 1); + expect(initialPoll.latestVotesByOption['option-1'], hasLength(1)); + + final voteToRemove = createDefaultPollVoteResponse( + id: 'vote-1', + pollId: 'poll-1', + optionId: 'option-1', + ); + + // Emit PollVoteRemovedFeedEvent + await tester.emitEvent( + PollVoteRemovedFeedEvent( + type: EventTypes.pollVoteRemoved, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + poll: createDefaultPollResponse( + id: 'poll-1', + ownVotesAndAnswers: [], + ), + pollVote: voteToRemove, + ), + ); + + // Verify vote was removed + final updatedPoll = tester.pollListState.polls.first; + expect(updatedPoll.voteCount, 0); + expect(updatedPoll.latestVotesByOption['option-1'], isNull); + }, + ); + + pollListTest( + 'PollAnswerCastedFeedEvent - should update poll with answer', + build: (client) => client.pollList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(polls: initialPolls), + ), + body: (tester) async { + // Initial state - has 3 polls + expect(tester.pollListState.polls, hasLength(3)); + + final newAnswer = createDefaultPollAnswerResponse( + id: 'answer-1', + pollId: 'poll-1', + answerText: 'My Custom Answer', + ); + + // Emit PollVoteCastedFeedEvent (resolved to PollAnswerCastedFeedEvent) + await tester.emitEvent( + PollVoteCastedFeedEvent( + type: EventTypes.pollVoteCasted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + poll: createDefaultPollResponse( + id: 'poll-1', + ownVotesAndAnswers: [newAnswer], + ), + pollVote: newAnswer, + ), + ); + + // Verify poll was updated with answer + final updatedPoll = tester.pollListState.polls.firstWhereOrNull( + (p) => p.id == 'poll-1', + ); + expect(updatedPoll, isNotNull); + expect(updatedPoll!.answersCount, 1); + expect(updatedPoll.latestAnswers, hasLength(1)); + expect(updatedPoll.latestAnswers.first.answerText, 'My Custom Answer'); + }, + ); + + pollListTest( + 'PollAnswerRemovedFeedEvent - should update poll when answer is removed', + build: (client) => client.pollList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith( + polls: [ + createDefaultPollResponse( + id: 'poll-1', + ownVotesAndAnswers: [ + createDefaultPollAnswerResponse(id: 'answer-1'), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has one poll with one answer + expect(tester.pollListState.polls, hasLength(1)); + final initialPoll = tester.pollListState.polls.first; + expect(initialPoll.answersCount, 1); + expect(initialPoll.latestAnswers, hasLength(1)); + + final answerToRemove = createDefaultPollAnswerResponse( + id: 'answer-1', + pollId: 'poll-1', + ); + + // Emit PollVoteRemovedFeedEvent (resolved to PollAnswerRemovedFeedEvent) + await tester.emitEvent( + PollVoteRemovedFeedEvent( + type: EventTypes.pollVoteRemoved, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + poll: createDefaultPollResponse( + id: 'poll-1', + ownVotesAndAnswers: [], + ), + pollVote: answerToRemove, + ), + ); + + // Verify answer was removed + final updatedPoll = tester.pollListState.polls.first; + expect(updatedPoll.answersCount, 0); + expect(updatedPoll.latestAnswers, isEmpty); + }, + ); + }); + // ============================================================ // FEATURE: Local Filtering // ============================================================ - group('PollListEventHandler - Local filtering', () { + group('Poll List - Local Filtering', () { final initialPolls = [ createDefaultPollResponse(id: 'poll-1'), createDefaultPollResponse(id: 'poll-2'), @@ -17,7 +472,7 @@ void main() { pollListTest( 'PollUpdatedFeedEvent - should remove poll when updated to non-matching status', build: (client) => client.pollList( - PollsQuery( + query.copyWith( filter: Filter.equal(PollsFilterField.isClosed, false), ), ), @@ -45,10 +500,11 @@ void main() { ); pollListTest( - 'No filter - should not remove poll when no filter specified', + 'PollClosedFeedEvent - should remove poll when closed and does not match filter', build: (client) => client.pollList( - // No filter specified, should accept all polls - const PollsQuery(), + query.copyWith( + filter: Filter.equal(PollsFilterField.isClosed, false), + ), ), setUp: (tester) => tester.get( modifyResponse: (it) => it.copyWith(polls: initialPolls), @@ -56,6 +512,33 @@ void main() { body: (tester) async { expect(tester.pollListState.polls, hasLength(3)); + // Send event with poll that is closed (doesn't match filter) + // The poll in the event should have isClosed: true so it doesn't match the filter + await tester.emitEvent( + PollClosedFeedEvent( + type: EventTypes.pollClosed, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + poll: createDefaultPollResponse(id: 'poll-1').copyWith( + isClosed: true, + ), + ), + ); + + expect(tester.pollListState.polls, hasLength(2)); + }, + ); + + pollListTest( + 'No filter - should not remove poll when no filter specified', + build: (client) => client.pollList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(polls: initialPolls), + ), + body: (tester) async { + expect(tester.pollListState.polls, hasLength(3)); + // Send event with poll that doesn't match filter (isClosed: false) await tester.emitEvent( PollUpdatedFeedEvent( diff --git a/packages/stream_feeds/test/state/poll_vote_list_test.dart b/packages/stream_feeds/test/state/poll_vote_list_test.dart index 2f00f6d1..6e4da3ce 100644 --- a/packages/stream_feeds/test/state/poll_vote_list_test.dart +++ b/packages/stream_feeds/test/state/poll_vote_list_test.dart @@ -3,24 +3,245 @@ import 'package:stream_feeds/stream_feeds.dart'; import 'package:stream_feeds_test/stream_feeds_test.dart'; void main() { + const query = PollVotesQuery(pollId: 'test-poll-id'); + // ============================================================ - // FEATURE: Local Filtering + // FEATURE: Query Operations // ============================================================ - group('PollVoteListEventHandler - Local filtering', () { - const pollId = 'test-poll-id'; + group('Poll Vote List - Query Operations', () { + pollVoteListTest( + 'get - should query initial votes via API', + build: (client) => client.pollVoteList(query), + body: (tester) async { + final result = await tester.get(); + + expect(result, isA>>()); + final votes = result.getOrThrow(); + + expect(votes, isA>()); + expect(votes, hasLength(3)); + }, + ); + + pollVoteListTest( + 'queryMorePollVotes - should load more votes via API', + build: (client) => client.pollVoteList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + next: 'next-cursor', + votes: [ + createDefaultPollVoteResponse(id: 'vote-1', pollId: query.pollId), + createDefaultPollVoteResponse(id: 'vote-2', pollId: query.pollId), + createDefaultPollVoteResponse(id: 'vote-3', pollId: query.pollId), + ], + ), + ), + body: (tester) async { + // Initial state - has votes + expect(tester.pollVoteListState.votes, hasLength(3)); + expect(tester.pollVoteListState.canLoadMore, isTrue); + final nextPageQuery = tester.pollVoteList.query.copyWith( + next: tester.pollVoteListState.pagination?.next, + ); + + tester.mockApi( + (api) => api.queryPollVotes( + pollId: nextPageQuery.pollId, + queryPollVotesRequest: nextPageQuery.toRequest(), + ), + result: PollVotesResponse( + duration: DateTime.now().toIso8601String(), + votes: [ + createDefaultPollVoteResponse(id: 'vote-4', pollId: query.pollId), + ], + ), + ); + + // Query more votes + final result = await tester.pollVoteList.queryMorePollVotes(); + + expect(result.isSuccess, isTrue); + final votes = result.getOrNull(); + expect(votes, isNotNull); + expect(votes, hasLength(1)); + + // Verify state was updated with merged votes + expect(tester.pollVoteListState.votes, hasLength(4)); + expect(tester.pollVoteListState.canLoadMore, isFalse); + + tester.verifyApi( + (api) => api.queryPollVotes( + pollId: nextPageQuery.pollId, + queryPollVotesRequest: nextPageQuery.toRequest(), + ), + ); + }, + ); + + pollVoteListTest( + 'queryMorePollVotes - should return empty list when no more votes', + build: (client) => client.pollVoteList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + votes: [ + createDefaultPollVoteResponse(id: 'vote-1', pollId: query.pollId), + createDefaultPollVoteResponse(id: 'vote-2', pollId: query.pollId), + createDefaultPollVoteResponse(id: 'vote-3', pollId: query.pollId), + ], + ), + ), + body: (tester) async { + // Initial state - has votes but no pagination + expect(tester.pollVoteListState.votes, hasLength(3)); + expect(tester.pollVoteListState.canLoadMore, isFalse); + // Query more votes (should return empty immediately) + final result = await tester.pollVoteList.queryMorePollVotes(); + + expect(result.isSuccess, isTrue); + final votes = result.getOrNull(); + expect(votes, isEmpty); + + // State should remain unchanged + expect(tester.pollVoteListState.votes, hasLength(3)); + expect(tester.pollVoteListState.canLoadMore, isFalse); + }, + ); + }); + + // ============================================================ + // FEATURE: Event Handling + // ============================================================ + + group('Poll Vote List - Event Handling', () { final initialVotes = [ - createDefaultPollVoteResponse(id: 'vote-1', pollId: pollId), - createDefaultPollVoteResponse(id: 'vote-2', pollId: pollId), - createDefaultPollVoteResponse(id: 'vote-3', pollId: pollId), + createDefaultPollVoteResponse(id: 'vote-1', pollId: query.pollId), + createDefaultPollVoteResponse(id: 'vote-2', pollId: query.pollId), + createDefaultPollVoteResponse(id: 'vote-3', pollId: query.pollId), ]; pollVoteListTest( - 'PollVoteChangedFeedEvent - should remove vote when changed to non-matching option', + 'PollDeletedFeedEvent - should clear all votes when poll is deleted', + build: (client) => client.pollVoteList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(votes: initialVotes), + ), + body: (tester) async { + // Initial state - has votes + expect(tester.pollVoteListState.votes, hasLength(3)); + + // Emit event + await tester.emitEvent( + PollDeletedFeedEvent( + type: EventTypes.pollDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + poll: createDefaultPollResponse(id: query.pollId), + ), + ); + + // Verify all votes were cleared + expect(tester.pollVoteListState.votes, isEmpty); + expect(tester.pollVoteListState.pagination, isNull); + }, + ); + + pollVoteListTest( + 'PollDeletedFeedEvent - should not clear votes for different poll', + build: (client) => client.pollVoteList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(votes: initialVotes), + ), + body: (tester) async { + // Initial state - has votes + expect(tester.pollVoteListState.votes, hasLength(3)); + + // Emit event for different poll + await tester.emitEvent( + PollDeletedFeedEvent( + type: EventTypes.pollDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + poll: createDefaultPollResponse(id: 'different-poll-id'), + ), + ); + + // Verify votes were not cleared + expect(tester.pollVoteListState.votes, hasLength(3)); + }, + ); + + pollVoteListTest( + 'PollVoteCastedFeedEvent - should add vote', + build: (client) => client.pollVoteList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(votes: initialVotes), + ), + body: (tester) async { + // Initial state - has votes + expect(tester.pollVoteListState.votes, hasLength(3)); + + // Emit event + await tester.emitEvent( + PollVoteCastedFeedEvent( + type: EventTypes.pollVoteCasted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + poll: createDefaultPollResponse(id: query.pollId), + pollVote: createDefaultPollVoteResponse( + id: 'vote-4', + pollId: query.pollId, + ), + ), + ); + + // Verify vote was added + expect(tester.pollVoteListState.votes, hasLength(4)); + expect( + tester.pollVoteListState.votes.any((v) => v.id == 'vote-4'), + isTrue, + ); + }, + ); + + pollVoteListTest( + 'PollVoteCastedFeedEvent - should not add vote for different poll', + build: (client) => client.pollVoteList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(votes: initialVotes), + ), + body: (tester) async { + // Initial state - has votes + expect(tester.pollVoteListState.votes, hasLength(3)); + + // Emit event for different poll + await tester.emitEvent( + PollVoteCastedFeedEvent( + type: EventTypes.pollVoteCasted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + poll: createDefaultPollResponse(id: query.pollId), + pollVote: createDefaultPollVoteResponse( + id: 'vote-4', + pollId: 'different-poll-id', + ), + ), + ); + + // Verify vote was not added + expect(tester.pollVoteListState.votes, hasLength(3)); + }, + ); + + pollVoteListTest( + 'PollVoteCastedFeedEvent - should not add vote that does not match filter', build: (client) => client.pollVoteList( - PollVotesQuery( - pollId: pollId, + query.copyWith( filter: Filter.equal(PollVotesFilterField.optionId, 'option-1'), ), ), @@ -28,33 +249,182 @@ void main() { modifyResponse: (it) => it.copyWith(votes: initialVotes), ), body: (tester) async { + // Initial state - has votes expect(tester.pollVoteListState.votes, hasLength(3)); - // Send event with vote that changed to non-matching optionId + // Emit event with vote that doesn't match filter + await tester.emitEvent( + PollVoteCastedFeedEvent( + type: EventTypes.pollVoteCasted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + poll: createDefaultPollResponse(id: query.pollId), + pollVote: createDefaultPollVoteResponse( + id: 'vote-4', + pollId: query.pollId, + optionId: 'option-2', + ), + ), + ); + + // Verify vote was not added + expect(tester.pollVoteListState.votes, hasLength(3)); + }, + ); + + pollVoteListTest( + 'PollVoteChangedFeedEvent - should update vote', + build: (client) => client.pollVoteList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(votes: initialVotes), + ), + body: (tester) async { + // Initial state - has votes + expect(tester.pollVoteListState.votes, hasLength(3)); + + // Emit event await tester.emitEvent( PollVoteChangedFeedEvent( type: EventTypes.pollVoteChanged, createdAt: DateTime.timestamp(), custom: const {}, fid: 'fid', - poll: createDefaultPollResponse(id: pollId), + poll: createDefaultPollResponse(id: query.pollId), pollVote: createDefaultPollVoteResponse( id: 'vote-1', - pollId: pollId, + pollId: query.pollId, ).copyWith(optionId: 'option-2'), ), ); + // Verify vote was updated + expect(tester.pollVoteListState.votes, hasLength(3)); + final updatedVote = tester.pollVoteListState.votes.firstWhere( + (v) => v.id == 'vote-1', + ); + expect(updatedVote.optionId, 'option-2'); + }, + ); + + pollVoteListTest( + 'PollVoteChangedFeedEvent - should not update vote for different poll', + build: (client) => client.pollVoteList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(votes: initialVotes), + ), + body: (tester) async { + // Initial state - has votes + expect(tester.pollVoteListState.votes, hasLength(3)); + final originalVote = tester.pollVoteListState.votes.firstWhere( + (v) => v.id == 'vote-1', + ); + + // Emit event for different poll + await tester.emitEvent( + PollVoteChangedFeedEvent( + type: EventTypes.pollVoteChanged, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + poll: createDefaultPollResponse(id: query.pollId), + pollVote: createDefaultPollVoteResponse( + id: 'vote-1', + pollId: 'different-poll-id', + ).copyWith(optionId: 'option-2'), + ), + ); + + // Verify vote was not updated + expect(tester.pollVoteListState.votes, hasLength(3)); + final unchangedVote = tester.pollVoteListState.votes.firstWhere( + (v) => v.id == 'vote-1', + ); + expect(unchangedVote.optionId, originalVote.optionId); + }, + ); + + pollVoteListTest( + 'PollVoteRemovedFeedEvent - should remove vote', + build: (client) => client.pollVoteList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(votes: initialVotes), + ), + body: (tester) async { + // Initial state - has votes + expect(tester.pollVoteListState.votes, hasLength(3)); + + // Emit event + await tester.emitEvent( + PollVoteRemovedFeedEvent( + type: EventTypes.pollVoteRemoved, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + poll: createDefaultPollResponse(id: query.pollId), + pollVote: createDefaultPollVoteResponse( + id: 'vote-1', + pollId: query.pollId, + ), + ), + ); + + // Verify vote was removed expect(tester.pollVoteListState.votes, hasLength(2)); + expect( + tester.pollVoteListState.votes.any((v) => v.id == 'vote-1'), + isFalse, + ); }, ); pollVoteListTest( - 'No filter - should not remove vote when no filter specified', + 'PollVoteRemovedFeedEvent - should not remove vote for different poll', + build: (client) => client.pollVoteList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(votes: initialVotes), + ), + body: (tester) async { + // Initial state - has votes + expect(tester.pollVoteListState.votes, hasLength(3)); + + // Emit event for different poll + await tester.emitEvent( + PollVoteRemovedFeedEvent( + type: EventTypes.pollVoteRemoved, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + poll: createDefaultPollResponse(id: 'different-poll-id'), + pollVote: createDefaultPollVoteResponse( + id: 'vote-1', + pollId: query.pollId, + ), + ), + ); + + // Verify vote was not removed + expect(tester.pollVoteListState.votes, hasLength(3)); + }, + ); + }); + + // ============================================================ + // FEATURE: Local Filtering + // ============================================================ + + group('Poll Vote List - Local Filtering', () { + final initialVotes = [ + createDefaultPollVoteResponse(id: 'vote-1', pollId: query.pollId), + createDefaultPollVoteResponse(id: 'vote-2', pollId: query.pollId), + createDefaultPollVoteResponse(id: 'vote-3', pollId: query.pollId), + ]; + + pollVoteListTest( + 'PollVoteChangedFeedEvent - should remove vote when changed to non-matching option', build: (client) => client.pollVoteList( - const PollVotesQuery( - pollId: pollId, - // No filter specified, should accept all votes + query.copyWith( + filter: Filter.equal(PollVotesFilterField.optionId, 'option-1'), ), ), setUp: (tester) => tester.get( @@ -63,16 +433,44 @@ void main() { body: (tester) async { expect(tester.pollVoteListState.votes, hasLength(3)); + // Send event with vote that changed to non-matching optionId + await tester.emitEvent( + PollVoteChangedFeedEvent( + type: EventTypes.pollVoteChanged, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + poll: createDefaultPollResponse(id: query.pollId), + pollVote: createDefaultPollVoteResponse( + id: 'vote-1', + pollId: query.pollId, + ).copyWith(optionId: 'option-2'), + ), + ); + + expect(tester.pollVoteListState.votes, hasLength(2)); + }, + ); + + pollVoteListTest( + 'No filter - should not remove vote when no filter specified', + build: (client) => client.pollVoteList(query), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(votes: initialVotes), + ), + body: (tester) async { + expect(tester.pollVoteListState.votes, hasLength(3)); + await tester.emitEvent( PollVoteChangedFeedEvent( type: EventTypes.pollVoteChanged, createdAt: DateTime.timestamp(), custom: const {}, fid: 'fid', - poll: createDefaultPollResponse(id: pollId), + poll: createDefaultPollResponse(id: query.pollId), pollVote: createDefaultPollVoteResponse( id: 'vote-1', - pollId: pollId, + pollId: query.pollId, ).copyWith(optionId: 'option-2'), ), ); diff --git a/packages/stream_feeds_test/lib/src/helpers/event_types.dart b/packages/stream_feeds_test/lib/src/helpers/event_types.dart index f7130588..25ab7069 100644 --- a/packages/stream_feeds_test/lib/src/helpers/event_types.dart +++ b/packages/stream_feeds_test/lib/src/helpers/event_types.dart @@ -1,15 +1,18 @@ class EventTypes { // Activity events static const activityAdded = 'feeds.activity.added'; + static const activityDeleted = 'feeds.activity.deleted'; static const activityMarked = 'feeds.activity.marked'; - static const activityUpdated = 'feeds.activity.updated'; static const activityPinned = 'feeds.activity.pinned'; + static const activityRemovedFromFeed = 'feeds.activity.removed_from_feed'; static const activityUnpinned = 'feeds.activity.unpinned'; + static const activityUpdated = 'feeds.activity.updated'; static const activityFeedback = 'feeds.activity.feedback'; // Reaction events static const activityReactionAdded = 'feeds.activity.reaction.added'; static const activityReactionDeleted = 'feeds.activity.reaction.deleted'; + static const activityReactionUpdated = 'feeds.activity.reaction.updated'; // Bookmark events static const bookmarkAdded = 'feeds.bookmark.added'; @@ -18,6 +21,19 @@ class EventTypes { // Bookmark folder events static const bookmarkFolderUpdated = 'feeds.bookmark_folder.updated'; + static const bookmarkFolderDeleted = 'feeds.bookmark_folder.deleted'; + + // Feed events + static const feedCreated = 'feeds.feed.created'; + static const feedUpdated = 'feeds.feed.updated'; + static const feedDeleted = 'feeds.feed.deleted'; + static const feedGroupChanged = 'feeds.feed_group.changed'; + static const feedGroupDeleted = 'feeds.feed_group.deleted'; + + // Feed member events + static const feedMemberAdded = 'feeds.feed_member.added'; + static const feedMemberUpdated = 'feeds.feed_member.updated'; + static const feedMemberRemoved = 'feeds.feed_member.removed'; // Comment events static const commentAdded = 'feeds.comment.added'; @@ -27,9 +43,6 @@ class EventTypes { static const commentReactionDeleted = 'feeds.comment.reaction.deleted'; static const commentReactionUpdated = 'feeds.comment.reaction.updated'; - // Feed events - static const feedUpdated = 'feeds.feed.updated'; - // Follow events static const followCreated = 'feeds.follow.created'; static const followDeleted = 'feeds.follow.deleted'; @@ -45,4 +58,7 @@ class EventTypes { // Stories events static const storiesFeedUpdated = 'feeds.stories_feed.updated'; + + // Notification events + static const notificationFeedUpdated = 'feeds.notification_feed.updated'; } diff --git a/packages/stream_feeds_test/lib/src/helpers/test_data.dart b/packages/stream_feeds_test/lib/src/helpers/test_data.dart index 31545f3e..d47c55af 100644 --- a/packages/stream_feeds_test/lib/src/helpers/test_data.dart +++ b/packages/stream_feeds_test/lib/src/helpers/test_data.dart @@ -1,5 +1,6 @@ -// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_redundant_argument_values, parameter_assignments +import 'package:collection/collection.dart'; import 'package:stream_feeds/stream_feeds.dart'; GetCommentsResponse createDefaultCommentsResponse({ @@ -90,9 +91,32 @@ ActivityResponse createDefaultActivityResponse({ bool? isWatched, List ownBookmarks = const [], List ownReactions = const [], + List latestReactions = const [], Map reactionGroups = const {}, List comments = const [], }) { + latestReactions = latestReactions.isEmpty ? ownReactions : latestReactions; + reactionGroups = switch (reactionGroups.isNotEmpty) { + true => reactionGroups, + _ => latestReactions.fold( + {}, + (prev, curr) => prev + ..update( + curr.type, + (it) => it.copyWith( + count: it.count + 1, + firstReactionAt: [it.firstReactionAt, curr.createdAt].min, + lastReactionAt: [it.lastReactionAt, curr.createdAt].max, + ), + ifAbsent: () => ReactionGroupResponse( + count: 1, + firstReactionAt: curr.createdAt, + lastReactionAt: curr.createdAt, + ), + ), + ), + }; + return ActivityResponse( id: id, attachments: const [], @@ -106,7 +130,7 @@ ActivityResponse createDefaultActivityResponse({ filterTags: const [], hidden: hidden, interestTags: const [], - latestReactions: const [], + latestReactions: latestReactions, mentionedUsers: const [], moderation: null, notificationContext: null, @@ -135,7 +159,8 @@ ActivityResponse createDefaultActivityResponse({ PollResponseData createDefaultPollResponse({ String id = 'poll-id', List? options, - List latestAnswers = const [], + List ownVotesAndAnswers = const [], + List latestVotesAndAnswers = const [], Map> latestVotesByOption = const {}, }) { options ??= [ @@ -143,6 +168,28 @@ PollResponseData createDefaultPollResponse({ createDefaultPollOptionResponse(id: 'option-2', text: 'Option 2'), ]; + latestVotesAndAnswers = switch (latestVotesAndAnswers.isNotEmpty) { + true => latestVotesAndAnswers, + _ => ownVotesAndAnswers, + }; + + final (latestAnswers, latestVotes) = latestVotesAndAnswers.partition( + (vote) => vote.isAnswer ?? false, + ); + + latestVotesByOption = switch (latestVotesByOption.isNotEmpty) { + true => latestVotesByOption, + _ => latestVotes.fold( + >{}, + (prev, curr) => prev + ..update( + curr.optionId, + (it) => [curr, ...it], + ifAbsent: () => [curr], + ), + ), + }; + return PollResponseData( id: id, name: 'name', @@ -156,7 +203,7 @@ PollResponseData createDefaultPollResponse({ enforceUniqueVote: true, latestAnswers: latestAnswers, latestVotesByOption: latestVotesByOption, - ownVotes: const [], + ownVotes: ownVotesAndAnswers, updatedAt: DateTime.now(), voteCount: latestVotesByOption.values.sumOf((it) => it.length), voteCountsByOption: latestVotesByOption.map( @@ -207,6 +254,8 @@ GetOrCreateFeedResponse createDefaultGetOrCreateFeedResponse({ FeedResponse createDefaultFeedResponse({ String id = 'id', String groupId = 'group', + String name = 'name', + String description = 'description', int followerCount = 0, int followingCount = 0, List? ownCapabilities, @@ -217,8 +266,8 @@ FeedResponse createDefaultFeedResponse({ id: id, groupId: groupId, feed: FeedId(group: groupId, id: id).toString(), - name: 'name', - description: 'description', + name: name, + description: description, visibility: FeedVisibility.public, createdAt: DateTime(2021, 1, 1), createdBy: createDefaultUserResponse(), @@ -240,19 +289,46 @@ CommentResponse createDefaultCommentResponse({ String? text, String? userId, String? parentId, + List ownReactions = const [], + List latestReactions = const [], + Map reactionGroups = const {}, }) { + latestReactions = latestReactions.isEmpty ? ownReactions : latestReactions; + reactionGroups = switch (reactionGroups.isNotEmpty) { + true => reactionGroups, + _ => latestReactions.fold( + {}, + (prev, curr) => prev + ..update( + curr.type, + (it) => it.copyWith( + count: it.count + 1, + firstReactionAt: [it.firstReactionAt, curr.createdAt].min, + lastReactionAt: [it.lastReactionAt, curr.createdAt].max, + ), + ifAbsent: () => ReactionGroupResponse( + count: 1, + firstReactionAt: curr.createdAt, + lastReactionAt: curr.createdAt, + ), + ), + ), + }; + return CommentResponse( id: id, confidenceScore: 0, createdAt: DateTime(2021, 1, 1), custom: const {}, downvoteCount: 0, + latestReactions: latestReactions, mentionedUsers: const [], objectId: objectId, objectType: objectType, - ownReactions: const [], + ownReactions: ownReactions, parentId: parentId, - reactionCount: 0, + reactionCount: reactionGroups.values.sumOf((group) => group.count), + reactionGroups: reactionGroups, replyCount: 0, score: 0, status: 'status', @@ -419,6 +495,23 @@ PinActivityResponse createDefaultPinActivityResponse({ ); } +ActivityPinResponse createDefaultActivityPinResponse({ + String activityId = 'activity-id', + String type = 'post', + String userId = 'user-id', +}) { + return ActivityPinResponse( + activity: createDefaultActivityResponse( + id: activityId, + type: type, + ), + createdAt: DateTime(2021, 1, 1), + feed: 'user:id', + updatedAt: DateTime(2021, 1, 2), + user: createDefaultUserResponse(id: userId), + ); +} + BookmarkResponse createDefaultBookmarkResponse({ String userId = 'user-id', String activityId = 'activity-id', @@ -516,49 +609,160 @@ DeleteActivityReactionResponse createDefaultDeleteReactionResponse({ ); } +FeedsReactionResponse createDefaultReactionResponse({ + String activityId = 'activity-id', + String? commentId, + String userId = 'user-id', + String reactionType = 'like', +}) { + return FeedsReactionResponse( + activityId: activityId, + commentId: commentId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ); +} + +QueryActivityReactionsResponse createDefaultQueryActivityReactionsResponse({ + String? next, + String? prev, + List reactions = const [], +}) { + return QueryActivityReactionsResponse( + next: next, + prev: prev, + reactions: reactions, + duration: '10ms', + ); +} + +QueryCommentReactionsResponse createDefaultQueryCommentReactionsResponse({ + String? next, + String? prev, + List reactions = const [], +}) { + return QueryCommentReactionsResponse( + next: next, + prev: prev, + reactions: reactions, + duration: '10ms', + ); +} + +QueryCommentsResponse createDefaultQueryCommentsResponse({ + String? next, + String? prev, + List comments = const [], +}) { + return QueryCommentsResponse( + next: next, + prev: prev, + comments: comments, + duration: '10ms', + ); +} + FeedMemberResponse createDefaultFeedMemberResponse({ String id = 'member-id', String role = 'member', + FeedMemberResponseStatus status = FeedMemberResponseStatus.member, }) { return FeedMemberResponse( createdAt: DateTime(2021, 1, 1), custom: const {}, role: role, - status: FeedMemberResponseStatus.member, + status: status, updatedAt: DateTime(2021, 2, 1), user: createDefaultUserResponse(id: id), ); } +QueryFeedMembersResponse createDefaultQueryFeedMembersResponse({ + String? next, + String? prev, + List members = const [], +}) { + return QueryFeedMembersResponse( + next: next, + prev: prev, + members: members, + duration: '10ms', + ); +} + FollowResponse createDefaultFollowResponse({ - String id = 'follow-id', + String sourceId = 'follow-source-id', + String targetId = 'follow-target-id', + String followerRole = 'follower', FollowResponseStatus status = FollowResponseStatus.accepted, }) { return FollowResponse( - createdAt: DateTime(2021, 1, 1), custom: const {}, - followerRole: 'follower', + followerRole: followerRole, pushPreference: FollowResponsePushPreference.all, - sourceFeed: createDefaultFeedResponse(id: 'source-$id'), + sourceFeed: createDefaultFeedResponse(id: sourceId, groupId: 'user'), status: status, - targetFeed: createDefaultFeedResponse(id: 'target-$id'), + targetFeed: createDefaultFeedResponse(id: targetId, groupId: 'user'), + createdAt: DateTime(2021, 1, 1), updatedAt: DateTime(2021, 2, 1), ); } BookmarkFolderResponse createDefaultBookmarkFolderResponse({ String id = 'folder-id', + String name = 'My Folder', }) { return BookmarkFolderResponse( createdAt: DateTime(2021, 1, 1), custom: const {}, id: id, - name: 'My Folder', + name: name, updatedAt: DateTime(2021, 2, 1), user: createDefaultUserResponse(), ); } +QueryBookmarkFoldersResponse createDefaultQueryBookmarkFoldersResponse({ + String? next, + String? prev, + List bookmarkFolders = const [], +}) { + return QueryBookmarkFoldersResponse( + next: next, + prev: prev, + bookmarkFolders: bookmarkFolders, + duration: '10ms', + ); +} + +QueryBookmarksResponse createDefaultQueryBookmarksResponse({ + String? next, + String? prev, + List bookmarks = const [], +}) { + return QueryBookmarksResponse( + next: next, + prev: prev, + bookmarks: bookmarks, + duration: '10ms', + ); +} + +QueryFeedsResponse createDefaultQueryFeedsResponse({ + String? next, + String? prev, + List feeds = const [], +}) { + return QueryFeedsResponse( + next: next, + prev: prev, + feeds: feeds, + duration: '10ms', + ); +} + PollVoteResponseData createDefaultPollVoteResponse({ String id = 'vote-id', String pollId = 'poll-id', @@ -652,3 +856,31 @@ ActivityFeedbackResponse createDefaultActivityFeedbackResponse({ activityId: activityId, ); } + +ConfigResponse createDefaultModerationConfigResponse({ + String key = 'config-key', + String team = 'team-id', + bool async = false, +}) { + return ConfigResponse( + key: key, + team: team, + async: async, + supportedVideoCallHarmTypes: const [], + createdAt: DateTime(2021, 1, 1), + updatedAt: DateTime(2021, 2, 1), + ); +} + +QueryModerationConfigsResponse createDefaultQueryModerationConfigsResponse({ + String? next, + String? prev, + List configs = const [], +}) { + return QueryModerationConfigsResponse( + next: next, + prev: prev, + configs: configs, + duration: '10ms', + ); +} diff --git a/packages/stream_feeds_test/lib/src/testers/activity_reaction_list_tester.dart b/packages/stream_feeds_test/lib/src/testers/activity_reaction_list_tester.dart new file mode 100644 index 00000000..80b6927f --- /dev/null +++ b/packages/stream_feeds_test/lib/src/testers/activity_reaction_list_tester.dart @@ -0,0 +1,162 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:test/test.dart' as test; + +import '../helpers/mocks.dart'; +import '../helpers/test_data.dart'; +import 'base_tester.dart'; + +/// Test helper for activity reaction list operations. +/// +/// Automatically sets up WebSocket connection, client, and test infrastructure. +/// Tests are tagged with 'activity-reaction-list' by default for filtering. +/// +/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). + +/// [build] constructs the [ActivityReactionList] under test using the provided [StreamFeedsClient]. +/// [setUp] is optional and runs before [body] for setting up mocks and test state. +/// [body] is the test callback that receives an [ActivityReactionListTester] for interactions. +/// [verify] is optional and runs after [body] for verifying API calls and interactions. +/// [tearDown] is optional and runs after [verify] for cleanup operations. +/// [skip] is optional, skip this test. +/// [tags] is optional, tags for test filtering. Defaults to ['activity-reaction-list']. +/// [timeout] is optional, custom timeout for this test. +/// +/// Example: +/// ```dart +/// activityReactionListTest( +/// 'queries initial reactions', +/// build: (client) => client.activityReactionList( +/// ActivityReactionsQuery( +/// activityId: 'activity-1', +/// ), +/// ), +/// setUp: (tester) => tester.get(), +/// body: (tester) async { +/// expect(tester.activityReactionListState.reactions, hasLength(3)); +/// }, +/// ); +/// ``` +@isTest +void activityReactionListTest( + String description, { + User user = const User(id: 'luke_skywalker'), + required ActivityReactionList Function(StreamFeedsClient client) build, + FutureOr Function(ActivityReactionListTester tester)? setUp, + required FutureOr Function(ActivityReactionListTester tester) body, + FutureOr Function(ActivityReactionListTester tester)? verify, + FutureOr Function(ActivityReactionListTester tester)? tearDown, + bool skip = false, + Iterable tags = const ['activity-reaction-list'], + test.Timeout? timeout, +}) { + return testWithTester( + description, + user: user, + build: build, + createTesterFn: _createActivityReactionListTester, + setUp: setUp, + body: body, + verify: verify, + tearDown: tearDown, + skip: skip, + tags: tags, + timeout: timeout, + ); +} + +/// A test utility for activity reaction list operations with WebSocket support. +/// +/// Provides helper methods for emitting events and verifying activity reaction list state. +/// +/// Resources are automatically cleaned up after the test completes. +final class ActivityReactionListTester + extends BaseTester { + const ActivityReactionListTester._({ + required ActivityReactionList activityReactionList, + required super.wsStreamController, + required super.feedsApi, + }) : super(subject: activityReactionList); + + /// The activity reaction list being tested. + ActivityReactionList get activityReactionList => subject; + + /// Current state of the activity reaction list. + ActivityReactionListState get activityReactionListState { + return activityReactionList.state; + } + + /// Stream of activity reaction list state updates. + Stream get activityReactionListStateStream { + return activityReactionList.stream; + } + + /// Gets the activity reaction list by fetching it from the API. + /// + /// Call this in event tests to set up initial state before emitting events. + /// Skip this in API tests that only verify method calls. + /// + /// Parameters: + /// - [modifyResponse]: Optional function to customize the activity reaction list response + Future>> get({ + QueryActivityReactionsResponse Function( + QueryActivityReactionsResponse, + )? modifyResponse, + }) { + final query = activityReactionList.query; + + final defaultReactionListResponse = + createDefaultQueryActivityReactionsResponse( + reactions: [ + createDefaultReactionResponse(activityId: query.activityId), + createDefaultReactionResponse( + activityId: query.activityId, + userId: 'user-2', + ), + createDefaultReactionResponse( + activityId: query.activityId, + userId: 'user-3', + ), + ], + ); + + mockApi( + (api) => api.queryActivityReactions( + activityId: query.activityId, + queryActivityReactionsRequest: query.toRequest(), + ), + result: switch (modifyResponse) { + final modifier? => modifier(defaultReactionListResponse), + _ => defaultReactionListResponse, + }, + ); + + return activityReactionList.get(); + } +} + +// Creates an ActivityReactionListTester for testing activity reaction list operations. +// +// Automatically sets up WebSocket connection and registers cleanup handlers. +// This function is for internal use by activityReactionListTest only. +Future _createActivityReactionListTester({ + required StreamFeedsClient client, + required ActivityReactionList subject, + required MockDefaultApi feedsApi, + required MockWebSocketChannel webSocketChannel, +}) { + // Dispose activity reaction list after test + test.addTearDown(subject.dispose); + + return createTester( + client: client, + webSocketChannel: webSocketChannel, + create: (wsStreamController) => ActivityReactionListTester._( + activityReactionList: subject, + wsStreamController: wsStreamController, + feedsApi: feedsApi, + ), + ); +} diff --git a/packages/stream_feeds_test/lib/src/testers/comment_list_tester.dart b/packages/stream_feeds_test/lib/src/testers/comment_list_tester.dart index 627257d3..f55c6ace 100644 --- a/packages/stream_feeds_test/lib/src/testers/comment_list_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/comment_list_tester.dart @@ -100,8 +100,7 @@ final class CommentListTester extends BaseTester { }) { final query = commentList.query; - final defaultCommentListResponse = QueryCommentsResponse( - duration: DateTime.now().toIso8601String(), + final defaultCommentListResponse = createDefaultQueryCommentsResponse( comments: [ createDefaultCommentResponse(id: 'comment-1', objectId: 'obj-1'), createDefaultCommentResponse(id: 'comment-2', objectId: 'obj-1'), diff --git a/packages/stream_feeds_test/lib/src/testers/comment_reaction_list_tester.dart b/packages/stream_feeds_test/lib/src/testers/comment_reaction_list_tester.dart new file mode 100644 index 00000000..cc058da0 --- /dev/null +++ b/packages/stream_feeds_test/lib/src/testers/comment_reaction_list_tester.dart @@ -0,0 +1,161 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:test/test.dart' as test; + +import '../helpers/mocks.dart'; +import '../helpers/test_data.dart'; +import 'base_tester.dart'; + +/// Test helper for comment reaction list operations. +/// +/// Automatically sets up WebSocket connection, client, and test infrastructure. +/// Tests are tagged with 'comment-reaction-list' by default for filtering. +/// +/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). + +/// [build] constructs the [CommentReactionList] under test using the provided [StreamFeedsClient]. +/// [setUp] is optional and runs before [body] for setting up mocks and test state. +/// [body] is the test callback that receives a [CommentReactionListTester] for interactions. +/// [verify] is optional and runs after [body] for verifying API calls and interactions. +/// [tearDown] is optional and runs after [verify] for cleanup operations. +/// [skip] is optional, skip this test. +/// [tags] is optional, tags for test filtering. Defaults to ['comment-reaction-list']. +/// [timeout] is optional, custom timeout for this test. +/// +/// Example: +/// ```dart +/// commentReactionListTest( +/// 'queries initial reactions', +/// build: (client) => client.commentReactionList( +/// CommentReactionsQuery( +/// commentId: 'comment-1', +/// ), +/// ), +/// setUp: (tester) => tester.get(), +/// body: (tester) async { +/// expect(tester.commentReactionListState.reactions, hasLength(3)); +/// }, +/// ); +/// ``` +@isTest +void commentReactionListTest( + String description, { + User user = const User(id: 'luke_skywalker'), + required CommentReactionList Function(StreamFeedsClient client) build, + FutureOr Function(CommentReactionListTester tester)? setUp, + required FutureOr Function(CommentReactionListTester tester) body, + FutureOr Function(CommentReactionListTester tester)? verify, + FutureOr Function(CommentReactionListTester tester)? tearDown, + bool skip = false, + Iterable tags = const ['comment-reaction-list'], + test.Timeout? timeout, +}) { + return testWithTester( + description, + user: user, + build: build, + createTesterFn: _createCommentReactionListTester, + setUp: setUp, + body: body, + verify: verify, + tearDown: tearDown, + skip: skip, + tags: tags, + timeout: timeout, + ); +} + +/// A test utility for comment reaction list operations with WebSocket support. +/// +/// Provides helper methods for emitting events and verifying comment reaction list state. +/// +/// Resources are automatically cleaned up after the test completes. +final class CommentReactionListTester extends BaseTester { + const CommentReactionListTester._({ + required CommentReactionList commentReactionList, + required super.wsStreamController, + required super.feedsApi, + }) : super(subject: commentReactionList); + + /// The comment reaction list being tested. + CommentReactionList get commentReactionList => subject; + + /// Current state of the comment reaction list. + CommentReactionListState get commentReactionListState { + return commentReactionList.state; + } + + /// Stream of comment reaction list state updates. + Stream get commentReactionListStateStream { + return commentReactionList.stream; + } + + /// Gets the comment reaction list by fetching it from the API. + /// + /// Call this in event tests to set up initial state before emitting events. + /// Skip this in API tests that only verify method calls. + /// + /// Parameters: + /// - [modifyResponse]: Optional function to customize the comment reaction list response + Future>> get({ + QueryCommentReactionsResponse Function( + QueryCommentReactionsResponse, + )? modifyResponse, + }) { + final query = commentReactionList.query; + + final defaultReactionListResponse = + createDefaultQueryCommentReactionsResponse( + reactions: [ + createDefaultReactionResponse(commentId: query.commentId), + createDefaultReactionResponse( + commentId: query.commentId, + userId: 'user-2', + ), + createDefaultReactionResponse( + commentId: query.commentId, + userId: 'user-3', + ), + ], + ); + + mockApi( + (api) => api.queryCommentReactions( + id: query.commentId, + queryCommentReactionsRequest: query.toRequest(), + ), + result: switch (modifyResponse) { + final modifier? => modifier(defaultReactionListResponse), + _ => defaultReactionListResponse, + }, + ); + + return commentReactionList.get(); + } +} + +// Creates a CommentReactionListTester for testing comment reaction list operations. +// +// Automatically sets up WebSocket connection and registers cleanup handlers. +// This function is for internal use by commentReactionListTest only. +Future _createCommentReactionListTester({ + required StreamFeedsClient client, + required CommentReactionList subject, + required MockDefaultApi feedsApi, + required MockWebSocketChannel webSocketChannel, +}) { + // Dispose comment reaction list after test + test.addTearDown(subject.dispose); + + return createTester( + client: client, + webSocketChannel: webSocketChannel, + create: (wsStreamController) => CommentReactionListTester._( + commentReactionList: subject, + wsStreamController: wsStreamController, + feedsApi: feedsApi, + ), + ); +} diff --git a/packages/stream_feeds_test/lib/src/testers/follow_list_tester.dart b/packages/stream_feeds_test/lib/src/testers/follow_list_tester.dart index c6355da5..f628364c 100644 --- a/packages/stream_feeds_test/lib/src/testers/follow_list_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/follow_list_tester.dart @@ -103,9 +103,18 @@ final class FollowListTester extends BaseTester { final defaultFollowListResponse = QueryFollowsResponse( duration: DateTime.now().toIso8601String(), follows: [ - createDefaultFollowResponse(id: 'follow-1'), - createDefaultFollowResponse(id: 'follow-2'), - createDefaultFollowResponse(id: 'follow-3'), + createDefaultFollowResponse( + sourceId: 'source-1', + targetId: 'target-1', + ), + createDefaultFollowResponse( + sourceId: 'source-2', + targetId: 'target-2', + ), + createDefaultFollowResponse( + sourceId: 'source-3', + targetId: 'target-3', + ), ], ); diff --git a/packages/stream_feeds_test/lib/src/testers/member_list_tester.dart b/packages/stream_feeds_test/lib/src/testers/member_list_tester.dart new file mode 100644 index 00000000..a26c187a --- /dev/null +++ b/packages/stream_feeds_test/lib/src/testers/member_list_tester.dart @@ -0,0 +1,147 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:test/test.dart' as test; + +import '../helpers/mocks.dart'; +import '../helpers/test_data.dart'; +import 'base_tester.dart'; + +/// Test helper for member list operations. +/// +/// Automatically sets up WebSocket connection, client, and test infrastructure. +/// Tests are tagged with 'member-list' by default for filtering. +/// +/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). + +/// [build] constructs the [MemberList] under test using the provided [StreamFeedsClient]. +/// [setUp] is optional and runs before [body] for setting up mocks and test state. +/// [body] is the test callback that receives a [MemberListTester] for interactions. +/// [verify] is optional and runs after [body] for verifying API calls and interactions. +/// [tearDown] is optional and runs after [verify] for cleanup operations. +/// [skip] is optional, skip this test. +/// [tags] is optional, tags for test filtering. Defaults to ['member-list']. +/// [timeout] is optional, custom timeout for this test. +/// +/// Example: +/// ```dart +/// memberListTest( +/// 'queries initial members', +/// build: (client) => client.memberList( +/// MembersQuery(fid: FeedId(group: 'user', id: 'john')), +/// ), +/// setUp: (tester) => tester.get(), +/// body: (tester) async { +/// expect(tester.memberListState.members, hasLength(3)); +/// }, +/// ); +/// ``` +@isTest +void memberListTest( + String description, { + User user = const User(id: 'luke_skywalker'), + required MemberList Function(StreamFeedsClient client) build, + FutureOr Function(MemberListTester tester)? setUp, + required FutureOr Function(MemberListTester tester) body, + FutureOr Function(MemberListTester tester)? verify, + FutureOr Function(MemberListTester tester)? tearDown, + bool skip = false, + Iterable tags = const ['member-list'], + test.Timeout? timeout, +}) { + return testWithTester( + description, + user: user, + build: build, + createTesterFn: _createMemberListTester, + setUp: setUp, + body: body, + verify: verify, + tearDown: tearDown, + skip: skip, + tags: tags, + timeout: timeout, + ); +} + +/// A test utility for member list operations with WebSocket support. +/// +/// Provides helper methods for emitting events and verifying member list state. +/// +/// Resources are automatically cleaned up after the test completes. +final class MemberListTester extends BaseTester { + const MemberListTester._({ + required MemberList memberList, + required super.wsStreamController, + required super.feedsApi, + }) : super(subject: memberList); + + /// The member list being tested. + MemberList get memberList => subject; + + /// Current state of the member list. + MemberListState get memberListState => memberList.state; + + /// Stream of member list state updates. + Stream get memberListStateStream => memberList.stream; + + /// Gets the member list by fetching it from the API. + /// + /// Call this in event tests to set up initial state before emitting events. + /// Skip this in API tests that only verify method calls. + /// + /// Parameters: + /// - [modifyResponse]: Optional function to customize the member list response + Future>> get({ + QueryFeedMembersResponse Function(QueryFeedMembersResponse)? modifyResponse, + }) { + final query = memberList.query; + + final defaultMemberListResponse = createDefaultQueryFeedMembersResponse( + members: [ + createDefaultFeedMemberResponse(id: 'member-1'), + createDefaultFeedMemberResponse(id: 'member-2'), + createDefaultFeedMemberResponse(id: 'member-3'), + ], + ); + + mockApi( + (api) => api.queryFeedMembers( + feedId: query.fid.id, + feedGroupId: query.fid.group, + queryFeedMembersRequest: query.toRequest(), + ), + result: switch (modifyResponse) { + final modifier? => modifier(defaultMemberListResponse), + _ => defaultMemberListResponse, + }, + ); + + return memberList.get(); + } +} + +// Creates a MemberListTester for testing member list operations. +// +// Automatically sets up WebSocket connection and registers cleanup handlers. +// This function is for internal use by memberListTest only. +Future _createMemberListTester({ + required StreamFeedsClient client, + required MemberList subject, + required MockDefaultApi feedsApi, + required MockWebSocketChannel webSocketChannel, +}) { + // Dispose member list after test + test.addTearDown(subject.dispose); + + return createTester( + client: client, + webSocketChannel: webSocketChannel, + create: (wsStreamController) => MemberListTester._( + memberList: subject, + wsStreamController: wsStreamController, + feedsApi: feedsApi, + ), + ); +} diff --git a/packages/stream_feeds_test/lib/src/testers/moderation_config_list_tester.dart b/packages/stream_feeds_test/lib/src/testers/moderation_config_list_tester.dart new file mode 100644 index 00000000..146b4ee7 --- /dev/null +++ b/packages/stream_feeds_test/lib/src/testers/moderation_config_list_tester.dart @@ -0,0 +1,150 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:test/test.dart' as test; + +import '../helpers/mocks.dart'; +import '../helpers/test_data.dart'; +import 'base_tester.dart'; + +/// Test helper for moderation config list operations. +/// +/// Automatically sets up WebSocket connection, client, and test infrastructure. +/// Tests are tagged with 'moderation-config-list' by default for filtering. +/// +/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). +/// [build] constructs the [ModerationConfigList] under test using the provided [StreamFeedsClient]. +/// [setUp] is optional and runs before [body] for setting up mocks and test state. +/// [body] is the test callback that receives a [ModerationConfigListTester] for interactions. +/// [verify] is optional and runs after [body] for verifying API calls and interactions. +/// [tearDown] is optional and runs after [verify] for cleanup operations. +/// [skip] is optional, skip this test. +/// [tags] is optional, tags for test filtering. Defaults to ['moderation-config-list']. +/// [timeout] is optional, custom timeout for this test. +/// +/// Example: +/// ```dart +/// moderationConfigListTest( +/// 'should query initial configs', +/// build: (client) => client.moderationConfigList(ModerationConfigsQuery()), +/// setUp: (tester) => tester.get(), +/// body: (tester) async { +/// expect(tester.moderationConfigListState.configs, hasLength(3)); +/// }, +/// ); +/// ``` +@isTest +void moderationConfigListTest( + String description, { + User user = const User(id: 'luke_skywalker'), + required ModerationConfigList Function(StreamFeedsClient client) build, + FutureOr Function(ModerationConfigListTester tester)? setUp, + required FutureOr Function(ModerationConfigListTester tester) body, + FutureOr Function(ModerationConfigListTester tester)? verify, + FutureOr Function(ModerationConfigListTester tester)? tearDown, + bool skip = false, + Iterable tags = const ['moderation-config-list'], + test.Timeout? timeout, +}) { + return testWithTester( + description, + user: user, + build: build, + createTesterFn: _createModerationConfigListTester, + setUp: setUp, + body: body, + verify: verify, + tearDown: tearDown, + skip: skip, + tags: tags, + timeout: timeout, + ); +} + +/// A test utility for moderation config list operations with WebSocket support. +/// +/// Provides helper methods for emitting events and verifying moderation config list state. +/// +/// Resources are automatically cleaned up after the test completes. +final class ModerationConfigListTester + extends BaseTester { + const ModerationConfigListTester._({ + required ModerationConfigList moderationConfigList, + required super.wsStreamController, + required super.feedsApi, + }) : super(subject: moderationConfigList); + + /// The moderation config list being tested. + ModerationConfigList get moderationConfigList => subject; + + /// Current state of the moderation config list. + ModerationConfigListState get moderationConfigListState { + return moderationConfigList.state; + } + + /// Stream of moderation config list state updates. + Stream get moderationConfigListStateStream { + return moderationConfigList.stream; + } + + /// Gets the moderation config list by fetching it from the API. + /// + /// Call this in event tests to set up initial state before emitting events. + /// Skip this in API tests that only verify method calls. + /// + /// Parameters: + /// - [modifyResponse]: Optional function to customize the moderation config list response + Future>> get({ + QueryModerationConfigsResponse Function( + QueryModerationConfigsResponse, + )? modifyResponse, + }) { + final query = moderationConfigList.query; + + final defaultConfigListResponse = + createDefaultQueryModerationConfigsResponse( + configs: [ + createDefaultModerationConfigResponse(key: 'config-1'), + createDefaultModerationConfigResponse(key: 'config-2'), + createDefaultModerationConfigResponse(key: 'config-3'), + ], + ); + + mockApi( + (api) => api.queryModerationConfigs( + queryModerationConfigsRequest: query.toRequest(), + ), + result: switch (modifyResponse) { + final modifier? => modifier(defaultConfigListResponse), + _ => defaultConfigListResponse, + }, + ); + + return moderationConfigList.get(); + } +} + +// Creates a ModerationConfigListTester for testing moderation config list operations. +// +// Automatically sets up WebSocket connection and registers cleanup handlers. +// This function is for internal use by moderationConfigListTest only. +Future _createModerationConfigListTester({ + required ModerationConfigList subject, + required StreamFeedsClient client, + required MockDefaultApi feedsApi, + required MockWebSocketChannel webSocketChannel, +}) { + // Dispose moderation config list after test + test.addTearDown(subject.dispose); + + return createTester( + client: client, + webSocketChannel: webSocketChannel, + create: (wsStreamController) => ModerationConfigListTester._( + moderationConfigList: subject, + wsStreamController: wsStreamController, + feedsApi: feedsApi, + ), + ); +} diff --git a/packages/stream_feeds_test/lib/stream_feeds_test.dart b/packages/stream_feeds_test/lib/stream_feeds_test.dart index ff013da2..924fd877 100644 --- a/packages/stream_feeds_test/lib/stream_feeds_test.dart +++ b/packages/stream_feeds_test/lib/stream_feeds_test.dart @@ -24,14 +24,18 @@ export 'src/helpers/web_socket_mocks.dart'; // Testers export 'src/testers/activity_comment_list_tester.dart'; export 'src/testers/activity_list_tester.dart'; +export 'src/testers/activity_reaction_list_tester.dart'; export 'src/testers/activity_tester.dart'; export 'src/testers/base_tester.dart'; export 'src/testers/bookmark_folder_list_tester.dart'; export 'src/testers/bookmark_list_tester.dart'; export 'src/testers/comment_list_tester.dart'; +export 'src/testers/comment_reaction_list_tester.dart'; export 'src/testers/comment_reply_list_tester.dart'; export 'src/testers/feed_list_tester.dart'; export 'src/testers/feed_tester.dart'; export 'src/testers/follow_list_tester.dart'; +export 'src/testers/member_list_tester.dart'; +export 'src/testers/moderation_config_list_tester.dart'; export 'src/testers/poll_list_tester.dart'; export 'src/testers/poll_vote_list_tester.dart'; diff --git a/packages/stream_feeds_test/pubspec.yaml b/packages/stream_feeds_test/pubspec.yaml index ba33e9d7..361b0500 100644 --- a/packages/stream_feeds_test/pubspec.yaml +++ b/packages/stream_feeds_test/pubspec.yaml @@ -7,6 +7,7 @@ environment: sdk: ^3.6.2 dependencies: + collection: ^1.18.0 meta: ^1.9.1 mocktail: ^1.0.4 stream_feeds: ^0.5.0