diff --git a/packages/stream_feeds/dart_test.yaml b/packages/stream_feeds/dart_test.yaml index e66bfe9d..badedb93 100644 --- a/packages/stream_feeds/dart_test.yaml +++ b/packages/stream_feeds/dart_test.yaml @@ -10,8 +10,10 @@ tags: comment-reply-list: feed: feed-list: + feeds-client: follow-list: member-list: + moderation-client: moderation-config-list: poll-list: poll-vote-list: \ No newline at end of file diff --git a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart index bee8acd5..75b6b9ed 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -56,7 +56,6 @@ import '../state/query/poll_votes_query.dart'; import '../state/query/polls_query.dart'; import '../ws/feeds_ws_event.dart'; import 'endpoint_config.dart'; -import 'moderation_client.dart'; class StreamFeedsClientImpl implements StreamFeedsClient { StreamFeedsClientImpl({ diff --git a/packages/stream_feeds/lib/src/client/moderation_client.dart b/packages/stream_feeds/lib/src/client/moderation_client.dart index 0d1c3df4..f14adb26 100644 --- a/packages/stream_feeds/lib/src/client/moderation_client.dart +++ b/packages/stream_feeds/lib/src/client/moderation_client.dart @@ -1,8 +1,9 @@ import 'package:stream_core/stream_core.dart'; -import '../../stream_feeds.dart' show ModerationConfigsQuery; import '../generated/api/api.dart' as api; +import '../models.dart' show ModerationConfigData, PaginationResult; import '../repository/moderation_repository.dart'; +import '../state.dart' show ModerationConfigsQuery; /// Controller exposing moderation functionalities. /// @@ -160,8 +161,9 @@ class ModerationClient { /// /// Retrieves moderation configurations using the specified [queryModerationConfigsRequest] filters and pagination. /// - /// Returns a [Result] containing a [api.QueryModerationConfigsResponse] or an error. - Future queryModerationConfigs({ + /// Returns a [Result] containing a [PaginationResult] of [ModerationConfigData] or an error. + Future>> + queryModerationConfigs({ required ModerationConfigsQuery queryModerationConfigsRequest, }) { return _moderationRepository diff --git a/packages/stream_feeds/lib/src/feeds_client.dart b/packages/stream_feeds/lib/src/feeds_client.dart index 814e28a0..c8fe475c 100644 --- a/packages/stream_feeds/lib/src/feeds_client.dart +++ b/packages/stream_feeds/lib/src/feeds_client.dart @@ -42,6 +42,8 @@ import 'state/query/moderation_configs_query.dart'; import 'state/query/poll_votes_query.dart'; import 'state/query/polls_query.dart'; +export 'client/moderation_client.dart'; + /// {@template stream_feeds_client} /// Stream Feeds client for building scalable newsfeeds and activity streams. /// diff --git a/packages/stream_feeds/lib/stream_feeds.dart b/packages/stream_feeds/lib/stream_feeds.dart index 7ca64223..0101a2bb 100644 --- a/packages/stream_feeds/lib/stream_feeds.dart +++ b/packages/stream_feeds/lib/stream_feeds.dart @@ -1,5 +1,7 @@ export 'package:stream_core/stream_core.dart'; +export 'src/cdn/cdn_api.dart'; +export 'src/cdn/feeds_cdn_client.dart'; export 'src/feeds_client.dart'; export 'src/generated/api/api.dart' hide User; export 'src/models.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 deleted file mode 100644 index e8cdc474..00000000 --- a/packages/stream_feeds/test/client/feeds_client_impl_test.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:stream_feeds/src/client/feeds_client_impl.dart'; -import 'package:stream_feeds/stream_feeds.dart'; - -import 'package:stream_feeds_test/stream_feeds_test.dart'; - -void main() { - test('Create a feeds client', () { - const user = User(id: 'userId'); - final token = generateTestUserToken(user.id); - - final client = StreamFeedsClient( - apiKey: 'apiKey', - user: user, - tokenProvider: TokenProvider.static(token), - ); - - expect(client, isA()); - }); -} diff --git a/packages/stream_feeds/test/client/feeds_client_test.dart b/packages/stream_feeds/test/client/feeds_client_test.dart new file mode 100644 index 00000000..e138b6bc --- /dev/null +++ b/packages/stream_feeds/test/client/feeds_client_test.dart @@ -0,0 +1,522 @@ +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:stream_feeds_test/stream_feeds_test.dart'; + +void main() { + // ============================================================ + // FEATURE: Connection Management + // ============================================================ + + group('connect', () { + feedsClientTest( + 'should connect successfully', + connect: (tester) => tester.mockSuccessfulAuth(tester.user.id), + body: (tester) async { + // Setup expectation for connection state transitions + final connectionStateExpectation = expectLater( + tester.client.connectionState.stream, + emitsInOrder([ + isA(), + isA(), + isA(), + isA(), + ]), + ); + + // Connect the client + await tester.client.connect(); + addTearDown(tester.client.disconnect); + + // Verify state transitions expectation + await connectionStateExpectation; + }, + ); + + feedsClientTest( + 'should handle connection failure', + connect: (tester) => tester.mockFailedAuth(errorCode: 41), + body: (tester) async { + // Setup expectation for connection state transitions + final connectionStateExpectation = expectLater( + tester.client.connectionState.stream, + emitsInOrder([ + isA(), + isA(), + isA(), + isA(), + isA(), + ]), + ); + + // Attempt connection - should fail + await expectLater( + tester.client.connect(), + throwsA(isA()), + ); + + // Verify state transitions expectation + await connectionStateExpectation; + }, + ); + }); + + group('disconnect', () { + feedsClientTest( + 'should disconnect successfully', + body: (tester) async { + // Setup expectation for disconnection state transitions + final connectionStateExpectation = expectLater( + tester.client.connectionState.stream, + emitsInOrder([ + isA(), + isA(), + isA(), + ]), + ); + + // Disconnect + await tester.client.disconnect(); + + // Verify state transitions expectation + await connectionStateExpectation; + }, + ); + }); + + // ============================================================ + // FEATURE: System Configuration + // ============================================================ + + group('updateSystemEnvironment', () { + feedsClientTest( + 'should update system environment successfully', + body: (tester) { + const environment = SystemEnvironment( + sdkName: 'my-app', + sdkIdentifier: 'flutter', + sdkVersion: '1.0.0', + ); + + // Should not throw + expect( + () => tester.client.updateSystemEnvironment(environment), + returnsNormally, + ); + }, + ); + }); + + // ============================================================ + // FEATURE: Activity Batch Operations + // ============================================================ + + group('upsertActivities', () { + setUpAll(() { + registerFallbackValue(const UpsertActivitiesRequest(activities: [])); + }); + + feedsClientTest( + 'should upsert activities successfully', + body: (tester) async { + final activities = [ + const ActivityRequest( + feeds: ['user:123'], + id: '1', + text: 'Hello World', + type: 'post', + ), + const ActivityRequest( + feeds: ['user:456'], + id: '2', + text: 'Another post', + type: 'post', + ), + ]; + + final request = UpsertActivitiesRequest(activities: activities); + + tester.mockApi( + (api) => api.upsertActivities(upsertActivitiesRequest: request), + result: createDefaultUpsertActivitiesResponse(count: 2), + ); + + final result = await tester.client.upsertActivities( + activities: activities, + ); + + expect(result.isSuccess, isTrue); + final data = result.getOrThrow(); + expect(data.length, equals(2)); + + tester.verifyApi( + (api) => api.upsertActivities(upsertActivitiesRequest: request), + ); + }, + ); + + feedsClientTest( + 'should handle upsert activities failure', + body: (tester) async { + final activities = [ + const ActivityRequest( + feeds: ['user:123'], + id: '1', + text: 'Hello', + type: 'post', + ), + ]; + + final request = UpsertActivitiesRequest(activities: activities); + + tester.mockApiFailure( + (api) => api.upsertActivities(upsertActivitiesRequest: request), + error: Exception('Failed to upsert activities'), + ); + + final result = await tester.client.upsertActivities( + activities: activities, + ); + + expect(result.isFailure, isTrue); + + tester.verifyApi( + (api) => api.upsertActivities(upsertActivitiesRequest: request), + ); + }, + ); + }); + + group('deleteActivities', () { + setUpAll(() { + registerFallbackValue(const DeleteActivitiesRequest(ids: [])); + }); + + feedsClientTest( + 'should delete activities successfully', + body: (tester) async { + final ids = ['activity-1', 'activity-2']; + final request = DeleteActivitiesRequest( + ids: ids, + hardDelete: false, + ); + + tester.mockApi( + (api) => api.deleteActivities(deleteActivitiesRequest: request), + result: createDefaultDeleteActivitiesResponse(ids: ids), + ); + + final result = await tester.client.deleteActivities( + ids: ids, + hardDelete: false, + ); + + expect(result.isSuccess, isTrue); + final response = result.getOrThrow(); + expect(response.duration, isNotEmpty); + + tester.verifyApi( + (api) => api.deleteActivities(deleteActivitiesRequest: request), + ); + }, + ); + + feedsClientTest( + 'should handle delete activities failure', + body: (tester) async { + final ids = ['activity-1']; + final request = DeleteActivitiesRequest( + ids: ids, + hardDelete: true, + ); + + tester.mockApiFailure( + (api) => api.deleteActivities(deleteActivitiesRequest: request), + error: Exception('Failed to delete activities'), + ); + + final result = await tester.client.deleteActivities( + ids: ids, + hardDelete: true, + ); + + expect(result.isFailure, isTrue); + + tester.verifyApi( + (api) => api.deleteActivities(deleteActivitiesRequest: request), + ); + }, + ); + }); + + // ============================================================ + // FEATURE: App Operations + // ============================================================ + + group('getApp', () { + feedsClientTest( + 'should get app data successfully', + body: (tester) async { + tester.mockApi( + (api) => api.getApp(), + result: createDefaultGetApplicationResponse(), + ); + + final result = await tester.client.getApp(); + + expect(result.isSuccess, isTrue); + final appData = result.getOrThrow(); + expect(appData.name, isNotEmpty); + + tester.verifyApi((api) => api.getApp()); + }, + ); + + feedsClientTest( + 'should handle get app failure', + body: (tester) async { + tester.mockApiFailure( + (api) => api.getApp(), + error: Exception('Failed to get app data'), + ); + + final result = await tester.client.getApp(); + + expect(result.isFailure, isTrue); + + tester.verifyApi((api) => api.getApp()); + }, + ); + }); + + // ============================================================ + // FEATURE: Device Operations + // ============================================================ + + group('queryDevices', () { + feedsClientTest( + 'should query devices successfully', + body: (tester) async { + tester.mockApi( + (api) => api.listDevices(), + result: createDefaultListDevicesResponse(), + ); + + final result = await tester.client.queryDevices(); + + expect(result.isSuccess, isTrue); + final response = result.getOrThrow(); + expect(response.duration, isNotEmpty); + + tester.verifyApi((api) => api.listDevices()); + }, + ); + + feedsClientTest( + 'should handle query devices failure', + body: (tester) async { + tester.mockApiFailure( + (api) => api.listDevices(), + error: Exception('Failed to query devices'), + ); + + final result = await tester.client.queryDevices(); + + expect(result.isFailure, isTrue); + + tester.verifyApi((api) => api.listDevices()); + }, + ); + }); + + group('createDevice', () { + setUpAll(() { + registerFallbackValue( + const CreateDeviceRequest( + id: 'fallback', + pushProvider: CreateDeviceRequestPushProvider.firebase, + ), + ); + }); + + feedsClientTest( + 'should create device successfully', + body: (tester) async { + const deviceId = 'firebase-token-123'; + const pushProvider = PushNotificationsProvider.firebase; + const pushProviderName = 'MyApp Firebase'; + + const request = CreateDeviceRequest( + id: deviceId, + pushProvider: CreateDeviceRequestPushProvider.firebase, + pushProviderName: pushProviderName, + ); + + tester.mockApi( + (api) => api.createDevice(createDeviceRequest: request), + result: createDefaultCreateDeviceResponse(), + ); + + final result = await tester.client.createDevice( + id: deviceId, + pushProvider: pushProvider, + pushProviderName: pushProviderName, + ); + + expect(result.isSuccess, isTrue); + + tester.verifyApi( + (api) => api.createDevice(createDeviceRequest: request), + ); + }, + ); + + feedsClientTest( + 'should handle create device failure', + body: (tester) async { + const deviceId = 'invalid-token'; + const pushProvider = PushNotificationsProvider.apn; + const pushProviderName = 'MyApp APN'; + + const request = CreateDeviceRequest( + id: deviceId, + pushProvider: CreateDeviceRequestPushProvider.apn, + pushProviderName: pushProviderName, + ); + + tester.mockApiFailure( + (api) => api.createDevice(createDeviceRequest: request), + error: Exception('Failed to create device'), + ); + + final result = await tester.client.createDevice( + id: deviceId, + pushProvider: pushProvider, + pushProviderName: pushProviderName, + ); + + expect(result.isFailure, isTrue); + + tester.verifyApi( + (api) => api.createDevice(createDeviceRequest: request), + ); + }, + ); + }); + + group('deleteDevice', () { + feedsClientTest( + 'should delete device successfully', + body: (tester) async { + const deviceId = 'firebase-token-123'; + + tester.mockApi( + (api) => api.deleteDevice(id: deviceId), + result: createDefaultDeleteDeviceResponse(), + ); + + final result = await tester.client.deleteDevice(id: deviceId); + + expect(result.isSuccess, isTrue); + + tester.verifyApi((api) => api.deleteDevice(id: deviceId)); + }, + ); + + feedsClientTest( + 'should handle delete device failure', + body: (tester) async { + const deviceId = 'invalid-device'; + + tester.mockApiFailure( + (api) => api.deleteDevice(id: deviceId), + error: Exception('Device not found'), + ); + + final result = await tester.client.deleteDevice(id: deviceId); + + expect(result.isFailure, isTrue); + + tester.verifyApi((api) => api.deleteDevice(id: deviceId)); + }, + ); + }); + + // ============================================================ + // FEATURE: CDN Operations + // ============================================================ + + group('deleteFile', () { + feedsClientTest( + 'should delete file successfully', + body: (tester) async { + const fileUrl = 'https://cdn.example.com/files/document.pdf'; + + tester.mockCdn( + (cdn) => cdn.deleteFile(url: fileUrl), + result: const DurationResponse(duration: '10ms'), + ); + + final result = await tester.client.deleteFile(url: fileUrl); + + expect(result.isSuccess, isTrue); + + tester.verifyCdn((cdn) => cdn.deleteFile(url: fileUrl)); + }, + ); + + feedsClientTest( + 'should handle delete file failure', + body: (tester) async { + const fileUrl = 'https://cdn.example.com/files/missing.pdf'; + + tester.mockCdnFailure( + (cdn) => cdn.deleteFile(url: fileUrl), + error: Exception('File not found'), + ); + + final result = await tester.client.deleteFile(url: fileUrl); + + expect(result.isFailure, isTrue); + + tester.verifyCdn((cdn) => cdn.deleteFile(url: fileUrl)); + }, + ); + }); + + group('deleteImage', () { + feedsClientTest( + 'should delete image successfully', + body: (tester) async { + const imageUrl = 'https://cdn.example.com/images/photo.jpg'; + + tester.mockCdn( + (cdn) => cdn.deleteImage(url: imageUrl), + result: const DurationResponse(duration: '10ms'), + ); + + final result = await tester.client.deleteImage(url: imageUrl); + + expect(result.isSuccess, isTrue); + + tester.verifyCdn((cdn) => cdn.deleteImage(url: imageUrl)); + }, + ); + + feedsClientTest( + 'should handle delete image failure', + body: (tester) async { + const imageUrl = 'https://cdn.example.com/images/missing.jpg'; + + tester.mockCdnFailure( + (cdn) => cdn.deleteImage(url: imageUrl), + error: Exception('Image not found'), + ); + + final result = await tester.client.deleteImage(url: imageUrl); + + expect(result.isFailure, isTrue); + + tester.verifyCdn((cdn) => cdn.deleteImage(url: imageUrl)); + }, + ); + }); +} diff --git a/packages/stream_feeds/test/client/moderation_client_test.dart b/packages/stream_feeds/test/client/moderation_client_test.dart new file mode 100644 index 00000000..e7a9936b --- /dev/null +++ b/packages/stream_feeds/test/client/moderation_client_test.dart @@ -0,0 +1,587 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:stream_feeds_test/stream_feeds_test.dart'; + +void main() { + // ============================================================ + // FEATURE: Ban Operations + // ============================================================ + + group('ban', () { + setUpAll(() { + registerFallbackValue(const BanRequest(targetUserId: 'fallback')); + }); + + moderationClientTest( + 'should ban user successfully', + body: (tester) async { + const request = BanRequest(targetUserId: 'user-123'); + + tester.mockApi( + (api) => api.ban(banRequest: request), + result: createDefaultBanResponse(), + ); + + final result = await tester.moderation.ban(banRequest: request); + + expect(result.isSuccess, isTrue); + tester.verifyApi((api) => api.ban(banRequest: request)); + }, + ); + + moderationClientTest( + 'should handle ban failure', + body: (tester) async { + const request = BanRequest(targetUserId: 'user-123'); + + tester.mockApiFailure( + (api) => api.ban(banRequest: request), + error: Exception('Failed to ban user'), + ); + + final result = await tester.moderation.ban(banRequest: request); + + expect(result.isFailure, isTrue); + tester.verifyApi((api) => api.ban(banRequest: request)); + }, + ); + }); + + // ============================================================ + // FEATURE: Mute Operations + // ============================================================ + + group('mute', () { + setUpAll(() { + registerFallbackValue(const MuteRequest(targetIds: [])); + }); + + moderationClientTest( + 'should mute users successfully', + body: (tester) async { + const request = MuteRequest(targetIds: ['user-123']); + + tester.mockApi( + (api) => api.mute(muteRequest: request), + result: createDefaultMuteResponse(), + ); + + final result = await tester.moderation.mute(muteRequest: request); + + expect(result.isSuccess, isTrue); + tester.verifyApi((api) => api.mute(muteRequest: request)); + }, + ); + + moderationClientTest( + 'should handle mute failure', + body: (tester) async { + const request = MuteRequest(targetIds: ['user-123']); + + tester.mockApiFailure( + (api) => api.mute(muteRequest: request), + error: Exception('Failed to mute user'), + ); + + final result = await tester.moderation.mute(muteRequest: request); + + expect(result.isFailure, isTrue); + tester.verifyApi((api) => api.mute(muteRequest: request)); + }, + ); + }); + + // ============================================================ + // FEATURE: Block Operations + // ============================================================ + + group('blockUsers', () { + setUpAll(() { + registerFallbackValue(const BlockUsersRequest(blockedUserId: 'fallback')); + }); + + moderationClientTest( + 'should block user successfully', + body: (tester) async { + const request = BlockUsersRequest(blockedUserId: 'user-123'); + + tester.mockApi( + (api) => api.blockUsers(blockUsersRequest: request), + result: createDefaultBlockUsersResponse(blockedUserId: 'user-123'), + ); + + final result = await tester.moderation.blockUsers( + blockUsersRequest: request, + ); + + expect(result.isSuccess, isTrue); + tester.verifyApi( + (api) => api.blockUsers(blockUsersRequest: request), + ); + }, + ); + + moderationClientTest( + 'should handle block failure', + body: (tester) async { + const request = BlockUsersRequest(blockedUserId: 'user-123'); + + tester.mockApiFailure( + (api) => api.blockUsers(blockUsersRequest: request), + error: Exception('Failed to block user'), + ); + + final result = await tester.moderation.blockUsers( + blockUsersRequest: request, + ); + + expect(result.isFailure, isTrue); + tester.verifyApi( + (api) => api.blockUsers(blockUsersRequest: request), + ); + }, + ); + }); + + group('unblockUsers', () { + setUpAll(() { + registerFallbackValue( + const UnblockUsersRequest(blockedUserId: 'fallback'), + ); + }); + + moderationClientTest( + 'should unblock user successfully', + body: (tester) async { + const request = UnblockUsersRequest(blockedUserId: 'user-123'); + + tester.mockApi( + (api) => api.unblockUsers(unblockUsersRequest: request), + result: createDefaultUnblockUsersResponse(), + ); + + final result = await tester.moderation.unblockUsers( + unblockUsersRequest: request, + ); + + expect(result.isSuccess, isTrue); + tester.verifyApi( + (api) => api.unblockUsers(unblockUsersRequest: request), + ); + }, + ); + + moderationClientTest( + 'should handle unblock failure', + body: (tester) async { + const request = UnblockUsersRequest(blockedUserId: 'user-123'); + + tester.mockApiFailure( + (api) => api.unblockUsers(unblockUsersRequest: request), + error: Exception('Failed to unblock user'), + ); + + final result = await tester.moderation.unblockUsers( + unblockUsersRequest: request, + ); + + expect(result.isFailure, isTrue); + tester.verifyApi( + (api) => api.unblockUsers(unblockUsersRequest: request), + ); + }, + ); + }); + + group('getBlockedUsers', () { + moderationClientTest( + 'should get blocked users successfully', + body: (tester) async { + tester.mockApi( + (api) => api.getBlockedUsers(), + result: createDefaultGetBlockedUsersResponse( + blocks: [ + createDefaultBlockedUserResponse(blockedUserId: 'user-123'), + ], + ), + ); + + final result = await tester.moderation.getBlockedUsers(); + + expect(result.isSuccess, isTrue); + tester.verifyApi((api) => api.getBlockedUsers()); + }, + ); + + moderationClientTest( + 'should handle get blocked users failure', + body: (tester) async { + tester.mockApiFailure( + (api) => api.getBlockedUsers(), + error: Exception('Failed to get blocked users'), + ); + + final result = await tester.moderation.getBlockedUsers(); + + expect(result.isFailure, isTrue); + tester.verifyApi((api) => api.getBlockedUsers()); + }, + ); + }); + + // ============================================================ + // FEATURE: Flag Operations + // ============================================================ + + group('flag', () { + setUpAll(() { + registerFallbackValue( + const FlagRequest(entityId: 'fallback', entityType: 'fallback'), + ); + }); + + moderationClientTest( + 'should flag content successfully', + body: (tester) async { + const request = FlagRequest( + entityId: 'activity-123', + entityType: 'activity', + ); + + tester.mockApi( + (api) => api.flag(flagRequest: request), + result: createDefaultFlagResponse(itemId: 'activity-123'), + ); + + final result = await tester.moderation.flag(flagRequest: request); + + expect(result.isSuccess, isTrue); + tester.verifyApi((api) => api.flag(flagRequest: request)); + }, + ); + + moderationClientTest( + 'should handle flag failure', + body: (tester) async { + const request = FlagRequest( + entityId: 'activity-123', + entityType: 'activity', + ); + + tester.mockApiFailure( + (api) => api.flag(flagRequest: request), + error: Exception('Failed to flag content'), + ); + + final result = await tester.moderation.flag(flagRequest: request); + + expect(result.isFailure, isTrue); + tester.verifyApi((api) => api.flag(flagRequest: request)); + }, + ); + }); + + // ============================================================ + // FEATURE: Moderation Actions + // ============================================================ + + group('submitAction', () { + setUpAll(() { + registerFallbackValue( + const SubmitActionRequest( + actionType: SubmitActionRequestActionType.markReviewed, + itemId: 'fallback', + ), + ); + }); + + moderationClientTest( + 'should submit moderation action successfully', + body: (tester) async { + const request = SubmitActionRequest( + actionType: SubmitActionRequestActionType.markReviewed, + itemId: 'item-123', + ); + + tester.mockApi( + (api) => api.submitAction(submitActionRequest: request), + result: createDefaultSubmitActionResponse(), + ); + + final result = await tester.moderation.submitAction( + submitActionRequest: request, + ); + + expect(result.isSuccess, isTrue); + tester.verifyApi( + (api) => api.submitAction(submitActionRequest: request), + ); + }, + ); + + moderationClientTest( + 'should handle submit action failure', + body: (tester) async { + const request = SubmitActionRequest( + actionType: SubmitActionRequestActionType.ban, + itemId: 'item-456', + ); + + tester.mockApiFailure( + (api) => api.submitAction(submitActionRequest: request), + error: Exception('Failed to submit action'), + ); + + final result = await tester.moderation.submitAction( + submitActionRequest: request, + ); + + expect(result.isFailure, isTrue); + tester.verifyApi( + (api) => api.submitAction(submitActionRequest: request), + ); + }, + ); + }); + + // ============================================================ + // FEATURE: Review Queue Operations + // ============================================================ + + group('queryReviewQueue', () { + setUpAll(() { + registerFallbackValue(const QueryReviewQueueRequest()); + }); + + moderationClientTest( + 'should query review queue successfully', + body: (tester) async { + const request = QueryReviewQueueRequest(limit: 20); + + tester.mockApi( + (api) => api.queryReviewQueue(queryReviewQueueRequest: request), + result: createDefaultQueryReviewQueueResponse(next: 'next-cursor'), + ); + + final result = await tester.moderation.queryReviewQueue( + queryReviewQueueRequest: request, + ); + + expect(result.isSuccess, isTrue); + tester.verifyApi( + (api) => api.queryReviewQueue(queryReviewQueueRequest: request), + ); + }, + ); + + moderationClientTest( + 'should handle query review queue failure', + body: (tester) async { + const request = QueryReviewQueueRequest(); + + tester.mockApiFailure( + (api) => api.queryReviewQueue(queryReviewQueueRequest: request), + error: Exception('Failed to query review queue'), + ); + + final result = await tester.moderation.queryReviewQueue( + queryReviewQueueRequest: request, + ); + + expect(result.isFailure, isTrue); + tester.verifyApi( + (api) => api.queryReviewQueue(queryReviewQueueRequest: request), + ); + }, + ); + }); + + // ============================================================ + // FEATURE: Config Operations + // ============================================================ + + group('upsertConfig', () { + setUpAll(() { + registerFallbackValue(const UpsertConfigRequest(key: 'fallback')); + }); + + moderationClientTest( + 'should upsert config successfully', + body: (tester) async { + const request = UpsertConfigRequest(key: 'config-1'); + + tester.mockApi( + (api) => api.upsertConfig(upsertConfigRequest: request), + result: createDefaultUpsertConfigResponse(key: 'config-1'), + ); + + final result = await tester.moderation.upsertConfig(request); + + expect(result.isSuccess, isTrue); + tester.verifyApi( + (api) => api.upsertConfig(upsertConfigRequest: request), + ); + }, + ); + + moderationClientTest( + 'should handle upsert config failure', + body: (tester) async { + const request = UpsertConfigRequest(key: 'config-1'); + + tester.mockApiFailure( + (api) => api.upsertConfig(upsertConfigRequest: request), + error: Exception('Failed to upsert config'), + ); + + final result = await tester.moderation.upsertConfig(request); + + expect(result.isFailure, isTrue); + tester.verifyApi( + (api) => api.upsertConfig(upsertConfigRequest: request), + ); + }, + ); + }); + + group('deleteConfig', () { + moderationClientTest( + 'should delete config successfully', + body: (tester) async { + const key = 'config-1'; + + tester.mockApi( + (api) => api.deleteConfig(key: key, team: null), + result: createDefaultDeleteModerationConfigResponse(), + ); + + final result = await tester.moderation.deleteConfig(key: key); + + expect(result.isSuccess, isTrue); + tester.verifyApi( + (api) => api.deleteConfig(key: key, team: null), + ); + }, + ); + + moderationClientTest( + 'should handle delete config failure', + body: (tester) async { + const key = 'config-1'; + + tester.mockApiFailure( + (api) => api.deleteConfig(key: key, team: null), + error: Exception('Failed to delete config'), + ); + + final result = await tester.moderation.deleteConfig(key: key); + + expect(result.isFailure, isTrue); + tester.verifyApi( + (api) => api.deleteConfig(key: key, team: null), + ); + }, + ); + }); + + group('getConfig', () { + moderationClientTest( + 'should get config successfully', + body: (tester) async { + const key = 'config-1'; + + tester.mockApi( + (api) => api.getConfig(key: key, team: null), + result: createDefaultGetConfigResponse(key: key), + ); + + final result = await tester.moderation.getConfig(key: key); + + expect(result.isSuccess, isTrue); + tester.verifyApi((api) => api.getConfig(key: key, team: null)); + }, + ); + + moderationClientTest( + 'should handle get config failure', + body: (tester) async { + const key = 'config-1'; + + tester.mockApiFailure( + (api) => api.getConfig(key: key, team: null), + error: Exception('Config not found'), + ); + + final result = await tester.moderation.getConfig(key: key); + + expect(result.isFailure, isTrue); + tester.verifyApi((api) => api.getConfig(key: key, team: null)); + }, + ); + }); + + group('queryModerationConfigs', () { + setUpAll(() { + registerFallbackValue(const QueryModerationConfigsRequest()); + }); + + moderationClientTest( + 'should query moderation configs successfully', + body: (tester) async { + const query = ModerationConfigsQuery(limit: 10); + final request = query.toRequest(); + + tester.mockApi( + (api) => api.queryModerationConfigs( + queryModerationConfigsRequest: request, + ), + result: createDefaultQueryModerationConfigsResponse( + configs: [createDefaultConfigResponse()], + ), + ); + + final result = await tester.moderation.queryModerationConfigs( + queryModerationConfigsRequest: query, + ); + + expect(result.isSuccess, isTrue); + final paginationResult = result.getOrThrow(); + expect(paginationResult.items, isNotEmpty); + + tester.verifyApi( + (api) => api.queryModerationConfigs( + queryModerationConfigsRequest: request, + ), + ); + }, + ); + + moderationClientTest( + 'should handle query moderation configs failure', + body: (tester) async { + const query = ModerationConfigsQuery(limit: 10); + final request = query.toRequest(); + + tester.mockApiFailure( + (api) => api.queryModerationConfigs( + queryModerationConfigsRequest: request, + ), + error: Exception('Failed to query configs'), + ); + + final result = await tester.moderation.queryModerationConfigs( + queryModerationConfigsRequest: query, + ); + + expect(result.isFailure, isTrue); + + tester.verifyApi( + (api) => api.queryModerationConfigs( + queryModerationConfigsRequest: request, + ), + ); + }, + ); + }); +} diff --git a/packages/stream_feeds_test/lib/src/helpers/api_mocker_mixin.dart b/packages/stream_feeds_test/lib/src/helpers/api_mocker_mixin.dart index f14b9208..e4fde66b 100644 --- a/packages/stream_feeds_test/lib/src/helpers/api_mocker_mixin.dart +++ b/packages/stream_feeds_test/lib/src/helpers/api_mocker_mixin.dart @@ -36,6 +36,26 @@ mixin ApiMockerMixin { return mockApiResult(apiCall, result: Result.success(result)); } + /// Sets up a mock API failure response for the given API call. + /// + /// Use this when you need to return an error: + /// ```dart + /// tester.mockApiFailure( + /// (api) => api.addActivity(...), + /// error: Exception('Failed to add activity'), + /// ); + /// ``` + void mockApiFailure( + Future> Function(MockDefaultApi api) apiCall, { + required Object error, + StackTrace? stackTrace, + }) { + return mockApiResult( + apiCall, + result: Result.failure(error, stackTrace ?? StackTrace.current), + ); + } + /// Sets up a mock API response with a custom Result. /// /// Use this when you need to return a failure: diff --git a/packages/stream_feeds_test/lib/src/helpers/cdn_mocker_mixin.dart b/packages/stream_feeds_test/lib/src/helpers/cdn_mocker_mixin.dart new file mode 100644 index 00000000..db521861 --- /dev/null +++ b/packages/stream_feeds_test/lib/src/helpers/cdn_mocker_mixin.dart @@ -0,0 +1,91 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import 'mocks.dart'; + +/// A mixin that provides utilities for mocking and verifying CDN API calls. +/// +/// This mixin helps set up expectations for CDN operations like file/image +/// upload and deletion, and verify that the expected calls were made. +mixin CdnMockerMixin { + /// The mock CDN API client instance. + MockCdnApi get cdnApi; + + /// Mocks a CDN API call to return a specific result. + /// + /// Use this to set up expectations for CDN operations: + /// ```dart + /// mockCdn( + /// (cdn) => cdn.deleteFile(url: 'https://example.com/file.pdf'), + /// result: const DurationResponse(duration: '10ms'), + /// ); + /// ``` + void mockCdn( + Future> Function(CdnApi cdn) call, { + required T result, + }) { + when(() => call(cdnApi)).thenAnswer( + (_) async => Result.success(result), + ); + } + + /// Mocks a CDN API call to fail with an error. + /// + /// Use this to simulate error scenarios: + /// ```dart + /// mockCdnFailure( + /// (cdn) => cdn.deleteImage(url: 'https://example.com/image.jpg'), + /// error: Exception('Failed to delete'), + /// ); + /// ``` + void mockCdnFailure( + Future> Function(CdnApi cdn) call, { + required Object error, + }) { + when(() => call(cdnApi)).thenAnswer( + (_) async => Result.failure(error), + ); + } + + /// Verifies that a CDN API call was made. + /// + /// Use this after performing operations to verify the expected CDN calls: + /// ```dart + /// verifyCdn( + /// (cdn) => cdn.deleteFile(url: 'https://example.com/file.pdf'), + /// ); + /// ``` + void verifyCdn( + Future> Function(CdnApi cdn) call, + ) { + verify(() => call(cdnApi)).called(1); + } + + /// Verifies that a CDN API call was made a specific number of times. + /// + /// ```dart + /// verifyCdnCalled( + /// (cdn) => cdn.uploadImage(file: any(named: 'file')), + /// times: 3, + /// ); + /// ``` + void verifyCdnCalled( + Future> Function(CdnApi cdn) call, { + required int times, + }) { + verify(() => call(cdnApi)).called(times); + } + + /// Verifies that a CDN API call was never made. + /// + /// ```dart + /// verifyNeverCalledCdn( + /// (cdn) => cdn.deleteFile(url: any(named: 'url')), + /// ); + /// ``` + void verifyNeverCalledCdn( + Future> Function(CdnApi cdn) call, + ) { + verifyNever(() => call(cdnApi)); + } +} diff --git a/packages/stream_feeds_test/lib/src/helpers/mocks.dart b/packages/stream_feeds_test/lib/src/helpers/mocks.dart index f43ddf4b..fe136d85 100644 --- a/packages/stream_feeds_test/lib/src/helpers/mocks.dart +++ b/packages/stream_feeds_test/lib/src/helpers/mocks.dart @@ -1,16 +1,18 @@ import 'dart:convert'; import 'package:mocktail/mocktail.dart'; -import 'package:stream_feeds/stream_feeds.dart' as api; +import 'package:stream_feeds/stream_feeds.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; -class MockDefaultApi extends Mock implements api.DefaultApi {} +class MockCdnApi extends Mock implements CdnApi {} + +class MockDefaultApi extends Mock implements DefaultApi {} class MockWebSocketSink extends Mock implements WebSocketSink {} class MockWebSocketChannel extends Mock implements WebSocketChannel {} -api.UserToken generateTestUserToken(String userId) { +UserToken generateTestUserToken(String userId) { String b64UrlNoPad(Object jsonObj) { final bytes = utf8.encode(jsonEncode(jsonObj)); return base64Url.encode(bytes).replaceAll('=', ''); @@ -22,5 +24,5 @@ api.UserToken generateTestUserToken(String userId) { final jwt = '${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'; // trailing dot = empty signature (valid for alg=none) - return api.UserToken(jwt); + return UserToken(jwt); } diff --git a/packages/stream_feeds_test/lib/src/helpers/test_data.dart b/packages/stream_feeds_test/lib/src/helpers/test_data.dart index d47c55af..f1b71165 100644 --- a/packages/stream_feeds_test/lib/src/helpers/test_data.dart +++ b/packages/stream_feeds_test/lib/src/helpers/test_data.dart @@ -884,3 +884,194 @@ QueryModerationConfigsResponse createDefaultQueryModerationConfigsResponse({ duration: '10ms', ); } + +ConfigResponse createDefaultConfigResponse({ + String key = 'default-config', + String team = 'default-team', +}) { + return ConfigResponse( + key: key, + team: team, + async: true, + createdAt: DateTime(2021, 1, 1), + updatedAt: DateTime(2021, 2, 1), + supportedVideoCallHarmTypes: const [], + ); +} + +BanResponse createDefaultBanResponse() { + return const BanResponse(duration: '10ms'); +} + +MuteResponse createDefaultMuteResponse({ + List? mutes, +}) { + return MuteResponse( + duration: '10ms', + mutes: mutes, + ); +} + +BlockUsersResponse createDefaultBlockUsersResponse({ + String blockedUserId = 'user-123', + String blockedByUserId = 'user-1', +}) { + return BlockUsersResponse( + blockedUserId: blockedUserId, + blockedByUserId: blockedByUserId, + createdAt: DateTime(2021, 1, 1), + duration: '10ms', + ); +} + +UnblockUsersResponse createDefaultUnblockUsersResponse() { + return const UnblockUsersResponse(duration: '10ms'); +} + +GetBlockedUsersResponse createDefaultGetBlockedUsersResponse({ + List blocks = const [], +}) { + return GetBlockedUsersResponse( + blocks: blocks, + duration: '10ms', + ); +} + +BlockedUserResponse createDefaultBlockedUserResponse({ + String blockedUserId = 'user-123', + String userId = 'user-1', +}) { + return BlockedUserResponse( + blockedUserId: blockedUserId, + blockedUser: createDefaultUserResponse(id: blockedUserId), + userId: userId, + user: createDefaultUserResponse(id: userId), + createdAt: DateTime(2021, 1, 1), + ); +} + +FlagResponse createDefaultFlagResponse({ + String itemId = 'activity-123', +}) { + return FlagResponse( + duration: '10ms', + itemId: itemId, + ); +} + +SubmitActionResponse createDefaultSubmitActionResponse({ + ReviewQueueItemResponse? item, +}) { + return SubmitActionResponse( + duration: '10ms', + item: item, + ); +} + +QueryReviewQueueResponse createDefaultQueryReviewQueueResponse({ + String? next, + String? prev, + List items = const [], +}) { + return QueryReviewQueueResponse( + actionConfig: const {}, + items: items, + next: next, + prev: prev, + stats: const {}, + duration: '10ms', + ); +} + +UpsertConfigResponse createDefaultUpsertConfigResponse({ + String key = 'config-1', +}) { + return UpsertConfigResponse( + config: createDefaultModerationConfigResponse(key: key), + duration: '10ms', + ); +} + +DeleteModerationConfigResponse createDefaultDeleteModerationConfigResponse() { + return const DeleteModerationConfigResponse(duration: '10ms'); +} + +GetConfigResponse createDefaultGetConfigResponse({ + String key = 'config-1', + String? team, +}) { + return GetConfigResponse( + config: createDefaultModerationConfigResponse( + key: key, + team: team ?? 'team-id', + ), + duration: '10ms', + ); +} + +UpsertActivitiesResponse createDefaultUpsertActivitiesResponse({ + int count = 1, +}) { + return UpsertActivitiesResponse( + activities: List.generate( + count, + (index) => createDefaultActivityResponse(id: 'activity-${index + 1}'), + ), + duration: '10ms', + ); +} + +DeleteActivitiesResponse createDefaultDeleteActivitiesResponse({ + List ids = const ['activity-1'], +}) { + return DeleteActivitiesResponse( + deletedIds: ids, + duration: '10ms', + ); +} + +GetApplicationResponse createDefaultGetApplicationResponse({ + String name = 'Test App', +}) { + return GetApplicationResponse( + app: AppResponseFields( + asyncUrlEnrichEnabled: false, + autoTranslationEnabled: false, + id: 1, + name: name, + placement: 'default', + fileUploadConfig: const FileUploadConfig( + allowedFileExtensions: ['jpg', 'png', 'gif', 'mp4'], + allowedMimeTypes: ['image/jpeg', 'image/png', 'video/mp4'], + blockedFileExtensions: [], + blockedMimeTypes: [], + sizeLimit: 10485760, + ), + imageUploadConfig: const FileUploadConfig( + allowedFileExtensions: ['jpg', 'png', 'gif'], + allowedMimeTypes: ['image/jpeg', 'image/png', 'image/gif'], + blockedFileExtensions: [], + blockedMimeTypes: [], + sizeLimit: 5242880, + ), + ), + duration: '10ms', + ); +} + +ListDevicesResponse createDefaultListDevicesResponse({ + List devices = const [], +}) { + return ListDevicesResponse( + devices: devices, + duration: '10ms', + ); +} + +DurationResponse createDefaultCreateDeviceResponse() { + return const DurationResponse(duration: '10ms'); +} + +DurationResponse createDefaultDeleteDeviceResponse() { + return const DurationResponse(duration: '10ms'); +} diff --git a/packages/stream_feeds_test/lib/src/testers/base_tester.dart b/packages/stream_feeds_test/lib/src/testers/base_tester.dart index d1194a8d..e9b0ada7 100644 --- a/packages/stream_feeds_test/lib/src/testers/base_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/base_tester.dart @@ -1,13 +1,13 @@ import 'dart:async'; -import 'dart:convert'; import 'package:meta/meta.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart' as test; import '../helpers/api_mocker_mixin.dart'; +import '../helpers/cdn_mocker_mixin.dart'; import '../helpers/mocks.dart'; -import '../helpers/web_socket_mocks.dart'; +import 'websocket_tester.dart'; /// Factory function signature for creating tester instances. /// @@ -15,6 +15,7 @@ import '../helpers/web_socket_mocks.dart'; typedef TesterFactory> = Future Function({ required S subject, required StreamFeedsClient client, + required MockCdnApi cdnApi, required MockDefaultApi feedsApi, required MockWebSocketChannel webSocketChannel, }); @@ -25,91 +26,179 @@ typedef TesterFactory> = Future Function({ /// and making assertions about the state object being tested. /// /// Type parameter [S] is the subject being tested. -abstract base class BaseTester with ApiMockerMixin { +abstract base class BaseTester with ApiMockerMixin, CdnMockerMixin { const BaseTester({ required this.subject, + required this.cdnApi, required this.feedsApi, - required StreamController wsStreamController, - }) : _wsStreamController = wsStreamController; + required WebSocketTester wsTester, + required StreamFeedsClient client, + }) : _client = client, + _wsTester = wsTester; /// The subject being tested. final S subject; + @override + @protected + final MockCdnApi cdnApi; + @override @protected final MockDefaultApi feedsApi; - // WebSocket stream controller for emitting events. - final StreamController _wsStreamController; + /// The underlying StreamFeedsClient from which the subject was built. + /// + /// Use this to access client-level properties and methods. + /// + /// Example: + /// ```dart + /// final userId = tester.client.user.id; + /// ``` + /// + /// Note: prefer using [subject] for testing the specific state object. + StreamFeedsClient get client => _client; + final StreamFeedsClient _client; + + /// The user for whom the test client is configured. + /// + /// This is the user that will be used for authentication and performing all + /// actions through the client. Convenience getter that returns `client.user`. + /// + /// Example: + /// ```dart + /// // Use the configured user's ID for authentication + /// tester.mockSuccessfulAuth(tester.user.id); + /// + /// // Verify actions are performed as this user + /// expect(tester.user.id, equals('luke_skywalker')); + /// ``` + User get user => _client.user; + + // WebSocket tester for managing WebSocket interactions. + final WebSocketTester _wsTester; + + /// Configures WebSocket mocks to simulate successful authentication for [userId]. + /// + /// Use this in test setup to configure how the WebSocket should respond to + /// connection attempts. + /// + /// Example: + /// ```dart + /// tester.mockSuccessfulAuth('user-123'); + /// await client.connect(); // Will succeed + /// ``` + void mockSuccessfulAuth(String userId) { + return _wsTester.mockSuccessfulAuth(userId); + } + + /// Configures WebSocket mocks to simulate authentication failure. + /// + /// The [errorCode] parameter allows customizing the error code returned. + /// Default is 40 (expiredToken), which prevents automatic reconnection. + /// + /// Use this in test setup when testing error scenarios. + /// + /// Example: + /// ```dart + /// // Default error code (no reconnection) + /// tester.mockFailedAuth(); + /// + /// // Custom error code (triggers reconnection) + /// tester.mockFailedAuth(errorCode: 5); + /// + /// await expectLater(client.connect(), throwsA(isA())); + /// ``` + void mockFailedAuth({int errorCode = 40}) { + return _wsTester.mockFailedAuth(errorCode: errorCode); + } - /// Emits a WebSocket event and waits for it to be processed. + /// Emits a WebSocket event and pumps the event loop. + /// + /// This method emits the given [event] through the WebSocket stream and + /// then pumps the event loop to allow async event handlers to run. + /// + /// Use this to test how state objects react to real-time events. + /// + /// Example: + /// ```dart + /// await tester.emitEvent({'type': 'activity.added', ...}); + /// expect(tester.subject.state.activities, hasLength(1)); + /// ``` Future emitEvent(Object event) async { - _wsStreamController.add(jsonEncode(event)); - await pump(); + _wsTester.emitEvent(event); + await pumpEventQueue(); } /// Waits for events to be processed. /// - /// By default uses a zero-duration delay to allow the event loop to process - /// pending events. Pass [duration] for longer waits if needed. + /// Returns a [Future] that completes after the [event loop][] has run the given + /// number of [times] (20 by default). + /// + /// [event loop]: https://medium.com/dartlang/dart-asynchronous-programming-isolates-and-event-loops-bffc3e296a6a + /// + /// Awaiting this approximates waiting until all asynchronous work (other than + /// work that's waiting for external resources) completes. /// /// Example: /// ```dart - /// await tester.pump(); // Default zero duration - /// await tester.pump(Duration(milliseconds: 100)); // Wait 100ms + /// // Pump event queue 20 times (default) + /// await tester.pumpEventQueue(); + /// + /// // Pump event queue 50 times for more complex async operations + /// await tester.pumpEventQueue(times: 50); /// ``` - Future pump([Duration duration = Duration.zero]) { - return Future.delayed(duration); + Future pumpEventQueue({int times = 20}) { + return test.pumpEventQueue(times: times); } } /// Creates a tester instance with WebSocket support. /// -/// This is a generic factory function that handles all the common setup -/// for creating test utilities. It automatically: -/// - Creates and registers WebSocket components for cleanup -/// - Sets up WebSocket connection -/// - Connects the client -/// - Registers client disconnection for cleanup +/// This is a generic factory function that handles common setup for creating +/// test utilities. It automatically: +/// - Creates a WebSocket stream controller +/// - Sets up WebSocket tester with the mock channel +/// - Registers cleanup for the stream controller /// -/// The create callback receives the WebSocket stream controller and should +/// The [create] callback receives the [WebSocketTester] instance and should /// return the concrete tester instance. /// /// This function is for internal use by concrete tester factories only. Future createTester>({ - required StreamFeedsClient client, required MockWebSocketChannel webSocketChannel, - required T Function(StreamController) create, + required T Function(WebSocketTester ws) create, }) async { // Create WebSocket stream controller final wsStreamController = StreamController(); test.addTearDown(wsStreamController.close); // Close controller after test - // Set up WebSocket channel mocks - whenListenWebSocket(webSocketChannel, wsStreamController); - - // Connect client - await client.connect(); - test.addTearDown(client.disconnect); // Disconnect client after test + // Create WebSocket tester + final ws = WebSocketTester( + channel: webSocketChannel, + streamController: wsStreamController, + ); - return create(wsStreamController); + return create(ws); } /// Generic test helper for state objects with WebSocket support. /// -/// Automatically sets up WebSocket connection, client, and test infrastructure. +/// Automatically sets up the test client, WebSocket infrastructure, and +/// coordinates the test lifecycle. /// /// Parameters: -/// - user: the authenticated user for the test client (defaults to luke_skywalker) -/// - build: constructs the subject under test using the provided StreamFeedsClient -/// - createTesterFn: the concrete tester factory function -/// - setUp: optional, runs before body for setting up mocks and test state -/// - body: the test callback that receives a tester for interactions -/// - verify: optional, runs after body for verifying API calls -/// - tearDown: optional, runs after verify for custom cleanup -/// - skip: optional, skip this test -/// - tags: optional, tags for test filtering -/// - timeout: optional, custom timeout for this test +/// - [user]: the user for whom the client is configured (defaults to luke_skywalker) +/// - [build]: constructs the subject under test using the provided StreamFeedsClient +/// - [createTesterFn]: the concrete tester factory function +/// - [connect]: optional, custom connection logic (defaults to successful auth + connect) +/// - [setUp]: optional, runs before body for setting up mocks and test state +/// - [body]: the test callback that receives a tester for interactions +/// - [verify]: optional, runs after body for verifying API calls +/// - [tearDown]: optional, runs after verify for custom cleanup +/// - [skip]: optional, skip this test +/// - [tags]: optional, tags for test filtering +/// - [timeout]: optional, custom timeout for this test /// /// This function is for internal use by concrete test helpers. void testWithTester>( @@ -117,6 +206,7 @@ void testWithTester>( User user = const User(id: 'luke_skywalker'), required S Function(StreamFeedsClient client) build, required TesterFactory createTesterFn, + FutureOr Function(T tester)? connect, FutureOr Function(T tester)? setUp, required FutureOr Function(T tester) body, FutureOr Function(T tester)? verify, @@ -132,6 +222,7 @@ void testWithTester>( timeout: timeout, () async { await _runZonedGuarded(() async { + final cdnApi = MockCdnApi(); final feedsApi = MockDefaultApi(); final webSocketChannel = MockWebSocketChannel(); @@ -143,15 +234,22 @@ void testWithTester>( ), feedsRestApi: feedsApi, wsProvider: (options) => webSocketChannel, + config: FeedsConfig( + cdnClient: FeedsCdnClient(cdnApi), + ), ); final tester = await createTesterFn( subject: build.call(client), client: client, + cdnApi: cdnApi, feedsApi: feedsApi, webSocketChannel: webSocketChannel, ); + final connectFn = connect ?? _defaultConnect; + await connectFn.call(tester); + await setUp?.call(tester); await body(tester); await verify?.call(tester); @@ -161,6 +259,19 @@ void testWithTester>( ); } +// Default connect implementation: mock successful auth and connect client. +Future _defaultConnect(BaseTester tester) async { + // Mock successful authentication for the client's user + tester.mockSuccessfulAuth(tester.client.user.id); + + // Connect client + await tester.client.connect(); + test.addTearDown(tester.client.disconnect); // Disconnect client after test + + // Verify client is connected + test.expect(tester.client.connectionState.value, test.isA()); +} + // Runs the test body in a guarded zone to catch all errors. // // This ensures that errors from event handlers, timers, and unawaited diff --git a/packages/stream_feeds_test/lib/src/testers/feeds_client_tester.dart b/packages/stream_feeds_test/lib/src/testers/feeds_client_tester.dart new file mode 100644 index 00000000..e7fd4710 --- /dev/null +++ b/packages/stream_feeds_test/lib/src/testers/feeds_client_tester.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:test/test.dart' as test; + +import '../helpers/mocks.dart'; +import 'base_tester.dart'; + +/// Test helper for feeds client operations. +/// +/// Automatically sets up client and test infrastructure. +/// Tests are tagged with 'feeds-client' by default for filtering. +/// +/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). +/// [setUp] is optional and runs before [body] for setting up mocks and test state. +/// [body] is the test callback that receives a [FeedsClientTester] for interactions. +/// [verify] is optional and runs after [body] for verifying API calls and interactions. +/// [tearDown] is optional and runs after [verify] for custom cleanup. +/// [skip] is optional, skip this test. +/// [tags] is optional, tags for test filtering. Defaults to ['feeds-client']. +/// [timeout] is optional, custom timeout for this test. +/// +/// Example: +/// ```dart +/// feedsClientTest( +/// 'should create feed successfully', +/// body: (tester) async { +/// final query = FeedQuery(fid: FeedId(group: 'user', id: 'john')); +/// +/// tester.mockApi( +/// (api) => api.getOrCreateFeed(...), +/// result: createDefaultGetOrCreateFeedResponse(), +/// ); +/// +/// final feed = tester.client.feed(query.fid); +/// await feed.getOrCreate(); +/// }, +/// ); +/// ``` +@isTest +void feedsClientTest( + String description, { + User user = const User(id: 'luke_skywalker'), + FutureOr Function(FeedsClientTester tester)? connect, + FutureOr Function(FeedsClientTester tester)? setUp, + required FutureOr Function(FeedsClientTester tester) body, + FutureOr Function(FeedsClientTester tester)? verify, + FutureOr Function(FeedsClientTester tester)? tearDown, + bool skip = false, + Iterable tags = const ['feeds-client'], + test.Timeout? timeout, +}) { + return testWithTester( + description, + user: user, + build: (client) => client, + createTesterFn: _createFeedsClientTester, + connect: connect, + setUp: setUp, + body: body, + verify: verify, + tearDown: tearDown, + skip: skip, + tags: tags, + timeout: timeout, + ); +} + +/// A test utility for feeds client operations. +/// +/// Provides helper methods for mocking and verifying feeds API calls and CDN operations. +/// +/// Resources are automatically cleaned up after the test completes. +final class FeedsClientTester extends BaseTester { + const FeedsClientTester._({ + required super.client, + required super.wsTester, + required super.feedsApi, + required super.cdnApi, + }) : super(subject: client); +} + +// Creates a FeedsClientTester for testing feeds client operations. +// +// Automatically sets up WebSocket connection and registers cleanup handlers. +// This function is for internal use by feedsClientTest only. +Future _createFeedsClientTester({ + required StreamFeedsClient subject, + required StreamFeedsClient client, + required MockCdnApi cdnApi, + required MockDefaultApi feedsApi, + required MockWebSocketChannel webSocketChannel, +}) { + return createTester( + webSocketChannel: webSocketChannel, + create: (wsTester) => FeedsClientTester._( + client: subject, + wsTester: wsTester, + cdnApi: cdnApi, + feedsApi: feedsApi, + ), + ); +} diff --git a/packages/stream_feeds_test/lib/src/testers/moderation_client_tester.dart b/packages/stream_feeds_test/lib/src/testers/moderation_client_tester.dart new file mode 100644 index 00000000..a25f2020 --- /dev/null +++ b/packages/stream_feeds_test/lib/src/testers/moderation_client_tester.dart @@ -0,0 +1,112 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:test/test.dart' as test; + +import '../helpers/mocks.dart'; +import 'base_tester.dart'; + +/// Test helper for moderation client operations. +/// +/// Automatically sets up client and test infrastructure. +/// Tests are tagged with 'moderation-client' by default for filtering. +/// +/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). +/// [setUp] is optional and runs before [body] for setting up mocks and test state. +/// [body] is the test callback that receives a [ModerationClientTester] for interactions. +/// [verify] is optional and runs after [body] for verifying API calls and interactions. +/// [tearDown] is optional and runs after [verify] for custom cleanup. +/// [skip] is optional, skip this test. +/// [tags] is optional, tags for test filtering. Defaults to ['moderation-client']. +/// [timeout] is optional, custom timeout for this test. +/// +/// Example: +/// ```dart +/// moderationClientTest( +/// 'should ban user via API', +/// setUp: (tester) { +/// tester.mockApi( +/// (api) => api.ban(banRequest: any(named: 'banRequest')), +/// result: createDefaultBanResponse(), +/// ); +/// }, +/// body: (tester) async { +/// const request = BanRequest(targetUserId: 'user-123'); +/// final result = await tester.moderation.ban(banRequest: request); +/// expect(result.isSuccess, isTrue); +/// }, +/// verify: (tester) => tester.verifyApi( +/// (api) => api.ban(banRequest: any(named: 'banRequest')), +/// ), +/// ); +/// ``` +@isTest +void moderationClientTest( + String description, { + User user = const User(id: 'luke_skywalker'), + FutureOr Function(ModerationClientTester tester)? connect, + FutureOr Function(ModerationClientTester tester)? setUp, + required FutureOr Function(ModerationClientTester tester) body, + FutureOr Function(ModerationClientTester tester)? verify, + FutureOr Function(ModerationClientTester tester)? tearDown, + bool skip = false, + Iterable tags = const ['moderation-client'], + test.Timeout? timeout, +}) { + return testWithTester( + description, + user: user, + build: (client) => client.moderation, + createTesterFn: _createModerationClientTester, + connect: connect, + setUp: setUp, + body: body, + verify: verify, + tearDown: tearDown, + skip: skip, + tags: tags, + timeout: timeout, + ); +} + +/// A test utility for moderation client operations. +/// +/// Provides helper methods for mocking and verifying moderation API calls. +/// +/// Resources are automatically cleaned up after the test completes. +final class ModerationClientTester extends BaseTester { + const ModerationClientTester._({ + required ModerationClient moderation, + required super.client, + required super.wsTester, + required super.feedsApi, + required super.cdnApi, + }) : super(subject: moderation); + + /// The Moderation client being tested. + ModerationClient get moderation => subject; +} + +// Creates a ModerationClientTester for testing moderation client operations. +// +// Automatically sets up WebSocket connection and registers cleanup handlers. +// This function is for internal use by moderationClientTest only. +Future _createModerationClientTester({ + required ModerationClient subject, + required StreamFeedsClient client, + required MockCdnApi cdnApi, + required MockDefaultApi feedsApi, + required MockWebSocketChannel webSocketChannel, +}) { + return createTester( + webSocketChannel: webSocketChannel, + create: (wsTester) => ModerationClientTester._( + moderation: subject, + client: client, + wsTester: wsTester, + cdnApi: cdnApi, + feedsApi: feedsApi, + ), + ); +} diff --git a/packages/stream_feeds_test/lib/src/testers/activity_comment_list_tester.dart b/packages/stream_feeds_test/lib/src/testers/state/activity_comment_list_tester.dart similarity index 89% rename from packages/stream_feeds_test/lib/src/testers/activity_comment_list_tester.dart rename to packages/stream_feeds_test/lib/src/testers/state/activity_comment_list_tester.dart index 2b1d57b4..faf18084 100644 --- a/packages/stream_feeds_test/lib/src/testers/activity_comment_list_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/state/activity_comment_list_tester.dart @@ -4,18 +4,19 @@ import 'package:meta/meta.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart' as test; -import '../helpers/mocks.dart'; -import '../helpers/test_data.dart'; -import 'base_tester.dart'; +import '../../helpers/mocks.dart'; +import '../../helpers/test_data.dart'; +import '../base_tester.dart'; /// Test helper for activity comment list operations. /// /// Automatically sets up WebSocket connection, client, and test infrastructure. /// Tests are tagged with 'activity-comment-list' by default for filtering. /// -/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). +/// [user] is optional, the user for whom the client is configured (defaults to luke_skywalker). /// [build] constructs the [ActivityCommentList] under test using the provided [StreamFeedsClient]. +/// [connect] is optional, custom connection logic (defaults to successful auth + connect). /// [setUp] is optional and runs before [body] for setting up mocks and test state. /// [body] is the test callback that receives an [ActivityCommentListTester] for interactions. /// [verify] is optional and runs after [body] for verifying API calls and interactions. @@ -45,6 +46,7 @@ void activityCommentListTest( String description, { User user = const User(id: 'luke_skywalker'), required ActivityCommentList Function(StreamFeedsClient client) build, + FutureOr Function(ActivityCommentListTester tester)? connect, FutureOr Function(ActivityCommentListTester tester)? setUp, required FutureOr Function(ActivityCommentListTester tester) body, FutureOr Function(ActivityCommentListTester tester)? verify, @@ -58,6 +60,7 @@ void activityCommentListTest( user: user, build: build, createTesterFn: _createActivityCommentListTester, + connect: connect, setUp: setUp, body: body, verify: verify, @@ -76,8 +79,10 @@ void activityCommentListTest( final class ActivityCommentListTester extends BaseTester { const ActivityCommentListTester._({ required ActivityCommentList activityCommentList, - required super.wsStreamController, + required super.client, + required super.wsTester, required super.feedsApi, + required super.cdnApi, }) : super(subject: activityCommentList); /// The activity comment list being tested. @@ -158,6 +163,7 @@ final class ActivityCommentListTester extends BaseTester { Future _createActivityCommentListTester({ required ActivityCommentList subject, required StreamFeedsClient client, + required MockCdnApi cdnApi, required MockDefaultApi feedsApi, required MockWebSocketChannel webSocketChannel, }) { @@ -165,11 +171,12 @@ Future _createActivityCommentListTester({ test.addTearDown(subject.dispose); return createTester( - client: client, webSocketChannel: webSocketChannel, - create: (wsStreamController) => ActivityCommentListTester._( + create: (wsTester) => ActivityCommentListTester._( activityCommentList: subject, - wsStreamController: wsStreamController, + client: client, + wsTester: wsTester, + cdnApi: cdnApi, feedsApi: feedsApi, ), ); diff --git a/packages/stream_feeds_test/lib/src/testers/activity_list_tester.dart b/packages/stream_feeds_test/lib/src/testers/state/activity_list_tester.dart similarity index 88% rename from packages/stream_feeds_test/lib/src/testers/activity_list_tester.dart rename to packages/stream_feeds_test/lib/src/testers/state/activity_list_tester.dart index 1fd69202..59e0018e 100644 --- a/packages/stream_feeds_test/lib/src/testers/activity_list_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/state/activity_list_tester.dart @@ -4,18 +4,19 @@ import 'package:meta/meta.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart' as test; -import '../helpers/mocks.dart'; -import '../helpers/test_data.dart'; -import 'base_tester.dart'; +import '../../helpers/mocks.dart'; +import '../../helpers/test_data.dart'; +import '../base_tester.dart'; /// Test helper for activity list operations. /// /// Automatically sets up WebSocket connection, client, and test infrastructure. /// Tests are tagged with 'activity-list' by default for filtering. /// -/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). +/// [user] is optional, the user for whom the client is configured (defaults to luke_skywalker). /// [build] constructs the [ActivityList] under test using the provided [StreamFeedsClient]. +/// [connect] is optional, custom connection logic (defaults to successful auth + connect). /// [setUp] is optional and runs before [body] for setting up mocks and test state. /// [body] is the test callback that receives an [ActivityListTester] for interactions. /// [verify] is optional and runs after [body] for verifying API calls and interactions. @@ -49,6 +50,7 @@ void activityListTest( String description, { User user = const User(id: 'luke_skywalker'), required ActivityList Function(StreamFeedsClient client) build, + FutureOr Function(ActivityListTester tester)? connect, FutureOr Function(ActivityListTester tester)? setUp, required FutureOr Function(ActivityListTester tester) body, FutureOr Function(ActivityListTester tester)? verify, @@ -62,6 +64,7 @@ void activityListTest( user: user, build: build, createTesterFn: _createActivityListTester, + connect: connect, setUp: setUp, body: body, verify: verify, @@ -80,8 +83,10 @@ void activityListTest( final class ActivityListTester extends BaseTester { const ActivityListTester._({ required ActivityList activityList, - required super.wsStreamController, + required super.client, + required super.wsTester, required super.feedsApi, + required super.cdnApi, }) : super(subject: activityList); /// The activity list being tested. @@ -132,6 +137,7 @@ final class ActivityListTester extends BaseTester { Future _createActivityListTester({ required ActivityList subject, required StreamFeedsClient client, + required MockCdnApi cdnApi, required MockDefaultApi feedsApi, required MockWebSocketChannel webSocketChannel, }) { @@ -139,11 +145,12 @@ Future _createActivityListTester({ test.addTearDown(subject.dispose); return createTester( - client: client, webSocketChannel: webSocketChannel, - create: (wsStreamController) => ActivityListTester._( + create: (wsTester) => ActivityListTester._( activityList: subject, - wsStreamController: wsStreamController, + client: client, + wsTester: wsTester, + cdnApi: cdnApi, feedsApi: feedsApi, ), ); diff --git a/packages/stream_feeds_test/lib/src/testers/activity_reaction_list_tester.dart b/packages/stream_feeds_test/lib/src/testers/state/activity_reaction_list_tester.dart similarity index 89% rename from packages/stream_feeds_test/lib/src/testers/activity_reaction_list_tester.dart rename to packages/stream_feeds_test/lib/src/testers/state/activity_reaction_list_tester.dart index 80b6927f..a3c6c02c 100644 --- a/packages/stream_feeds_test/lib/src/testers/activity_reaction_list_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/state/activity_reaction_list_tester.dart @@ -4,18 +4,19 @@ import 'package:meta/meta.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart' as test; -import '../helpers/mocks.dart'; -import '../helpers/test_data.dart'; -import 'base_tester.dart'; +import '../../helpers/mocks.dart'; +import '../../helpers/test_data.dart'; +import '../base_tester.dart'; /// Test helper for activity reaction list operations. /// /// Automatically sets up WebSocket connection, client, and test infrastructure. /// Tests are tagged with 'activity-reaction-list' by default for filtering. /// -/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). +/// [user] is optional, the user for whom the client is configured (defaults to luke_skywalker). /// [build] constructs the [ActivityReactionList] under test using the provided [StreamFeedsClient]. +/// [connect] is optional, custom connection logic (defaults to successful auth + connect). /// [setUp] is optional and runs before [body] for setting up mocks and test state. /// [body] is the test callback that receives an [ActivityReactionListTester] for interactions. /// [verify] is optional and runs after [body] for verifying API calls and interactions. @@ -44,6 +45,7 @@ void activityReactionListTest( String description, { User user = const User(id: 'luke_skywalker'), required ActivityReactionList Function(StreamFeedsClient client) build, + FutureOr Function(ActivityReactionListTester tester)? connect, FutureOr Function(ActivityReactionListTester tester)? setUp, required FutureOr Function(ActivityReactionListTester tester) body, FutureOr Function(ActivityReactionListTester tester)? verify, @@ -57,6 +59,7 @@ void activityReactionListTest( user: user, build: build, createTesterFn: _createActivityReactionListTester, + connect: connect, setUp: setUp, body: body, verify: verify, @@ -76,8 +79,10 @@ final class ActivityReactionListTester extends BaseTester { const ActivityReactionListTester._({ required ActivityReactionList activityReactionList, - required super.wsStreamController, + required super.client, + required super.wsTester, required super.feedsApi, + required super.cdnApi, }) : super(subject: activityReactionList); /// The activity reaction list being tested. @@ -142,8 +147,9 @@ final class ActivityReactionListTester // Automatically sets up WebSocket connection and registers cleanup handlers. // This function is for internal use by activityReactionListTest only. Future _createActivityReactionListTester({ - required StreamFeedsClient client, required ActivityReactionList subject, + required StreamFeedsClient client, + required MockCdnApi cdnApi, required MockDefaultApi feedsApi, required MockWebSocketChannel webSocketChannel, }) { @@ -151,11 +157,12 @@ Future _createActivityReactionListTester({ test.addTearDown(subject.dispose); return createTester( - client: client, webSocketChannel: webSocketChannel, - create: (wsStreamController) => ActivityReactionListTester._( + create: (wsTester) => ActivityReactionListTester._( activityReactionList: subject, - wsStreamController: wsStreamController, + client: client, + wsTester: wsTester, + cdnApi: cdnApi, feedsApi: feedsApi, ), ); diff --git a/packages/stream_feeds_test/lib/src/testers/activity_tester.dart b/packages/stream_feeds_test/lib/src/testers/state/activity_tester.dart similarity index 91% rename from packages/stream_feeds_test/lib/src/testers/activity_tester.dart rename to packages/stream_feeds_test/lib/src/testers/state/activity_tester.dart index 99d2dd88..fc23980e 100644 --- a/packages/stream_feeds_test/lib/src/testers/activity_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/state/activity_tester.dart @@ -4,18 +4,19 @@ import 'package:meta/meta.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart' as test; -import '../helpers/mocks.dart'; -import '../helpers/test_data.dart'; -import 'base_tester.dart'; +import '../../helpers/mocks.dart'; +import '../../helpers/test_data.dart'; +import '../base_tester.dart'; /// Test helper for activity operations. /// /// Automatically sets up WebSocket connection, client, and test infrastructure. /// Tests are tagged with 'activity' by default for filtering. /// -/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). +/// [user] is optional, the user for whom the client is configured (defaults to luke_skywalker). /// [build] constructs the [Activity] under test using the provided [StreamFeedsClient]. +/// [connect] is optional, custom connection logic (defaults to successful auth + connect). /// [setUp] is optional and runs before [body] for setting up mocks and test state. /// [body] is the test callback that receives an [ActivityTester] for interactions. /// [verify] is optional and runs after [body] for verifying API calls and interactions. @@ -84,6 +85,7 @@ void activityTest( String description, { User user = const User(id: 'luke_skywalker'), required Activity Function(StreamFeedsClient client) build, + FutureOr Function(ActivityTester tester)? connect, FutureOr Function(ActivityTester tester)? setUp, required FutureOr Function(ActivityTester tester) body, FutureOr Function(ActivityTester tester)? verify, @@ -97,6 +99,7 @@ void activityTest( user: user, build: build, createTesterFn: _createActivityTester, + connect: connect, setUp: setUp, body: body, verify: verify, @@ -115,8 +118,10 @@ void activityTest( final class ActivityTester extends BaseTester { const ActivityTester._({ required Activity activity, - required super.wsStreamController, + required super.client, + required super.wsTester, required super.feedsApi, + required super.cdnApi, }) : super(subject: activity); /// The activity being tested. @@ -184,6 +189,7 @@ final class ActivityTester extends BaseTester { Future _createActivityTester({ required Activity subject, required StreamFeedsClient client, + required MockCdnApi cdnApi, required MockDefaultApi feedsApi, required MockWebSocketChannel webSocketChannel, }) { @@ -191,12 +197,13 @@ Future _createActivityTester({ test.addTearDown(subject.dispose); return createTester( - client: client, webSocketChannel: webSocketChannel, - create: (wsStreamController) => ActivityTester._( + create: (wsTester) => ActivityTester._( activity: subject, + client: client, + wsTester: wsTester, + cdnApi: cdnApi, feedsApi: feedsApi, - wsStreamController: wsStreamController, ), ); } diff --git a/packages/stream_feeds_test/lib/src/testers/bookmark_folder_list_tester.dart b/packages/stream_feeds_test/lib/src/testers/state/bookmark_folder_list_tester.dart similarity index 89% rename from packages/stream_feeds_test/lib/src/testers/bookmark_folder_list_tester.dart rename to packages/stream_feeds_test/lib/src/testers/state/bookmark_folder_list_tester.dart index 1e44c546..3dc0d81d 100644 --- a/packages/stream_feeds_test/lib/src/testers/bookmark_folder_list_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/state/bookmark_folder_list_tester.dart @@ -4,18 +4,19 @@ import 'package:meta/meta.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart' as test; -import '../helpers/mocks.dart'; -import '../helpers/test_data.dart'; -import 'base_tester.dart'; +import '../../helpers/mocks.dart'; +import '../../helpers/test_data.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. /// -/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). +/// [user] is optional, the user for whom the client is configured (defaults to luke_skywalker). /// [build] constructs the [BookmarkFolderList] under test using the provided [StreamFeedsClient]. +/// [connect] is optional, custom connection logic (defaults to successful auth + connect). /// [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. @@ -44,6 +45,7 @@ void bookmarkFolderListTest( String description, { User user = const User(id: 'luke_skywalker'), required BookmarkFolderList Function(StreamFeedsClient client) build, + FutureOr Function(BookmarkFolderListTester tester)? connect, FutureOr Function(BookmarkFolderListTester tester)? setUp, required FutureOr Function(BookmarkFolderListTester tester) body, FutureOr Function(BookmarkFolderListTester tester)? verify, @@ -57,6 +59,7 @@ void bookmarkFolderListTest( user: user, build: build, createTesterFn: _createBookmarkFolderListTester, + connect: connect, setUp: setUp, body: body, verify: verify, @@ -75,8 +78,10 @@ void bookmarkFolderListTest( final class BookmarkFolderListTester extends BaseTester { const BookmarkFolderListTester._({ required BookmarkFolderList bookmarkFolderList, - required super.wsStreamController, + required super.client, + required super.wsTester, required super.feedsApi, + required super.cdnApi, }) : super(subject: bookmarkFolderList); /// The bookmark folder list being tested. @@ -136,6 +141,7 @@ final class BookmarkFolderListTester extends BaseTester { Future _createBookmarkFolderListTester({ required BookmarkFolderList subject, required StreamFeedsClient client, + required MockCdnApi cdnApi, required MockDefaultApi feedsApi, required MockWebSocketChannel webSocketChannel, }) { @@ -143,11 +149,12 @@ Future _createBookmarkFolderListTester({ test.addTearDown(subject.dispose); return createTester( - client: client, webSocketChannel: webSocketChannel, - create: (wsStreamController) => BookmarkFolderListTester._( + create: (wsTester) => BookmarkFolderListTester._( bookmarkFolderList: subject, - wsStreamController: wsStreamController, + client: client, + wsTester: wsTester, + cdnApi: cdnApi, feedsApi: feedsApi, ), ); diff --git a/packages/stream_feeds_test/lib/src/testers/bookmark_list_tester.dart b/packages/stream_feeds_test/lib/src/testers/state/bookmark_list_tester.dart similarity index 88% rename from packages/stream_feeds_test/lib/src/testers/bookmark_list_tester.dart rename to packages/stream_feeds_test/lib/src/testers/state/bookmark_list_tester.dart index 9a2aa6a2..966bbbf1 100644 --- a/packages/stream_feeds_test/lib/src/testers/bookmark_list_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/state/bookmark_list_tester.dart @@ -4,18 +4,19 @@ import 'package:meta/meta.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart' as test; -import '../helpers/mocks.dart'; -import '../helpers/test_data.dart'; -import 'base_tester.dart'; +import '../../helpers/mocks.dart'; +import '../../helpers/test_data.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. /// -/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). +/// [user] is optional, the user for whom the client is configured (defaults to luke_skywalker). /// [build] constructs the [BookmarkList] under test using the provided [StreamFeedsClient]. +/// [connect] is optional, custom connection logic (defaults to successful auth + connect). /// [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. @@ -44,6 +45,7 @@ void bookmarkListTest( String description, { User user = const User(id: 'luke_skywalker'), required BookmarkList Function(StreamFeedsClient client) build, + FutureOr Function(BookmarkListTester tester)? connect, FutureOr Function(BookmarkListTester tester)? setUp, required FutureOr Function(BookmarkListTester tester) body, FutureOr Function(BookmarkListTester tester)? verify, @@ -57,6 +59,7 @@ void bookmarkListTest( user: user, build: build, createTesterFn: _createBookmarkListTester, + connect: connect, setUp: setUp, body: body, verify: verify, @@ -75,8 +78,10 @@ void bookmarkListTest( final class BookmarkListTester extends BaseTester { const BookmarkListTester._({ required BookmarkList bookmarkList, - required super.wsStreamController, + required super.client, + required super.wsTester, required super.feedsApi, + required super.cdnApi, }) : super(subject: bookmarkList); /// The bookmark list being tested. @@ -139,6 +144,7 @@ final class BookmarkListTester extends BaseTester { Future _createBookmarkListTester({ required BookmarkList subject, required StreamFeedsClient client, + required MockCdnApi cdnApi, required MockDefaultApi feedsApi, required MockWebSocketChannel webSocketChannel, }) { @@ -146,11 +152,12 @@ Future _createBookmarkListTester({ test.addTearDown(subject.dispose); return createTester( - client: client, webSocketChannel: webSocketChannel, - create: (wsStreamController) => BookmarkListTester._( + create: (wsTester) => BookmarkListTester._( bookmarkList: subject, - wsStreamController: wsStreamController, + client: client, + wsTester: wsTester, + cdnApi: cdnApi, feedsApi: feedsApi, ), ); diff --git a/packages/stream_feeds_test/lib/src/testers/comment_list_tester.dart b/packages/stream_feeds_test/lib/src/testers/state/comment_list_tester.dart similarity index 88% rename from packages/stream_feeds_test/lib/src/testers/comment_list_tester.dart rename to packages/stream_feeds_test/lib/src/testers/state/comment_list_tester.dart index f55c6ace..53e9597f 100644 --- a/packages/stream_feeds_test/lib/src/testers/comment_list_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/state/comment_list_tester.dart @@ -4,18 +4,19 @@ import 'package:meta/meta.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart' as test; -import '../helpers/mocks.dart'; -import '../helpers/test_data.dart'; -import 'base_tester.dart'; +import '../../helpers/mocks.dart'; +import '../../helpers/test_data.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. /// -/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). +/// [user] is optional, the user for whom the client is configured (defaults to luke_skywalker). /// [build] constructs the [CommentList] under test using the provided [StreamFeedsClient]. +/// [connect] is optional, custom connection logic (defaults to successful auth + connect). /// [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. @@ -44,6 +45,7 @@ void commentListTest( String description, { User user = const User(id: 'luke_skywalker'), required CommentList Function(StreamFeedsClient client) build, + FutureOr Function(CommentListTester tester)? connect, FutureOr Function(CommentListTester tester)? setUp, required FutureOr Function(CommentListTester tester) body, FutureOr Function(CommentListTester tester)? verify, @@ -57,6 +59,7 @@ void commentListTest( user: user, build: build, createTesterFn: _createCommentListTester, + connect: connect, setUp: setUp, body: body, verify: verify, @@ -75,8 +78,10 @@ void commentListTest( final class CommentListTester extends BaseTester { const CommentListTester._({ required CommentList commentList, - required super.wsStreamController, + required super.client, + required super.wsTester, required super.feedsApi, + required super.cdnApi, }) : super(subject: commentList); /// The comment list being tested. @@ -129,6 +134,7 @@ final class CommentListTester extends BaseTester { Future _createCommentListTester({ required CommentList subject, required StreamFeedsClient client, + required MockCdnApi cdnApi, required MockDefaultApi feedsApi, required MockWebSocketChannel webSocketChannel, }) { @@ -136,11 +142,12 @@ Future _createCommentListTester({ test.addTearDown(subject.dispose); return createTester( - client: client, webSocketChannel: webSocketChannel, - create: (wsStreamController) => CommentListTester._( + create: (wsTester) => CommentListTester._( commentList: subject, - wsStreamController: wsStreamController, + client: client, + wsTester: wsTester, + cdnApi: cdnApi, feedsApi: feedsApi, ), ); diff --git a/packages/stream_feeds_test/lib/src/testers/comment_reaction_list_tester.dart b/packages/stream_feeds_test/lib/src/testers/state/comment_reaction_list_tester.dart similarity index 89% rename from packages/stream_feeds_test/lib/src/testers/comment_reaction_list_tester.dart rename to packages/stream_feeds_test/lib/src/testers/state/comment_reaction_list_tester.dart index cc058da0..50797861 100644 --- a/packages/stream_feeds_test/lib/src/testers/comment_reaction_list_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/state/comment_reaction_list_tester.dart @@ -4,18 +4,19 @@ import 'package:meta/meta.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart' as test; -import '../helpers/mocks.dart'; -import '../helpers/test_data.dart'; -import 'base_tester.dart'; +import '../../helpers/mocks.dart'; +import '../../helpers/test_data.dart'; +import '../base_tester.dart'; /// Test helper for comment reaction list operations. /// /// Automatically sets up WebSocket connection, client, and test infrastructure. /// Tests are tagged with 'comment-reaction-list' by default for filtering. /// -/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). +/// [user] is optional, the user for whom the client is configured (defaults to luke_skywalker). /// [build] constructs the [CommentReactionList] under test using the provided [StreamFeedsClient]. +/// [connect] is optional, custom connection logic (defaults to successful auth + connect). /// [setUp] is optional and runs before [body] for setting up mocks and test state. /// [body] is the test callback that receives a [CommentReactionListTester] for interactions. /// [verify] is optional and runs after [body] for verifying API calls and interactions. @@ -44,6 +45,7 @@ void commentReactionListTest( String description, { User user = const User(id: 'luke_skywalker'), required CommentReactionList Function(StreamFeedsClient client) build, + FutureOr Function(CommentReactionListTester tester)? connect, FutureOr Function(CommentReactionListTester tester)? setUp, required FutureOr Function(CommentReactionListTester tester) body, FutureOr Function(CommentReactionListTester tester)? verify, @@ -57,6 +59,7 @@ void commentReactionListTest( user: user, build: build, createTesterFn: _createCommentReactionListTester, + connect: connect, setUp: setUp, body: body, verify: verify, @@ -75,8 +78,10 @@ void commentReactionListTest( final class CommentReactionListTester extends BaseTester { const CommentReactionListTester._({ required CommentReactionList commentReactionList, - required super.wsStreamController, + required super.client, + required super.wsTester, required super.feedsApi, + required super.cdnApi, }) : super(subject: commentReactionList); /// The comment reaction list being tested. @@ -141,8 +146,9 @@ final class CommentReactionListTester extends BaseTester { // Automatically sets up WebSocket connection and registers cleanup handlers. // This function is for internal use by commentReactionListTest only. Future _createCommentReactionListTester({ - required StreamFeedsClient client, required CommentReactionList subject, + required StreamFeedsClient client, + required MockCdnApi cdnApi, required MockDefaultApi feedsApi, required MockWebSocketChannel webSocketChannel, }) { @@ -150,11 +156,12 @@ Future _createCommentReactionListTester({ test.addTearDown(subject.dispose); return createTester( - client: client, webSocketChannel: webSocketChannel, - create: (wsStreamController) => CommentReactionListTester._( + create: (wsTester) => CommentReactionListTester._( commentReactionList: subject, - wsStreamController: wsStreamController, + client: client, + wsTester: wsTester, + cdnApi: cdnApi, feedsApi: feedsApi, ), ); diff --git a/packages/stream_feeds_test/lib/src/testers/comment_reply_list_tester.dart b/packages/stream_feeds_test/lib/src/testers/state/comment_reply_list_tester.dart similarity index 89% rename from packages/stream_feeds_test/lib/src/testers/comment_reply_list_tester.dart rename to packages/stream_feeds_test/lib/src/testers/state/comment_reply_list_tester.dart index 8987e80b..2af5c9ae 100644 --- a/packages/stream_feeds_test/lib/src/testers/comment_reply_list_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/state/comment_reply_list_tester.dart @@ -4,18 +4,19 @@ import 'package:meta/meta.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart' as test; -import '../helpers/mocks.dart'; -import '../helpers/test_data.dart'; -import 'base_tester.dart'; +import '../../helpers/mocks.dart'; +import '../../helpers/test_data.dart'; +import '../base_tester.dart'; /// Test helper for comment reply list operations. /// /// Automatically sets up WebSocket connection, client, and test infrastructure. /// Tests are tagged with 'comment-reply-list' by default for filtering. /// -/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). +/// [user] is optional, the user for whom the client is configured (defaults to luke_skywalker). /// [build] constructs the [CommentReplyList] under test using the provided [StreamFeedsClient]. +/// [connect] is optional, custom connection logic (defaults to successful auth + connect). /// [setUp] is optional and runs before [body] for setting up mocks and test state. /// [body] is the test callback that receives a [CommentReplyListTester] for interactions. /// [verify] is optional and runs after [body] for verifying API calls and interactions. @@ -46,6 +47,7 @@ void commentReplyListTest( String description, { User user = const User(id: 'luke_skywalker'), required CommentReplyList Function(StreamFeedsClient client) build, + FutureOr Function(CommentReplyListTester tester)? connect, FutureOr Function(CommentReplyListTester tester)? setUp, required FutureOr Function(CommentReplyListTester tester) body, FutureOr Function(CommentReplyListTester tester)? verify, @@ -59,6 +61,7 @@ void commentReplyListTest( user: user, build: build, createTesterFn: _createCommentReplyListTester, + connect: connect, setUp: setUp, body: body, verify: verify, @@ -77,8 +80,10 @@ void commentReplyListTest( final class CommentReplyListTester extends BaseTester { const CommentReplyListTester._({ required CommentReplyList commentReplyList, - required super.wsStreamController, + required super.client, + required super.wsTester, required super.feedsApi, + required super.cdnApi, }) : super(subject: commentReplyList); /// The comment reply list being tested. @@ -161,6 +166,7 @@ final class CommentReplyListTester extends BaseTester { Future _createCommentReplyListTester({ required CommentReplyList subject, required StreamFeedsClient client, + required MockCdnApi cdnApi, required MockDefaultApi feedsApi, required MockWebSocketChannel webSocketChannel, }) { @@ -168,11 +174,12 @@ Future _createCommentReplyListTester({ test.addTearDown(subject.dispose); return createTester( - client: client, webSocketChannel: webSocketChannel, - create: (wsStreamController) => CommentReplyListTester._( + create: (wsTester) => CommentReplyListTester._( commentReplyList: subject, - wsStreamController: wsStreamController, + client: client, + wsTester: wsTester, + cdnApi: cdnApi, feedsApi: feedsApi, ), ); diff --git a/packages/stream_feeds_test/lib/src/testers/feed_list_tester.dart b/packages/stream_feeds_test/lib/src/testers/state/feed_list_tester.dart similarity index 88% rename from packages/stream_feeds_test/lib/src/testers/feed_list_tester.dart rename to packages/stream_feeds_test/lib/src/testers/state/feed_list_tester.dart index 820b8aab..82017915 100644 --- a/packages/stream_feeds_test/lib/src/testers/feed_list_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/state/feed_list_tester.dart @@ -4,18 +4,19 @@ import 'package:meta/meta.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart' as test; -import '../helpers/mocks.dart'; -import '../helpers/test_data.dart'; -import 'base_tester.dart'; +import '../../helpers/mocks.dart'; +import '../../helpers/test_data.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. /// -/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). +/// [user] is optional, the user for whom the client is configured (defaults to luke_skywalker). /// [build] constructs the [FeedList] under test using the provided [StreamFeedsClient]. +/// [connect] is optional, custom connection logic (defaults to successful auth + connect). /// [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. @@ -44,6 +45,7 @@ void feedListTest( String description, { User user = const User(id: 'luke_skywalker'), required FeedList Function(StreamFeedsClient client) build, + FutureOr Function(FeedListTester tester)? connect, FutureOr Function(FeedListTester tester)? setUp, required FutureOr Function(FeedListTester tester) body, FutureOr Function(FeedListTester tester)? verify, @@ -57,6 +59,7 @@ void feedListTest( user: user, build: build, createTesterFn: _createFeedListTester, + connect: connect, setUp: setUp, body: body, verify: verify, @@ -75,8 +78,10 @@ void feedListTest( final class FeedListTester extends BaseTester { const FeedListTester._({ required FeedList feedList, - required super.wsStreamController, + required super.client, + required super.wsTester, required super.feedsApi, + required super.cdnApi, }) : super(subject: feedList); /// The feed list being tested. @@ -130,6 +135,7 @@ final class FeedListTester extends BaseTester { Future _createFeedListTester({ required FeedList subject, required StreamFeedsClient client, + required MockCdnApi cdnApi, required MockDefaultApi feedsApi, required MockWebSocketChannel webSocketChannel, }) { @@ -137,11 +143,12 @@ Future _createFeedListTester({ test.addTearDown(subject.dispose); return createTester( - client: client, webSocketChannel: webSocketChannel, - create: (wsStreamController) => FeedListTester._( + create: (wsTester) => FeedListTester._( feedList: subject, - wsStreamController: wsStreamController, + client: client, + wsTester: wsTester, + cdnApi: cdnApi, feedsApi: feedsApi, ), ); diff --git a/packages/stream_feeds_test/lib/src/testers/feed_tester.dart b/packages/stream_feeds_test/lib/src/testers/state/feed_tester.dart similarity index 88% rename from packages/stream_feeds_test/lib/src/testers/feed_tester.dart rename to packages/stream_feeds_test/lib/src/testers/state/feed_tester.dart index fa3988e5..02d64e32 100644 --- a/packages/stream_feeds_test/lib/src/testers/feed_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/state/feed_tester.dart @@ -4,18 +4,19 @@ import 'package:meta/meta.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart' as test; -import '../helpers/mocks.dart'; -import '../helpers/test_data.dart'; -import 'base_tester.dart'; +import '../../helpers/mocks.dart'; +import '../../helpers/test_data.dart'; +import '../base_tester.dart'; /// Test helper for feed operations. /// /// Automatically sets up WebSocket connection, client, and test infrastructure. /// Tests are tagged with 'feed' by default for filtering. /// -/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). +/// [user] is optional, the user for whom the client is configured (defaults to luke_skywalker). /// [build] constructs the [Feed] under test using the provided [StreamFeedsClient]. +/// [connect] is optional, custom connection logic (defaults to successful auth + connect). /// [setUp] is optional and runs before [body] for setting up mocks and test state. /// [body] is the test callback that receives a [FeedTester] for interactions. /// [verify] is optional and runs after [body] for verifying API calls and interactions. @@ -49,6 +50,7 @@ void feedTest( String description, { User user = const User(id: 'luke_skywalker'), required Feed Function(StreamFeedsClient client) build, + FutureOr Function(FeedTester tester)? connect, FutureOr Function(FeedTester tester)? setUp, required FutureOr Function(FeedTester tester) body, FutureOr Function(FeedTester tester)? verify, @@ -62,6 +64,7 @@ void feedTest( user: user, build: build, createTesterFn: _createFeedTester, + connect: connect, setUp: setUp, body: body, verify: verify, @@ -80,8 +83,10 @@ void feedTest( final class FeedTester extends BaseTester { const FeedTester._({ required Feed feed, - required super.wsStreamController, + required super.client, + required super.wsTester, required super.feedsApi, + required super.cdnApi, }) : super(subject: feed); /// The feed being tested. @@ -137,6 +142,7 @@ final class FeedTester extends BaseTester { Future _createFeedTester({ required Feed subject, required StreamFeedsClient client, + required MockCdnApi cdnApi, required MockDefaultApi feedsApi, required MockWebSocketChannel webSocketChannel, }) { @@ -144,11 +150,12 @@ Future _createFeedTester({ test.addTearDown(subject.dispose); return createTester( - client: client, webSocketChannel: webSocketChannel, - create: (wsStreamController) => FeedTester._( + create: (wsTester) => FeedTester._( feed: subject, - wsStreamController: wsStreamController, + client: client, + wsTester: wsTester, + cdnApi: cdnApi, feedsApi: feedsApi, ), ); diff --git a/packages/stream_feeds_test/lib/src/testers/follow_list_tester.dart b/packages/stream_feeds_test/lib/src/testers/state/follow_list_tester.dart similarity index 88% rename from packages/stream_feeds_test/lib/src/testers/follow_list_tester.dart rename to packages/stream_feeds_test/lib/src/testers/state/follow_list_tester.dart index f628364c..64287e30 100644 --- a/packages/stream_feeds_test/lib/src/testers/follow_list_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/state/follow_list_tester.dart @@ -4,18 +4,19 @@ import 'package:meta/meta.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart' as test; -import '../helpers/mocks.dart'; -import '../helpers/test_data.dart'; -import 'base_tester.dart'; +import '../../helpers/mocks.dart'; +import '../../helpers/test_data.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. /// -/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). +/// [user] is optional, the user for whom the client is configured (defaults to luke_skywalker). /// [build] constructs the [FollowList] under test using the provided [StreamFeedsClient]. +/// [connect] is optional, custom connection logic (defaults to successful auth + connect). /// [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. @@ -44,6 +45,7 @@ void followListTest( String description, { User user = const User(id: 'luke_skywalker'), required FollowList Function(StreamFeedsClient client) build, + FutureOr Function(FollowListTester tester)? connect, FutureOr Function(FollowListTester tester)? setUp, required FutureOr Function(FollowListTester tester) body, FutureOr Function(FollowListTester tester)? verify, @@ -57,6 +59,7 @@ void followListTest( user: user, build: build, createTesterFn: _createFollowListTester, + connect: connect, setUp: setUp, body: body, verify: verify, @@ -75,8 +78,10 @@ void followListTest( final class FollowListTester extends BaseTester { const FollowListTester._({ required FollowList followList, - required super.wsStreamController, + required super.client, + required super.wsTester, required super.feedsApi, + required super.cdnApi, }) : super(subject: followList); /// The follow list being tested. @@ -139,6 +144,7 @@ final class FollowListTester extends BaseTester { Future _createFollowListTester({ required FollowList subject, required StreamFeedsClient client, + required MockCdnApi cdnApi, required MockDefaultApi feedsApi, required MockWebSocketChannel webSocketChannel, }) { @@ -146,11 +152,12 @@ Future _createFollowListTester({ test.addTearDown(subject.dispose); return createTester( - client: client, webSocketChannel: webSocketChannel, - create: (wsStreamController) => FollowListTester._( + create: (wsTester) => FollowListTester._( followList: subject, - wsStreamController: wsStreamController, + client: client, + wsTester: wsTester, + cdnApi: cdnApi, feedsApi: feedsApi, ), ); diff --git a/packages/stream_feeds_test/lib/src/testers/member_list_tester.dart b/packages/stream_feeds_test/lib/src/testers/state/member_list_tester.dart similarity index 88% rename from packages/stream_feeds_test/lib/src/testers/member_list_tester.dart rename to packages/stream_feeds_test/lib/src/testers/state/member_list_tester.dart index a26c187a..05b79b43 100644 --- a/packages/stream_feeds_test/lib/src/testers/member_list_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/state/member_list_tester.dart @@ -4,18 +4,19 @@ import 'package:meta/meta.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart' as test; -import '../helpers/mocks.dart'; -import '../helpers/test_data.dart'; -import 'base_tester.dart'; +import '../../helpers/mocks.dart'; +import '../../helpers/test_data.dart'; +import '../base_tester.dart'; /// Test helper for member list operations. /// /// Automatically sets up WebSocket connection, client, and test infrastructure. /// Tests are tagged with 'member-list' by default for filtering. /// -/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). +/// [user] is optional, the user for whom the client is configured (defaults to luke_skywalker). /// [build] constructs the [MemberList] under test using the provided [StreamFeedsClient]. +/// [connect] is optional, custom connection logic (defaults to successful auth + connect). /// [setUp] is optional and runs before [body] for setting up mocks and test state. /// [body] is the test callback that receives a [MemberListTester] for interactions. /// [verify] is optional and runs after [body] for verifying API calls and interactions. @@ -42,6 +43,7 @@ void memberListTest( String description, { User user = const User(id: 'luke_skywalker'), required MemberList Function(StreamFeedsClient client) build, + FutureOr Function(MemberListTester tester)? connect, FutureOr Function(MemberListTester tester)? setUp, required FutureOr Function(MemberListTester tester) body, FutureOr Function(MemberListTester tester)? verify, @@ -55,6 +57,7 @@ void memberListTest( user: user, build: build, createTesterFn: _createMemberListTester, + connect: connect, setUp: setUp, body: body, verify: verify, @@ -73,8 +76,10 @@ void memberListTest( final class MemberListTester extends BaseTester { const MemberListTester._({ required MemberList memberList, - required super.wsStreamController, + required super.client, + required super.wsTester, required super.feedsApi, + required super.cdnApi, }) : super(subject: memberList); /// The member list being tested. @@ -127,8 +132,9 @@ final class MemberListTester extends BaseTester { // Automatically sets up WebSocket connection and registers cleanup handlers. // This function is for internal use by memberListTest only. Future _createMemberListTester({ - required StreamFeedsClient client, required MemberList subject, + required StreamFeedsClient client, + required MockCdnApi cdnApi, required MockDefaultApi feedsApi, required MockWebSocketChannel webSocketChannel, }) { @@ -136,11 +142,12 @@ Future _createMemberListTester({ test.addTearDown(subject.dispose); return createTester( - client: client, webSocketChannel: webSocketChannel, - create: (wsStreamController) => MemberListTester._( + create: (wsTester) => MemberListTester._( memberList: subject, - wsStreamController: wsStreamController, + client: client, + wsTester: wsTester, + cdnApi: cdnApi, feedsApi: feedsApi, ), ); diff --git a/packages/stream_feeds_test/lib/src/testers/moderation_config_list_tester.dart b/packages/stream_feeds_test/lib/src/testers/state/moderation_config_list_tester.dart similarity index 88% rename from packages/stream_feeds_test/lib/src/testers/moderation_config_list_tester.dart rename to packages/stream_feeds_test/lib/src/testers/state/moderation_config_list_tester.dart index 146b4ee7..f5056c3b 100644 --- a/packages/stream_feeds_test/lib/src/testers/moderation_config_list_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/state/moderation_config_list_tester.dart @@ -4,17 +4,18 @@ import 'package:meta/meta.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart' as test; -import '../helpers/mocks.dart'; -import '../helpers/test_data.dart'; -import 'base_tester.dart'; +import '../../helpers/mocks.dart'; +import '../../helpers/test_data.dart'; +import '../base_tester.dart'; /// Test helper for moderation config list operations. /// /// Automatically sets up WebSocket connection, client, and test infrastructure. /// Tests are tagged with 'moderation-config-list' by default for filtering. /// -/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). +/// [user] is optional, the user for whom the client is configured (defaults to luke_skywalker). /// [build] constructs the [ModerationConfigList] under test using the provided [StreamFeedsClient]. +/// [connect] is optional, custom connection logic (defaults to successful auth + connect). /// [setUp] is optional and runs before [body] for setting up mocks and test state. /// [body] is the test callback that receives a [ModerationConfigListTester] for interactions. /// [verify] is optional and runs after [body] for verifying API calls and interactions. @@ -39,6 +40,7 @@ void moderationConfigListTest( String description, { User user = const User(id: 'luke_skywalker'), required ModerationConfigList Function(StreamFeedsClient client) build, + FutureOr Function(ModerationConfigListTester tester)? connect, FutureOr Function(ModerationConfigListTester tester)? setUp, required FutureOr Function(ModerationConfigListTester tester) body, FutureOr Function(ModerationConfigListTester tester)? verify, @@ -52,6 +54,7 @@ void moderationConfigListTest( user: user, build: build, createTesterFn: _createModerationConfigListTester, + connect: connect, setUp: setUp, body: body, verify: verify, @@ -71,8 +74,10 @@ final class ModerationConfigListTester extends BaseTester { const ModerationConfigListTester._({ required ModerationConfigList moderationConfigList, - required super.wsStreamController, + required super.client, + required super.wsTester, required super.feedsApi, + required super.cdnApi, }) : super(subject: moderationConfigList); /// The moderation config list being tested. @@ -132,6 +137,7 @@ final class ModerationConfigListTester Future _createModerationConfigListTester({ required ModerationConfigList subject, required StreamFeedsClient client, + required MockCdnApi cdnApi, required MockDefaultApi feedsApi, required MockWebSocketChannel webSocketChannel, }) { @@ -139,11 +145,12 @@ Future _createModerationConfigListTester({ test.addTearDown(subject.dispose); return createTester( - client: client, webSocketChannel: webSocketChannel, - create: (wsStreamController) => ModerationConfigListTester._( + create: (wsTester) => ModerationConfigListTester._( moderationConfigList: subject, - wsStreamController: wsStreamController, + client: client, + wsTester: wsTester, + cdnApi: cdnApi, feedsApi: feedsApi, ), ); diff --git a/packages/stream_feeds_test/lib/src/testers/poll_list_tester.dart b/packages/stream_feeds_test/lib/src/testers/state/poll_list_tester.dart similarity index 88% rename from packages/stream_feeds_test/lib/src/testers/poll_list_tester.dart rename to packages/stream_feeds_test/lib/src/testers/state/poll_list_tester.dart index 8d7d63ec..3eec3874 100644 --- a/packages/stream_feeds_test/lib/src/testers/poll_list_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/state/poll_list_tester.dart @@ -4,18 +4,19 @@ import 'package:meta/meta.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart' as test; -import '../helpers/mocks.dart'; -import '../helpers/test_data.dart'; -import 'base_tester.dart'; +import '../../helpers/mocks.dart'; +import '../../helpers/test_data.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. /// -/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). +/// [user] is optional, the user for whom the client is configured (defaults to luke_skywalker). /// [build] constructs the [PollList] under test using the provided [StreamFeedsClient]. +/// [connect] is optional, custom connection logic (defaults to successful auth + connect). /// [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. @@ -44,6 +45,7 @@ void pollListTest( String description, { User user = const User(id: 'luke_skywalker'), required PollList Function(StreamFeedsClient client) build, + FutureOr Function(PollListTester tester)? connect, FutureOr Function(PollListTester tester)? setUp, required FutureOr Function(PollListTester tester) body, FutureOr Function(PollListTester tester)? verify, @@ -57,6 +59,7 @@ void pollListTest( user: user, build: build, createTesterFn: _createPollListTester, + connect: connect, setUp: setUp, body: body, verify: verify, @@ -75,8 +78,10 @@ void pollListTest( final class PollListTester extends BaseTester { const PollListTester._({ required PollList pollList, - required super.wsStreamController, + required super.client, + required super.wsTester, required super.feedsApi, + required super.cdnApi, }) : super(subject: pollList); /// The poll list being tested. @@ -130,6 +135,7 @@ final class PollListTester extends BaseTester { Future _createPollListTester({ required PollList subject, required StreamFeedsClient client, + required MockCdnApi cdnApi, required MockDefaultApi feedsApi, required MockWebSocketChannel webSocketChannel, }) { @@ -137,11 +143,12 @@ Future _createPollListTester({ test.addTearDown(subject.dispose); return createTester( - client: client, webSocketChannel: webSocketChannel, - create: (wsStreamController) => PollListTester._( + create: (wsTester) => PollListTester._( pollList: subject, - wsStreamController: wsStreamController, + client: client, + wsTester: wsTester, + cdnApi: cdnApi, feedsApi: feedsApi, ), ); diff --git a/packages/stream_feeds_test/lib/src/testers/poll_vote_list_tester.dart b/packages/stream_feeds_test/lib/src/testers/state/poll_vote_list_tester.dart similarity index 88% rename from packages/stream_feeds_test/lib/src/testers/poll_vote_list_tester.dart rename to packages/stream_feeds_test/lib/src/testers/state/poll_vote_list_tester.dart index b5938455..3824b1bf 100644 --- a/packages/stream_feeds_test/lib/src/testers/poll_vote_list_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/state/poll_vote_list_tester.dart @@ -4,18 +4,19 @@ import 'package:meta/meta.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart' as test; -import '../helpers/mocks.dart'; -import '../helpers/test_data.dart'; -import 'base_tester.dart'; +import '../../helpers/mocks.dart'; +import '../../helpers/test_data.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. /// -/// [user] is optional, the authenticated user for the test client (defaults to luke_skywalker). +/// [user] is optional, the user for whom the client is configured (defaults to luke_skywalker). /// [build] constructs the [PollVoteList] under test using the provided [StreamFeedsClient]. +/// [connect] is optional, custom connection logic (defaults to successful auth + connect). /// [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. @@ -44,6 +45,7 @@ void pollVoteListTest( String description, { User user = const User(id: 'luke_skywalker'), required PollVoteList Function(StreamFeedsClient client) build, + FutureOr Function(PollVoteListTester tester)? connect, FutureOr Function(PollVoteListTester tester)? setUp, required FutureOr Function(PollVoteListTester tester) body, FutureOr Function(PollVoteListTester tester)? verify, @@ -57,6 +59,7 @@ void pollVoteListTest( user: user, build: build, createTesterFn: _createPollVoteListTester, + connect: connect, setUp: setUp, body: body, verify: verify, @@ -75,8 +78,10 @@ void pollVoteListTest( final class PollVoteListTester extends BaseTester { const PollVoteListTester._({ required PollVoteList pollVoteList, - required super.wsStreamController, + required super.client, + required super.wsTester, required super.feedsApi, + required super.cdnApi, }) : super(subject: pollVoteList); /// The poll vote list being tested. @@ -140,6 +145,7 @@ final class PollVoteListTester extends BaseTester { Future _createPollVoteListTester({ required PollVoteList subject, required StreamFeedsClient client, + required MockCdnApi cdnApi, required MockDefaultApi feedsApi, required MockWebSocketChannel webSocketChannel, }) { @@ -147,11 +153,12 @@ Future _createPollVoteListTester({ test.addTearDown(subject.dispose); return createTester( - client: client, webSocketChannel: webSocketChannel, - create: (wsStreamController) => PollVoteListTester._( + create: (wsTester) => PollVoteListTester._( pollVoteList: subject, - wsStreamController: wsStreamController, + client: client, + wsTester: wsTester, + cdnApi: cdnApi, feedsApi: feedsApi, ), ); diff --git a/packages/stream_feeds_test/lib/src/testers/websocket_tester.dart b/packages/stream_feeds_test/lib/src/testers/websocket_tester.dart new file mode 100644 index 00000000..961e005f --- /dev/null +++ b/packages/stream_feeds_test/lib/src/testers/websocket_tester.dart @@ -0,0 +1,276 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:mocktail/mocktail.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../helpers/mocks.dart'; + +/// A test utility for managing WebSocket connection mocking and event emission. +/// +/// This class encapsulates WebSocket channel mocking, authentication simulation, +/// and event emission for testing real-time features. It provides methods to +/// configure WebSocket behavior for both successful and failed connection scenarios. +/// +/// ## Usage +/// +/// ```dart +/// final wsTester = WebSocketTester( +/// channel: mockWebSocketChannel, +/// streamController: wsStreamController, +/// ); +/// +/// // Setup for successful connection +/// wsTester.mockSuccessfulAuth('user-123'); +/// +/// // Or setup for failed authentication +/// wsTester.mockFailedAuth(); +/// ``` +/// +/// This class is used internally by testers and should not be instantiated directly +/// in test code. +final class WebSocketTester { + /// Creates a [WebSocketTester] with the given [channel] and [streamController]. + /// + /// The [channel] is the mock WebSocket channel to configure, and [streamController] + /// is used to emit events that simulate server responses. + WebSocketTester({ + required MockWebSocketChannel channel, + required StreamController streamController, + }) : _channel = channel, + _streamController = streamController; + + // The mock WebSocket channel being configured. + final MockWebSocketChannel _channel; + + // The stream controller used to emit WebSocket events. + final StreamController _streamController; + + // Function to reset previous mock configuration, allowing reconfiguration + // between test scenarios. + WebSocketResetFunction? _resetFunction; + + /// Configures WebSocket mocks to simulate successful authentication. + /// + /// Sets up the WebSocket channel to respond with a health check event when + /// authentication is attempted with the specified [userId]. If a different + /// user ID is provided during authentication, it will simulate a failure. + /// + /// Call this before connecting the client in your test setup. + /// + /// Example: + /// ```dart + /// wsTester.mockSuccessfulAuth('user-123'); + /// await client.connect(); // Will succeed + /// ``` + void mockSuccessfulAuth(String userId) { + _resetFunction?.call(); // Reset previous mocks if any + _resetFunction = _whenListenWebSocket( + _channel, + _streamController, + onConnectionAttempt: (token) { + if (token.userId != userId) { + // Wrong user ID - simulate authentication failure (Invalid signature) + return emitEvent(_createConnectionErrorEvent(43)); + } + + // Correct user ID - simulate successful authentication + return emitEvent(_createConnectedEvent(userId)); + }, + ); + } + + /// Configures WebSocket mocks to simulate authentication failure. + /// + /// Sets up the WebSocket channel to always respond with an error event + /// when authentication is attempted, regardless of the provided credentials. + /// The WebSocket stream will be closed after emitting the error event to + /// simulate the server closing the connection. + /// + /// The [errorCode] parameter allows customizing the error code returned. + /// Default is 40 (expiredToken), which prevents automatic reconnection. + /// All errors use HTTP status code 401. + /// + /// **Backend Error Codes (401 Status):** + /// + /// **No Reconnection (Token expired errors 40-42):** + /// - `40`: expiredToken - Token has expired + /// - `41`: tokenNotValidYet - Token not yet valid + /// - `42`: tokenUsedBeforeIAT - Token used before issued + /// + /// **Triggers Reconnection:** + /// - `2`: accessKeyError - Invalid API key + /// - `5`: authFailed - Authentication failed + /// - `43`: invalidTokenSignature - Invalid signature + /// + /// Call this before connecting the client when testing error scenarios. + /// + /// Example: + /// ```dart + /// // Test with token expired error (no reconnection) + /// wsTester.mockFailedAuth(); + /// + /// // Test with auth failed error (triggers reconnection) + /// wsTester.mockFailedAuth(errorCode: 5); + /// + /// await expectLater( + /// client.connect(), + /// throwsA(isA()), + /// ); + /// ``` + void mockFailedAuth({int errorCode = 40}) { + _resetFunction?.call(); // Reset previous mocks if any + _resetFunction = _whenListenWebSocket( + _channel, + _streamController, + onConnectionAttempt: (_) { + // Always emit authentication failure event + return emitEvent(_createConnectionErrorEvent(errorCode)); + }, + ); + } + + /// Emits a WebSocket event to simulate a server message. + /// + /// The [event] should be a JSON-encodable object representing a WebSocket + /// event. This is typically used to test real-time event handling in state + /// objects. + /// + /// **Note:** You must call [mockSuccessfulAuth] or [mockFailedAuth] before + /// calling this method. + /// + /// Example: + /// ```dart + /// wsTester.emitEvent({ + /// 'type': 'activity.added', + /// 'activity': {...}, + /// }); + /// ``` + void emitEvent(Object event) { + _streamController.add(jsonEncode(event)); + } +} + +// A function type for resetting WebSocket mock configuration. +// +// This allows reconfiguring WebSocket mocks between different test scenarios +// by resetting the previous mock setup. +typedef WebSocketResetFunction = void Function(); + +// Configures WebSocket channel mocks for testing. +// +// Sets up the [webSocketChannel] to stream events from [wsStreamController] +// and configures authentication handling. When a token is sent through the +// WebSocket sink, the [onConnectionAttempt] callback is invoked to simulate +// server authentication response. +// +// Returns a function that can be called to reset the mock configuration, +// allowing the WebSocket to be reconfigured for different test scenarios. +WebSocketResetFunction _whenListenWebSocket( + MockWebSocketChannel webSocketChannel, + StreamController wsStreamController, { + void Function(UserToken token)? onConnectionAttempt, +}) { + final webSocketSink = MockWebSocketSink(); + final webSocketStream = wsStreamController.stream.asBroadcastStream(); + + // Mock sink close operation + when( + () => webSocketSink.close(any(), any()), + ).thenAnswer((_) => Future.value()); + + // Mock channel ready state + when( + () => webSocketChannel.ready, + ).thenAnswer((_) => Future.value()); + + // Mock channel stream to use our test stream controller + when( + () => webSocketChannel.stream, + ).thenAnswer((_) => webSocketStream); + + // Mock channel sink to use our test sink + when( + () => webSocketChannel.sink, + ).thenAnswer((_) => webSocketSink); + + // Handle authentication: when a token is sent, invoke the callback + when( + () => webSocketSink.add(any()), + ).thenAnswer((invocation) { + final event = jsonDecode( + invocation.positionalArguments.first as String, + ) as Map; + + // Extract token from authentication message + if (event['token'] case final String token) { + final userToken = UserToken(token); + return onConnectionAttempt?.call(userToken); + } + }); + + // Return reset function to clear this mock configuration + return () => reset(webSocketSink); +} + +// Creates a connected event for successful WebSocket authentication. +// +// This event simulates a successful server response during the authentication +// handshake. The connected event signals that the connection is established +// and ready to receive real-time updates. +Map _createConnectedEvent(String userId) { + final now = DateTime.timestamp().millisecondsSinceEpoch; + return { + 'type': 'connection.ok', + 'connection_id': 'test-connection-id', + 'created_at': now, + 'me': { + 'id': userId, + 'banned': false, + 'channel_mutes': >[], + 'created_at': now, + 'updated_at': now, + 'custom': {}, + 'devices': >[], + 'invisible': false, + 'language': 'en', + 'mutes': >[], + 'online': true, + 'role': 'user', + 'teams': [], + 'total_unread_count': 0, + 'unread_channels': 0, + 'unread_count': 0, + 'unread_threads': 0, + }, + }; +} + +// Creates a connection error event for failed WebSocket authentication. +// +// This event simulates an authentication failure response from the server. +// The error contains details about why the authentication failed. All errors +// use HTTP status code 401. +// +// The [errorCode] parameter determines the specific error code returned, +// which controls the automatic reconnection behavior: +// - Error codes 40-42 prevent automatic reconnection (token expired errors) +// - Other error codes (2, 5, 43) trigger automatic reconnection +// +// See [mockFailedAuth] for the complete list of backend error codes and +// their reconnection behavior. +Map _createConnectionErrorEvent(int errorCode) { + return { + 'type': 'connection.error', + 'connection_id': 'test-connection-id', + 'created_at': DateTime.timestamp().millisecondsSinceEpoch, + 'error': { + 'code': errorCode, + 'message': 'Authentication failed', + 'StatusCode': 401, + 'details': [], + 'duration': '0ms', + 'more_info': 'https://getstream.io/feeds/docs/', + }, + }; +} diff --git a/packages/stream_feeds_test/lib/stream_feeds_test.dart b/packages/stream_feeds_test/lib/stream_feeds_test.dart index 924fd877..0c6cf6c6 100644 --- a/packages/stream_feeds_test/lib/stream_feeds_test.dart +++ b/packages/stream_feeds_test/lib/stream_feeds_test.dart @@ -16,26 +16,30 @@ export 'package:test/test.dart'; // Helpers export 'src/helpers/api_mocker_mixin.dart'; +export 'src/helpers/cdn_mocker_mixin.dart'; export 'src/helpers/event_types.dart'; export 'src/helpers/mocks.dart'; export 'src/helpers/test_data.dart'; export 'src/helpers/web_socket_mocks.dart'; // Testers -export 'src/testers/activity_comment_list_tester.dart'; -export 'src/testers/activity_list_tester.dart'; -export 'src/testers/activity_reaction_list_tester.dart'; -export 'src/testers/activity_tester.dart'; export 'src/testers/base_tester.dart'; -export 'src/testers/bookmark_folder_list_tester.dart'; -export 'src/testers/bookmark_list_tester.dart'; -export 'src/testers/comment_list_tester.dart'; -export 'src/testers/comment_reaction_list_tester.dart'; -export 'src/testers/comment_reply_list_tester.dart'; -export 'src/testers/feed_list_tester.dart'; -export 'src/testers/feed_tester.dart'; -export 'src/testers/follow_list_tester.dart'; -export 'src/testers/member_list_tester.dart'; -export 'src/testers/moderation_config_list_tester.dart'; -export 'src/testers/poll_list_tester.dart'; -export 'src/testers/poll_vote_list_tester.dart'; +export 'src/testers/feeds_client_tester.dart'; +export 'src/testers/moderation_client_tester.dart'; +export 'src/testers/state/activity_comment_list_tester.dart'; +export 'src/testers/state/activity_list_tester.dart'; +export 'src/testers/state/activity_reaction_list_tester.dart'; +export 'src/testers/state/activity_tester.dart'; +export 'src/testers/state/bookmark_folder_list_tester.dart'; +export 'src/testers/state/bookmark_list_tester.dart'; +export 'src/testers/state/comment_list_tester.dart'; +export 'src/testers/state/comment_reaction_list_tester.dart'; +export 'src/testers/state/comment_reply_list_tester.dart'; +export 'src/testers/state/feed_list_tester.dart'; +export 'src/testers/state/feed_tester.dart'; +export 'src/testers/state/follow_list_tester.dart'; +export 'src/testers/state/member_list_tester.dart'; +export 'src/testers/state/moderation_config_list_tester.dart'; +export 'src/testers/state/poll_list_tester.dart'; +export 'src/testers/state/poll_vote_list_tester.dart'; +export 'src/testers/websocket_tester.dart';