diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index fde1769..1b103d1 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -1,6 +1,7 @@ ## unreleased - [BREAKING] Change `queryFollowSuggestions` return type to `List`. - [BREAKING] Remove `activitySelectorOptions` from `FeedQuery`. +- Add `activityFeedback` method to `Feed` and `Activity` for submitting activity feedback. - Add `hidden` and `preview` fields to `ActivityData`. - Update follower and following counts on the feed state when receiving follow websocket events. - Fix FeedsReactionData id for updating reactions in the feed state. diff --git a/packages/stream_feeds/dart_test.yaml b/packages/stream_feeds/dart_test.yaml new file mode 100644 index 0000000..32f0343 --- /dev/null +++ b/packages/stream_feeds/dart_test.yaml @@ -0,0 +1,4 @@ +tags: + feed: + activity: + activity-list: \ No newline at end of file diff --git a/packages/stream_feeds/lib/src/repository/activities_repository.dart b/packages/stream_feeds/lib/src/repository/activities_repository.dart index 580680b..1e7e85a 100644 --- a/packages/stream_feeds/lib/src/repository/activities_repository.dart +++ b/packages/stream_feeds/lib/src/repository/activities_repository.dart @@ -248,4 +248,20 @@ class ActivitiesRepository { return PaginationResult(items: reactions, pagination: pagination); }); } + + /// Submits activity feedback. + /// + /// Submits feedback for the activity with the specified [activityId] using + /// the provided [request]. + /// + /// Returns a [Result] containing void or an error. + Future> activityFeedback( + String activityId, + api.ActivityFeedbackRequest request, + ) { + return _api.activityFeedback( + activityId: activityId, + activityFeedbackRequest: request, + ); + } } diff --git a/packages/stream_feeds/lib/src/state.dart b/packages/stream_feeds/lib/src/state.dart index f2c748b..b3d3848 100644 --- a/packages/stream_feeds/lib/src/state.dart +++ b/packages/stream_feeds/lib/src/state.dart @@ -1,5 +1,6 @@ export 'state/activity.dart'; export 'state/activity_comment_list.dart'; +export 'state/activity_list.dart'; export 'state/comment_list.dart'; export 'state/comment_reaction_list.dart'; export 'state/comment_reply_list.dart'; diff --git a/packages/stream_feeds/lib/src/state/activity.dart b/packages/stream_feeds/lib/src/state/activity.dart index f4dc8e9..18cdbfc 100644 --- a/packages/stream_feeds/lib/src/state/activity.dart +++ b/packages/stream_feeds/lib/src/state/activity.dart @@ -68,7 +68,13 @@ class Activity with Disposable { ); // Attach event handlers for real-time updates - final handler = ActivityEventHandler(fid: fid, state: _stateNotifier); + final handler = ActivityEventHandler( + fid: fid, + state: _stateNotifier, + activityId: activityId, + currentUserId: currentUserId, + ); + _eventsSubscription = eventsEmitter.listen(handler.handleEvent); } @@ -117,6 +123,20 @@ class Activity with Disposable { return result; } + /// Submits feedback for this activity. + /// + /// Submits feedback for this activity using the provided [activityFeedbackRequest]. + /// + /// Returns a [Result] indicating success or failure of the operation. + Future> activityFeedback({ + required api.ActivityFeedbackRequest activityFeedbackRequest, + }) { + return activitiesRepository.activityFeedback( + activityId, + activityFeedbackRequest, + ); + } + /// Queries the comments for this activity. /// /// Returns a [Result] containing a list of [ThreadedCommentData] or an error. diff --git a/packages/stream_feeds/lib/src/state/activity_list.dart b/packages/stream_feeds/lib/src/state/activity_list.dart index f2015fd..301f075 100644 --- a/packages/stream_feeds/lib/src/state/activity_list.dart +++ b/packages/stream_feeds/lib/src/state/activity_list.dart @@ -39,8 +39,10 @@ class ActivityList with Disposable { final handler = ActivityListEventHandler( query: query, state: _stateNotifier, + currentUserId: currentUserId, capabilitiesRepository: capabilitiesRepository, ); + _eventsSubscription = eventsEmitter.listen(handler.handleEvent); } 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 80da089..de56f34 100644 --- a/packages/stream_feeds/lib/src/state/activity_list_state.dart +++ b/packages/stream_feeds/lib/src/state/activity_list_state.dart @@ -69,6 +69,20 @@ class ActivityListStateNotifier extends StateNotifier { state = state.copyWith(activities: updatedActivities); } + /// Handles updates to the activity list state when an activity is hidden. + void onActivityHidden({ + 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(); + + state = state.copyWith(activities: updatedActivities); + } + /// Handles the addition of a bookmark. void onBookmarkAdded(BookmarkData bookmark) { final updatedActivities = state.activities.map((activity) { diff --git a/packages/stream_feeds/lib/src/state/activity_state.dart b/packages/stream_feeds/lib/src/state/activity_state.dart index dad2e39..09e61cd 100644 --- a/packages/stream_feeds/lib/src/state/activity_state.dart +++ b/packages/stream_feeds/lib/src/state/activity_state.dart @@ -47,6 +47,16 @@ class ActivityStateNotifier extends StateNotifier { ); } + /// Handles updates to the activity's hidden status. + 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; diff --git a/packages/stream_feeds/lib/src/state/event/activity_event_handler.dart b/packages/stream_feeds/lib/src/state/event/activity_event_handler.dart index 7ecf793..0895daa 100644 --- a/packages/stream_feeds/lib/src/state/event/activity_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/activity_event_handler.dart @@ -17,10 +17,14 @@ class ActivityEventHandler implements StateEventHandler { const ActivityEventHandler({ required this.fid, required this.state, + required this.activityId, + required this.currentUserId, }); final FeedId fid; final ActivityStateNotifier state; + final String activityId; + final String currentUserId; @override void handleEvent(WsEvent event) { @@ -68,6 +72,21 @@ class ActivityEventHandler implements StateEventHandler { 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', + ); + } + } + // Handle other activity events here as needed } } diff --git a/packages/stream_feeds/lib/src/state/event/activity_list_event_handler.dart b/packages/stream_feeds/lib/src/state/event/activity_list_event_handler.dart index 91c2d0e..5992857 100644 --- a/packages/stream_feeds/lib/src/state/event/activity_list_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/activity_list_event_handler.dart @@ -21,11 +21,13 @@ class ActivityListEventHandler const ActivityListEventHandler({ required this.query, required this.state, + required this.currentUserId, required this.capabilitiesRepository, }); final ActivitiesQuery query; final ActivityListStateNotifier state; + final String currentUserId; @override final CapabilitiesRepository capabilitiesRepository; @@ -112,6 +114,21 @@ class ActivityListEventHandler return state.onCommentRemoved(event.comment.toModel()); } + 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', + ); + } + } + // Handle other activity list events here as needed } } diff --git a/packages/stream_feeds/lib/src/state/event/feed_event_handler.dart b/packages/stream_feeds/lib/src/state/event/feed_event_handler.dart index e9d7b13..953bece 100644 --- a/packages/stream_feeds/lib/src/state/event/feed_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/feed_event_handler.dart @@ -21,11 +21,13 @@ class FeedEventHandler with FeedCapabilitiesMixin implements StateEventHandler { const FeedEventHandler({ required this.query, required this.state, + required this.currentUserId, required this.capabilitiesRepository, }); final FeedQuery query; final FeedStateNotifier state; + final String currentUserId; @override final CapabilitiesRepository capabilitiesRepository; @@ -212,6 +214,22 @@ class FeedEventHandler with FeedCapabilitiesMixin implements StateEventHandler { ); } + 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', + ); + } + } + // Handle other events if necessary } } diff --git a/packages/stream_feeds/lib/src/state/feed.dart b/packages/stream_feeds/lib/src/state/feed.dart index 5fec37e..09c00cc 100644 --- a/packages/stream_feeds/lib/src/state/feed.dart +++ b/packages/stream_feeds/lib/src/state/feed.dart @@ -72,6 +72,7 @@ class Feed with Disposable { final handler = FeedEventHandler( query: query, state: _stateNotifier, + currentUserId: currentUserId, capabilitiesRepository: capabilitiesRepository, ); @@ -218,6 +219,22 @@ class Feed with Disposable { ); } + /// Submits feedback for an activity. + /// + /// Submits feedback for the activity with the specified [activityId] using + /// the provided [activityFeedbackRequest]. + /// + /// Returns a [Result] indicating success or failure of the operation. + Future> activityFeedback({ + required String activityId, + required api.ActivityFeedbackRequest activityFeedbackRequest, + }) { + return activitiesRepository.activityFeedback( + activityId, + activityFeedbackRequest, + ); + } + /// Marks an activity as read or unread. /// /// [request] The request containing the mark activity data. diff --git a/packages/stream_feeds/lib/src/state/feed_state.dart b/packages/stream_feeds/lib/src/state/feed_state.dart index 47864fd..eda0dc4 100644 --- a/packages/stream_feeds/lib/src/state/feed_state.dart +++ b/packages/stream_feeds/lib/src/state/feed_state.dart @@ -161,6 +161,29 @@ class FeedStateNotifier extends StateNotifier { ); } + /// Handles updates to the feed state when an activity is hidden. + void onActivityHidden({ + 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, + ); + } + /// Handles updates to the feed state when an activity is pinned. void onActivityPinned(ActivityPinData activityPin) { // Upsert the pinned activity into the existing pinned activities list diff --git a/packages/stream_feeds/test/state/activity_list_test.dart b/packages/stream_feeds/test/state/activity_list_test.dart index 522ceac..5e3191e 100644 --- a/packages/stream_feeds/test/state/activity_list_test.dart +++ b/packages/stream_feeds/test/state/activity_list_test.dart @@ -54,9 +54,9 @@ void main() { QueryActivitiesResponse( duration: DateTime.now().toIso8601String(), activities: [ - createDefaultActivityResponse(id: 'activity-1').activity, - createDefaultActivityResponse(id: 'activity-2').activity, - createDefaultActivityResponse(id: 'activity-3').activity, + createDefaultActivityResponse(id: 'activity-1'), + createDefaultActivityResponse(id: 'activity-2'), + createDefaultActivityResponse(id: 'activity-3'), ], ), ), @@ -91,7 +91,7 @@ void main() { activity: createDefaultActivityResponse( id: 'activity-1', // Doesn't match 'post' filter - ).activity.copyWith(type: 'comment'), + ).copyWith(type: 'comment'), ), ), ); @@ -126,7 +126,7 @@ void main() { activity: createDefaultActivityResponse( id: 'activity-2', // Doesn't match 'post' filter - ).activity.copyWith(type: 'comment'), + ).copyWith(type: 'comment'), reaction: FeedsReactionResponse( activityId: 'activity-2', type: 'like', @@ -167,7 +167,7 @@ void main() { fid: 'fid', activity: createDefaultActivityResponse( id: 'activity-3', - ).activity.copyWith(type: 'share'), + ).copyWith(type: 'share'), reaction: FeedsReactionResponse( activityId: 'activity-3', type: 'like', @@ -211,7 +211,7 @@ void main() { activity: createDefaultActivityResponse( id: 'activity-1', // Doesn't match 'post' filter - ).activity.copyWith(type: 'comment'), + ).copyWith(type: 'comment'), ), ), ), @@ -249,7 +249,7 @@ void main() { activity: createDefaultActivityResponse( id: 'activity-2', // Doesn't match 'post' filter - ).activity.copyWith(type: 'share'), + ).copyWith(type: 'share'), ), ), ), @@ -285,7 +285,7 @@ void main() { activity: createDefaultActivityResponse( id: 'activity-3', // Doesn't match 'post' filter - ).activity.copyWith(type: 'comment'), + ).copyWith(type: 'comment'), comment: createDefaultCommentResponse( objectId: 'activity-3', ), @@ -323,7 +323,7 @@ void main() { fid: 'fid', activity: createDefaultActivityResponse( id: 'activity-1', - ).activity.copyWith( + ).copyWith( type: 'post', // Matches first condition filterTags: ['general'], // Doesn't match second condition ), // Doesn't match any condition @@ -362,7 +362,7 @@ void main() { fid: 'fid', activity: createDefaultActivityResponse( id: 'activity-1', - ).activity.copyWith( + ).copyWith( type: 'post', // Matches first condition filterTags: ['general'], // Doesn't match second condition ), // Doesn't match any condition @@ -403,7 +403,7 @@ void main() { fid: 'fid', activity: createDefaultActivityResponse( id: 'activity-1', - ).activity.copyWith(type: 'share'), + ).copyWith(type: 'share'), ), ), ); @@ -414,4 +414,82 @@ void main() { }, ); }); + + // ============================================================ + // FEATURE: Activity Feedback + // ============================================================ + + group('Activity feedback', () { + const activityId = 'activity-1'; + + activityListTest( + 'marks activity hidden on ActivityFeedbackEvent', + build: (client) => client.activityList(const ActivitiesQuery()), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: activityId), + ], + ), + ), + body: (tester) async { + tester.expect((al) => al.state.activities, hasLength(1)); + tester.expect((al) => al.state.activities.first.hidden, false); + + 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.expect((al) => al.state.activities, hasLength(1)); + tester.expect((al) => al.state.activities.first.hidden, true); + }, + ); + + activityListTest( + 'marks activity unhidden on ActivityFeedbackEvent', + build: (client) => client.activityList(const ActivitiesQuery()), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: activityId, hidden: true), + ], + ), + ), + body: (tester) async { + tester.expect((al) => al.state.activities, hasLength(1)); + tester.expect((al) => al.state.activities.first.hidden, true); + + 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.expect((al) => al.state.activities, hasLength(1)); + tester.expect((al) => al.state.activities.first.hidden, false); + }, + ); + }); } diff --git a/packages/stream_feeds/test/state/activity_test.dart b/packages/stream_feeds/test/state/activity_test.dart index 93c2427..eddb7c3 100644 --- a/packages/stream_feeds/test/state/activity_test.dart +++ b/packages/stream_feeds/test/state/activity_test.dart @@ -32,10 +32,15 @@ void main() { group('Getting an activity', () { test('fetch activity and comments', () async { - const activityId = 'id'; + const activityId = 'activity-1'; + const feedId = FeedId(group: 'user', id: 'john'); + when(() => feedsApi.getActivity(id: activityId)).thenAnswer( - (_) async => Result.success(createDefaultActivityResponse()), + (_) async => Result.success( + createDefaultGetActivityResponse(id: activityId), + ), ); + when( () => feedsApi.getComments( objectId: activityId, @@ -46,24 +51,109 @@ void main() { (_) async => Result.success(createDefaultCommentsResponse()), ); - final activity = client.activity( - activityId: activityId, - fid: const FeedId(group: 'group', id: 'id'), - ); - - expect(activity, isA()); - expect(activity.activityId, 'id'); - - verifyNever(() => feedsApi.getActivity(id: 'id')); - + final activity = client.activity(activityId: activityId, fid: feedId); final result = await activity.get(); - verify(() => feedsApi.getActivity(id: 'id')).called(1); + verify(() => feedsApi.getActivity(id: activityId)).called(1); expect(result, isA>()); - expect(result.getOrNull()?.id, 'id'); + expect(result.getOrNull()?.id, activityId); }); }); + // ============================================================ + // FEATURE: Activity Feedback + // ============================================================ + + group('Activity feedback', () { + const activityId = 'activity-1'; + const feedId = FeedId(group: 'user', id: 'john'); + + 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), + ), + 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), + ), + ), + ); + + activityTest( + 'marks activity hidden on ActivityFeedbackEvent', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith(hidden: false), + ), + body: (tester) async { + tester.expect((a) => a.state.activity?.hidden, false); + + 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.expect((a) => a.state.activity?.hidden, true); + }, + ); + + activityTest( + 'marks activity unhidden on ActivityFeedbackEvent', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith(hidden: true), + ), + body: (tester) async { + tester.expect((a) => a.state.activity?.hidden, true); + + 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.expect((a) => a.state.activity?.hidden, false); + }, + ); + }); + group('Poll events', () { late StreamController wsStreamController; late MockWebSocketSink webSocketSink; @@ -88,8 +178,9 @@ void main() { void setupMockActivity({GetActivityResponse? activity}) { const activityId = 'id'; when(() => feedsApi.getActivity(id: activityId)).thenAnswer( - (_) async => - Result.success(activity ?? createDefaultActivityResponse()), + (_) async => Result.success( + activity ?? createDefaultGetActivityResponse(), + ), ); when( () => feedsApi.getComments( @@ -108,7 +199,7 @@ void main() { final firstOptionId = poll.options.first.id; setupMockActivity( - activity: createDefaultActivityResponse(poll: poll), + activity: createDefaultGetActivityResponse(poll: poll), ); final activity = client.activity( @@ -151,7 +242,7 @@ void main() { test('poll answer casted', () async { final poll = createDefaultPollResponseData(); setupMockActivity( - activity: createDefaultActivityResponse(poll: poll), + activity: createDefaultGetActivityResponse(poll: poll), ); final activity = client.activity( @@ -208,7 +299,7 @@ void main() { ], ); setupMockActivity( - activity: createDefaultActivityResponse(poll: poll), + activity: createDefaultGetActivityResponse(poll: poll), ); final activity = client.activity( @@ -269,7 +360,7 @@ void main() { ); final pollId = poll.id; setupMockActivity( - activity: createDefaultActivityResponse(poll: poll), + activity: createDefaultGetActivityResponse(poll: poll), ); final activity = client.activity( @@ -314,7 +405,7 @@ void main() { test('poll closed', () async { final poll = createDefaultPollResponseData(); setupMockActivity( - activity: createDefaultActivityResponse(poll: poll), + activity: createDefaultGetActivityResponse(poll: poll), ); final activity = client.activity( @@ -349,7 +440,7 @@ void main() { test('poll deleted', () async { final poll = createDefaultPollResponseData(); setupMockActivity( - activity: createDefaultActivityResponse(poll: poll), + activity: createDefaultGetActivityResponse(poll: poll), ); final activity = client.activity( diff --git a/packages/stream_feeds/test/state/feed_test.dart b/packages/stream_feeds/test/state/feed_test.dart index 61e4409..f619562 100644 --- a/packages/stream_feeds/test/state/feed_test.dart +++ b/packages/stream_feeds/test/state/feed_test.dart @@ -108,6 +108,116 @@ void main() { }); }); + // ============================================================ + // 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.feed(group: feedId.group, id: feedId.id), + setUp: (tester) { + const activityFeedbackRequest = ActivityFeedbackRequest(hide: true); + tester.mockApi( + (api) => api.activityFeedback( + activityId: activityId, + activityFeedbackRequest: activityFeedbackRequest, + ), + result: createDefaultActivityFeedbackResponse(activityId: activityId), + ); + }, + body: (tester) async { + const activityFeedbackRequest = ActivityFeedbackRequest(hide: true); + final result = await tester.feed.activityFeedback( + activityId: activityId, + activityFeedbackRequest: activityFeedbackRequest, + ); + + expect(result.isSuccess, isTrue); + }, + verify: (tester) { + const activityFeedbackRequest = ActivityFeedbackRequest(hide: true); + tester.verifyApi( + (api) => api.activityFeedback( + activityId: activityId, + activityFeedbackRequest: activityFeedbackRequest, + ), + ); + }, + ); + + feedTest( + 'marks activity hidden on ActivityFeedbackEvent', + build: (client) => client.feed(group: feedId.group, id: feedId.id), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: activityId, hidden: false), + ], + ), + ), + body: (tester) async { + tester.expect((f) => f.state.activities.length, 1); + tester.expect((f) => f.state.activities.first.hidden, false); + + 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.expect((f) => f.state.activities.first.hidden, true); + }, + ); + + feedTest( + 'marks activity unhidden on ActivityFeedbackEvent', + build: (client) => client.feed(group: feedId.group, id: feedId.id), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: activityId, hidden: true), + ], + ), + ), + body: (tester) async { + tester.expect((f) => f.state.activities.length, 1); + tester.expect((f) => f.state.activities.first.hidden, true); + + 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.expect((f) => f.state.activities.first.hidden, false); + }, + ); + }); + group('Follow events', () { late StreamController wsStreamController; late MockWebSocketSink webSocketSink; @@ -414,9 +524,9 @@ void main() { await client.connect(); final initialActivities = [ - createDefaultActivityResponse(id: 'activity-1').activity, - createDefaultActivityResponse(id: 'activity-2').activity, - createDefaultActivityResponse(id: 'activity-3').activity, + createDefaultActivityResponse(id: 'activity-1'), + createDefaultActivityResponse(id: 'activity-2'), + createDefaultActivityResponse(id: 'activity-3'), ]; final initialPinnedActivities = [ @@ -475,7 +585,7 @@ void main() { activity: createDefaultActivityResponse( id: 'activity-4', // Doesn't match 'post' filter - ).activity.copyWith(type: 'comment'), + ).copyWith(type: 'comment'), ), ), ); @@ -511,7 +621,7 @@ void main() { activity: createDefaultActivityResponse( id: 'activity-1', // Doesn't match 'post' filter - ).activity.copyWith(type: 'comment'), + ).copyWith(type: 'comment'), ), ), ); @@ -547,7 +657,7 @@ void main() { activity: createDefaultActivityResponse( id: 'activity-1', // Doesn't match 'post' filter - ).activity.copyWith(type: 'comment'), + ).copyWith(type: 'comment'), reaction: FeedsReactionResponse( activityId: 'activity-1', type: 'like', @@ -592,7 +702,7 @@ void main() { fid: feedId.rawValue, activity: createDefaultActivityResponse( id: 'activity-1', - ).activity.copyWith( + ).copyWith( filterTags: ['general'], // Doesn't have 'important' tag ), comment: createDefaultCommentResponse( @@ -633,7 +743,7 @@ void main() { activity: createDefaultActivityResponse( id: 'activity-2', // Doesn't match 'post' filter - ).activity.copyWith(type: 'comment'), + ).copyWith(type: 'comment'), reaction: FeedsReactionResponse( activityId: 'activity-2', type: 'like', @@ -756,7 +866,7 @@ void main() { ).copyWith( activity: createDefaultActivityResponse( id: 'activity-1', - ).activity.copyWith( + ).copyWith( feeds: [feedId.rawValue], // Activity belongs to this feed filterTags: ['general'], // Doesn't have 'important' tag ), @@ -801,7 +911,7 @@ void main() { activity: createDefaultActivityResponse( id: 'activity-2', feeds: [feedId.rawValue], // Activity belongs to this feed - ).activity.copyWith( + ).copyWith( filterTags: ['general'], // Doesn't have 'important' tag ), ), @@ -840,7 +950,7 @@ void main() { fid: feedId.rawValue, activity: createDefaultActivityResponse( id: 'activity-4', - ).activity.copyWith( + ).copyWith( type: 'post', // Matches first condition filterTags: ['general'], // Doesn't match second condition ), @@ -880,7 +990,7 @@ void main() { fid: feedId.rawValue, activity: createDefaultActivityResponse( id: 'activity-4', - ).activity.copyWith( + ).copyWith( type: 'post', // Matches first condition filterTags: ['general'], // Doesn't match second condition ), @@ -923,7 +1033,7 @@ void main() { activity: createDefaultActivityResponse( id: 'activity-4', // Doesn't match 'post' activity type - ).activity.copyWith(type: 'post'), + ).copyWith(type: 'post'), ), ), ); @@ -959,15 +1069,15 @@ void main() { test('Watch story should update isWatched', () async { const feedId = FeedId(group: 'stories', id: 'target'); - final activity1 = createDefaultActivityResponse().activity.copyWith( - isWatched: false, - id: 'storyActivityId1', - ); + final activity1 = createDefaultActivityResponse().copyWith( + isWatched: false, + id: 'storyActivityId1', + ); - final activity2 = createDefaultActivityResponse().activity.copyWith( - isWatched: false, - id: 'storyActivityId2', - ); + final activity2 = createDefaultActivityResponse().copyWith( + isWatched: false, + id: 'storyActivityId2', + ); when( () => feedsApi.getOrCreateFeed( @@ -1036,17 +1146,9 @@ void main() { const nextPagination = 'next'; const prevPagination = 'prev'; - final activity1 = createDefaultActivityResponse() - .activity - .copyWith(id: 'storyActivityId1'); - - final activity2 = createDefaultActivityResponse() - .activity - .copyWith(id: 'storyActivityId2'); - - final activity3 = createDefaultActivityResponse() - .activity - .copyWith(id: 'storyActivityId3'); + final activity1 = createDefaultActivityResponse(id: 'storyActivityId1'); + final activity2 = createDefaultActivityResponse(id: 'storyActivityId2'); + final activity3 = createDefaultActivityResponse(id: 'storyActivityId3'); when( () => feedsApi.getOrCreateFeed( @@ -1108,13 +1210,8 @@ void main() { () async { const feedId = FeedId(group: 'stories', id: 'target'); - final activity1 = createDefaultActivityResponse() - .activity - .copyWith(id: 'storyActivityId1'); - - final activity2 = createDefaultActivityResponse() - .activity - .copyWith(id: 'storyActivityId2'); + final activity1 = createDefaultActivityResponse(id: 'storyActivityId1'); + final activity2 = createDefaultActivityResponse(id: 'storyActivityId2'); when( () => feedsApi.getOrCreateFeed( diff --git a/packages/stream_feeds/test/test_utils.dart b/packages/stream_feeds/test/test_utils.dart index 05e9261..b8a8960 100644 --- a/packages/stream_feeds/test/test_utils.dart +++ b/packages/stream_feeds/test/test_utils.dart @@ -1,4 +1,7 @@ export 'test_utils/event_types.dart'; export 'test_utils/fakes.dart'; export 'test_utils/mocks.dart'; +export 'test_utils/testers/activity_list_tester.dart'; +export 'test_utils/testers/activity_tester.dart'; +export 'test_utils/testers/feed_tester.dart'; export 'test_utils/ws_test_helpers.dart'; diff --git a/packages/stream_feeds/test/test_utils/event_types.dart b/packages/stream_feeds/test/test_utils/event_types.dart index 2eca0d8..32a411e 100644 --- a/packages/stream_feeds/test/test_utils/event_types.dart +++ b/packages/stream_feeds/test/test_utils/event_types.dart @@ -1,5 +1,6 @@ class EventTypes { static const String activityMarked = 'feeds.activity.marked'; + static const String activityFeedback = 'feeds.activity.feedback'; static const String followCreated = 'feeds.follow.created'; static const String followDeleted = 'feeds.follow.deleted'; diff --git a/packages/stream_feeds/test/test_utils/fakes.dart b/packages/stream_feeds/test/test_utils/fakes.dart index 79ddb5a..1e95a74 100644 --- a/packages/stream_feeds/test/test_utils/fakes.dart +++ b/packages/stream_feeds/test/test_utils/fakes.dart @@ -31,50 +31,67 @@ UserResponse createDefaultUserResponse({ ); } -GetActivityResponse createDefaultActivityResponse({ +GetActivityResponse createDefaultGetActivityResponse({ String id = 'id', String type = 'post', - List feeds = const [], PollResponseData? poll, + List feeds = const [], + bool hidden = false, }) { return GetActivityResponse( - activity: ActivityResponse( + activity: createDefaultActivityResponse( id: id, - attachments: const [], - bookmarkCount: 0, - collections: const {}, - commentCount: 0, - comments: const [], - createdAt: DateTime(2021, 1, 1), - custom: const {}, - feeds: feeds, - filterTags: const [], - hidden: false, - interestTags: const [], - latestReactions: const [], - mentionedUsers: const [], - moderation: null, - notificationContext: null, - ownBookmarks: const [], - ownReactions: const [], - parent: null, - poll: poll, - popularity: 0, - preview: false, - reactionCount: 0, - reactionGroups: const {}, - restrictReplies: 'everyone', - score: 0, - searchData: const {}, - shareCount: 0, - text: null, type: type, - updatedAt: DateTime(2021, 2, 1), - user: createDefaultUserResponse(), - visibility: ActivityResponseVisibility.public, - visibilityTag: null, + poll: poll, + feeds: feeds, + hidden: hidden, ), - duration: 'duration', + duration: '10ms', + ); +} + +ActivityResponse createDefaultActivityResponse({ + String id = 'id', + String type = 'post', + List feeds = const [], + PollResponseData? poll, + bool hidden = false, +}) { + return ActivityResponse( + id: id, + attachments: const [], + bookmarkCount: 0, + collections: const {}, + commentCount: 0, + comments: const [], + createdAt: DateTime(2021, 1, 1), + custom: const {}, + feeds: feeds, + filterTags: const [], + hidden: hidden, + interestTags: const [], + latestReactions: const [], + mentionedUsers: const [], + moderation: null, + notificationContext: null, + ownBookmarks: const [], + ownReactions: const [], + parent: null, + poll: poll, + popularity: 0, + preview: false, + reactionCount: 0, + reactionGroups: const {}, + restrictReplies: 'everyone', + score: 0, + searchData: const {}, + shareCount: 0, + text: null, + type: type, + updatedAt: DateTime(2021, 2, 1), + user: createDefaultUserResponse(), + visibility: ActivityResponseVisibility.public, + visibilityTag: null, ); } @@ -204,7 +221,7 @@ PinActivityResponse createDefaultPinActivityResponse({ activity: createDefaultActivityResponse( id: activityId, type: type, - ).activity, + ), createdAt: DateTime(2021, 1, 1), duration: 'duration', feed: 'user:id', @@ -218,7 +235,7 @@ BookmarkResponse createDefaultBookmarkResponse({ String folderId = 'folder-id', }) { return BookmarkResponse( - activity: createDefaultActivityResponse(id: activityId).activity, + activity: createDefaultActivityResponse(id: activityId), createdAt: DateTime(2021, 1, 1), custom: const {}, folder: createDefaultBookmarkFolderResponse(id: folderId), @@ -288,7 +305,7 @@ AggregatedActivityResponse createDefaultAggregatedActivityResponse({ List? activities, String group = 'group', }) { - activities ??= [createDefaultActivityResponse().activity]; + activities ??= [createDefaultActivityResponse()]; return AggregatedActivityResponse( activities: activities, activityCount: activities.length, @@ -337,3 +354,12 @@ GetFollowSuggestionsResponse createDefaultGetFollowSuggestionsResponse({ suggestions: suggestions ?? [createDefaultFeedSuggestionResponse()], ); } + +ActivityFeedbackResponse createDefaultActivityFeedbackResponse({ + String activityId = 'activity-id', +}) { + return ActivityFeedbackResponse( + duration: '10ms', + activityId: activityId, + ); +} diff --git a/packages/stream_feeds/test/test_utils/testers/activity_list_tester.dart b/packages/stream_feeds/test/test_utils/testers/activity_list_tester.dart new file mode 100644 index 0000000..443be10 --- /dev/null +++ b/packages/stream_feeds/test/test_utils/testers/activity_list_tester.dart @@ -0,0 +1,141 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:test/test.dart' as test; + +import '../fakes.dart'; +import '../mocks.dart'; +import 'base_tester.dart'; + +/// Test helper for activity list operations. +/// +/// Automatically sets up WebSocket connection, client, and test infrastructure. +/// Tests are tagged with 'activity-list' by default for filtering. +/// +/// [build] constructs the [ActivityList] 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 [ActivityListTester] 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-list']. +/// [timeout] is optional, custom timeout for this test. +/// +/// Example: +/// ```dart +/// activityListTest( +/// 'marks activity as hidden on event', +/// build: (client) => client.activityList(ActivitiesQuery()), +/// setUp: (tester) => tester.mockApi( +/// (api) => api.queryActivities(...), +/// result: QueryActivitiesResponse(activities: [...]), +/// ), +/// body: (tester) async { +/// await tester.activityList.get(); +/// +/// expect(tester.activityList.state.activities.first.hidden, false); +/// +/// await tester.emitEvent(ActivityFeedbackEvent(...)); +/// +/// expect(tester.activityList.state.activities.first.hidden, true); +/// }, +/// ); +/// ``` +@isTest +void activityListTest( + String description, { + required ActivityList Function(StreamFeedsClient client) build, + FutureOr Function(ActivityListTester tester)? setUp, + required FutureOr Function(ActivityListTester tester) body, + FutureOr Function(ActivityListTester tester)? verify, + FutureOr Function(ActivityListTester tester)? tearDown, + bool skip = false, + Iterable tags = const ['activity-list'], + test.Timeout? timeout, +}) { + return testWithTester( + description, + build: build, + createTesterFn: _createActivityListTester, + setUp: setUp, + body: body, + verify: verify, + tearDown: tearDown, + skip: skip, + tags: tags, + timeout: timeout, + ); +} + +/// A test utility for activity list operations with WebSocket support. +/// +/// Provides helper methods for emitting events and verifying activity list state. +/// +/// Resources are automatically cleaned up after the test completes. +final class ActivityListTester extends BaseTester { + const ActivityListTester._({ + required ActivityList activityList, + required super.wsStreamController, + required super.feedsApi, + }) : super(subject: activityList); + + /// The activity list being tested. + ActivityList get activityList => subject; + + /// Gets the activity 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 list response + Future>> get({ + QueryActivitiesResponse Function(QueryActivitiesResponse)? modifyResponse, + }) { + final query = activityList.query; + + final defaultActivityListResponse = QueryActivitiesResponse( + duration: DateTime.now().toIso8601String(), + activities: [ + createDefaultActivityResponse(id: 'activity-1'), + createDefaultActivityResponse(id: 'activity-2'), + createDefaultActivityResponse(id: 'activity-3'), + ], + ); + + mockApi( + (api) => api.queryActivities(queryActivitiesRequest: query.toRequest()), + result: switch (modifyResponse) { + final modifier? => modifier(defaultActivityListResponse), + _ => defaultActivityListResponse, + }, + ); + + return activityList.get(); + } +} + +// Creates an ActivityListTester for testing activity list operations. +// +// Automatically sets up WebSocket connection and registers cleanup handlers. +// This function is for internal use by activityListTest only. +Future _createActivityListTester({ + required ActivityList subject, + required StreamFeedsClient client, + required MockDefaultApi feedsApi, + required MockWebSocketChannel webSocketChannel, +}) { + // Dispose activity list after test + test.addTearDown(subject.dispose); + + return createTester( + client: client, + webSocketChannel: webSocketChannel, + create: (wsStreamController) => ActivityListTester._( + activityList: subject, + wsStreamController: wsStreamController, + feedsApi: feedsApi, + ), + ); +} diff --git a/packages/stream_feeds/test/test_utils/testers/activity_tester.dart b/packages/stream_feeds/test/test_utils/testers/activity_tester.dart new file mode 100644 index 0000000..094a823 --- /dev/null +++ b/packages/stream_feeds/test/test_utils/testers/activity_tester.dart @@ -0,0 +1,192 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:test/test.dart' as test; + +import '../fakes.dart'; +import '../mocks.dart'; +import 'base_tester.dart'; + +/// Test helper for activity operations. +/// +/// Automatically sets up WebSocket connection, client, and test infrastructure. +/// Tests are tagged with 'activity' by default for filtering. +/// +/// [build] constructs the [Activity] 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 [ActivityTester] 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']. +/// [timeout] is optional, custom timeout for this test. +/// +/// Example (API test): +/// ```dart +/// activityTest( +/// 'submits feedback via API', +/// build: (client) => client.activity( +/// activityId: 'activity-1', +/// fid: FeedId(group: 'user', id: 'john'), +/// ), +/// setUp: (tester) => tester.mockApi( +/// (api) => api.activityFeedback( +/// activityId: 'activity-1', +/// activityFeedbackRequest: ActivityFeedbackRequest(hide: true), +/// ), +/// result: createDefaultActivityFeedbackResponse(), +/// ), +/// body: (tester) async { +/// final result = await tester.activity.activityFeedback( +/// activityFeedbackRequest: ActivityFeedbackRequest(hide: true), +/// ); +/// +/// expect(result.isSuccess, isTrue); +/// }, +/// verify: (tester) { +/// tester.verifyApi( +/// (api) => api.activityFeedback( +/// activityId: 'activity-1', +/// activityFeedbackRequest: ActivityFeedbackRequest(hide: true), +/// ), +/// ); +/// }, +/// ); +/// ``` +/// +/// Example (event test): +/// ```dart +/// activityTest( +/// 'marks activity hidden on event', +/// build: (client) => client.activity( +/// activityId: 'activity-1', +/// fid: FeedId(group: 'user', id: 'john'), +/// ), +/// setUp: (tester) async { +/// await tester.get( +/// modifyResponse: (response) => response.copyWith(hidden: false), +/// ); +/// }, +/// body: (tester) async { +/// expect(tester.activity.state.activity?.hidden, false); +/// +/// await tester.emitEvent(ActivityFeedbackEvent(...)); +/// +/// expect(tester.activity.state.activity?.hidden, true); +/// }, +/// ); +/// ``` +@isTest +void activityTest( + String description, { + required Activity Function(StreamFeedsClient client) build, + FutureOr Function(ActivityTester tester)? setUp, + required FutureOr Function(ActivityTester tester) body, + FutureOr Function(ActivityTester tester)? verify, + FutureOr Function(ActivityTester tester)? tearDown, + bool skip = false, + Iterable tags = const ['activity'], + test.Timeout? timeout, +}) { + return testWithTester( + description, + build: build, + createTesterFn: _createActivityTester, + setUp: setUp, + body: body, + verify: verify, + tearDown: tearDown, + skip: skip, + tags: tags, + timeout: timeout, + ); +} + +/// A test utility for activity operations with WebSocket support. +/// +/// Provides helper methods for emitting events and verifying activity state. +/// +/// Resources are automatically cleaned up after the test completes. +final class ActivityTester extends BaseTester { + const ActivityTester._({ + required Activity activity, + required super.wsStreamController, + required super.feedsApi, + }) : super(subject: activity); + + /// The activity being tested. + Activity get activity => subject; + + /// Gets the activity 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 response + /// - [modifyCommentsResponse]: Optional function to customize the comments response + Future> get({ + ActivityResponse Function(ActivityResponse)? modifyResponse, + GetCommentsResponse Function(GetCommentsResponse)? modifyCommentsResponse, + }) { + final activityId = activity.activityId; + final feedId = activity.fid.rawValue; + + final defaultActivityResponse = createDefaultActivityResponse( + id: activityId, + feeds: [feedId], + ); + + mockApi( + (api) => api.getActivity(id: activityId), + result: GetActivityResponse( + activity: switch (modifyResponse) { + final modifier? => modifier(defaultActivityResponse), + _ => defaultActivityResponse, + }, + duration: DateTime.timestamp().toIso8601String(), + ), + ); + + final defaultCommentsResponse = createDefaultCommentsResponse(); + + mockApi( + (api) => api.getComments( + objectId: activityId, + objectType: 'activity', + depth: 3, // default depth for comments + ), + result: switch (modifyCommentsResponse) { + final modifier? => modifier(defaultCommentsResponse), + _ => defaultCommentsResponse, + }, + ); + + return activity.get(); + } +} + +// Creates an ActivityTester for testing activity operations. +// +// Automatically sets up WebSocket connection and registers cleanup handlers. +// This function is for internal use by activityTest only. +Future _createActivityTester({ + required Activity subject, + required StreamFeedsClient client, + required MockDefaultApi feedsApi, + required MockWebSocketChannel webSocketChannel, +}) { + // Dispose activity after test + test.addTearDown(subject.dispose); + + return createTester( + client: client, + webSocketChannel: webSocketChannel, + create: (wsStreamController) => ActivityTester._( + activity: subject, + feedsApi: feedsApi, + wsStreamController: wsStreamController, + ), + ); +} diff --git a/packages/stream_feeds/test/test_utils/testers/api_mocker_mixin.dart b/packages/stream_feeds/test/test_utils/testers/api_mocker_mixin.dart new file mode 100644 index 0000000..aa9d7df --- /dev/null +++ b/packages/stream_feeds/test/test_utils/testers/api_mocker_mixin.dart @@ -0,0 +1,109 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../mocks.dart'; + +/// Mixin for test utilities that need to mock and verify API calls. +/// +/// Provides a clean interface for mocking API calls and verifying +/// interactions without exposing the underlying API client. +mixin ApiMockerMixin { + /// The feeds API client used for mocking. + MockDefaultApi get feedsApi; + + /// Sets up a mock API response for the given API call. + /// + /// The API instance is injected into the callback for clean syntax: + /// ```dart + /// activityTest( + /// 'test', + /// build: (client) => client.activity(...), + /// setUp: (tester) async { + /// tester.mockApi( + /// (api) => api.activityFeedback( + /// activityId: 'activity-1', + /// activityFeedbackRequest: request, + /// ), + /// result: createDefaultActivityFeedbackResponse(), + /// ); + /// }, + /// ); + /// ``` + void mockApi( + Future> Function(MockDefaultApi api) apiCall, { + required T result, + }) { + return mockApiResult(apiCall, result: Result.success(result)); + } + + /// Sets up a mock API response with a custom Result. + /// + /// Use this when you need to return a failure: + /// ```dart + /// tester.mockApiResult( + /// (api) => api.addActivity(...), + /// result: Result.failure(NetworkException('Error')), + /// ); + /// ``` + void mockApiResult( + Future> Function(MockDefaultApi api) apiCall, { + required Result result, + }) { + return when( + () => apiCall(feedsApi), + ).thenAnswer((_) async => result); + } + + /// Verifies that an API call was made exactly once. + /// + /// The API instance is injected into the callback: + /// ```dart + /// activityTest( + /// 'test', + /// build: (client) => client.activity(...), + /// body: (tester) async { + /// tester.verifyApi( + /// (api) => api.activityFeedback( + /// activityId: 'activity-1', + /// activityFeedbackRequest: request, + /// ), + /// ); + /// }, + /// ); + /// ``` + void verifyApi( + Future> Function(MockDefaultApi api) apiCall, + ) { + return verifyApiCalled(apiCall, times: 1); + } + + /// Verifies that an API call was made a specific number of times. + /// + /// Use this when you need to verify multiple calls: + /// ```dart + /// tester.verifyApiCalled( + /// (api) => api.addActivity(...), + /// times: 3, + /// ); + /// ``` + void verifyApiCalled( + Future> Function(MockDefaultApi api) apiCall, { + required int times, + }) { + return verify(() => apiCall(feedsApi)).called(times); + } + + /// Verifies that an API call was never made. + /// + /// Use this to ensure an API wasn't called: + /// ```dart + /// tester.verifyNeverCalled( + /// (api) => api.deleteActivity(activityId: 'activity-1'), + /// ); + /// ``` + VerificationResult verifyNeverCalled( + Future> Function(MockDefaultApi api) apiCall, + ) { + return verifyNever(() => apiCall(feedsApi)); + } +} diff --git a/packages/stream_feeds/test/test_utils/testers/base_tester.dart b/packages/stream_feeds/test/test_utils/testers/base_tester.dart new file mode 100644 index 0000000..ed2974b --- /dev/null +++ b/packages/stream_feeds/test/test_utils/testers/base_tester.dart @@ -0,0 +1,232 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:meta/meta.dart'; +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:test/test.dart' as test; + +import '../mocks.dart'; +import '../ws_test_helpers.dart'; +import 'api_mocker_mixin.dart'; + +/// Factory function signature for creating tester instances. +/// +/// All concrete tester factory functions must conform to this signature. +typedef TesterFactory> = Future Function({ + required S subject, + required StreamFeedsClient client, + required MockDefaultApi feedsApi, + required MockWebSocketChannel webSocketChannel, +}); + +/// Base class for all test utilities with WebSocket support. +/// +/// Provides common functionality for emitting events, pumping the event queue, +/// and making assertions about the state object being tested. +/// +/// Type parameter [S] is the subject being tested. +abstract base class BaseTester with ApiMockerMixin { + const BaseTester({ + required this.subject, + required this.feedsApi, + required StreamController wsStreamController, + }) : _wsStreamController = wsStreamController; + + /// The subject being tested. + final S subject; + + @override + @protected + final MockDefaultApi feedsApi; + + // WebSocket stream controller for emitting events. + final StreamController _wsStreamController; + + /// Emits a WebSocket event and waits for it to be processed. + Future emitEvent(Object event) async { + _wsStreamController.add(jsonEncode(event)); + await pump(); + } + + /// Waits for events to be processed. + /// + /// By default uses a zero-duration delay to allow the event loop to process + /// pending events. Pass [duration] for longer waits if needed. + /// + /// Example: + /// ```dart + /// await tester.pump(); // Default zero duration + /// await tester.pump(Duration(milliseconds: 100)); // Wait 100ms + /// ``` + Future pump([Duration duration = Duration.zero]) { + return Future.delayed(duration); + } + + /// Asserts that [actual] matches [matcher]. + /// + /// The [actual] parameter is a function that receives the subject + /// and returns a value to assert. + /// + /// Example: + /// ```dart + /// tester.expect((subject) => subject.state.someField, expectedValue); + /// ``` + void expect( + Object? Function(S subject) actual, + Object? matcher, { + String? reason, + }) { + return test.expect( + actual(subject), + test.wrapMatcher(matcher), + reason: reason, + ); + } + + /// Asserts that [actual] matches [matcher] asynchronously. + /// + /// The [actual] parameter is a function that receives the subject + /// and returns a value to assert. + /// + /// Example: + /// ```dart + /// await tester.expectLater( + /// (subject) => subject.stream, + /// emits(expectedValue), + /// ); + /// ``` + Future expectLater( + Object? Function(S subject) actual, + Object? matcher, { + String? reason, + }) { + return test.expectLater( + actual(subject), + test.wrapMatcher(matcher), + reason: reason, + ); + } +} + +/// Creates a tester instance with WebSocket support. +/// +/// This is a generic factory function that handles all the common setup +/// for creating test utilities. It automatically: +/// - Creates and registers WebSocket components for cleanup +/// - Sets up WebSocket connection +/// - Connects the client +/// - Registers client disconnection for cleanup +/// +/// The create callback receives the WebSocket stream controller and should +/// return the concrete tester instance. +/// +/// This function is for internal use by concrete tester factories only. +Future createTester>({ + required StreamFeedsClient client, + required MockWebSocketChannel webSocketChannel, + required T Function(StreamController) create, +}) async { + // Create WebSocket components + final wsStreamController = StreamController(); + final webSocketSink = MockWebSocketSink(); + + // Register automatic cleanup + test.addTearDown(() async { + await webSocketSink.close(); + await wsStreamController.close(); + }); + + // Setup WebSocket connection + WsTestConnection( + wsStreamController: wsStreamController, + webSocketSink: webSocketSink, + webSocketChannel: webSocketChannel, + ).setUp(); + + // Connect client + await client.connect(); + test.addTearDown(client.disconnect); // Disconnect client after test + + return create(wsStreamController); +} + +/// Generic test helper for state objects with WebSocket support. +/// +/// Automatically sets up WebSocket connection, client, and test infrastructure. +/// +/// Parameters: +/// - build: constructs the subject under test using the provided StreamFeedsClient +/// - createTesterFn: the concrete tester factory function +/// - setUp: optional, runs before body for setting up mocks and test state +/// - body: the test callback that receives a tester for interactions +/// - verify: optional, runs after body for verifying API calls +/// - tearDown: optional, runs after verify for custom cleanup +/// - skip: optional, skip this test +/// - tags: optional, tags for test filtering +/// - timeout: optional, custom timeout for this test +/// +/// This function is for internal use by concrete test helpers. +void testWithTester>( + String description, { + required S Function(StreamFeedsClient client) build, + required TesterFactory createTesterFn, + FutureOr Function(T tester)? setUp, + required FutureOr Function(T tester) body, + FutureOr Function(T tester)? verify, + FutureOr Function(T tester)? tearDown, + bool skip = false, + Iterable tags = const [], + test.Timeout? timeout, +}) { + return test.test( + description, + skip: skip, + tags: tags, + timeout: timeout, + () async { + await _runZonedGuarded(() async { + const user = User(id: 'luke_skywalker'); + final userToken = UserToken(testToken); + + final feedsApi = MockDefaultApi(); + final webSocketChannel = MockWebSocketChannel(); + + final client = StreamFeedsClient( + apiKey: 'apiKey', + user: user, + tokenProvider: TokenProvider.static(userToken), + feedsRestApi: feedsApi, + wsProvider: (options) => webSocketChannel, + ); + + final tester = await createTesterFn( + subject: build.call(client), + client: client, + feedsApi: feedsApi, + webSocketChannel: webSocketChannel, + ); + + await setUp?.call(tester); + await body(tester); + await verify?.call(tester); + await tearDown?.call(tester); + }); + }, + ); +} + +// Runs the test body in a guarded zone to catch all errors. +// +// This ensures that errors from event handlers, timers, and unawaited +// futures are properly caught and reported, not just errors in the +// main async chain. +Future _runZonedGuarded(Future Function() body) { + final completer = Completer(); + runZonedGuarded(() async { + await body(); + if (!completer.isCompleted) completer.complete(); + }, (error, stackTrace) { + if (!completer.isCompleted) completer.completeError(error, stackTrace); + }); + return completer.future; +} diff --git a/packages/stream_feeds/test/test_utils/testers/feed_tester.dart b/packages/stream_feeds/test/test_utils/testers/feed_tester.dart new file mode 100644 index 0000000..7dcb880 --- /dev/null +++ b/packages/stream_feeds/test/test_utils/testers/feed_tester.dart @@ -0,0 +1,144 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:test/test.dart' as test; + +import '../fakes.dart'; +import '../mocks.dart'; +import 'base_tester.dart'; + +/// Test helper for feed operations. +/// +/// Automatically sets up WebSocket connection, client, and test infrastructure. +/// Tests are tagged with 'feed' by default for filtering. +/// +/// [build] constructs the [Feed] 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 [FeedTester] 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 ['feed']. +/// [timeout] is optional, custom timeout for this test. +/// +/// Example: +/// ```dart +/// feedTest( +/// 'marks activity as hidden on event', +/// build: (client) => client.feedFromId(FeedId(group: 'user', id: 'john')), +/// setUp: (tester) => tester.mockApi( +/// (api) => api.getOrCreateFeed(...), +/// result: createDefaultGetOrCreateFeedResponse(activities: [...]), +/// ), +/// body: (tester) async { +/// await tester.feed.getOrCreate(); +/// +/// expect(tester.feed.state.activities.first.hidden, false); +/// +/// await tester.emitEvent(ActivityFeedbackEvent(...)); +/// +/// expect(tester.feed.state.activities.first.hidden, true); +/// }, +/// ); +/// ``` +@isTest +void feedTest( + String description, { + required Feed Function(StreamFeedsClient client) build, + FutureOr Function(FeedTester tester)? setUp, + required FutureOr Function(FeedTester tester) body, + FutureOr Function(FeedTester tester)? verify, + FutureOr Function(FeedTester tester)? tearDown, + bool skip = false, + Iterable tags = const ['feed'], + test.Timeout? timeout, +}) { + return testWithTester( + description, + build: build, + createTesterFn: _createFeedTester, + setUp: setUp, + body: body, + verify: verify, + tearDown: tearDown, + skip: skip, + tags: tags, + timeout: timeout, + ); +} + +/// A test utility for feed operations with WebSocket support. +/// +/// Provides helper methods for emitting events and verifying feed state. +/// +/// Resources are automatically cleaned up after the test completes. +final class FeedTester extends BaseTester { + const FeedTester._({ + required Feed feed, + required super.wsStreamController, + required super.feedsApi, + }) : super(subject: feed); + + /// The feed being tested. + Feed get feed => subject; + + /// Gets or creates the feed 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 feed response + Future> getOrCreate({ + GetOrCreateFeedResponse Function(GetOrCreateFeedResponse)? modifyResponse, + }) { + final feedId = feed.fid; + + final defaultFeedResponse = createDefaultGetOrCreateFeedResponse( + activities: [ + createDefaultActivityResponse(id: 'activity-1'), + createDefaultActivityResponse(id: 'activity-2'), + createDefaultActivityResponse(id: 'activity-3'), + ], + ); + + mockApi( + (api) => api.getOrCreateFeed( + feedId: feedId.id, + feedGroupId: feedId.group, + getOrCreateFeedRequest: FeedQuery(fid: feedId).toRequest(), + ), + result: switch (modifyResponse) { + final modifier? => modifier(defaultFeedResponse), + _ => defaultFeedResponse, + }, + ); + + return feed.getOrCreate(); + } +} + +// Creates a FeedTester for testing feed operations. +// +// Automatically sets up WebSocket connection and registers cleanup handlers. +// This function is for internal use by feedTest only. +Future _createFeedTester({ + required Feed subject, + required StreamFeedsClient client, + required MockDefaultApi feedsApi, + required MockWebSocketChannel webSocketChannel, +}) { + // Dispose feed after test + test.addTearDown(subject.dispose); + + return createTester( + client: client, + webSocketChannel: webSocketChannel, + create: (wsStreamController) => FeedTester._( + feed: subject, + wsStreamController: wsStreamController, + feedsApi: feedsApi, + ), + ); +}