diff --git a/packages/stream_feeds/dart_test.yaml b/packages/stream_feeds/dart_test.yaml index 32f0343..29d8c70 100644 --- a/packages/stream_feeds/dart_test.yaml +++ b/packages/stream_feeds/dart_test.yaml @@ -1,4 +1,11 @@ tags: feed: + feed-list: activity: - activity-list: \ No newline at end of file + activity-list: + bookmark-list: + bookmark-folder-list: + comment-list: + follow-list: + poll-list: + poll-vote-list: \ No newline at end of file diff --git a/packages/stream_feeds/lib/src/models.dart b/packages/stream_feeds/lib/src/models.dart index 45eed7f..cdc51ae 100644 --- a/packages/stream_feeds/lib/src/models.dart +++ b/packages/stream_feeds/lib/src/models.dart @@ -1,5 +1,8 @@ export 'models/activity_data.dart'; export 'models/aggregated_activity_data.dart'; +export 'models/bookmark_data.dart'; +export 'models/bookmark_folder_data.dart'; +export 'models/comment_data.dart'; export 'models/feed_data.dart'; export 'models/feed_id.dart'; export 'models/feed_input_data.dart'; diff --git a/packages/stream_feeds/lib/src/state.dart b/packages/stream_feeds/lib/src/state.dart index b3d3848..b88594e 100644 --- a/packages/stream_feeds/lib/src/state.dart +++ b/packages/stream_feeds/lib/src/state.dart @@ -1,11 +1,26 @@ export 'state/activity.dart'; export 'state/activity_comment_list.dart'; export 'state/activity_list.dart'; +export 'state/activity_list_state.dart'; +export 'state/activity_state.dart'; +export 'state/bookmark_folder_list.dart'; +export 'state/bookmark_folder_list_state.dart'; +export 'state/bookmark_list.dart'; +export 'state/bookmark_list_state.dart'; export 'state/comment_list.dart'; +export 'state/comment_list_state.dart'; export 'state/comment_reaction_list.dart'; export 'state/comment_reply_list.dart'; export 'state/feed.dart'; +export 'state/feed_list.dart'; +export 'state/feed_list_state.dart'; export 'state/feed_state.dart'; +export 'state/follow_list.dart'; +export 'state/follow_list_state.dart'; +export 'state/poll_list.dart'; +export 'state/poll_list_state.dart'; +export 'state/poll_vote_list.dart'; +export 'state/poll_vote_list_state.dart'; export 'state/query/activities_query.dart'; export 'state/query/activity_comments_query.dart'; export 'state/query/bookmark_folders_query.dart'; diff --git a/packages/stream_feeds/test/client/feeds_client_impl_test.dart b/packages/stream_feeds/test/client/feeds_client_impl_test.dart index ba7449e..59ca90a 100644 --- a/packages/stream_feeds/test/client/feeds_client_impl_test.dart +++ b/packages/stream_feeds/test/client/feeds_client_impl_test.dart @@ -6,10 +6,13 @@ import '../test_utils.dart'; void main() { test('Create a feeds client', () { + const user = User(id: 'userId'); + final token = generateTestUserToken(user.id); + final client = StreamFeedsClient( apiKey: 'apiKey', - user: const User(id: 'userId'), - tokenProvider: TokenProvider.static(UserToken(testToken)), + user: user, + tokenProvider: TokenProvider.static(token), ); expect(client, isA()); diff --git a/packages/stream_feeds/test/state/activity_list_test.dart b/packages/stream_feeds/test/state/activity_list_test.dart index 5e3191e..d49e705 100644 --- a/packages/stream_feeds/test/state/activity_list_test.dart +++ b/packages/stream_feeds/test/state/activity_list_test.dart @@ -1,381 +1,284 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:mocktail/mocktail.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart'; import '../test_utils.dart'; void main() { - late StreamFeedsClient client; - late MockDefaultApi feedsApi; - late MockWebSocketChannel webSocketChannel; - - setUp(() { - feedsApi = MockDefaultApi(); - webSocketChannel = MockWebSocketChannel(); - - client = StreamFeedsClient( - apiKey: 'apiKey', - user: const User(id: 'luke_skywalker'), - tokenProvider: TokenProvider.static(UserToken(testToken)), - feedsRestApi: feedsApi, - wsProvider: (options) => webSocketChannel, - ); - }); - - tearDown(() { - client.disconnect(); - }); + // ============================================================ + // FEATURE: Local Filtering + // ============================================================ group('Local filtering with real-time events', () { - late StreamController wsStreamController; - late MockWebSocketSink webSocketSink; - - setUp(() async { - wsStreamController = StreamController(); - webSocketSink = MockWebSocketSink(); - WsTestConnection( - wsStreamController: wsStreamController, - webSocketSink: webSocketSink, - webSocketChannel: webSocketChannel, - ).setUp(); - - await client.connect(); - - // Setup default mock response for queryActivities - when( - () => feedsApi.queryActivities( - queryActivitiesRequest: any(named: 'queryActivitiesRequest'), - ), - ).thenAnswer( - (_) async => Result.success( - QueryActivitiesResponse( - duration: DateTime.now().toIso8601String(), - activities: [ - createDefaultActivityResponse(id: 'activity-1'), - createDefaultActivityResponse(id: 'activity-2'), - createDefaultActivityResponse(id: 'activity-3'), - ], - ), - ), - ); - }); + final defaultActivities = [ + createDefaultActivityResponse(id: 'activity-1'), + createDefaultActivityResponse(id: 'activity-2'), + createDefaultActivityResponse(id: 'activity-3'), + ]; - tearDown(() async { - await webSocketSink.close(); - await wsStreamController.close(); - }); - - test( + activityListTest( 'ActivityUpdatedEvent - should remove activity when updated to non-matching type', - () async { - final activityList = client.activityList( - ActivitiesQuery( - filter: Filter.equal(ActivitiesFilterField.type, 'post'), - ), - ); + build: (client) => client.activityList( + ActivitiesQuery( + filter: Filter.equal(ActivitiesFilterField.type, 'post'), + ), + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(activities: defaultActivities), + ), + body: (tester) async { + expect(tester.activityListState.activities, hasLength(3)); - await activityList.get(); - expect(activityList.state.activities, hasLength(3)); - - // Send ActivityUpdatedEvent with type that doesn't match filter - wsStreamController.add( - jsonEncode( - ActivityUpdatedEvent( - type: 'feeds.activity.updated', - createdAt: DateTime.now(), - custom: const {}, - fid: 'fid', - activity: createDefaultActivityResponse( - id: 'activity-1', - // Doesn't match 'post' filter - ).copyWith(type: 'comment'), - ), + await tester.emitEvent( + ActivityUpdatedEvent( + type: EventTypes.activityUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + activity: createDefaultActivityResponse(id: 'activity-1') + .copyWith(type: 'comment'), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - expect(activityList.state.activities, hasLength(2)); + await tester.pump(); + expect(tester.activityListState.activities, hasLength(2)); }, ); - test( + activityListTest( 'ActivityReactionAddedEvent - should remove activity when reaction causes filter mismatch', - () async { - final activityList = client.activityList( - ActivitiesQuery( - filter: Filter.equal(ActivitiesFilterField.type, 'post'), - ), - ); + build: (client) => client.activityList( + ActivitiesQuery( + filter: Filter.equal(ActivitiesFilterField.type, 'post'), + ), + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(activities: defaultActivities), + ), + body: (tester) async { + expect(tester.activityListState.activities, hasLength(3)); - await activityList.get(); - expect(activityList.state.activities, hasLength(3)); - - // Send ActivityReactionAddedEvent with activity that doesn't match filter - wsStreamController.add( - jsonEncode( - ActivityReactionAddedEvent( - type: 'feeds.activity.reaction.added', - createdAt: DateTime.now(), - custom: const {}, - fid: 'fid', - activity: createDefaultActivityResponse( - id: 'activity-2', - // Doesn't match 'post' filter - ).copyWith(type: 'comment'), - reaction: FeedsReactionResponse( - activityId: 'activity-2', - type: 'like', - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - user: createDefaultUserResponse(), - ), + await tester.emitEvent( + ActivityReactionAddedEvent( + type: EventTypes.activityReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + activity: createDefaultActivityResponse(id: 'activity-2') + .copyWith(type: 'comment'), + reaction: FeedsReactionResponse( + activityId: 'activity-2', + type: 'like', + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(), ), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - expect(activityList.state.activities, hasLength(2)); + await tester.pump(); + expect(tester.activityListState.activities, hasLength(2)); }, ); - test( + activityListTest( 'ActivityReactionDeletedEvent - should remove activity when reaction deletion causes filter mismatch', - () async { - final activityList = client.activityList( - ActivitiesQuery( - filter: Filter.equal(ActivitiesFilterField.type, 'post'), - ), - ); + build: (client) => client.activityList( + ActivitiesQuery( + filter: Filter.equal(ActivitiesFilterField.type, 'post'), + ), + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(activities: defaultActivities), + ), + body: (tester) async { + expect(tester.activityListState.activities, hasLength(3)); - await activityList.get(); - expect(activityList.state.activities, hasLength(3)); - - // Send ActivityReactionDeletedEvent with activity that doesn't match filter - wsStreamController.add( - jsonEncode( - ActivityReactionDeletedEvent( - type: 'feeds.activity.reaction.deleted', - createdAt: DateTime.now(), - custom: const {}, - fid: 'fid', - activity: createDefaultActivityResponse( - id: 'activity-3', - ).copyWith(type: 'share'), - reaction: FeedsReactionResponse( - activityId: 'activity-3', - type: 'like', - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - user: createDefaultUserResponse(), - ), + await tester.emitEvent( + ActivityReactionDeletedEvent( + type: EventTypes.activityReactionDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + activity: createDefaultActivityResponse(id: 'activity-3') + .copyWith(type: 'share'), + reaction: FeedsReactionResponse( + activityId: 'activity-3', + type: 'like', + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(), ), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - expect(activityList.state.activities, hasLength(2)); + await tester.pump(); + expect(tester.activityListState.activities, hasLength(2)); }, ); - test( + activityListTest( 'BookmarkAddedEvent - should remove activity when bookmark causes filter mismatch', - () async { - final activityList = client.activityList( - ActivitiesQuery( - filter: Filter.equal(ActivitiesFilterField.type, 'post'), - ), - ); + build: (client) => client.activityList( + ActivitiesQuery( + filter: Filter.equal(ActivitiesFilterField.type, 'post'), + ), + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(activities: defaultActivities), + ), + body: (tester) async { + expect(tester.activityListState.activities, hasLength(3)); - await activityList.get(); - expect(activityList.state.activities, hasLength(3)); - - // Send BookmarkAddedEvent with activity that doesn't match filter - wsStreamController.add( - jsonEncode( - BookmarkAddedEvent( - type: 'feeds.bookmark.added', - createdAt: DateTime.now(), - custom: const {}, - bookmark: createDefaultBookmarkResponse( - activityId: 'activity-1', - ).copyWith( - activity: createDefaultActivityResponse( - id: 'activity-1', - // Doesn't match 'post' filter - ).copyWith(type: 'comment'), - ), + await tester.emitEvent( + BookmarkAddedEvent( + type: EventTypes.bookmarkAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + activityId: 'activity-1', + ).copyWith( + activity: createDefaultActivityResponse(id: 'activity-1') + .copyWith(type: 'comment'), ), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - expect(activityList.state.activities, hasLength(2)); + await tester.pump(); + expect(tester.activityListState.activities, hasLength(2)); }, ); - test( + activityListTest( 'BookmarkDeletedEvent - should remove activity when bookmark deletion causes filter mismatch', - () async { - final activityList = client.activityList( - ActivitiesQuery( - filter: Filter.equal(ActivitiesFilterField.type, 'post'), - ), - ); + build: (client) => client.activityList( + ActivitiesQuery( + filter: Filter.equal(ActivitiesFilterField.type, 'post'), + ), + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(activities: defaultActivities), + ), + body: (tester) async { + expect(tester.activityListState.activities, hasLength(3)); - await activityList.get(); - expect(activityList.state.activities, hasLength(3)); - - // Send BookmarkDeletedEvent with activity that doesn't match filter - wsStreamController.add( - jsonEncode( - BookmarkDeletedEvent( - type: 'feeds.bookmark.deleted', - createdAt: DateTime.now(), - custom: const {}, - bookmark: createDefaultBookmarkResponse( - activityId: 'activity-2', - ).copyWith( - activity: createDefaultActivityResponse( - id: 'activity-2', - // Doesn't match 'post' filter - ).copyWith(type: 'share'), - ), + await tester.emitEvent( + BookmarkDeletedEvent( + type: EventTypes.bookmarkDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + activityId: 'activity-2', + ).copyWith( + activity: createDefaultActivityResponse(id: 'activity-2') + .copyWith(type: 'share'), ), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - expect(activityList.state.activities, hasLength(2)); + await tester.pump(); + expect(tester.activityListState.activities, hasLength(2)); }, ); - test( + activityListTest( 'CommentAddedEvent - should remove activity when comment causes filter mismatch', - () async { - final activityList = client.activityList( - ActivitiesQuery( - filter: Filter.equal(ActivitiesFilterField.type, 'post'), - ), - ); + build: (client) => client.activityList( + ActivitiesQuery( + filter: Filter.equal(ActivitiesFilterField.type, 'post'), + ), + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(activities: defaultActivities), + ), + body: (tester) async { + expect(tester.activityListState.activities, hasLength(3)); - await activityList.get(); - expect(activityList.state.activities, hasLength(3)); - - // Send CommentAddedEvent with activity that doesn't match filter - wsStreamController.add( - jsonEncode( - CommentAddedEvent( - type: 'feeds.comment.added', - createdAt: DateTime.now(), - custom: const {}, - fid: 'fid', - activity: createDefaultActivityResponse( - id: 'activity-3', - // Doesn't match 'post' filter - ).copyWith(type: 'comment'), - comment: createDefaultCommentResponse( - objectId: 'activity-3', - ), + await tester.emitEvent( + CommentAddedEvent( + type: EventTypes.commentAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + activity: createDefaultActivityResponse(id: 'activity-3') + .copyWith(type: 'comment'), + comment: createDefaultCommentResponse( + objectId: 'activity-3', ), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - expect(activityList.state.activities, hasLength(2)); + await tester.pump(); + expect(tester.activityListState.activities, hasLength(2)); }, ); - test('Complex filter with AND - should filter correctly', () async { - final activityList = client.activityList( + activityListTest( + 'Complex filter with AND - should filter correctly', + build: (client) => client.activityList( ActivitiesQuery( filter: Filter.and([ Filter.equal(ActivitiesFilterField.type, 'post'), Filter.equal(ActivitiesFilterField.filterTags, ['featured']), ]), ), - ); - - await activityList.get(); - expect(activityList.state.activities, hasLength(3)); + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(activities: defaultActivities), + ), + body: (tester) async { + expect(tester.activityListState.activities, hasLength(3)); - // Send ActivityUpdatedEvent that matches only one condition - wsStreamController.add( - jsonEncode( + await tester.emitEvent( ActivityUpdatedEvent( - type: 'feeds.activity.updated', - createdAt: DateTime.now(), + type: EventTypes.activityUpdated, + createdAt: DateTime.timestamp(), custom: const {}, fid: 'fid', - activity: createDefaultActivityResponse( - id: 'activity-1', - ).copyWith( + activity: createDefaultActivityResponse(id: 'activity-1').copyWith( type: 'post', // Matches first condition filterTags: ['general'], // Doesn't match second condition - ), // Doesn't match any condition + ), ), - ), - ); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); + ); - expect(activityList.state.activities, hasLength(2)); - }); + await tester.pump(); + expect(tester.activityListState.activities, hasLength(2)); + }, + ); - test( + activityListTest( 'Complex filter with OR - should only keep activities matching any condition', - () async { - final activityList = client.activityList( - ActivitiesQuery( - filter: Filter.or([ - Filter.equal(ActivitiesFilterField.type, 'post'), - Filter.equal(ActivitiesFilterField.filterTags, ['featured']), - ]), - ), - ); + build: (client) => client.activityList( + ActivitiesQuery( + filter: Filter.or([ + Filter.equal(ActivitiesFilterField.type, '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 activityList.get(); - expect(activityList.state.activities, hasLength(3)); - - // Send ActivityUpdatedEvent that matches only one condition - wsStreamController.add( - jsonEncode( - ActivityUpdatedEvent( - type: 'feeds.activity.updated', - createdAt: DateTime.now(), - custom: const {}, - fid: 'fid', - activity: createDefaultActivityResponse( - id: 'activity-1', - ).copyWith( - type: 'post', // Matches first condition - filterTags: ['general'], // Doesn't match second condition - ), // Doesn't match any condition + await tester.emitEvent( + ActivityUpdatedEvent( + type: EventTypes.activityUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + activity: createDefaultActivityResponse(id: 'activity-1').copyWith( + type: 'post', // Matches first condition + filterTags: ['general'], // Doesn't match second condition ), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + await tester.pump(); + expect(tester.activityListState.activities, hasLength(3)); - expect(activityList.state.activities, hasLength(3)); - - final updatedActivity = activityList.state.activities.firstWhere( + final updatedActivity = tester.activityListState.activities.firstWhere( (activity) => activity.id == 'activity-1', ); @@ -383,34 +286,28 @@ void main() { }, ); - test( + activityListTest( 'No filter - filtering is disabled when no filter specified', - () async { - final activityList = client.activityList( - const ActivitiesQuery(), // No filter - ); + 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 activityList.get(); - expect(activityList.state.activities, hasLength(3)); - - // Send ActivityUpdatedEvent with any type - wsStreamController.add( - jsonEncode( - ActivityUpdatedEvent( - type: 'feeds.activity.updated', - createdAt: DateTime.now(), - custom: const {}, - fid: 'fid', - activity: createDefaultActivityResponse( - id: 'activity-1', - ).copyWith(type: 'share'), - ), + await tester.emitEvent( + ActivityUpdatedEvent( + type: EventTypes.activityUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + activity: createDefaultActivityResponse(id: 'activity-1') + .copyWith(type: 'share'), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - expect(activityList.state.activities, hasLength(3)); + await tester.pump(); + expect(tester.activityListState.activities, hasLength(3)); }, ); }); @@ -433,8 +330,8 @@ void main() { ), ), body: (tester) async { - tester.expect((al) => al.state.activities, hasLength(1)); - tester.expect((al) => al.state.activities.first.hidden, false); + expect(tester.activityListState.activities, hasLength(1)); + expect(tester.activityListState.activities.first.hidden, false); await tester.emitEvent( ActivityFeedbackEvent( @@ -452,8 +349,8 @@ void main() { ), ); - tester.expect((al) => al.state.activities, hasLength(1)); - tester.expect((al) => al.state.activities.first.hidden, true); + expect(tester.activityListState.activities, hasLength(1)); + expect(tester.activityListState.activities.first.hidden, true); }, ); @@ -468,8 +365,8 @@ void main() { ), ), body: (tester) async { - tester.expect((al) => al.state.activities, hasLength(1)); - tester.expect((al) => al.state.activities.first.hidden, true); + expect(tester.activityListState.activities, hasLength(1)); + expect(tester.activityListState.activities.first.hidden, true); await tester.emitEvent( ActivityFeedbackEvent( @@ -487,8 +384,8 @@ void main() { ), ); - tester.expect((al) => al.state.activities, hasLength(1)); - tester.expect((al) => al.state.activities.first.hidden, false); + expect(tester.activityListState.activities, hasLength(1)); + expect(tester.activityListState.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 eddb7c3..77ea249 100644 --- a/packages/stream_feeds/test/state/activity_test.dart +++ b/packages/stream_feeds/test/state/activity_test.dart @@ -1,63 +1,30 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:mocktail/mocktail.dart'; -import 'package:stream_feeds/src/state/activity_state.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart'; import '../test_utils.dart'; void main() { - late StreamFeedsClient client; - late MockDefaultApi feedsApi; - late MockWebSocketChannel webSocketChannel; - - setUp(() { - feedsApi = MockDefaultApi(); - webSocketChannel = MockWebSocketChannel(); - - client = StreamFeedsClient( - apiKey: 'apiKey', - user: const User(id: 'luke_skywalker'), - tokenProvider: TokenProvider.static(UserToken(testToken)), - feedsRestApi: feedsApi, - wsProvider: (options) => webSocketChannel, - ); - }); + const activityId = 'activity-1'; + const feedId = FeedId(group: 'user', id: 'john'); - tearDown(() { - client.disconnect(); - }); + // ============================================================ + // FEATURE: Activity Retrieval + // ============================================================ group('Getting an activity', () { - test('fetch activity and comments', () async { - const activityId = 'activity-1'; - const feedId = FeedId(group: 'user', id: 'john'); - - when(() => feedsApi.getActivity(id: activityId)).thenAnswer( - (_) async => Result.success( - createDefaultGetActivityResponse(id: activityId), - ), - ); - - when( - () => feedsApi.getComments( - objectId: activityId, - objectType: 'activity', - depth: 3, - ), - ).thenAnswer( - (_) async => Result.success(createDefaultCommentsResponse()), - ); - - final activity = client.activity(activityId: activityId, fid: feedId); - final result = await activity.get(); + activityTest( + 'fetch activity and comments', + build: (client) => client.activity(activityId: activityId, fid: feedId), + body: (tester) async { + final result = await tester.get(); - verify(() => feedsApi.getActivity(id: activityId)).called(1); - expect(result, isA>()); - expect(result.getOrNull()?.id, activityId); - }); + expect(result, isA>()); + expect(result.getOrNull()?.id, activityId); + }, + verify: (tester) => tester.verifyApi( + (api) => api.getActivity(id: activityId), + ), + ); }); // ============================================================ @@ -65,9 +32,6 @@ void main() { // ============================================================ 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), @@ -102,7 +66,7 @@ void main() { modifyResponse: (response) => response.copyWith(hidden: false), ), body: (tester) async { - tester.expect((a) => a.state.activity?.hidden, false); + expect(tester.activityState.activity?.hidden, false); await tester.emitEvent( ActivityFeedbackEvent( @@ -120,7 +84,7 @@ void main() { ), ); - tester.expect((a) => a.state.activity?.hidden, true); + expect(tester.activityState.activity?.hidden, true); }, ); @@ -131,7 +95,7 @@ void main() { modifyResponse: (response) => response.copyWith(hidden: true), ), body: (tester) async { - tester.expect((a) => a.state.activity?.hidden, true); + expect(tester.activityState.activity?.hidden, true); await tester.emitEvent( ActivityFeedbackEvent( @@ -149,325 +113,265 @@ void main() { ), ); - tester.expect((a) => a.state.activity?.hidden, false); + expect(tester.activityState.activity?.hidden, false); }, ); }); - group('Poll events', () { - late StreamController wsStreamController; - late MockWebSocketSink webSocketSink; - - setUp(() async { - wsStreamController = StreamController(); - webSocketSink = MockWebSocketSink(); - WsTestConnection( - wsStreamController: wsStreamController, - webSocketSink: webSocketSink, - webSocketChannel: webSocketChannel, - ).setUp(); - - await client.connect(); - }); - - tearDown(() async { - await webSocketSink.close(); - await wsStreamController.close(); - }); + // ============================================================ + // FEATURE: Poll Events + // ============================================================ - void setupMockActivity({GetActivityResponse? activity}) { - const activityId = 'id'; - when(() => feedsApi.getActivity(id: activityId)).thenAnswer( - (_) async => Result.success( - activity ?? createDefaultGetActivityResponse(), - ), - ); - when( - () => feedsApi.getComments( - objectId: activityId, - objectType: 'activity', - depth: 3, - ), - ).thenAnswer( - (_) async => Result.success(createDefaultCommentsResponse()), + 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'), + ], + }, ); - } - test('poll vote casted', () async { - final poll = createDefaultPollResponseData(); - final pollId = poll.id; - final firstOptionId = poll.options.first.id; + activityTest( + 'poll vote casted', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(poll: pollWithVotes), + ), + 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', + ); + + await tester.emitEvent( + PollVoteCastedFeedEvent( + type: EventTypes.pollVoteCasted, + 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), + }, + ), + ), + ); - setupMockActivity( - activity: createDefaultGetActivityResponse(poll: poll), - ); + final updatedPollData = tester.activityState.poll; + expect(updatedPollData!.voteCount, 4); - final activity = client.activity( - activityId: 'id', - fid: const FeedId(group: 'group', id: 'id'), + expect(updatedPollData.latestVotesByOption, hasLength(2)); + expect(updatedPollData.latestVotesByOption['option-2'], hasLength(2)); + }, ); - await activity.get(); - - expect(poll.voteCount, 0); - activity.notifier.stream.listen( - expectAsync1( - (event) { - expect(event, isA()); - expect(event.poll?.id, 'poll-id'); - expect(event.poll?.voteCount, 1); - }, + activityTest( + 'poll vote removed', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(poll: pollWithVotes), ), - ); - wsStreamController.add( - jsonEncode( - PollVoteCastedFeedEvent( - createdAt: DateTime.now(), - custom: const {}, - fid: 'fid', - poll: poll.copyWith(voteCount: 1), - pollVote: PollVoteResponseData( - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - id: 'voteId1', - optionId: firstOptionId, - pollId: pollId, + 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', + ); + + await tester.emitEvent( + PollVoteRemovedFeedEvent( + type: EventTypes.pollVoteRemoved, + 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), + }, + ), ), - type: EventTypes.pollVoteCasted, - ).toJson(), - ), - ); - }); - - test('poll answer casted', () async { - final poll = createDefaultPollResponseData(); - setupMockActivity( - activity: createDefaultGetActivityResponse(poll: poll), - ); + ); - final activity = client.activity( - activityId: 'id', - fid: const FeedId(group: 'group', id: 'id'), - ); - await activity.get(); - - activity.notifier.stream.listen( - expectAsync1( - (event) { - expect(event, isA()); - expect(event.poll?.id, 'poll-id'); - expect(event.poll?.answersCount, 1); - expect(event.poll?.latestAnswers.length, 1); - }, - ), - ); + final updatedPollData = tester.activityState.poll; + expect(updatedPollData!.voteCount, 2); - wsStreamController.add( - jsonEncode( - PollVoteCastedFeedEvent( - createdAt: DateTime.now(), - custom: const {}, - fid: 'fid', - poll: poll.copyWith(answersCount: 1), - pollVote: PollVoteResponseData( - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - id: 'voteId1', - answerText: 'answerText1', - isAnswer: true, - optionId: 'optionId1', - pollId: 'pollId1', - ), - type: EventTypes.pollVoteCasted, - ), - ), + expect(updatedPollData.latestVotesByOption, hasLength(2)); + expect(updatedPollData.latestVotesByOption['option-1'], hasLength(1)); + }, ); }); - test('poll answer removed', () async { - final poll = createDefaultPollResponseData( + group('Answer operations', () { + final pollWithAnswers = createDefaultPollResponse( latestAnswers: [ - PollVoteResponseData( - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - id: 'voteId1', - answerText: 'answerText1', - isAnswer: true, - optionId: 'optionId1', - pollId: 'pollId1', - ), + createDefaultPollAnswerResponse(id: 'answer-1'), + createDefaultPollAnswerResponse(id: 'answer-2'), + createDefaultPollAnswerResponse(id: 'answer-3'), ], ); - setupMockActivity( - activity: createDefaultGetActivityResponse(poll: poll), - ); - final activity = client.activity( - activityId: 'id', - fid: const FeedId(group: 'group', id: 'id'), - ); - await activity.get(); - - expect(poll.answersCount, 1); - expect(poll.latestAnswers.length, 1); - - activity.notifier.stream.listen( - expectAsync1( - (event) { - expect(event, isA()); - expect(event.poll?.id, 'poll-id'); - expect(event.poll?.answersCount, 0); - expect(event.poll?.latestAnswers.length, 0); - }, + activityTest( + 'poll answer casted', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(poll: pollWithAnswers), ), - ); + body: (tester) async { + final pollData = tester.activityState.poll; + expect(pollData!.answersCount, 3); - wsStreamController.add( - jsonEncode( - PollVoteRemovedFeedEvent( - createdAt: DateTime.now(), - custom: const {}, - fid: 'fid', - poll: poll.copyWith(answersCount: 0), - pollVote: PollVoteResponseData( - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - id: 'voteId1', - answerText: 'answerText1', - isAnswer: true, - optionId: 'optionId1', - pollId: 'pollId1', - ), - type: EventTypes.pollVoteRemoved, - ), - ), - ); - }); + expect(pollData.latestAnswers, hasLength(3)); - test('poll vote removed', () async { - final poll = createDefaultPollResponseData( - latestVotesByOption: { - 'optionId1': [ - PollVoteResponseData( - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - id: 'voteId1', - optionId: 'optionId1', - pollId: 'pollId1', + final newAnswer = createDefaultPollAnswerResponse( + id: 'answer-4', + pollId: pollData.id, + answerText: 'Answer 4', + ); + + await tester.emitEvent( + PollVoteCastedFeedEvent( + type: EventTypes.pollVoteCasted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + pollVote: newAnswer, + poll: pollWithAnswers.copyWith( + answersCount: 4, + latestAnswers: List.from( + pollWithAnswers.latestAnswers, + )..add(newAnswer), + ), ), - ], + ); + + final updatedPollData = tester.activityState.poll; + expect(updatedPollData!.answersCount, 4); + + expect(updatedPollData.latestAnswers, hasLength(4)); }, ); - final pollId = poll.id; - setupMockActivity( - activity: createDefaultGetActivityResponse(poll: poll), - ); - final activity = client.activity( - activityId: 'id', - fid: const FeedId(group: 'group', id: 'id'), - ); - await activity.get(); - - expect(poll.voteCount, 1); - expect(poll.latestVotesByOption.length, 1); - - activity.notifier.stream.listen( - expectAsync1( - (event) { - expect(event, isA()); - expect(event.poll?.id, 'poll-id'); - expect(event.poll?.voteCount, 0); - expect(event.poll?.latestVotesByOption.length, 0); - }, + activityTest( + 'poll answer removed', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(poll: pollWithAnswers), ), - ); - wsStreamController.add( - jsonEncode( - PollVoteRemovedFeedEvent( - createdAt: DateTime.now(), - custom: const {}, - fid: 'fid', - poll: poll.copyWith(voteCount: 0, latestVotesByOption: {}), - pollVote: PollVoteResponseData( - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - id: 'voteId1', - optionId: 'optionId1', - pollId: pollId, + 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', + ); + + await tester.emitEvent( + PollVoteRemovedFeedEvent( + type: EventTypes.pollVoteRemoved, + 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), + ), ), - type: EventTypes.pollVoteRemoved, - ), - ), + ); + + final updatedPollData = tester.activityState.poll; + expect(updatedPollData!.answersCount, 2); + + expect(updatedPollData.latestAnswers, hasLength(2)); + }, ); }); - test('poll closed', () async { - final poll = createDefaultPollResponseData(); - setupMockActivity( - activity: createDefaultGetActivityResponse(poll: poll), - ); + group('State changes', () { + final defaultPoll = createDefaultPollResponse(); - final activity = client.activity( - activityId: 'id', - fid: const FeedId(group: 'group', id: 'id'), - ); - await activity.get(); - - activity.notifier.stream.listen( - expectAsync1( - (event) { - expect(event, isA()); - expect(event.poll?.id, 'poll-id'); - expect(event.poll?.isClosed, true); - }, + activityTest( + 'poll closed', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(poll: defaultPoll), ), - ); + body: (tester) async { + expect(tester.activityState.poll?.isClosed, false); - wsStreamController.add( - jsonEncode( - PollClosedFeedEvent( - createdAt: DateTime.now(), - custom: const {}, - fid: 'fid', - poll: poll.copyWith(isClosed: true), - type: EventTypes.pollClosed, - ), - ), - ); - }); + await tester.emitEvent( + PollClosedFeedEvent( + type: EventTypes.pollClosed, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + poll: defaultPoll.copyWith(isClosed: true), + ), + ); - test('poll deleted', () async { - final poll = createDefaultPollResponseData(); - setupMockActivity( - activity: createDefaultGetActivityResponse(poll: poll), + expect(tester.activityState.poll?.isClosed, true); + }, ); - final activity = client.activity( - activityId: 'id', - fid: const FeedId(group: 'group', id: 'id'), - ); - await activity.get(); - - activity.notifier.stream.listen( - expectAsync1( - (event) { - expect(event, isA()); - expect(event.poll, null); - }, + activityTest( + 'poll deleted', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(poll: defaultPoll), ), - ); + body: (tester) async { + expect(tester.activityState.poll, isNotNull); - wsStreamController.add( - jsonEncode( - PollDeletedFeedEvent( - createdAt: DateTime.now(), - custom: const {}, - fid: 'fid', - poll: poll, - type: EventTypes.pollDeleted, - ), - ), + await tester.emitEvent( + PollDeletedFeedEvent( + type: EventTypes.pollDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + poll: defaultPoll, + ), + ); + + expect(tester.activityState.poll, isNull); + }, ); }); }); 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 012735e..b4e586a 100644 --- a/packages/stream_feeds/test/state/bookmark_folder_list_test.dart +++ b/packages/stream_feeds/test/state/bookmark_folder_list_test.dart @@ -1,135 +1,77 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:mocktail/mocktail.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart'; import '../test_utils.dart'; void main() { - late StreamFeedsClient client; - late MockDefaultApi feedsApi; - late MockWebSocketChannel webSocketChannel; - - setUp(() { - feedsApi = MockDefaultApi(); - webSocketChannel = MockWebSocketChannel(); - - client = StreamFeedsClient( - apiKey: 'apiKey', - user: const User(id: 'luke_skywalker'), - tokenProvider: TokenProvider.static(UserToken(testToken)), - feedsRestApi: feedsApi, - wsProvider: (options) => webSocketChannel, - ); - }); - - tearDown(() { - client.disconnect(); - }); + // ============================================================ + // FEATURE: Local Filtering + // ============================================================ group('BookmarkFolderListEventHandler - Local filtering', () { - late StreamController wsStreamController; - late MockWebSocketSink webSocketSink; - - setUp(() async { - wsStreamController = StreamController(); - webSocketSink = MockWebSocketSink(); - WsTestConnection( - wsStreamController: wsStreamController, - webSocketSink: webSocketSink, - webSocketChannel: webSocketChannel, - ).setUp(); - - await client.connect(); + final initialFolders = [ + createDefaultBookmarkFolderResponse(id: 'folder-1'), + createDefaultBookmarkFolderResponse(id: 'folder-2'), + createDefaultBookmarkFolderResponse(id: 'folder-3'), + ]; - when( - () => feedsApi.queryBookmarkFolders( - queryBookmarkFoldersRequest: any( - named: 'queryBookmarkFoldersRequest', - ), - ), - ).thenAnswer( - (_) async => Result.success( - QueryBookmarkFoldersResponse( - duration: DateTime.now().toIso8601String(), - bookmarkFolders: [ - createDefaultBookmarkFolderResponse(id: 'folder-1'), - createDefaultBookmarkFolderResponse(id: 'folder-2'), - createDefaultBookmarkFolderResponse(id: 'folder-3'), - ], - ), - ), - ); - }); - - tearDown(() async { - await webSocketSink.close(); - await wsStreamController.close(); - }); - - test( + bookmarkFolderListTest( 'BookmarkFolderUpdatedEvent - should remove folder when updated to non-matching name', - () async { - final folderList = client.bookmarkFolderList( - BookmarkFoldersQuery( - filter: Filter.equal( - BookmarkFoldersFilterField.folderName, - 'My Folder', - ), + build: (client) => client.bookmarkFolderList( + BookmarkFoldersQuery( + filter: Filter.equal( + BookmarkFoldersFilterField.folderName, + 'My Folder', ), - ); - - await folderList.get(); - expect(folderList.state.bookmarkFolders, hasLength(3)); + ), + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(bookmarkFolders: initialFolders), + ), + body: (tester) async { + expect(tester.bookmarkFolderListState.bookmarkFolders, hasLength(3)); // Send event with folder that has different name (doesn't match filter) - wsStreamController.add( - jsonEncode( - BookmarkFolderUpdatedEvent( - type: 'feeds.bookmark_folder.updated', - createdAt: DateTime.now(), - custom: const {}, - bookmarkFolder: createDefaultBookmarkFolderResponse( - id: 'folder-1', - // Doesn't match folder name - ).copyWith(name: 'Different Folder'), - ), + await tester.emitEvent( + BookmarkFolderUpdatedEvent( + type: EventTypes.bookmarkFolderUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmarkFolder: createDefaultBookmarkFolderResponse( + id: 'folder-1', + // Doesn't match folder name + ).copyWith(name: 'Different Folder'), ), ); - await Future.delayed(Duration.zero); - expect(folderList.state.bookmarkFolders, hasLength(2)); + expect(tester.bookmarkFolderListState.bookmarkFolders, hasLength(2)); }, ); - test( + bookmarkFolderListTest( 'No filter - should not remove folder when no filter specified', - () async { - final folderList = client.bookmarkFolderList( - // No filter - all folders should be accepted - const BookmarkFoldersQuery(), - ); - - await folderList.get(); - expect(folderList.state.bookmarkFolders, hasLength(3)); - - wsStreamController.add( - jsonEncode( - BookmarkFolderUpdatedEvent( - type: 'feeds.bookmark_folder.updated', - createdAt: DateTime.now(), - custom: const {}, - bookmarkFolder: createDefaultBookmarkFolderResponse( - id: 'folder-1', - ).copyWith(name: 'Different Folder'), - ), + build: (client) => client.bookmarkFolderList( + // No filter - all folders should be accepted + const BookmarkFoldersQuery(), + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(bookmarkFolders: initialFolders), + ), + body: (tester) async { + expect(tester.bookmarkFolderListState.bookmarkFolders, hasLength(3)); + + await tester.emitEvent( + BookmarkFolderUpdatedEvent( + type: EventTypes.bookmarkFolderUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmarkFolder: createDefaultBookmarkFolderResponse( + id: 'folder-1', + ).copyWith(name: 'Different Folder'), ), ); - await Future.delayed(Duration.zero); - expect(folderList.state.bookmarkFolders, hasLength(3)); + expect(tester.bookmarkFolderListState.bookmarkFolders, hasLength(3)); }, ); }); diff --git a/packages/stream_feeds/test/state/bookmark_list_test.dart b/packages/stream_feeds/test/state/bookmark_list_test.dart index 137916c..b6f2152 100644 --- a/packages/stream_feeds/test/state/bookmark_list_test.dart +++ b/packages/stream_feeds/test/state/bookmark_list_test.dart @@ -1,139 +1,83 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:mocktail/mocktail.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart'; import '../test_utils.dart'; void main() { - late StreamFeedsClient client; - late MockDefaultApi feedsApi; - late MockWebSocketChannel webSocketChannel; - - setUp(() { - feedsApi = MockDefaultApi(); - webSocketChannel = MockWebSocketChannel(); - - client = StreamFeedsClient( - apiKey: 'apiKey', - user: const User(id: 'luke_skywalker'), - tokenProvider: TokenProvider.static(UserToken(testToken)), - feedsRestApi: feedsApi, - wsProvider: (options) => webSocketChannel, - ); - }); - - tearDown(() { - client.disconnect(); - }); + // ============================================================ + // FEATURE: Local Filtering + // ============================================================ group('BookmarkListEventHandler - Local filtering', () { - late StreamController wsStreamController; - late MockWebSocketSink webSocketSink; - - setUp(() async { - wsStreamController = StreamController(); - webSocketSink = MockWebSocketSink(); - WsTestConnection( - wsStreamController: wsStreamController, - webSocketSink: webSocketSink, - webSocketChannel: webSocketChannel, - ).setUp(); - - await client.connect(); - - when( - () => feedsApi.queryBookmarks( - queryBookmarksRequest: any(named: 'queryBookmarksRequest'), - ), - ).thenAnswer( - (_) async => Result.success( - QueryBookmarksResponse( - duration: DateTime.now().toIso8601String(), - bookmarks: [ - createDefaultBookmarkResponse( - folderId: 'folder-1', - activityId: 'activity-1', - ), - createDefaultBookmarkResponse( - folderId: 'folder-1', - activityId: 'activity-2', - ), - createDefaultBookmarkResponse( - folderId: 'folder-1', - activityId: 'activity-3', - ), - ], - ), - ), - ); - }); - - tearDown(() async { - await webSocketSink.close(); - await wsStreamController.close(); - }); - - test( + final initialBookmarks = [ + createDefaultBookmarkResponse( + folderId: 'folder-1', + activityId: 'activity-1', + ), + createDefaultBookmarkResponse( + folderId: 'folder-1', + activityId: 'activity-2', + ), + createDefaultBookmarkResponse( + folderId: 'folder-1', + activityId: 'activity-3', + ), + ]; + + bookmarkListTest( 'BookmarkUpdatedEvent - should remove bookmark when updated to non-matching user', - () async { - final bookmarkList = client.bookmarkList( - BookmarksQuery( - filter: Filter.equal(BookmarksFilterField.folderId, 'folder-1'), - ), - ); - - await bookmarkList.get(); - expect(bookmarkList.state.bookmarks, hasLength(3)); - - wsStreamController.add( - jsonEncode( - BookmarkUpdatedEvent( - type: 'feeds.bookmark.updated', - createdAt: DateTime.now(), - custom: const {}, - bookmark: createDefaultBookmarkResponse( - folderId: 'folder-2', - activityId: 'activity-1', - ), + build: (client) => client.bookmarkList( + BookmarksQuery( + filter: Filter.equal(BookmarksFilterField.folderId, 'folder-1'), + ), + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(bookmarks: initialBookmarks), + ), + body: (tester) async { + expect(tester.bookmarkListState.bookmarks, hasLength(3)); + + await tester.emitEvent( + BookmarkUpdatedEvent( + type: EventTypes.bookmarkUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + folderId: 'folder-2', + activityId: 'activity-1', ), ), ); - await Future.delayed(Duration.zero); - expect(bookmarkList.state.bookmarks, hasLength(2)); + expect(tester.bookmarkListState.bookmarks, hasLength(2)); }, ); - test( + bookmarkListTest( 'No filter - should not remove bookmark when no filter specified', - () async { - final bookmarkList = client.bookmarkList( - // No filter, all bookmarks should be accepted - const BookmarksQuery(), - ); - - await bookmarkList.get(); - expect(bookmarkList.state.bookmarks, hasLength(3)); - - wsStreamController.add( - jsonEncode( - BookmarkUpdatedEvent( - type: 'feeds.bookmark.updated', - createdAt: DateTime.now(), - custom: const {}, - bookmark: createDefaultBookmarkResponse( - folderId: 'folder-2', - activityId: 'activity-1', - ), + build: (client) => client.bookmarkList( + // No filter, all bookmarks should be accepted + const BookmarksQuery(), + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(bookmarks: initialBookmarks), + ), + body: (tester) async { + expect(tester.bookmarkListState.bookmarks, hasLength(3)); + + await tester.emitEvent( + BookmarkUpdatedEvent( + type: EventTypes.bookmarkUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + folderId: 'folder-2', + activityId: 'activity-1', ), ), ); - await Future.delayed(Duration.zero); - expect(bookmarkList.state.bookmarks, hasLength(3)); + expect(tester.bookmarkListState.bookmarks, hasLength(3)); }, ); }); diff --git a/packages/stream_feeds/test/state/comment_list_test.dart b/packages/stream_feeds/test/state/comment_list_test.dart index b0d3363..3a31572 100644 --- a/packages/stream_feeds/test/state/comment_list_test.dart +++ b/packages/stream_feeds/test/state/comment_list_test.dart @@ -1,139 +1,76 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:mocktail/mocktail.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart'; import '../test_utils.dart'; void main() { - late StreamFeedsClient client; - late MockDefaultApi feedsApi; - late MockWebSocketChannel webSocketChannel; - - setUpAll(() { - registerFallbackValue(const QueryCommentsRequest(filter: {})); - }); - - setUp(() { - feedsApi = MockDefaultApi(); - webSocketChannel = MockWebSocketChannel(); - - client = StreamFeedsClient( - apiKey: 'apiKey', - user: const User(id: 'luke_skywalker'), - tokenProvider: TokenProvider.static(UserToken(testToken)), - feedsRestApi: feedsApi, - wsProvider: (options) => webSocketChannel, - ); - }); - - tearDown(() { - client.disconnect(); - }); + // ============================================================ + // FEATURE: Local Filtering + // ============================================================ group('CommentListEventHandler - Local filtering', () { - late StreamController wsStreamController; - late MockWebSocketSink webSocketSink; - - setUp(() async { - wsStreamController = StreamController(); - webSocketSink = MockWebSocketSink(); - WsTestConnection( - wsStreamController: wsStreamController, - webSocketSink: webSocketSink, - webSocketChannel: webSocketChannel, - ).setUp(); + final initialComments = [ + createDefaultCommentResponse(id: 'comment-1', objectId: 'obj-1'), + createDefaultCommentResponse(id: 'comment-2', objectId: 'obj-1'), + createDefaultCommentResponse(id: 'comment-3', objectId: 'obj-1'), + ]; - await client.connect(); - - when( - () => feedsApi.queryComments( - queryCommentsRequest: any(named: 'queryCommentsRequest'), - ), - ).thenAnswer( - (_) async => Result.success( - QueryCommentsResponse( - duration: DateTime.now().toIso8601String(), - comments: [ - createDefaultCommentResponse(id: 'comment-1', objectId: 'obj-1'), - createDefaultCommentResponse(id: 'comment-2', objectId: 'obj-1'), - createDefaultCommentResponse(id: 'comment-3', objectId: 'obj-1'), - ], - ), - ), - ); - }); - - tearDown(() async { - await webSocketSink.close(); - await wsStreamController.close(); - }); - - test( + commentListTest( 'CommentUpdatedEvent - should remove comment when updated to non-matching status', - () async { - final commentList = client.commentList( - CommentsQuery( - filter: Filter.equal(CommentsFilterField.status, 'active'), - ), - ); - - await commentList.get(); - expect(commentList.state.comments, hasLength(3)); - - wsStreamController.add( - jsonEncode( - CommentUpdatedEvent( - type: 'feeds.comment.updated', - createdAt: DateTime.now(), - custom: const {}, - fid: 'user:source', - comment: createDefaultCommentResponse( - id: 'comment-1', - objectId: 'obj-1', - ).copyWith(status: 'deleted'), - ), + build: (client) => client.commentList( + CommentsQuery( + filter: Filter.equal(CommentsFilterField.status, 'active'), + ), + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(comments: initialComments), + ), + body: (tester) async { + expect(tester.commentListState.comments, hasLength(3)); + + await tester.emitEvent( + CommentUpdatedEvent( + type: EventTypes.commentUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:source', + comment: createDefaultCommentResponse( + id: 'comment-1', + objectId: 'obj-1', + ).copyWith(status: 'deleted'), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - expect(commentList.state.comments, hasLength(2)); + expect(tester.commentListState.comments, hasLength(2)); }, ); - test( + commentListTest( 'No filter - should not remove comment when no filter specified', - () async { - final commentList = client.commentList( - // No filter, all comments should be accepted - const CommentsQuery(), - ); - - await commentList.get(); - expect(commentList.state.comments, hasLength(3)); - - wsStreamController.add( - jsonEncode( - CommentUpdatedEvent( - type: 'feeds.comment.updated', - createdAt: DateTime.now(), - custom: const {}, - fid: 'user:source', - comment: createDefaultCommentResponse( - id: 'comment-1', - objectId: 'obj-1', - ).copyWith(status: 'deleted'), - ), + build: (client) => client.commentList( + // No filter, all comments should be accepted + const CommentsQuery(), + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(comments: initialComments), + ), + body: (tester) async { + expect(tester.commentListState.comments, hasLength(3)); + + await tester.emitEvent( + CommentUpdatedEvent( + type: EventTypes.commentUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:source', + comment: createDefaultCommentResponse( + id: 'comment-1', + objectId: 'obj-1', + ).copyWith(status: 'deleted'), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - expect(commentList.state.comments, hasLength(3)); + expect(tester.commentListState.comments, hasLength(3)); }, ); }); diff --git a/packages/stream_feeds/test/state/feed_list_test.dart b/packages/stream_feeds/test/state/feed_list_test.dart index c779ba1..6ea10e4 100644 --- a/packages/stream_feeds/test/state/feed_list_test.dart +++ b/packages/stream_feeds/test/state/feed_list_test.dart @@ -1,132 +1,74 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:mocktail/mocktail.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart'; import '../test_utils.dart'; void main() { - late StreamFeedsClient client; - late MockDefaultApi feedsApi; - late MockWebSocketChannel webSocketChannel; - - setUp(() { - feedsApi = MockDefaultApi(); - webSocketChannel = MockWebSocketChannel(); - - client = StreamFeedsClient( - apiKey: 'apiKey', - user: const User(id: 'luke_skywalker'), - tokenProvider: TokenProvider.static(UserToken(testToken)), - feedsRestApi: feedsApi, - wsProvider: (options) => webSocketChannel, - ); - }); - - tearDown(() { - client.disconnect(); - }); + // ============================================================ + // FEATURE: Local Filtering + // ============================================================ group('FeedListEventHandler - Local filtering', () { - late StreamController wsStreamController; - late MockWebSocketSink webSocketSink; - - setUp(() async { - wsStreamController = StreamController(); - webSocketSink = MockWebSocketSink(); - WsTestConnection( - wsStreamController: wsStreamController, - webSocketSink: webSocketSink, - webSocketChannel: webSocketChannel, - ).setUp(); - - await client.connect(); - - when( - () => feedsApi.queryFeeds( - queryFeedsRequest: any(named: 'queryFeedsRequest'), - ), - ).thenAnswer( - (_) async => Result.success( - QueryFeedsResponse( - duration: DateTime.now().toIso8601String(), - feeds: [ - createDefaultFeedResponse(id: 'feed-1'), - createDefaultFeedResponse(id: 'feed-2'), - createDefaultFeedResponse(id: 'feed-3'), - ], - ), - ), - ); - }); - - tearDown(() async { - await webSocketSink.close(); - await wsStreamController.close(); - }); + final initialFeeds = [ + createDefaultFeedResponse(id: 'feed-1'), + createDefaultFeedResponse(id: 'feed-2'), + createDefaultFeedResponse(id: 'feed-3'), + ]; - test( + feedListTest( 'FeedUpdatedEvent - should remove feed when updated to non-matching visibility', - () async { - final feedList = client.feedList( - FeedsQuery( - filter: Filter.equal(FeedsFilterField.visibility, 'public'), - ), - ); - - await feedList.get(); - expect(feedList.state.feeds, hasLength(3)); - - wsStreamController.add( - jsonEncode( - FeedUpdatedEvent( - type: 'feeds.feed.updated', - createdAt: DateTime.now(), - custom: const {}, - fid: 'user:feed-1', - feed: createDefaultFeedResponse( - id: 'feed-1', - ).copyWith(visibility: FeedVisibility.private), - ), + build: (client) => client.feedList( + FeedsQuery( + filter: Filter.equal(FeedsFilterField.visibility, 'public'), + ), + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(feeds: initialFeeds), + ), + body: (tester) async { + expect(tester.feedListState.feeds, hasLength(3)); + + await tester.emitEvent( + FeedUpdatedEvent( + type: EventTypes.feedUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:feed-1', + feed: createDefaultFeedResponse( + id: 'feed-1', + ).copyWith(visibility: FeedVisibility.private), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - expect(feedList.state.feeds, hasLength(2)); + expect(tester.feedListState.feeds, hasLength(2)); }, ); - test( + feedListTest( 'No filter - should not remove feed when no filter specified', - () async { - final feedList = client.feedList( - // No filter specified, all feeds should be accepted - const FeedsQuery(), - ); - - await feedList.get(); - expect(feedList.state.feeds, hasLength(3)); - - wsStreamController.add( - jsonEncode( - FeedUpdatedEvent( - type: 'feeds.feed.updated', - createdAt: DateTime.now(), - custom: const {}, - fid: 'user:feed-1', - feed: createDefaultFeedResponse( - id: 'feed-1', - ).copyWith(visibility: FeedVisibility.private), - ), + build: (client) => client.feedList( + // No filter specified, all feeds should be accepted + const FeedsQuery(), + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(feeds: initialFeeds), + ), + body: (tester) async { + expect(tester.feedListState.feeds, hasLength(3)); + + await tester.emitEvent( + FeedUpdatedEvent( + type: EventTypes.feedUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:feed-1', + feed: createDefaultFeedResponse( + id: 'feed-1', + ).copyWith(visibility: FeedVisibility.private), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - expect(feedList.state.feeds, hasLength(3)); + expect(tester.feedListState.feeds, hasLength(3)); }, ); }); diff --git a/packages/stream_feeds/test/state/feed_test.dart b/packages/stream_feeds/test/state/feed_test.dart index f619562..3a49ca5 100644 --- a/packages/stream_feeds/test/state/feed_test.dart +++ b/packages/stream_feeds/test/state/feed_test.dart @@ -1,8 +1,5 @@ // ignore_for_file: avoid_redundant_argument_values -import 'dart:async'; -import 'dart:convert'; - import 'package:mocktail/mocktail.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart'; @@ -10,102 +7,82 @@ import 'package:test/test.dart'; import '../test_utils.dart'; void main() { - late StreamFeedsClient client; - late MockDefaultApi feedsApi; - late MockWebSocketChannel webSocketChannel; - - setUp(() { - feedsApi = MockDefaultApi(); - webSocketChannel = MockWebSocketChannel(); - - client = StreamFeedsClient( - apiKey: 'apiKey', - user: const User(id: 'luke_skywalker'), - tokenProvider: TokenProvider.static(UserToken(testToken)), - feedsRestApi: feedsApi, - wsProvider: (options) => webSocketChannel, - ); - }); - - tearDown(() { - client.disconnect(); - }); + // ============================================================ + // FEATURE: Feed Operations + // ============================================================ group('Get a Feed', () { - test('get feed', () async { - const feedId = FeedId(group: 'group', id: 'id'); - when( - () => feedsApi.getOrCreateFeed( - feedGroupId: feedId.group, - feedId: feedId.id, - getOrCreateFeedRequest: any(named: 'getOrCreateFeedRequest'), - ), - ).thenAnswer( - (_) async => Result.success(createDefaultGetOrCreateFeedResponse()), - ); - - final feed = client.feed(group: feedId.group, id: feedId.id); - final result = await feed.getOrCreate(); + feedTest( + 'get feed', + build: (client) => client.feed(group: 'group', id: 'id'), + body: (tester) async { + final result = await tester.getOrCreate(); - expect(result, isA>()); - final feedData = result.getOrThrow(); + expect(result, isA>()); + final feedData = result.getOrThrow(); - expect(feedData, isA()); - expect(feedData.id, 'id'); - expect(feedData.name, 'name'); - expect(feedData.description, 'description'); - }); + expect(feedData, isA()); + expect(feedData.id, 'id'); + expect(feedData.groupId, 'group'); + }, + ); }); group('Query follow suggestions', () { - test('should return list of FeedSuggestionData', () async { - const feedId = FeedId(group: 'user', id: 'john'); - when( - () => feedsApi.getFollowSuggestions( + 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'), ), - ).thenAnswer( - (_) async => Result.success( - 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, - ), - ], - ), + 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 feed = client.feed(group: feedId.group, id: feedId.id); - final result = await feed.queryFollowSuggestions(limit: 10); + ), + body: (tester) async { + final result = await tester.feed.queryFollowSuggestions(limit: 10); - expect(result, isA>>()); + expect(result, isA>>()); - final suggestions = result.getOrThrow(); - expect(suggestions.length, 2); + 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 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); - }); + 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'), + ), + ), + ); }); // ============================================================ @@ -118,7 +95,7 @@ void main() { feedTest( 'submits feedback via API', - build: (client) => client.feed(group: feedId.group, id: feedId.id), + build: (client) => client.feedFromId(feedId), setUp: (tester) { const activityFeedbackRequest = ActivityFeedbackRequest(hide: true); tester.mockApi( @@ -151,7 +128,7 @@ void main() { feedTest( 'marks activity hidden on ActivityFeedbackEvent', - build: (client) => client.feed(group: feedId.group, id: feedId.id), + build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( modifyResponse: (response) => response.copyWith( activities: [ @@ -160,8 +137,8 @@ void main() { ), ), body: (tester) async { - tester.expect((f) => f.state.activities.length, 1); - tester.expect((f) => f.state.activities.first.hidden, false); + expect(tester.feedState.activities, hasLength(1)); + expect(tester.feedState.activities.first.hidden, false); await tester.emitEvent( ActivityFeedbackEvent( @@ -179,13 +156,14 @@ void main() { ), ); - tester.expect((f) => f.state.activities.first.hidden, true); + expect(tester.feedState.activities, hasLength(1)); + expect(tester.feedState.activities.first.hidden, true); }, ); feedTest( 'marks activity unhidden on ActivityFeedbackEvent', - build: (client) => client.feed(group: feedId.group, id: feedId.id), + build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( modifyResponse: (response) => response.copyWith( activities: [ @@ -194,8 +172,8 @@ void main() { ), ), body: (tester) async { - tester.expect((f) => f.state.activities.length, 1); - tester.expect((f) => f.state.activities.first.hidden, true); + expect(tester.feedState.activities, hasLength(1)); + expect(tester.feedState.activities.first.hidden, true); await tester.emitEvent( ActivityFeedbackEvent( @@ -213,78 +191,37 @@ void main() { ), ); - tester.expect((f) => f.state.activities.first.hidden, false); + expect(tester.feedState.activities, hasLength(1)); + expect(tester.feedState.activities.first.hidden, false); }, ); }); group('Follow events', () { - late StreamController wsStreamController; - late MockWebSocketSink webSocketSink; - - setUp(() async { - wsStreamController = StreamController(); - webSocketSink = MockWebSocketSink(); - WsTestConnection( - wsStreamController: wsStreamController, - webSocketSink: webSocketSink, - webSocketChannel: webSocketChannel, - ).setUp(); - - await client.connect(); - }); - - tearDown(() async { - await webSocketSink.close(); - await wsStreamController.close(); - }); - - test('follow target feed should update follower count', () async { - const targetFeedId = FeedId(group: 'group', id: 'target'); - const sourceFeedId = FeedId(group: 'group', id: 'source'); - - when( - () => feedsApi.getOrCreateFeed( - feedGroupId: targetFeedId.group, - feedId: targetFeedId.id, - getOrCreateFeedRequest: any(named: 'getOrCreateFeedRequest'), - ), - ).thenAnswer( - (_) async => Result.success(createDefaultGetOrCreateFeedResponse()), - ); - - final feed = client.feedFromId(targetFeedId); - - final result = await feed.getOrCreate(); - final feedData = result.getOrThrow(); - - expect(feedData.followerCount, 0); - expect(feedData.followingCount, 0); + const targetFeedId = FeedId(group: 'group', id: 'target'); + const sourceFeedId = FeedId(group: 'group', id: 'source'); - feed.notifier.stream.listen( - expectAsync1( - (event) { - expect(event, isA()); - expect(event.feed?.followerCount, 1); - expect(event.feed?.followingCount, 0); - }, - ), - ); + feedTest( + '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); - wsStreamController.add( - jsonEncode( + await tester.emitEvent( FollowCreatedEvent( type: EventTypes.followCreated, - createdAt: DateTime.now(), + createdAt: DateTime.timestamp(), custom: const {}, fid: targetFeedId.toString(), follow: FollowResponse( - createdAt: DateTime.now(), + createdAt: DateTime.timestamp(), custom: const {}, followerRole: 'followerRole', pushPreference: FollowResponsePushPreference.none, - requestAcceptedAt: DateTime.now(), - requestRejectedAt: DateTime.now(), + requestAcceptedAt: DateTime.timestamp(), + requestRejectedAt: DateTime.timestamp(), sourceFeed: createDefaultFeedResponse( id: sourceFeedId.id, groupId: sourceFeedId.group, @@ -296,59 +233,37 @@ void main() { groupId: targetFeedId.group, followerCount: 1, ), - updatedAt: DateTime.now(), + updatedAt: DateTime.timestamp(), ), ), - ), - ); - }); - - test('follow source feed should update following count', () async { - const targetFeedId = FeedId(group: 'group', id: 'target'); - const sourceFeedId = FeedId(group: 'group', id: 'source'); - - when( - () => feedsApi.getOrCreateFeed( - feedGroupId: sourceFeedId.group, - feedId: sourceFeedId.id, - getOrCreateFeedRequest: any(named: 'getOrCreateFeedRequest'), - ), - ).thenAnswer( - (_) async => Result.success(createDefaultGetOrCreateFeedResponse()), - ); - - final feed = client.feedFromId(sourceFeedId); - - final result = await feed.getOrCreate(); - final feedData = result.getOrThrow(); + ); - expect(feedData.followerCount, 0); - expect(feedData.followingCount, 0); + expect(tester.feedState.feed?.followerCount, 1); + expect(tester.feedState.feed?.followingCount, 0); + }, + ); - feed.notifier.stream.listen( - expectAsync1( - (event) { - expect(event, isA()); - expect(event.feed?.followerCount, 0); - expect(event.feed?.followingCount, 1); - }, - ), - ); + 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); - wsStreamController.add( - jsonEncode( + await tester.emitEvent( FollowCreatedEvent( type: EventTypes.followCreated, - createdAt: DateTime.now(), + createdAt: DateTime.timestamp(), custom: const {}, fid: sourceFeedId.toString(), follow: FollowResponse( - createdAt: DateTime.now(), + createdAt: DateTime.timestamp(), custom: const {}, followerRole: 'followerRole', pushPreference: FollowResponsePushPreference.none, - requestAcceptedAt: DateTime.now(), - requestRejectedAt: DateTime.now(), + requestAcceptedAt: DateTime.timestamp(), + requestRejectedAt: DateTime.timestamp(), sourceFeed: createDefaultFeedResponse( id: sourceFeedId.id, groupId: sourceFeedId.group, @@ -360,64 +275,46 @@ void main() { groupId: targetFeedId.group, followerCount: 1, ), - updatedAt: DateTime.now(), + updatedAt: DateTime.timestamp(), ), ), - ), - ); - }); - - test('follow deleted target feed should update follower count', () async { - const targetFeedId = FeedId(group: 'group', id: 'target'); - const sourceFeedId = FeedId(group: 'group', id: 'source'); - - when( - () => feedsApi.getOrCreateFeed( - feedGroupId: targetFeedId.group, - feedId: targetFeedId.id, - getOrCreateFeedRequest: any(named: 'getOrCreateFeedRequest'), - ), - ).thenAnswer( - (_) async => Result.success( - createDefaultGetOrCreateFeedResponse( + ); + + expect(tester.feedState.feed?.followerCount, 0); + expect(tester.feedState.feed?.followingCount, 1); + }, + ); + + feedTest( + '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, ), ), - ); - - final feed = client.feedFromId(targetFeedId); - - final result = await feed.getOrCreate(); - final feedData = result.getOrThrow(); - - expect(feedData.followerCount, 1); - expect(feedData.followingCount, 1); - - feed.notifier.stream.listen( - expectAsync1( - (event) { - expect(event, isA()); - expect(event.feed?.followerCount, 0); - expect(event.feed?.followingCount, 1); - }, - ), - ); + ), + body: (tester) async { + expect(tester.feedState.feed?.followerCount, 1); + expect(tester.feedState.feed?.followingCount, 1); - wsStreamController.add( - jsonEncode( + await tester.emitEvent( FollowDeletedEvent( type: EventTypes.followDeleted, - createdAt: DateTime.now(), + createdAt: DateTime.timestamp(), custom: const {}, fid: targetFeedId.toString(), follow: FollowResponse( - createdAt: DateTime.now(), + createdAt: DateTime.timestamp(), custom: const {}, followerRole: 'followerRole', pushPreference: FollowResponsePushPreference.none, - requestAcceptedAt: DateTime.now(), - requestRejectedAt: DateTime.now(), + requestAcceptedAt: DateTime.timestamp(), + requestRejectedAt: DateTime.timestamp(), sourceFeed: createDefaultFeedResponse( id: sourceFeedId.id, groupId: sourceFeedId.group, @@ -429,64 +326,46 @@ void main() { groupId: targetFeedId.group, followerCount: 0, ), - updatedAt: DateTime.now(), + updatedAt: DateTime.timestamp(), ), ), - ), - ); - }); - - test('follow deleted source feed should update following count', () async { - const targetFeedId = FeedId(group: 'group', id: 'target'); - const sourceFeedId = FeedId(group: 'group', id: 'source'); - - when( - () => feedsApi.getOrCreateFeed( - feedGroupId: sourceFeedId.group, - feedId: sourceFeedId.id, - getOrCreateFeedRequest: any(named: 'getOrCreateFeedRequest'), - ), - ).thenAnswer( - (_) async => Result.success( - createDefaultGetOrCreateFeedResponse( - followingCount: 1, - followerCount: 1, - ), - ), - ); - - final feed = client.feedFromId(sourceFeedId); - - final result = await feed.getOrCreate(); - final feedData = result.getOrThrow(); + ); - expect(feedData.followerCount, 1); - expect(feedData.followingCount, 1); + expect(tester.feedState.feed?.followerCount, 0); + expect(tester.feedState.feed?.followingCount, 1); + }, + ); - feed.notifier.stream.listen( - expectAsync1( - (event) { - expect(event, isA()); - expect(event.feed?.followerCount, 1); - expect(event.feed?.followingCount, 0); - }, + 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, + ), ), - ); + ), + body: (tester) async { + expect(tester.feedState.feed?.followerCount, 1); + expect(tester.feedState.feed?.followingCount, 1); - wsStreamController.add( - jsonEncode( + await tester.emitEvent( FollowDeletedEvent( type: EventTypes.followDeleted, - createdAt: DateTime.now(), + createdAt: DateTime.timestamp(), custom: const {}, fid: sourceFeedId.toString(), follow: FollowResponse( - createdAt: DateTime.now(), + createdAt: DateTime.timestamp(), custom: const {}, followerRole: 'followerRole', pushPreference: FollowResponsePushPreference.none, - requestAcceptedAt: DateTime.now(), - requestRejectedAt: DateTime.now(), + requestAcceptedAt: DateTime.timestamp(), + requestRejectedAt: DateTime.timestamp(), sourceFeed: createDefaultFeedResponse( id: sourceFeedId.id, groupId: sourceFeedId.group, @@ -498,436 +377,384 @@ void main() { groupId: targetFeedId.group, followerCount: 0, ), - updatedAt: DateTime.now(), + updatedAt: DateTime.timestamp(), ), ), - ), - ); - }); + ); + + expect(tester.feedState.feed?.followerCount, 1); + expect(tester.feedState.feed?.followingCount, 0); + }, + ); }); - group('Local filtering with real-time events', () { - late StreamController wsStreamController; - late MockWebSocketSink webSocketSink; + // ============================================================ + // FEATURE: Local Filtering + // ============================================================ + group('Local filtering with real-time events', () { const feedId = FeedId(group: 'user', id: 'test'); - setUp(() async { - wsStreamController = StreamController(); - webSocketSink = MockWebSocketSink(); - WsTestConnection( - wsStreamController: wsStreamController, - webSocketSink: webSocketSink, - webSocketChannel: webSocketChannel, - ).setUp(); - - await client.connect(); - - final initialActivities = [ - createDefaultActivityResponse(id: 'activity-1'), - createDefaultActivityResponse(id: 'activity-2'), - createDefaultActivityResponse(id: 'activity-3'), - ]; - - final initialPinnedActivities = [ - ActivityPinResponse( - feed: feedId.rawValue, - activity: initialActivities.first, - createdAt: DateTime(2022, 1, 1), - updatedAt: DateTime(2022, 1, 1), - user: createDefaultUserResponse(id: 'user-1'), - ), - ]; - - // Setup default mock response - when( - () => feedsApi.getOrCreateFeed( - feedGroupId: feedId.group, - feedId: feedId.id, - getOrCreateFeedRequest: any(named: 'getOrCreateFeedRequest'), - ), - ).thenAnswer( - (_) async => Result.success( - createDefaultGetOrCreateFeedResponse().copyWith( - activities: initialActivities, - pinnedActivities: initialPinnedActivities, - ), - ), - ); - }); - - tearDown(() async { - await webSocketSink.close(); - await wsStreamController.close(); - }); + 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'), + ), + ]; - test( + feedTest( 'ActivityAddedEvent - should not add activity that does not match filter', - () async { - final feed = client.feedFromQuery( - FeedQuery( - fid: feedId, - activityFilter: Filter.equal(ActivitiesFilterField.type, 'post'), - ), - ); - - await feed.getOrCreate(); - expect(feed.state.activities, hasLength(3)); + build: (client) => client.feedFromQuery( + FeedQuery( + fid: feedId, + activityFilter: Filter.equal(ActivitiesFilterField.type, 'post'), + ), + ), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith(activities: initialActivities), + ), + body: (tester) async { + expect(tester.feedState.activities, hasLength(3)); // Send ActivityAddedEvent with type 'comment' (doesn't match filter) - wsStreamController.add( - jsonEncode( - ActivityAddedEvent( - type: 'feeds.activity.added', - createdAt: DateTime.now(), - custom: const {}, - fid: feedId.rawValue, - activity: createDefaultActivityResponse( - id: 'activity-4', - // Doesn't match 'post' filter - ).copyWith(type: 'comment'), - ), + 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'), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - expect(feed.state.activities, hasLength(3)); + expect(tester.feedState.activities, hasLength(3)); }, ); - test( + feedTest( 'ActivityUpdatedEvent - should remove activity when updated to non-matching type', - () async { - final feed = client.feedFromQuery( - FeedQuery( - fid: feedId, - activityFilter: Filter.equal(ActivitiesFilterField.type, 'post'), - ), - ); - - await feed.getOrCreate(); - expect(feed.state.activities, hasLength(3)); + build: (client) => client.feedFromQuery( + FeedQuery( + fid: feedId, + activityFilter: Filter.equal(ActivitiesFilterField.type, 'post'), + ), + ), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith(activities: initialActivities), + ), + body: (tester) async { + expect(tester.feedState.activities, hasLength(3)); // Send ActivityUpdatedEvent with type that doesn't match filter - wsStreamController.add( - jsonEncode( - ActivityUpdatedEvent( - type: 'feeds.activity.updated', - createdAt: DateTime.now(), - custom: const {}, - fid: feedId.rawValue, - activity: createDefaultActivityResponse( - id: 'activity-1', - // Doesn't match 'post' filter - ).copyWith(type: 'comment'), - ), + 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'), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - expect(feed.state.activities, hasLength(2)); + expect(tester.feedState.activities, hasLength(2)); }, ); - test( + feedTest( 'ActivityReactionAddedEvent - should remove activity when reaction causes filter mismatch', - () async { - final feed = client.feedFromQuery( - FeedQuery( - fid: feedId, - activityFilter: Filter.equal(ActivitiesFilterField.type, 'post'), - ), - ); - - await feed.getOrCreate(); - expect(feed.state.activities, hasLength(3)); + build: (client) => client.feedFromQuery( + FeedQuery( + fid: feedId, + activityFilter: Filter.equal(ActivitiesFilterField.type, 'post'), + ), + ), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith(activities: initialActivities), + ), + body: (tester) async { + expect(tester.feedState.activities, hasLength(3)); // Send ActivityReactionAddedEvent with activity that doesn't match filter - wsStreamController.add( - jsonEncode( - ActivityReactionAddedEvent( - type: 'feeds.activity.reaction.added', - createdAt: DateTime.now(), - custom: const {}, - fid: feedId.rawValue, - activity: createDefaultActivityResponse( - id: 'activity-1', - // Doesn't match 'post' filter - ).copyWith(type: 'comment'), - reaction: FeedsReactionResponse( - activityId: 'activity-1', - type: 'like', - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - user: createDefaultUserResponse(), - ), + await tester.emitEvent( + ActivityReactionAddedEvent( + type: EventTypes.activityReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse( + id: 'activity-1', + // Doesn't match 'post' filter + ).copyWith(type: 'comment'), + reaction: FeedsReactionResponse( + activityId: 'activity-1', + type: 'like', + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(), ), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - expect(feed.state.activities, hasLength(2)); + expect(tester.feedState.activities, hasLength(2)); }, ); - test( + feedTest( 'CommentAddedEvent - should remove activity when comment causes filter mismatch', - () async { - final feed = client.feedFromQuery( - FeedQuery( - fid: feedId, - activityFilter: Filter.in_( - ActivitiesFilterField.filterTags, - ['important'], - ), + build: (client) => client.feedFromQuery( + FeedQuery( + fid: feedId, + activityFilter: Filter.in_( + ActivitiesFilterField.filterTags, + ['important'], ), - ); - - await feed.getOrCreate(); - expect(feed.state.activities, hasLength(3)); + ), + ), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith(activities: initialActivities), + ), + body: (tester) async { + expect(tester.feedState.activities, hasLength(3)); // Send CommentAddedEvent with activity that doesn't have 'important' tag - wsStreamController.add( - jsonEncode( - CommentAddedEvent( - type: 'feeds.comment.added', - createdAt: DateTime.now(), - custom: const {}, - fid: feedId.rawValue, - activity: createDefaultActivityResponse( - id: 'activity-1', - ).copyWith( - filterTags: ['general'], // Doesn't have 'important' tag - ), - comment: createDefaultCommentResponse( - objectId: 'activity-1', - ), + await tester.emitEvent( + CommentAddedEvent( + type: EventTypes.commentAdded, + 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', ), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - expect(feed.state.activities, hasLength(2)); + expect(tester.feedState.activities, hasLength(2)); }, ); - test( + feedTest( 'ActivityReactionDeletedEvent - should remove activity when reaction deletion causes filter mismatch', - () async { - final feed = client.feedFromQuery( - FeedQuery( - fid: feedId, - activityFilter: Filter.equal(ActivitiesFilterField.type, 'post'), - ), - ); - - await feed.getOrCreate(); - expect(feed.state.activities, hasLength(3)); + build: (client) => client.feedFromQuery( + FeedQuery( + fid: feedId, + activityFilter: Filter.equal(ActivitiesFilterField.type, 'post'), + ), + ), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith(activities: initialActivities), + ), + body: (tester) async { + expect(tester.feedState.activities, hasLength(3)); // Send ActivityReactionDeletedEvent with activity that doesn't match filter - wsStreamController.add( - jsonEncode( - ActivityReactionDeletedEvent( - type: 'feeds.activity.reaction.deleted', - createdAt: DateTime.now(), - 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.now(), - updatedAt: DateTime.now(), - user: createDefaultUserResponse(), - ), + await tester.emitEvent( + ActivityReactionDeletedEvent( + type: EventTypes.activityReactionDeleted, + 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(), ), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - expect(feed.state.activities, hasLength(2)); + expect(tester.feedState.activities, hasLength(2)); }, ); - test( + feedTest( 'ActivityPinnedEvent - should remove activity when pinned activity does not match filter', - () async { - final feed = client.feedFromQuery( - FeedQuery( - fid: feedId, - activityFilter: Filter.equal(ActivitiesFilterField.type, 'post'), - ), - ); - - await feed.getOrCreate(); - expect(feed.state.activities, hasLength(3)); - expect(feed.state.pinnedActivities, hasLength(1)); + build: (client) => client.feedFromQuery( + FeedQuery( + fid: feedId, + activityFilter: Filter.equal(ActivitiesFilterField.type, 'post'), + ), + ), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: initialActivities, + pinnedActivities: initialPinnedActivities, + ), + ), + body: (tester) async { + expect(tester.feedState.activities, hasLength(3)); + expect(tester.feedState.pinnedActivities, hasLength(1)); // Send ActivityPinnedEvent with activity that doesn't match filter - wsStreamController.add( - jsonEncode( - ActivityPinnedEvent( - type: 'feeds.activity.pinned', - createdAt: DateTime.now(), - custom: const {}, - fid: feedId.rawValue, - pinnedActivity: createDefaultPinActivityResponse( - activityId: 'activity-1', - type: 'comment', // Doesn't match 'post' filter - ), + await tester.emitEvent( + ActivityPinnedEvent( + type: EventTypes.activityPinned, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + pinnedActivity: createDefaultPinActivityResponse( + activityId: 'activity-1', + type: 'comment', // Doesn't match 'post' filter ), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - expect(feed.state.activities, hasLength(2)); - expect(feed.state.pinnedActivities, isEmpty); + expect(tester.feedState.activities, hasLength(2)); + expect(tester.feedState.pinnedActivities, isEmpty); }, ); - test( + feedTest( 'ActivityUnpinnedEvent - should remove activity when unpinned activity does not match filter', - () async { - final feed = client.feedFromQuery( - FeedQuery( - fid: feedId, - activityFilter: Filter.equal(ActivitiesFilterField.type, 'post'), - ), - ); - - await feed.getOrCreate(); - expect(feed.state.activities, hasLength(3)); - expect(feed.state.pinnedActivities, hasLength(1)); + build: (client) => client.feedFromQuery( + FeedQuery( + fid: feedId, + activityFilter: Filter.equal(ActivitiesFilterField.type, 'post'), + ), + ), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: initialActivities, + pinnedActivities: initialPinnedActivities, + ), + ), + body: (tester) async { + expect(tester.feedState.activities, hasLength(3)); + expect(tester.feedState.pinnedActivities, hasLength(1)); // Send ActivityUnpinnedEvent with activity that doesn't match filter - wsStreamController.add( - jsonEncode( - ActivityUnpinnedEvent( - type: 'feeds.activity.unpinned', - createdAt: DateTime.now(), - custom: const {}, - fid: feedId.rawValue, - pinnedActivity: createDefaultPinActivityResponse( - activityId: 'activity-1', - type: 'comment', // Doesn't match 'post' filter - ), + await tester.emitEvent( + ActivityUnpinnedEvent( + type: EventTypes.activityUnpinned, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + pinnedActivity: createDefaultPinActivityResponse( + activityId: 'activity-1', + type: 'comment', // Doesn't match 'post' filter ), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - expect(feed.state.activities, hasLength(2)); - expect(feed.state.pinnedActivities, isEmpty); + expect(tester.feedState.activities, hasLength(2)); + expect(tester.feedState.pinnedActivities, isEmpty); }, ); - test( + feedTest( 'BookmarkAddedEvent - should remove activity when bookmark causes filter mismatch', - () async { - final feed = client.feedFromQuery( - FeedQuery( - fid: feedId, - activityFilter: Filter.in_( - ActivitiesFilterField.filterTags, - ['important'], - ), + build: (client) => client.feedFromQuery( + FeedQuery( + fid: feedId, + activityFilter: Filter.in_( + ActivitiesFilterField.filterTags, + ['important'], ), - ); - - await feed.getOrCreate(); - expect(feed.state.activities, hasLength(3)); + ), + ), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: initialActivities, + pinnedActivities: initialPinnedActivities, + ), + ), + body: (tester) async { + expect(tester.feedState.activities, hasLength(3)); // Send BookmarkAddedEvent with activity that doesn't have 'important' tag - wsStreamController.add( - jsonEncode( - BookmarkAddedEvent( - type: 'feeds.bookmark.added', - createdAt: DateTime.now(), - custom: const {}, - bookmark: createDefaultBookmarkResponse( - activityId: 'activity-1', + await tester.emitEvent( + BookmarkAddedEvent( + type: EventTypes.bookmarkAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + activityId: 'activity-1', + ).copyWith( + activity: createDefaultActivityResponse( + id: 'activity-1', ).copyWith( - activity: createDefaultActivityResponse( - id: 'activity-1', - ).copyWith( - feeds: [feedId.rawValue], // Activity belongs to this feed - filterTags: ['general'], // Doesn't have 'important' tag - ), + feeds: [feedId.rawValue], // Activity belongs to this feed + filterTags: ['general'], // Doesn't have 'important' tag ), ), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - expect(feed.state.activities, hasLength(2)); + expect(tester.feedState.activities, hasLength(2)); }, ); - test( + feedTest( 'BookmarkDeletedEvent - should remove activity when bookmark deletion causes filter mismatch', - () async { - final feed = client.feedFromQuery( - FeedQuery( - fid: feedId, - activityFilter: Filter.in_( - ActivitiesFilterField.filterTags, - ['important'], - ), + build: (client) => client.feedFromQuery( + FeedQuery( + fid: feedId, + activityFilter: Filter.in_( + ActivitiesFilterField.filterTags, + ['important'], ), - ); - - await feed.getOrCreate(); - expect(feed.state.activities, hasLength(3)); + ), + ), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: initialActivities, + pinnedActivities: initialPinnedActivities, + ), + ), + body: (tester) async { + expect(tester.feedState.activities, hasLength(3)); // Send BookmarkDeletedEvent with activity that doesn't have 'important' tag - wsStreamController.add( - jsonEncode( - BookmarkDeletedEvent( - type: 'feeds.bookmark.deleted', - createdAt: DateTime.now(), - custom: const {}, - bookmark: createDefaultBookmarkResponse( - activityId: 'activity-2', + await tester.emitEvent( + BookmarkDeletedEvent( + type: EventTypes.bookmarkDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + activityId: 'activity-2', + ).copyWith( + activity: createDefaultActivityResponse( + id: 'activity-2', + feeds: [feedId.rawValue], // Activity belongs to this feed ).copyWith( - activity: createDefaultActivityResponse( - id: 'activity-2', - feeds: [feedId.rawValue], // Activity belongs to this feed - ).copyWith( - filterTags: ['general'], // Doesn't have 'important' tag - ), + filterTags: ['general'], // Doesn't have 'important' tag ), ), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - expect(feed.state.activities, hasLength(2)); + expect(tester.feedState.activities, hasLength(2)); }, ); - test('Complex filter with AND - should filter correctly', () async { - final feed = client.feedFromQuery( + feedTest( + 'Complex filter with AND - should filter correctly', + build: (client) => client.feedFromQuery( FeedQuery( fid: feedId, activityFilter: Filter.and([ @@ -935,17 +762,20 @@ void main() { Filter.in_(ActivitiesFilterField.filterTags, ['featured']), ]), ), - ); - - await feed.getOrCreate(); - expect(feed.state.activities, hasLength(3)); + ), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: initialActivities, + pinnedActivities: initialPinnedActivities, + ), + ), + body: (tester) async { + expect(tester.feedState.activities, hasLength(3)); - // Send ActivityAddedEvent that matches only one condition - wsStreamController.add( - jsonEncode( + await tester.emitEvent( ActivityAddedEvent( - type: 'feeds.activity.added', - createdAt: DateTime.now(), + type: EventTypes.activityAdded, + createdAt: DateTime.timestamp(), custom: const {}, fid: feedId.rawValue, activity: createDefaultActivityResponse( @@ -955,317 +785,254 @@ void main() { filterTags: ['general'], // Doesn't match second condition ), ), - ), - ); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); + ); - expect(feed.state.activities, hasLength(3)); - }); + expect(tester.feedState.activities, hasLength(3)); + }, + ); - test( + feedTest( 'Complex filter with OR - should add activities matching any condition', - () async { - final feed = client.feedFromQuery( - FeedQuery( - fid: feedId, - activityFilter: Filter.or([ - Filter.equal(ActivitiesFilterField.type, 'post'), - Filter.in_(ActivitiesFilterField.filterTags, ['featured']), - ]), - ), - ); - - await feed.getOrCreate(); - expect(feed.state.activities, hasLength(3)); + build: (client) => client.feedFromQuery( + FeedQuery( + fid: feedId, + activityFilter: Filter.or([ + Filter.equal(ActivitiesFilterField.type, '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)); - // Send ActivityAddedEvent that matches only one condition - wsStreamController.add( - jsonEncode( - ActivityAddedEvent( - type: 'feeds.activity.added', - createdAt: DateTime.now(), - custom: const {}, - fid: feedId.rawValue, - activity: createDefaultActivityResponse( - id: 'activity-4', - ).copyWith( - type: 'post', // Matches first condition - filterTags: ['general'], // Doesn't match second condition - ), + 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 ), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Activity should be added because it matches first condition - expect(feed.state.activities, hasLength(4)); + expect(tester.feedState.activities, hasLength(4)); }, ); - test( + feedTest( 'No filter - filtering is disabled when no filter specified', - () async { - final feed = client.feedFromQuery( - const FeedQuery( - fid: feedId, - // No activityFilter - all activities should be accepted - ), - ); - - await feed.getOrCreate(); - + 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(feed.query.activityFilter, isNull); - expect(feed.state.activities, hasLength(3)); + expect(tester.feed.query.activityFilter, isNull); + expect(tester.feedState.activities, hasLength(3)); // Send ActivityAddedEvent that matches only one condition - wsStreamController.add( - jsonEncode( - ActivityAddedEvent( - type: 'feeds.activity.added', - createdAt: DateTime.now(), - custom: const {}, - fid: feedId.rawValue, - activity: createDefaultActivityResponse( - id: 'activity-4', - // Doesn't match 'post' activity type - ).copyWith(type: 'post'), - ), + await tester.emitEvent( + ActivityAddedEvent( + type: EventTypes.activityAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse( + id: 'activity-4', + // Doesn't match 'post' activity type + ).copyWith(type: 'post'), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - expect(feed.state.activities, hasLength(4)); + expect(tester.feedState.activities, hasLength(4)); }, ); }); + // ============================================================ + // FEATURE: Story Events + // ============================================================ + group('Story events', () { - late StreamController wsStreamController; - late MockWebSocketSink webSocketSink; - - setUp(() async { - wsStreamController = StreamController(); - webSocketSink = MockWebSocketSink(); - WsTestConnection( - wsStreamController: wsStreamController, - webSocketSink: webSocketSink, - webSocketChannel: webSocketChannel, - ).setUp(); - - await client.connect(); - }); - - tearDown(() async { - await webSocketSink.close(); - await wsStreamController.close(); - }); - - test('Watch story should update isWatched', () async { - const feedId = FeedId(group: 'stories', id: 'target'); - final activity1 = createDefaultActivityResponse().copyWith( - isWatched: false, - id: 'storyActivityId1', - ); - - final activity2 = createDefaultActivityResponse().copyWith( - isWatched: false, - id: 'storyActivityId2', - ); - - when( - () => feedsApi.getOrCreateFeed( - feedGroupId: feedId.group, - feedId: feedId.id, - getOrCreateFeedRequest: any(named: 'getOrCreateFeedRequest'), - ), - ).thenAnswer( - (_) async => Result.success( - createDefaultGetOrCreateFeedResponse( - aggregatedActivities: [ - createDefaultAggregatedActivityResponse( - activities: [activity1, activity2], - ), - ], - ), - ), - ); - - final feed = client.feedFromId(feedId); - - final result = await feed.getOrCreate(); - result.getOrThrow(); - - expect(feed.state.aggregatedActivities.length, 1); - expect( - feed.state.aggregatedActivities.first.activities.first.isWatched, - false, - ); - expect( - feed.state.aggregatedActivities.first.activities[1].isWatched, - false, - ); - - feed.notifier.stream.listen( - expectAsync1( - (event) { - expect(event, isA()); - expect( - event.aggregatedActivities.first.activities.first.isWatched, - true, - ); - expect( - event.aggregatedActivities.first.activities[1].isWatched, - false, - ); - }, + const feedId = FeedId(group: 'stories', id: 'target'); + final initialStories = [ + createDefaultActivityResponse(id: 'storyActivityId1'), + createDefaultActivityResponse(id: 'storyActivityId2'), + ]; + + feedTest( + '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); - wsStreamController.add( - jsonEncode( + await tester.emitEvent( ActivityMarkEvent( type: EventTypes.activityMarked, - createdAt: DateTime.now(), + createdAt: DateTime.timestamp(), custom: const {}, - fid: feedId.toString(), - markWatched: [activity1.id], + fid: feedId.rawValue, + markWatched: const ['storyActivityId1'], ), - ), - ); - }); + ); - test('Pagination should load more aggregated activities', () async { - const feedId = FeedId(group: 'stories', id: 'target'); - const nextPagination = 'next'; - const prevPagination = 'prev'; + final updatedAllUserStories = tester.feedState.aggregatedActivities; + expect(updatedAllUserStories, hasLength(1)); - final activity1 = createDefaultActivityResponse(id: 'storyActivityId1'); - final activity2 = createDefaultActivityResponse(id: 'storyActivityId2'); - final activity3 = createDefaultActivityResponse(id: 'storyActivityId3'); + final updatedFirstUserStories = updatedAllUserStories.first.activities; + expect(updatedFirstUserStories, hasLength(2)); + expect(updatedFirstUserStories[0].isWatched ?? false, isTrue); + expect(updatedFirstUserStories[1].isWatched ?? false, isFalse); + }, + ); - when( - () => feedsApi.getOrCreateFeed( - feedGroupId: feedId.group, - feedId: feedId.id, - getOrCreateFeedRequest: any(named: 'getOrCreateFeedRequest'), + feedTest( + 'Pagination should load more aggregated activities', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + next: 'nextPageToken', + aggregatedActivities: [ + createDefaultAggregatedActivityResponse( + group: 'group1', + activities: initialStories, + ), + ], ), - ).thenAnswer( - (invocation) async { - final request = - invocation.namedArguments[const Symbol('getOrCreateFeedRequest')] - as GetOrCreateFeedRequest; - - if (request.next == null) { - return Result.success( - createDefaultGetOrCreateFeedResponse( - nextPagination: nextPagination, - aggregatedActivities: [ - createDefaultAggregatedActivityResponse( - group: 'group1', - activities: [activity1, activity2], - ), - ], - ), - ); - } - if (request.next == nextPagination) { - return Result.success( - createDefaultGetOrCreateFeedResponse( - prevPagination: prevPagination, - aggregatedActivities: [ - createDefaultAggregatedActivityResponse( - group: 'group2', - activities: [activity3], - ), - ], - ), - ); - } - throw Exception('Unexpected request'); - }, - ); - - final feed = client.feedFromId(feedId); - - final result = await feed.getOrCreate(); - result.getOrThrow(); - - expect(feed.state.aggregatedActivities.length, 1); - expect(feed.state.aggregatedActivities.first.activities.length, 2); - - await feed.queryMoreActivities(); - - expect(feed.state.aggregatedActivities.length, 2); - expect(feed.state.aggregatedActivities.last.activities.length, 1); - }); + ), + body: (tester) async { + final userStories = tester.feedState.aggregatedActivities; + expect(userStories, hasLength(1)); - test('StoriesFeedUpdatedEvent should update aggregated activities', - () async { - const feedId = FeedId(group: 'stories', id: 'target'); + final firstUserStories = userStories.first.activities; + expect(firstUserStories, hasLength(2)); - final activity1 = createDefaultActivityResponse(id: 'storyActivityId1'); - final activity2 = createDefaultActivityResponse(id: 'storyActivityId2'); + final nextPageQuery = tester.feed.query.copyWith( + activityNext: tester.feedState.activitiesPagination?.next, + ); - when( - () => feedsApi.getOrCreateFeed( - feedGroupId: feedId.group, - feedId: feedId.id, - getOrCreateFeedRequest: any(named: 'getOrCreateFeedRequest'), - ), - ).thenAnswer( - (_) async => Result.success( - createDefaultGetOrCreateFeedResponse( + tester.mockApi( + (api) => api.getOrCreateFeed( + feedId: feedId.id, + feedGroupId: feedId.group, + getOrCreateFeedRequest: nextPageQuery.toRequest(), + ), + result: createDefaultGetOrCreateFeedResponse( + prevPagination: 'prevPageToken', aggregatedActivities: [ createDefaultAggregatedActivityResponse( - group: 'group1', - activities: [activity1], + group: 'group2', + activities: [ + createDefaultActivityResponse(id: 'storyActivityId3'), + ], ), ], ), - ), - ); + ); - final feed = client.feedFromId(feedId); + // Fetch more activities + await tester.feed.queryMoreActivities(); - final result = await feed.getOrCreate(); - result.getOrThrow(); - expect(feed.state.aggregatedActivities.length, 1); - expect(feed.state.aggregatedActivities.first.activities.length, 1); + final updatedUserStories = tester.feedState.aggregatedActivities; + expect(updatedUserStories, hasLength(2)); - feed.notifier.stream.listen( - expectAsync1( - (event) { - expect(event, isA()); + final lastUserStories = updatedUserStories.last.activities; + expect(lastUserStories, hasLength(1)); + }, + verify: (tester) { + final nextPageQuery = tester.feed.query.copyWith( + activityNext: tester.feedState.activitiesPagination?.next, + ); + + tester.verifyApi( + (api) => api.getOrCreateFeed( + feedId: feedId.id, + feedGroupId: feedId.group, + getOrCreateFeedRequest: nextPageQuery.toRequest(), + ), + ); + }, + ); - expect(event.aggregatedActivities.length, 1); - expect(event.aggregatedActivities.first.activities.length, 2); - }, + feedTest( + 'StoriesFeedUpdatedEvent should update aggregated activities', + 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)); - wsStreamController.add( - jsonEncode( + await tester.emitEvent( StoriesFeedUpdatedEvent( type: EventTypes.storiesFeedUpdated, - createdAt: DateTime.now(), + createdAt: DateTime.timestamp(), custom: const {}, - fid: feedId.toString(), + fid: feedId.rawValue, aggregatedActivities: [ createDefaultAggregatedActivityResponse( group: 'group1', - activities: [activity1, activity2], + activities: [ + ...initialStories, + createDefaultActivityResponse(id: 'storyActivityId3'), + ], ), ], ), - ), - ); - }); + ); + + final updatedUserStories = tester.feedState.aggregatedActivities; + expect(updatedUserStories, hasLength(1)); + + final updatedFirstUserStories = updatedUserStories.first.activities; + expect(updatedFirstUserStories, hasLength(3)); + }, + ); }); } diff --git a/packages/stream_feeds/test/state/follow_list_test.dart b/packages/stream_feeds/test/state/follow_list_test.dart index 2b9a58b..5ff17d3 100644 --- a/packages/stream_feeds/test/state/follow_list_test.dart +++ b/packages/stream_feeds/test/state/follow_list_test.dart @@ -1,139 +1,81 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:mocktail/mocktail.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart'; import '../test_utils.dart'; void main() { - late StreamFeedsClient client; - late MockDefaultApi feedsApi; - late MockWebSocketChannel webSocketChannel; - - setUp(() { - feedsApi = MockDefaultApi(); - webSocketChannel = MockWebSocketChannel(); - - client = StreamFeedsClient( - apiKey: 'apiKey', - user: const User(id: 'luke_skywalker'), - tokenProvider: TokenProvider.static(UserToken(testToken)), - feedsRestApi: feedsApi, - wsProvider: (options) => webSocketChannel, - ); - }); - - tearDown(() { - client.disconnect(); - }); + // ============================================================ + // FEATURE: Local Filtering + // ============================================================ group('FollowListEventHandler - Local filtering', () { - late StreamController wsStreamController; - late MockWebSocketSink webSocketSink; - - setUp(() async { - wsStreamController = StreamController(); - webSocketSink = MockWebSocketSink(); - WsTestConnection( - wsStreamController: wsStreamController, - webSocketSink: webSocketSink, - webSocketChannel: webSocketChannel, - ).setUp(); - - await client.connect(); - - when( - () => feedsApi.queryFollows( - queryFollowsRequest: any(named: 'queryFollowsRequest'), - ), - ).thenAnswer( - (_) async => Result.success( - QueryFollowsResponse( - duration: DateTime.now().toIso8601String(), - follows: [ - createDefaultFollowResponse(id: 'follow-1'), - createDefaultFollowResponse(id: 'follow-2'), - createDefaultFollowResponse(id: 'follow-3'), - ], - ), - ), - ); - }); - - tearDown(() async { - await webSocketSink.close(); - await wsStreamController.close(); - }); + final initialFollows = [ + createDefaultFollowResponse(id: 'follow-1'), + createDefaultFollowResponse(id: 'follow-2'), + createDefaultFollowResponse(id: 'follow-3'), + ]; - test( + followListTest( 'FollowUpdatedEvent - should remove follow when updated to non-matching status', - () async { - final followList = client.followList( - FollowsQuery( - filter: Filter.equal( - FollowsFilterField.status, - FollowStatus.accepted, - ), + build: (client) => client.followList( + FollowsQuery( + filter: Filter.equal( + FollowsFilterField.status, + FollowStatus.accepted, ), - ); - - await followList.get(); - expect(followList.state.follows, hasLength(3)); - - wsStreamController.add( - jsonEncode( - FollowUpdatedEvent( - type: 'feeds.follow.updated', - createdAt: DateTime.now(), - custom: const {}, - fid: 'user:follow-1', - follow: createDefaultFollowResponse( - id: 'follow-1', - ).copyWith( - status: FollowResponseStatus.rejected, - ), + ), + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(follows: initialFollows), + ), + body: (tester) async { + expect(tester.followListState.follows, hasLength(3)); + + await tester.emitEvent( + FollowUpdatedEvent( + type: EventTypes.followUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:follow-1', + follow: createDefaultFollowResponse( + id: 'follow-1', + ).copyWith( + status: FollowResponseStatus.rejected, ), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - expect(followList.state.follows, hasLength(2)); + expect(tester.followListState.follows, hasLength(2)); }, ); - test( + followListTest( 'No filter - should not remove follow when no filter specified', - () async { - final followList = client.followList( - // No filter specified, should accept all follows - const FollowsQuery(), - ); - - await followList.get(); - expect(followList.state.follows, hasLength(3)); - - wsStreamController.add( - jsonEncode( - FollowUpdatedEvent( - type: 'feeds.follow.updated', - createdAt: DateTime.now(), - custom: const {}, - fid: 'user:follow-1', - follow: createDefaultFollowResponse( - id: 'follow-1', - ).copyWith( - status: FollowResponseStatus.rejected, - ), + build: (client) => client.followList( + // No filter specified, should accept all follows + const FollowsQuery(), + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(follows: initialFollows), + ), + body: (tester) async { + expect(tester.followListState.follows, hasLength(3)); + + await tester.emitEvent( + FollowUpdatedEvent( + type: EventTypes.followUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:follow-1', + follow: createDefaultFollowResponse( + id: 'follow-1', + ).copyWith( + status: FollowResponseStatus.rejected, ), ), ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); - expect(followList.state.follows, hasLength(3)); + expect(tester.followListState.follows, hasLength(3)); }, ); }); diff --git a/packages/stream_feeds/test/state/poll_list_test.dart b/packages/stream_feeds/test/state/poll_list_test.dart index 5d802bb..dc14edf 100644 --- a/packages/stream_feeds/test/state/poll_list_test.dart +++ b/packages/stream_feeds/test/state/poll_list_test.dart @@ -1,132 +1,76 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:mocktail/mocktail.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart'; import '../test_utils.dart'; void main() { - late StreamFeedsClient client; - late MockDefaultApi feedsApi; - late MockWebSocketChannel webSocketChannel; - - setUp(() { - feedsApi = MockDefaultApi(); - webSocketChannel = MockWebSocketChannel(); - - client = StreamFeedsClient( - apiKey: 'apiKey', - user: const User(id: 'luke_skywalker'), - tokenProvider: TokenProvider.static(UserToken(testToken)), - feedsRestApi: feedsApi, - wsProvider: (options) => webSocketChannel, - ); - }); - - tearDown(() { - client.disconnect(); - }); + // ============================================================ + // FEATURE: Local Filtering + // ============================================================ group('PollListEventHandler - Local filtering', () { - late StreamController wsStreamController; - late MockWebSocketSink webSocketSink; - - setUp(() async { - wsStreamController = StreamController(); - webSocketSink = MockWebSocketSink(); - WsTestConnection( - wsStreamController: wsStreamController, - webSocketSink: webSocketSink, - webSocketChannel: webSocketChannel, - ).setUp(); - - await client.connect(); - - when( - () => feedsApi.queryPolls( - queryPollsRequest: any(named: 'queryPollsRequest'), - ), - ).thenAnswer( - (_) async => Result.success( - QueryPollsResponse( - duration: DateTime.now().toIso8601String(), - polls: [ - createDefaultPollResponseData(id: 'poll-1'), - createDefaultPollResponseData(id: 'poll-2'), - createDefaultPollResponseData(id: 'poll-3'), - ], - ), - ), - ); - }); + final initialPolls = [ + createDefaultPollResponse(id: 'poll-1'), + createDefaultPollResponse(id: 'poll-2'), + createDefaultPollResponse(id: 'poll-3'), + ]; - tearDown(() async { - await webSocketSink.close(); - await wsStreamController.close(); - }); - - test( + pollListTest( 'PollUpdatedFeedEvent - should remove poll when updated to non-matching status', - () async { - final pollList = client.pollList( - PollsQuery( - filter: Filter.equal(PollsFilterField.isClosed, false), - ), - ); - - await pollList.get(); - expect(pollList.state.polls, hasLength(3)); + build: (client) => client.pollList( + PollsQuery( + filter: Filter.equal(PollsFilterField.isClosed, false), + ), + ), + 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) - wsStreamController.add( - jsonEncode( - PollUpdatedFeedEvent( - type: 'feeds.poll.updated', - createdAt: DateTime.now(), - custom: const {}, - fid: 'fid', - poll: createDefaultPollResponseData( - id: 'poll-1', - ).copyWith(isClosed: true), - ), + await tester.emitEvent( + PollUpdatedFeedEvent( + type: EventTypes.pollUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + poll: createDefaultPollResponse( + id: 'poll-1', + ).copyWith(isClosed: true), ), ); - await Future.delayed(Duration.zero); - expect(pollList.state.polls, hasLength(2)); + expect(tester.pollListState.polls, hasLength(2)); }, ); - test( + pollListTest( 'No filter - should not remove poll when no filter specified', - () async { - final pollList = client.pollList( - // No filter specified, should accept all polls - const PollsQuery(), - ); - - await pollList.get(); - expect(pollList.state.polls, hasLength(3)); + build: (client) => client.pollList( + // No filter specified, should accept all polls + const PollsQuery(), + ), + 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) - wsStreamController.add( - jsonEncode( - PollUpdatedFeedEvent( - type: 'feeds.poll.updated', - createdAt: DateTime.now(), - custom: const {}, - fid: 'fid', - poll: createDefaultPollResponseData( - id: 'poll-1', - ).copyWith(isClosed: true), - ), + await tester.emitEvent( + PollUpdatedFeedEvent( + type: EventTypes.pollUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + poll: createDefaultPollResponse( + id: 'poll-1', + ).copyWith(isClosed: true), ), ); - await Future.delayed(Duration.zero); - expect(pollList.state.polls, hasLength(3)); + expect(tester.pollListState.polls, hasLength(3)); }, ); }); 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 df2fea3..68f1fd7 100644 --- a/packages/stream_feeds/test/state/poll_vote_list_test.dart +++ b/packages/stream_feeds/test/state/poll_vote_list_test.dart @@ -1,140 +1,84 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:mocktail/mocktail.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart'; import '../test_utils.dart'; void main() { - late StreamFeedsClient client; - late MockDefaultApi feedsApi; - late MockWebSocketChannel webSocketChannel; - - setUp(() { - feedsApi = MockDefaultApi(); - webSocketChannel = MockWebSocketChannel(); - - client = StreamFeedsClient( - apiKey: 'apiKey', - user: const User(id: 'luke_skywalker'), - tokenProvider: TokenProvider.static(UserToken(testToken)), - feedsRestApi: feedsApi, - wsProvider: (options) => webSocketChannel, - ); - }); - - tearDown(() { - client.disconnect(); - }); + // ============================================================ + // FEATURE: Local Filtering + // ============================================================ group('PollVoteListEventHandler - Local filtering', () { - late StreamController wsStreamController; - late MockWebSocketSink webSocketSink; const pollId = 'test-poll-id'; - setUp(() async { - wsStreamController = StreamController(); - webSocketSink = MockWebSocketSink(); - WsTestConnection( - wsStreamController: wsStreamController, - webSocketSink: webSocketSink, - webSocketChannel: webSocketChannel, - ).setUp(); - - await client.connect(); + final initialVotes = [ + createDefaultPollVoteResponse(id: 'vote-1', pollId: pollId), + createDefaultPollVoteResponse(id: 'vote-2', pollId: pollId), + createDefaultPollVoteResponse(id: 'vote-3', pollId: pollId), + ]; - when( - () => feedsApi.queryPollVotes( + pollVoteListTest( + 'PollVoteChangedFeedEvent - should remove vote when changed to non-matching option', + build: (client) => client.pollVoteList( + PollVotesQuery( pollId: pollId, - queryPollVotesRequest: any(named: 'queryPollVotesRequest'), + filter: Filter.equal(PollVotesFilterField.optionId, 'option-1'), ), - ).thenAnswer( - (_) async => Result.success( - PollVotesResponse( - duration: DateTime.now().toIso8601String(), - votes: [ - createDefaultPollVoteResponse(id: 'vote-1', pollId: pollId), - createDefaultPollVoteResponse(id: 'vote-2', pollId: pollId), - createDefaultPollVoteResponse(id: 'vote-3', pollId: pollId), - ], - ), - ), - ); - }); - - tearDown(() async { - await webSocketSink.close(); - await wsStreamController.close(); - }); - - test( - 'PollVoteChangedFeedEvent - should remove vote when changed to non-matching option', - () async { - final voteList = client.pollVoteList( - PollVotesQuery( - pollId: pollId, - filter: Filter.equal(PollVotesFilterField.optionId, 'option-1'), - ), - ); - - await voteList.get(); - expect(voteList.state.votes, hasLength(3)); + ), + setUp: (tester) => tester.get( + modifyResponse: (it) => it.copyWith(votes: initialVotes), + ), + body: (tester) async { + expect(tester.pollVoteListState.votes, hasLength(3)); // Send event with vote that changed to non-matching optionId - wsStreamController.add( - jsonEncode( - PollVoteChangedFeedEvent( - type: 'feeds.poll.vote_changed', - createdAt: DateTime.now(), - custom: const {}, - fid: 'fid', - poll: createDefaultPollResponseData(id: pollId), - pollVote: createDefaultPollVoteResponse( - id: 'vote-1', - pollId: pollId, - ).copyWith(optionId: 'option-2'), - ), + await tester.emitEvent( + PollVoteChangedFeedEvent( + type: EventTypes.pollVoteChanged, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'fid', + poll: createDefaultPollResponse(id: pollId), + pollVote: createDefaultPollVoteResponse( + id: 'vote-1', + pollId: pollId, + ).copyWith(optionId: 'option-2'), ), ); - await Future.delayed(Duration.zero); - expect(voteList.state.votes, hasLength(2)); + expect(tester.pollVoteListState.votes, hasLength(2)); }, ); - test( + pollVoteListTest( 'No filter - should not remove vote when no filter specified', - () async { - final voteList = client.pollVoteList( - const PollVotesQuery( - pollId: pollId, - // No filter specified, should accept all votes - ), - ); - - await voteList.get(); - expect(voteList.state.votes, hasLength(3)); - - wsStreamController.add( - jsonEncode( - PollVoteChangedFeedEvent( - type: 'feeds.poll.vote_changed', - createdAt: DateTime.now(), - custom: const {}, - fid: 'fid', - poll: createDefaultPollResponseData(id: pollId), - pollVote: createDefaultPollVoteResponse( - id: 'vote-1', - pollId: pollId, - ).copyWith(optionId: 'option-2'), - ), + build: (client) => client.pollVoteList( + const PollVotesQuery( + pollId: pollId, + // No filter specified, should accept all votes + ), + ), + 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), + pollVote: createDefaultPollVoteResponse( + id: 'vote-1', + pollId: pollId, + ).copyWith(optionId: 'option-2'), ), ); - await Future.delayed(Duration.zero); - expect(voteList.state.votes, hasLength(3)); + expect(tester.pollVoteListState.votes, hasLength(3)); }, ); }); diff --git a/packages/stream_feeds/test/test_utils.dart b/packages/stream_feeds/test/test_utils.dart index b8a8960..c3c35ef 100644 --- a/packages/stream_feeds/test/test_utils.dart +++ b/packages/stream_feeds/test/test_utils.dart @@ -3,5 +3,12 @@ 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/bookmark_folder_list_tester.dart'; +export 'test_utils/testers/bookmark_list_tester.dart'; +export 'test_utils/testers/comment_list_tester.dart'; +export 'test_utils/testers/feed_list_tester.dart'; export 'test_utils/testers/feed_tester.dart'; -export 'test_utils/ws_test_helpers.dart'; +export 'test_utils/testers/follow_list_tester.dart'; +export 'test_utils/testers/poll_list_tester.dart'; +export 'test_utils/testers/poll_vote_list_tester.dart'; +export 'test_utils/web_socket_mocks.dart'; diff --git a/packages/stream_feeds/test/test_utils/api_mocker_mixin.dart b/packages/stream_feeds/test/test_utils/api_mocker_mixin.dart new file mode 100644 index 0000000..f14b920 --- /dev/null +++ b/packages/stream_feeds/test/test_utils/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/event_types.dart b/packages/stream_feeds/test/test_utils/event_types.dart index 32a411e..9f487a0 100644 --- a/packages/stream_feeds/test/test_utils/event_types.dart +++ b/packages/stream_feeds/test/test_utils/event_types.dart @@ -1,15 +1,44 @@ class EventTypes { - static const String activityMarked = 'feeds.activity.marked'; - static const String activityFeedback = 'feeds.activity.feedback'; + // Activity events + static const activityAdded = 'feeds.activity.added'; + static const activityMarked = 'feeds.activity.marked'; + static const activityUpdated = 'feeds.activity.updated'; + static const activityPinned = 'feeds.activity.pinned'; + static const activityUnpinned = 'feeds.activity.unpinned'; + static const activityFeedback = 'feeds.activity.feedback'; - static const String followCreated = 'feeds.follow.created'; - static const String followDeleted = 'feeds.follow.deleted'; - static const String followUpdated = 'feeds.follow.updated'; + // Reaction events + static const activityReactionAdded = 'feeds.activity.reaction.added'; + static const activityReactionDeleted = 'feeds.activity.reaction.deleted'; - static const String pollClosed = 'feeds.poll.closed'; - static const String pollDeleted = 'feeds.poll.deleted'; - static const String pollVoteCasted = 'feeds.poll.vote_casted'; - static const String pollVoteRemoved = 'feeds.poll.vote_removed'; + // Bookmark events + static const bookmarkAdded = 'feeds.bookmark.added'; + static const bookmarkDeleted = 'feeds.bookmark.deleted'; + static const bookmarkUpdated = 'feeds.bookmark.updated'; - static const String storiesFeedUpdated = 'feeds.stories_feed.updated'; + // Bookmark folder events + static const bookmarkFolderUpdated = 'feeds.bookmark_folder.updated'; + + // Comment events + static const commentAdded = 'feeds.comment.added'; + static const commentUpdated = 'feeds.comment.updated'; + + // Feed events + static const feedUpdated = 'feeds.feed.updated'; + + // Follow events + static const followCreated = 'feeds.follow.created'; + static const followDeleted = 'feeds.follow.deleted'; + static const followUpdated = 'feeds.follow.updated'; + + // Poll events + static const pollClosed = 'feeds.poll.closed'; + static const pollDeleted = 'feeds.poll.deleted'; + static const pollUpdated = 'feeds.poll.updated'; + static const pollVoteCasted = 'feeds.poll.vote_casted'; + static const pollVoteChanged = 'feeds.poll.vote_changed'; + static const pollVoteRemoved = 'feeds.poll.vote_removed'; + + // Stories events + static const storiesFeedUpdated = 'feeds.stories_feed.updated'; } diff --git a/packages/stream_feeds/test/test_utils/fakes.dart b/packages/stream_feeds/test/test_utils/fakes.dart index 1e95a74..a7045f0 100644 --- a/packages/stream_feeds/test/test_utils/fakes.dart +++ b/packages/stream_feeds/test/test_utils/fakes.dart @@ -56,6 +56,7 @@ ActivityResponse createDefaultActivityResponse({ List feeds = const [], PollResponseData? poll, bool hidden = false, + bool? isWatched, }) { return ActivityResponse( id: id, @@ -88,6 +89,7 @@ ActivityResponse createDefaultActivityResponse({ shareCount: 0, text: null, type: type, + isWatched: isWatched, updatedAt: DateTime(2021, 2, 1), user: createDefaultUserResponse(), visibility: ActivityResponseVisibility.public, @@ -95,11 +97,17 @@ ActivityResponse createDefaultActivityResponse({ ); } -PollResponseData createDefaultPollResponseData({ +PollResponseData createDefaultPollResponse({ String id = 'poll-id', + List? options, List latestAnswers = const [], Map> latestVotesByOption = const {}, }) { + options ??= [ + createDefaultPollOptionResponse(id: 'option-1', text: 'Option 1'), + createDefaultPollOptionResponse(id: 'option-2', text: 'Option 2'), + ]; + return PollResponseData( id: id, name: 'name', @@ -122,18 +130,18 @@ PollResponseData createDefaultPollResponseData({ (k, e) => MapEntry(k, e.length), ), votingVisibility: 'visibility', - options: const [ - PollOptionResponseData( - id: 'id1', - text: 'text1', - custom: {}, - ), - PollOptionResponseData( - id: 'id2', - text: 'text2', - custom: {}, - ), - ], + options: options, + ); +} + +PollOptionResponseData createDefaultPollOptionResponse({ + String id = 'option-id', + String text = 'Option Text', +}) { + return PollOptionResponseData( + id: id, + text: text, + custom: const {}, ); } @@ -290,13 +298,30 @@ BookmarkFolderResponse createDefaultBookmarkFolderResponse({ PollVoteResponseData createDefaultPollVoteResponse({ String id = 'vote-id', String pollId = 'poll-id', - String optionId = 'option-1', + String optionId = 'option-id', }) { return PollVoteResponseData( createdAt: DateTime(2021, 1, 1), id: id, optionId: optionId, pollId: pollId, + isAnswer: false, + updatedAt: DateTime(2021, 2, 1), + ); +} + +PollVoteResponseData createDefaultPollAnswerResponse({ + String id = 'answer-id', + String pollId = 'poll-id', + String answerText = 'My Answer', +}) { + return PollVoteResponseData( + createdAt: DateTime(2021, 1, 1), + id: id, + optionId: '', + pollId: pollId, + isAnswer: true, + answerText: answerText, updatedAt: DateTime(2021, 2, 1), ); } diff --git a/packages/stream_feeds/test/test_utils/mocks.dart b/packages/stream_feeds/test/test_utils/mocks.dart index 02689cb..f43ddf4 100644 --- a/packages/stream_feeds/test/test_utils/mocks.dart +++ b/packages/stream_feeds/test/test_utils/mocks.dart @@ -1,38 +1,26 @@ +import 'dart:convert'; + import 'package:mocktail/mocktail.dart'; -import 'package:stream_feeds/src/client/feeds_client_impl.dart'; -import 'package:stream_feeds/src/repository/feeds_repository.dart'; -import 'package:stream_feeds/stream_feeds.dart'; import 'package:stream_feeds/stream_feeds.dart' as api; import 'package:web_socket_channel/web_socket_channel.dart'; -class MockFeedsRepository extends Mock implements FeedsRepository {} - -class MockFeedsClient extends Mock implements StreamFeedsClient {} - -class MockWebSocketClient extends Mock implements StreamWebSocketClient {} - class MockDefaultApi extends Mock implements api.DefaultApi {} -class MockWebSocketChannel extends Mock implements WebSocketChannel {} - class MockWebSocketSink extends Mock implements WebSocketSink {} -class FakeFeedsClient extends Fake implements StreamFeedsClientImpl { - FakeFeedsClient({ - User? user, - }) : user = user ?? fakeUser; +class MockWebSocketChannel extends Mock implements WebSocketChannel {} - @override - final User user; +api.UserToken generateTestUserToken(String userId) { + String b64UrlNoPad(Object jsonObj) { + final bytes = utf8.encode(jsonEncode(jsonObj)); + return base64Url.encode(bytes).replaceAll('=', ''); + } - @override - final EventEmitter events = MutableEventEmitter(); -} + final header = {'alg': 'none', 'typ': 'JWT'}; + final payload = {'user_id': userId}; -const fakeUser = User( - id: 'user_id', - name: 'user_name', -); + final jwt = '${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'; + // trailing dot = empty signature (valid for alg=none) -const testToken = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoibHVrZV9za3l3YWxrZXIifQ.hZ59SWtp_zLKVV9ShkqkTsCGi_jdPHly7XNCf5T_Ev0'; + return api.UserToken(jwt); +} 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 index 443be10..4840367 100644 --- a/packages/stream_feeds/test/test_utils/testers/activity_list_tester.dart +++ b/packages/stream_feeds/test/test_utils/testers/activity_list_tester.dart @@ -83,6 +83,12 @@ final class ActivityListTester extends BaseTester { /// The activity list being tested. ActivityList get activityList => subject; + /// Current state of the activity list. + ActivityListState get activityListState => activityList.state; + + /// Stream of activity list state updates. + Stream get activityListStateStream => activityList.stream; + /// Gets the activity list by fetching it from the API. /// /// Call this in event tests to set up initial state before emitting events. diff --git a/packages/stream_feeds/test/test_utils/testers/activity_tester.dart b/packages/stream_feeds/test/test_utils/testers/activity_tester.dart index 094a823..3f38b38 100644 --- a/packages/stream_feeds/test/test_utils/testers/activity_tester.dart +++ b/packages/stream_feeds/test/test_utils/testers/activity_tester.dart @@ -118,6 +118,12 @@ final class ActivityTester extends BaseTester { /// The activity being tested. Activity get activity => subject; + /// Current state of the activity. + ActivityState get activityState => activity.state; + + /// Stream of activity state updates. + Stream get activityStateStream => activity.stream; + /// Gets the activity by fetching it from the API. /// /// Call this in event tests to set up initial state before emitting events. diff --git a/packages/stream_feeds/test/test_utils/testers/base_tester.dart b/packages/stream_feeds/test/test_utils/testers/base_tester.dart index ed2974b..c9f6f82 100644 --- a/packages/stream_feeds/test/test_utils/testers/base_tester.dart +++ b/packages/stream_feeds/test/test_utils/testers/base_tester.dart @@ -5,9 +5,9 @@ import 'package:meta/meta.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart' as test; +import '../api_mocker_mixin.dart'; import '../mocks.dart'; -import '../ws_test_helpers.dart'; -import 'api_mocker_mixin.dart'; +import '../web_socket_mocks.dart'; /// Factory function signature for creating tester instances. /// @@ -61,51 +61,6 @@ abstract base class BaseTester with ApiMockerMixin { 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. @@ -126,22 +81,12 @@ Future createTester>({ required MockWebSocketChannel webSocketChannel, required T Function(StreamController) create, }) async { - // Create WebSocket components + // Create WebSocket stream controller final wsStreamController = StreamController(); - final webSocketSink = MockWebSocketSink(); - - // Register automatic cleanup - test.addTearDown(() async { - await webSocketSink.close(); - await wsStreamController.close(); - }); + test.addTearDown(wsStreamController.close); // Close controller after test - // Setup WebSocket connection - WsTestConnection( - wsStreamController: wsStreamController, - webSocketSink: webSocketSink, - webSocketChannel: webSocketChannel, - ).setUp(); + // Set up WebSocket channel mocks + whenListenWebSocket(webSocketChannel, wsStreamController); // Connect client await client.connect(); @@ -186,7 +131,7 @@ void testWithTester>( () async { await _runZonedGuarded(() async { const user = User(id: 'luke_skywalker'); - final userToken = UserToken(testToken); + final userToken = generateTestUserToken(user.id); final feedsApi = MockDefaultApi(); final webSocketChannel = MockWebSocketChannel(); diff --git a/packages/stream_feeds/test/test_utils/testers/bookmark_folder_list_tester.dart b/packages/stream_feeds/test/test_utils/testers/bookmark_folder_list_tester.dart new file mode 100644 index 0000000..da3de8c --- /dev/null +++ b/packages/stream_feeds/test/test_utils/testers/bookmark_folder_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 '../fakes.dart'; +import '../mocks.dart'; +import 'base_tester.dart'; + +/// Test helper for bookmark folder list operations. +/// +/// Automatically sets up WebSocket connection, client, and test infrastructure. +/// Tests are tagged with 'bookmark-folder-list' by default for filtering. +/// +/// [build] constructs the [BookmarkFolderList] 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 [BookmarkFolderListTester] 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 ['bookmark-folder-list']. +/// [timeout] is optional, custom timeout for this test. +/// +/// Example: +/// ```dart +/// bookmarkFolderListTest( +/// 'removes folder when updated to non-matching name', +/// build: (client) => client.bookmarkFolderList(BookmarkFoldersQuery()), +/// setUp: (tester) => tester.get(), +/// body: (tester) async { +/// expect(tester.bookmarkFolderListState.bookmarkFolders, hasLength(3)); +/// +/// await tester.emitEvent(BookmarkFolderUpdatedEvent(...)); +/// +/// expect(tester.bookmarkFolderListState.bookmarkFolders, hasLength(2)); +/// }, +/// ); +/// ``` +@isTest +void bookmarkFolderListTest( + String description, { + required BookmarkFolderList Function(StreamFeedsClient client) build, + FutureOr Function(BookmarkFolderListTester tester)? setUp, + required FutureOr Function(BookmarkFolderListTester tester) body, + FutureOr Function(BookmarkFolderListTester tester)? verify, + FutureOr Function(BookmarkFolderListTester tester)? tearDown, + bool skip = false, + Iterable tags = const ['bookmark-folder-list'], + test.Timeout? timeout, +}) { + return testWithTester( + description, + build: build, + createTesterFn: _createBookmarkFolderListTester, + setUp: setUp, + body: body, + verify: verify, + tearDown: tearDown, + skip: skip, + tags: tags, + timeout: timeout, + ); +} + +/// A test utility for bookmark folder list operations with WebSocket support. +/// +/// Provides helper methods for emitting events and verifying bookmark folder list state. +/// +/// Resources are automatically cleaned up after the test completes. +final class BookmarkFolderListTester extends BaseTester { + const BookmarkFolderListTester._({ + required BookmarkFolderList bookmarkFolderList, + required super.wsStreamController, + required super.feedsApi, + }) : super(subject: bookmarkFolderList); + + /// The bookmark folder list being tested. + BookmarkFolderList get bookmarkFolderList => subject; + + /// Current state of the bookmark folder list. + BookmarkFolderListState get bookmarkFolderListState { + return bookmarkFolderList.state; + } + + /// Stream of bookmark folder list state updates. + Stream get bookmarkFolderListStateStream { + return bookmarkFolderList.stream; + } + + /// Gets the bookmark folder 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 bookmark folder list response + Future>> get({ + QueryBookmarkFoldersResponse Function( + QueryBookmarkFoldersResponse, + )? modifyResponse, + }) { + final query = bookmarkFolderList.query; + + final defaultBookmarkFolderListResponse = QueryBookmarkFoldersResponse( + duration: DateTime.now().toIso8601String(), + bookmarkFolders: [ + createDefaultBookmarkFolderResponse(id: 'folder-1'), + createDefaultBookmarkFolderResponse(id: 'folder-2'), + createDefaultBookmarkFolderResponse(id: 'folder-3'), + ], + ); + + mockApi( + (api) => api.queryBookmarkFolders( + queryBookmarkFoldersRequest: query.toRequest(), + ), + result: switch (modifyResponse) { + final modifier? => modifier(defaultBookmarkFolderListResponse), + _ => defaultBookmarkFolderListResponse, + }, + ); + + return bookmarkFolderList.get(); + } +} + +// Creates a BookmarkFolderListTester for testing bookmark folder list operations. +// +// Automatically sets up WebSocket connection and registers cleanup handlers. +// This function is for internal use by bookmarkFolderListTest only. +Future _createBookmarkFolderListTester({ + required BookmarkFolderList subject, + required StreamFeedsClient client, + required MockDefaultApi feedsApi, + required MockWebSocketChannel webSocketChannel, +}) { + // Dispose bookmark folder list after test + test.addTearDown(subject.dispose); + + return createTester( + client: client, + webSocketChannel: webSocketChannel, + create: (wsStreamController) => BookmarkFolderListTester._( + bookmarkFolderList: subject, + wsStreamController: wsStreamController, + feedsApi: feedsApi, + ), + ); +} diff --git a/packages/stream_feeds/test/test_utils/testers/bookmark_list_tester.dart b/packages/stream_feeds/test/test_utils/testers/bookmark_list_tester.dart new file mode 100644 index 0000000..87380a4 --- /dev/null +++ b/packages/stream_feeds/test/test_utils/testers/bookmark_list_tester.dart @@ -0,0 +1,153 @@ +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 bookmark list operations. +/// +/// Automatically sets up WebSocket connection, client, and test infrastructure. +/// Tests are tagged with 'bookmark-list' by default for filtering. +/// +/// [build] constructs the [BookmarkList] 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 [BookmarkListTester] 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 ['bookmark-list']. +/// [timeout] is optional, custom timeout for this test. +/// +/// Example: +/// ```dart +/// bookmarkListTest( +/// 'removes bookmark when updated to non-matching folder', +/// build: (client) => client.bookmarkList(BookmarksQuery()), +/// setUp: (tester) => tester.get(), +/// body: (tester) async { +/// expect(tester.bookmarkListState.bookmarks, hasLength(3)); +/// +/// await tester.emitEvent(BookmarkUpdatedEvent(...)); +/// +/// expect(tester.bookmarkListState.bookmarks, hasLength(2)); +/// }, +/// ); +/// ``` +@isTest +void bookmarkListTest( + String description, { + required BookmarkList Function(StreamFeedsClient client) build, + FutureOr Function(BookmarkListTester tester)? setUp, + required FutureOr Function(BookmarkListTester tester) body, + FutureOr Function(BookmarkListTester tester)? verify, + FutureOr Function(BookmarkListTester tester)? tearDown, + bool skip = false, + Iterable tags = const ['bookmark-list'], + test.Timeout? timeout, +}) { + return testWithTester( + description, + build: build, + createTesterFn: _createBookmarkListTester, + setUp: setUp, + body: body, + verify: verify, + tearDown: tearDown, + skip: skip, + tags: tags, + timeout: timeout, + ); +} + +/// A test utility for bookmark list operations with WebSocket support. +/// +/// Provides helper methods for emitting events and verifying bookmark list state. +/// +/// Resources are automatically cleaned up after the test completes. +final class BookmarkListTester extends BaseTester { + const BookmarkListTester._({ + required BookmarkList bookmarkList, + required super.wsStreamController, + required super.feedsApi, + }) : super(subject: bookmarkList); + + /// The bookmark list being tested. + BookmarkList get bookmarkList => subject; + + /// Current state of the bookmark list. + BookmarkListState get bookmarkListState => bookmarkList.state; + + /// Stream of bookmark list state updates. + Stream get bookmarkListStateStream => bookmarkList.stream; + + /// Gets the bookmark 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 bookmark list response + Future>> get({ + QueryBookmarksResponse Function(QueryBookmarksResponse)? modifyResponse, + }) { + final query = bookmarkList.query; + + final defaultBookmarkListResponse = QueryBookmarksResponse( + duration: DateTime.now().toIso8601String(), + bookmarks: [ + createDefaultBookmarkResponse( + folderId: 'folder-1', + activityId: 'activity-1', + ), + createDefaultBookmarkResponse( + folderId: 'folder-1', + activityId: 'activity-2', + ), + createDefaultBookmarkResponse( + folderId: 'folder-1', + activityId: 'activity-3', + ), + ], + ); + + mockApi( + (api) => api.queryBookmarks( + queryBookmarksRequest: query.toRequest(), + ), + result: switch (modifyResponse) { + final modifier? => modifier(defaultBookmarkListResponse), + _ => defaultBookmarkListResponse, + }, + ); + + return bookmarkList.get(); + } +} + +// Creates a BookmarkListTester for testing bookmark list operations. +// +// Automatically sets up WebSocket connection and registers cleanup handlers. +// This function is for internal use by bookmarkListTest only. +Future _createBookmarkListTester({ + required BookmarkList subject, + required StreamFeedsClient client, + required MockDefaultApi feedsApi, + required MockWebSocketChannel webSocketChannel, +}) { + // Dispose bookmark list after test + test.addTearDown(subject.dispose); + + return createTester( + client: client, + webSocketChannel: webSocketChannel, + create: (wsStreamController) => BookmarkListTester._( + bookmarkList: subject, + wsStreamController: wsStreamController, + feedsApi: feedsApi, + ), + ); +} diff --git a/packages/stream_feeds/test/test_utils/testers/comment_list_tester.dart b/packages/stream_feeds/test/test_utils/testers/comment_list_tester.dart new file mode 100644 index 0000000..880d1fa --- /dev/null +++ b/packages/stream_feeds/test/test_utils/testers/comment_list_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 comment list operations. +/// +/// Automatically sets up WebSocket connection, client, and test infrastructure. +/// Tests are tagged with 'comment-list' by default for filtering. +/// +/// [build] constructs the [CommentList] 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 [CommentListTester] 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-list']. +/// [timeout] is optional, custom timeout for this test. +/// +/// Example: +/// ```dart +/// commentListTest( +/// 'removes comment when updated to non-matching status', +/// build: (client) => client.commentList(CommentsQuery()), +/// setUp: (tester) => tester.get(), +/// body: (tester) async { +/// expect(tester.commentListState.comments, hasLength(3)); +/// +/// await tester.emitEvent(CommentUpdatedEvent(...)); +/// +/// expect(tester.commentListState.comments, hasLength(2)); +/// }, +/// ); +/// ``` +@isTest +void commentListTest( + String description, { + required CommentList Function(StreamFeedsClient client) build, + FutureOr Function(CommentListTester tester)? setUp, + required FutureOr Function(CommentListTester tester) body, + FutureOr Function(CommentListTester tester)? verify, + FutureOr Function(CommentListTester tester)? tearDown, + bool skip = false, + Iterable tags = const ['comment-list'], + test.Timeout? timeout, +}) { + return testWithTester( + description, + build: build, + createTesterFn: _createCommentListTester, + setUp: setUp, + body: body, + verify: verify, + tearDown: tearDown, + skip: skip, + tags: tags, + timeout: timeout, + ); +} + +/// A test utility for comment list operations with WebSocket support. +/// +/// Provides helper methods for emitting events and verifying comment list state. +/// +/// Resources are automatically cleaned up after the test completes. +final class CommentListTester extends BaseTester { + const CommentListTester._({ + required CommentList commentList, + required super.wsStreamController, + required super.feedsApi, + }) : super(subject: commentList); + + /// The comment list being tested. + CommentList get commentList => subject; + + /// Current state of the comment list. + CommentListState get commentListState => commentList.state; + + /// Stream of comment list state updates. + Stream get commentListStateStream => commentList.stream; + + /// Gets the comment 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 list response + Future>> get({ + QueryCommentsResponse Function(QueryCommentsResponse)? modifyResponse, + }) { + final query = commentList.query; + + final defaultCommentListResponse = QueryCommentsResponse( + duration: DateTime.now().toIso8601String(), + comments: [ + createDefaultCommentResponse(id: 'comment-1', objectId: 'obj-1'), + createDefaultCommentResponse(id: 'comment-2', objectId: 'obj-1'), + createDefaultCommentResponse(id: 'comment-3', objectId: 'obj-1'), + ], + ); + + mockApi( + (api) => api.queryComments( + queryCommentsRequest: query.toRequest(), + ), + result: switch (modifyResponse) { + final modifier? => modifier(defaultCommentListResponse), + _ => defaultCommentListResponse, + }, + ); + + return commentList.get(); + } +} + +// Creates a CommentListTester for testing comment list operations. +// +// Automatically sets up WebSocket connection and registers cleanup handlers. +// This function is for internal use by commentListTest only. +Future _createCommentListTester({ + required CommentList subject, + required StreamFeedsClient client, + required MockDefaultApi feedsApi, + required MockWebSocketChannel webSocketChannel, +}) { + // Dispose comment list after test + test.addTearDown(subject.dispose); + + return createTester( + client: client, + webSocketChannel: webSocketChannel, + create: (wsStreamController) => CommentListTester._( + commentList: subject, + wsStreamController: wsStreamController, + feedsApi: feedsApi, + ), + ); +} diff --git a/packages/stream_feeds/test/test_utils/testers/feed_list_tester.dart b/packages/stream_feeds/test/test_utils/testers/feed_list_tester.dart new file mode 100644 index 0000000..393bd81 --- /dev/null +++ b/packages/stream_feeds/test/test_utils/testers/feed_list_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 list operations. +/// +/// Automatically sets up WebSocket connection, client, and test infrastructure. +/// Tests are tagged with 'feed-list' by default for filtering. +/// +/// [build] constructs the [FeedList] 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 [FeedListTester] 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-list']. +/// [timeout] is optional, custom timeout for this test. +/// +/// Example: +/// ```dart +/// feedListTest( +/// 'removes feed when updated to non-matching visibility', +/// build: (client) => client.feedList(FeedsQuery()), +/// setUp: (tester) => tester.get(), +/// body: (tester) async { +/// expect(tester.feedListState.feeds, hasLength(3)); +/// +/// await tester.emitEvent(FeedUpdatedEvent(...)); +/// +/// expect(tester.feedListState.feeds, hasLength(2)); +/// }, +/// ); +/// ``` +@isTest +void feedListTest( + String description, { + required FeedList Function(StreamFeedsClient client) build, + FutureOr Function(FeedListTester tester)? setUp, + required FutureOr Function(FeedListTester tester) body, + FutureOr Function(FeedListTester tester)? verify, + FutureOr Function(FeedListTester tester)? tearDown, + bool skip = false, + Iterable tags = const ['feed-list'], + test.Timeout? timeout, +}) { + return testWithTester( + description, + build: build, + createTesterFn: _createFeedListTester, + setUp: setUp, + body: body, + verify: verify, + tearDown: tearDown, + skip: skip, + tags: tags, + timeout: timeout, + ); +} + +/// A test utility for feed list operations with WebSocket support. +/// +/// Provides helper methods for emitting events and verifying feed list state. +/// +/// Resources are automatically cleaned up after the test completes. +final class FeedListTester extends BaseTester { + const FeedListTester._({ + required FeedList feedList, + required super.wsStreamController, + required super.feedsApi, + }) : super(subject: feedList); + + /// The feed list being tested. + FeedList get feedList => subject; + + /// Current state of the feed list. + FeedListState get feedListState => feedList.state; + + /// Stream of feed list state updates. + Stream get feedListStateStream => feedList.stream; + + /// Gets the feed 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 feed list response + Future>> get({ + QueryFeedsResponse Function(QueryFeedsResponse)? modifyResponse, + }) { + final query = feedList.query; + + final defaultFeedListResponse = QueryFeedsResponse( + duration: DateTime.now().toIso8601String(), + feeds: [ + createDefaultFeedResponse(id: 'feed-1'), + createDefaultFeedResponse(id: 'feed-2'), + createDefaultFeedResponse(id: 'feed-3'), + ], + ); + + mockApi( + (api) => api.queryFeeds( + queryFeedsRequest: query.toRequest(), + ), + result: switch (modifyResponse) { + final modifier? => modifier(defaultFeedListResponse), + _ => defaultFeedListResponse, + }, + ); + + return feedList.get(); + } +} + +// Creates a FeedListTester for testing feed list operations. +// +// Automatically sets up WebSocket connection and registers cleanup handlers. +// This function is for internal use by feedListTest only. +Future _createFeedListTester({ + required FeedList subject, + required StreamFeedsClient client, + required MockDefaultApi feedsApi, + required MockWebSocketChannel webSocketChannel, +}) { + // Dispose feed list after test + test.addTearDown(subject.dispose); + + return createTester( + client: client, + webSocketChannel: webSocketChannel, + create: (wsStreamController) => FeedListTester._( + feedList: subject, + wsStreamController: wsStreamController, + feedsApi: feedsApi, + ), + ); +} diff --git a/packages/stream_feeds/test/test_utils/testers/feed_tester.dart b/packages/stream_feeds/test/test_utils/testers/feed_tester.dart index 7dcb880..0746e55 100644 --- a/packages/stream_feeds/test/test_utils/testers/feed_tester.dart +++ b/packages/stream_feeds/test/test_utils/testers/feed_tester.dart @@ -83,6 +83,12 @@ final class FeedTester extends BaseTester { /// The feed being tested. Feed get feed => subject; + /// The current state of the feed. + FeedState get feedState => feed.state; + + /// Stream of feed state updates. + Stream get feedStateStream => feed.stream; + /// Gets or creates the feed by fetching it from the API. /// /// Call this in event tests to set up initial state before emitting events. @@ -94,6 +100,7 @@ final class FeedTester extends BaseTester { GetOrCreateFeedResponse Function(GetOrCreateFeedResponse)? modifyResponse, }) { final feedId = feed.fid; + final query = feed.query; final defaultFeedResponse = createDefaultGetOrCreateFeedResponse( activities: [ @@ -107,7 +114,7 @@ final class FeedTester extends BaseTester { (api) => api.getOrCreateFeed( feedId: feedId.id, feedGroupId: feedId.group, - getOrCreateFeedRequest: FeedQuery(fid: feedId).toRequest(), + getOrCreateFeedRequest: query.toRequest(), ), result: switch (modifyResponse) { final modifier? => modifier(defaultFeedResponse), diff --git a/packages/stream_feeds/test/test_utils/testers/follow_list_tester.dart b/packages/stream_feeds/test/test_utils/testers/follow_list_tester.dart new file mode 100644 index 0000000..bb8d4b0 --- /dev/null +++ b/packages/stream_feeds/test/test_utils/testers/follow_list_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 follow list operations. +/// +/// Automatically sets up WebSocket connection, client, and test infrastructure. +/// Tests are tagged with 'follow-list' by default for filtering. +/// +/// [build] constructs the [FollowList] 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 [FollowListTester] 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 ['follow-list']. +/// [timeout] is optional, custom timeout for this test. +/// +/// Example: +/// ```dart +/// followListTest( +/// 'removes follow when updated to non-matching status', +/// build: (client) => client.followList(FollowsQuery()), +/// setUp: (tester) => tester.get(), +/// body: (tester) async { +/// expect(tester.followListState.follows, hasLength(3)); +/// +/// await tester.emitEvent(FollowUpdatedEvent(...)); +/// +/// expect(tester.followListState.follows, hasLength(2)); +/// }, +/// ); +/// ``` +@isTest +void followListTest( + String description, { + required FollowList Function(StreamFeedsClient client) build, + FutureOr Function(FollowListTester tester)? setUp, + required FutureOr Function(FollowListTester tester) body, + FutureOr Function(FollowListTester tester)? verify, + FutureOr Function(FollowListTester tester)? tearDown, + bool skip = false, + Iterable tags = const ['follow-list'], + test.Timeout? timeout, +}) { + return testWithTester( + description, + build: build, + createTesterFn: _createFollowListTester, + setUp: setUp, + body: body, + verify: verify, + tearDown: tearDown, + skip: skip, + tags: tags, + timeout: timeout, + ); +} + +/// A test utility for follow list operations with WebSocket support. +/// +/// Provides helper methods for emitting events and verifying follow list state. +/// +/// Resources are automatically cleaned up after the test completes. +final class FollowListTester extends BaseTester { + const FollowListTester._({ + required FollowList followList, + required super.wsStreamController, + required super.feedsApi, + }) : super(subject: followList); + + /// The follow list being tested. + FollowList get followList => subject; + + /// Current state of the follow list. + FollowListState get followListState => followList.state; + + /// Stream of follow list state updates. + Stream get followListStateStream => followList.stream; + + /// Gets the follow 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 follow list response + Future>> get({ + QueryFollowsResponse Function(QueryFollowsResponse)? modifyResponse, + }) { + final query = followList.query; + + final defaultFollowListResponse = QueryFollowsResponse( + duration: DateTime.now().toIso8601String(), + follows: [ + createDefaultFollowResponse(id: 'follow-1'), + createDefaultFollowResponse(id: 'follow-2'), + createDefaultFollowResponse(id: 'follow-3'), + ], + ); + + mockApi( + (api) => api.queryFollows( + queryFollowsRequest: query.toRequest(), + ), + result: switch (modifyResponse) { + final modifier? => modifier(defaultFollowListResponse), + _ => defaultFollowListResponse, + }, + ); + + return followList.get(); + } +} + +// Creates a FollowListTester for testing follow list operations. +// +// Automatically sets up WebSocket connection and registers cleanup handlers. +// This function is for internal use by followListTest only. +Future _createFollowListTester({ + required FollowList subject, + required StreamFeedsClient client, + required MockDefaultApi feedsApi, + required MockWebSocketChannel webSocketChannel, +}) { + // Dispose follow list after test + test.addTearDown(subject.dispose); + + return createTester( + client: client, + webSocketChannel: webSocketChannel, + create: (wsStreamController) => FollowListTester._( + followList: subject, + wsStreamController: wsStreamController, + feedsApi: feedsApi, + ), + ); +} diff --git a/packages/stream_feeds/test/test_utils/testers/poll_list_tester.dart b/packages/stream_feeds/test/test_utils/testers/poll_list_tester.dart new file mode 100644 index 0000000..c7279c9 --- /dev/null +++ b/packages/stream_feeds/test/test_utils/testers/poll_list_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 poll list operations. +/// +/// Automatically sets up WebSocket connection, client, and test infrastructure. +/// Tests are tagged with 'poll-list' by default for filtering. +/// +/// [build] constructs the [PollList] 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 [PollListTester] 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 ['poll-list']. +/// [timeout] is optional, custom timeout for this test. +/// +/// Example: +/// ```dart +/// pollListTest( +/// 'removes poll when updated to non-matching status', +/// build: (client) => client.pollList(PollsQuery()), +/// setUp: (tester) => tester.get(), +/// body: (tester) async { +/// expect(tester.pollListState.polls, hasLength(3)); +/// +/// await tester.emitEvent(PollUpdatedFeedEvent(...)); +/// +/// expect(tester.pollListState.polls, hasLength(2)); +/// }, +/// ); +/// ``` +@isTest +void pollListTest( + String description, { + required PollList Function(StreamFeedsClient client) build, + FutureOr Function(PollListTester tester)? setUp, + required FutureOr Function(PollListTester tester) body, + FutureOr Function(PollListTester tester)? verify, + FutureOr Function(PollListTester tester)? tearDown, + bool skip = false, + Iterable tags = const ['poll-list'], + test.Timeout? timeout, +}) { + return testWithTester( + description, + build: build, + createTesterFn: _createPollListTester, + setUp: setUp, + body: body, + verify: verify, + tearDown: tearDown, + skip: skip, + tags: tags, + timeout: timeout, + ); +} + +/// A test utility for poll list operations with WebSocket support. +/// +/// Provides helper methods for emitting events and verifying poll list state. +/// +/// Resources are automatically cleaned up after the test completes. +final class PollListTester extends BaseTester { + const PollListTester._({ + required PollList pollList, + required super.wsStreamController, + required super.feedsApi, + }) : super(subject: pollList); + + /// The poll list being tested. + PollList get pollList => subject; + + /// Current state of the poll list. + PollListState get pollListState => pollList.state; + + /// Stream of poll list state updates. + Stream get pollListStateStream => pollList.stream; + + /// Gets the poll 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 poll list response + Future>> get({ + QueryPollsResponse Function(QueryPollsResponse)? modifyResponse, + }) { + final query = pollList.query; + + final defaultPollListResponse = QueryPollsResponse( + duration: DateTime.now().toIso8601String(), + polls: [ + createDefaultPollResponse(id: 'poll-1'), + createDefaultPollResponse(id: 'poll-2'), + createDefaultPollResponse(id: 'poll-3'), + ], + ); + + mockApi( + (api) => api.queryPolls( + queryPollsRequest: query.toRequest(), + ), + result: switch (modifyResponse) { + final modifier? => modifier(defaultPollListResponse), + _ => defaultPollListResponse, + }, + ); + + return pollList.get(); + } +} + +// Creates a PollListTester for testing poll list operations. +// +// Automatically sets up WebSocket connection and registers cleanup handlers. +// This function is for internal use by pollListTest only. +Future _createPollListTester({ + required PollList subject, + required StreamFeedsClient client, + required MockDefaultApi feedsApi, + required MockWebSocketChannel webSocketChannel, +}) { + // Dispose poll list after test + test.addTearDown(subject.dispose); + + return createTester( + client: client, + webSocketChannel: webSocketChannel, + create: (wsStreamController) => PollListTester._( + pollList: subject, + wsStreamController: wsStreamController, + feedsApi: feedsApi, + ), + ); +} diff --git a/packages/stream_feeds/test/test_utils/testers/poll_vote_list_tester.dart b/packages/stream_feeds/test/test_utils/testers/poll_vote_list_tester.dart new file mode 100644 index 0000000..a55a4eb --- /dev/null +++ b/packages/stream_feeds/test/test_utils/testers/poll_vote_list_tester.dart @@ -0,0 +1,154 @@ +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 poll vote list operations. +/// +/// Automatically sets up WebSocket connection, client, and test infrastructure. +/// Tests are tagged with 'poll-vote-list' by default for filtering. +/// +/// [build] constructs the [PollVoteList] 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 [PollVoteListTester] 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 ['poll-vote-list']. +/// [timeout] is optional, custom timeout for this test. +/// +/// Example: +/// ```dart +/// pollVoteListTest( +/// 'removes vote when changed to non-matching option', +/// build: (client) => client.pollVoteList(PollVotesQuery(pollId: 'poll-1')), +/// setUp: (tester) => tester.get(), +/// body: (tester) async { +/// expect(tester.pollVoteListState.votes, hasLength(3)); +/// +/// await tester.emitEvent(PollVoteChangedFeedEvent(...)); +/// +/// expect(tester.pollVoteListState.votes, hasLength(2)); +/// }, +/// ); +/// ``` +@isTest +void pollVoteListTest( + String description, { + required PollVoteList Function(StreamFeedsClient client) build, + FutureOr Function(PollVoteListTester tester)? setUp, + required FutureOr Function(PollVoteListTester tester) body, + FutureOr Function(PollVoteListTester tester)? verify, + FutureOr Function(PollVoteListTester tester)? tearDown, + bool skip = false, + Iterable tags = const ['poll-vote-list'], + test.Timeout? timeout, +}) { + return testWithTester( + description, + build: build, + createTesterFn: _createPollVoteListTester, + setUp: setUp, + body: body, + verify: verify, + tearDown: tearDown, + skip: skip, + tags: tags, + timeout: timeout, + ); +} + +/// A test utility for poll vote list operations with WebSocket support. +/// +/// Provides helper methods for emitting events and verifying poll vote list state. +/// +/// Resources are automatically cleaned up after the test completes. +final class PollVoteListTester extends BaseTester { + const PollVoteListTester._({ + required PollVoteList pollVoteList, + required super.wsStreamController, + required super.feedsApi, + }) : super(subject: pollVoteList); + + /// The poll vote list being tested. + PollVoteList get pollVoteList => subject; + + /// Current state of the poll vote list. + PollVoteListState get pollVoteListState => pollVoteList.state; + + /// Stream of poll vote list state updates. + Stream get pollVoteListStateStream => pollVoteList.stream; + + /// Gets the poll vote 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 poll vote list response + Future>> get({ + PollVotesResponse Function(PollVotesResponse)? modifyResponse, + }) { + final query = pollVoteList.query; + + final defaultPollVoteListResponse = PollVotesResponse( + duration: DateTime.now().toIso8601String(), + votes: [ + createDefaultPollVoteResponse( + id: 'vote-1', + pollId: query.pollId, + ), + createDefaultPollVoteResponse( + id: 'vote-2', + pollId: query.pollId, + ), + createDefaultPollVoteResponse( + id: 'vote-3', + pollId: query.pollId, + ), + ], + ); + + mockApi( + (api) => api.queryPollVotes( + pollId: query.pollId, + queryPollVotesRequest: query.toRequest(), + ), + result: switch (modifyResponse) { + final modifier? => modifier(defaultPollVoteListResponse), + _ => defaultPollVoteListResponse, + }, + ); + + return pollVoteList.get(); + } +} + +// Creates a PollVoteListTester for testing poll vote list operations. +// +// Automatically sets up WebSocket connection and registers cleanup handlers. +// This function is for internal use by pollVoteListTest only. +Future _createPollVoteListTester({ + required PollVoteList subject, + required StreamFeedsClient client, + required MockDefaultApi feedsApi, + required MockWebSocketChannel webSocketChannel, +}) { + // Dispose poll vote list after test + test.addTearDown(subject.dispose); + + return createTester( + client: client, + webSocketChannel: webSocketChannel, + create: (wsStreamController) => PollVoteListTester._( + pollVoteList: subject, + wsStreamController: wsStreamController, + feedsApi: feedsApi, + ), + ); +} diff --git a/packages/stream_feeds/test/test_utils/web_socket_mocks.dart b/packages/stream_feeds/test/test_utils/web_socket_mocks.dart new file mode 100644 index 0000000..f4ee984 --- /dev/null +++ b/packages/stream_feeds/test/test_utils/web_socket_mocks.dart @@ -0,0 +1,65 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:mocktail/mocktail.dart'; +import 'package:stream_feeds/src/ws/events/events.dart'; + +import 'mocks.dart'; + +/// Sets up WebSocket channel mocks for testing. +/// +/// Configures [webSocketChannel] to use [wsStreamController] for its stream +/// when the channel's stream is listened to. Also creates a mock sink for +/// sending messages and handles authentication by automatically responding +/// with a health check event when a token is sent. +/// +/// Example: +/// ```dart +/// final wsStreamController = StreamController(); +/// whenListenWebSocket(webSocketChannel, wsStreamController); +/// ``` +void whenListenWebSocket( + MockWebSocketChannel webSocketChannel, + StreamController wsStreamController, +) { + final webSocketSink = MockWebSocketSink(); + final webSocketStream = wsStreamController.stream.asBroadcastStream(); + + when( + webSocketSink.close, + ).thenAnswer((_) => Future.value()); + + when( + () => webSocketChannel.ready, + ).thenAnswer((_) => Future.value()); + + when( + () => webSocketChannel.stream, + ).thenAnswer((_) => webSocketStream); + + when( + () => webSocketChannel.sink, + ).thenAnswer((_) => webSocketSink); + + // Handle authentication: when a token is sent, respond with health check + when( + () => webSocketSink.add(any()), + ).thenAnswer((invocation) { + final event = jsonDecode( + invocation.positionalArguments.first as String, + ) as Map; + + if (event['token'] != null) { + return wsStreamController.add( + jsonEncode( + HealthCheckEvent( + connectionId: 'connectionId', + createdAt: DateTime.now(), + custom: const {}, + type: 'health.check', + ), + ), + ); + } + }); +} diff --git a/packages/stream_feeds/test/test_utils/ws_test_helpers.dart b/packages/stream_feeds/test/test_utils/ws_test_helpers.dart deleted file mode 100644 index 8676e32..0000000 --- a/packages/stream_feeds/test/test_utils/ws_test_helpers.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:mocktail/mocktail.dart'; -import 'package:stream_feeds/src/ws/events/events.dart'; -import 'package:stream_feeds/stream_feeds.dart'; - -import 'mocks.dart'; - -class WsTestConnection { - WsTestConnection({ - required this.wsStreamController, - required this.webSocketSink, - required this.webSocketChannel, - }); - - StreamController wsStreamController; - MockWebSocketSink webSocketSink; - MockWebSocketChannel webSocketChannel; - - void setUp() { - final wsAuthenticationHelper = WsAuthenticationHelper( - wsStreamController: wsStreamController, - ); - - when(() => webSocketSink.add(any())).thenAnswer( - wsAuthenticationHelper.handleAuthentication, - ); - - when(() => webSocketSink.close()).thenAnswer( - (_) => Future.value(), - ); - - when(() => webSocketChannel.ready).thenAnswer( - (_) => Future.value(), - ); - when(() => webSocketChannel.stream).thenAnswer( - (_) => wsStreamController.stream, - ); - - when(() => webSocketChannel.sink).thenAnswer( - (_) => webSocketSink, - ); - } -} - -class WsAuthenticationHelper { - WsAuthenticationHelper({ - required this.wsStreamController, - }); - - StreamController wsStreamController; - - /// Returns true if the event is a [WsAuthMessageRequest]. - /// If it is, it will add a [HealthCheckEvent] to the stream. - bool handleAuthentication(Invocation invocation) { - final Map event = jsonDecode( - invocation.positionalArguments.first as String, - ); - - if (event['token'] != null) { - wsStreamController.add( - jsonEncode( - HealthCheckEvent( - connectionId: 'connectionId', - createdAt: DateTime.now(), - custom: const {}, - type: 'health.check', - ), - ), - ); - - return true; - } - - return false; - } -}