diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 5bc59ae8..fc37cd5c 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -51,9 +51,9 @@ class Channel { String? _id; String? _cid; - final Map _extraData; + final Map _extraData; - set extraData(Map extraData) { + set extraData(Map extraData) { if (_initializedCompleter.isCompleted) { throw StateError( 'Once the channel is initialized you should use channel.update ' @@ -734,7 +734,7 @@ class Channel { /// Edit the channel custom data Future update( - Map channelData, [ + Map channelData, [ Message? updateMessage, ]) async { _checkInitialized(); @@ -814,19 +814,24 @@ class Channel { final messageId = message.id; final res = await _client.sendAction(id!, type, messageId, formData); + // update the passed message with response message if (res.message != null) { state!.addMessage(res.message!); } else { + // remove the passed message if response does + // not contain message final oldIndex = state!.messages.indexWhere((m) => m.id == messageId); - Message? oldMessage; + // remove regular message if present if (oldIndex != -1) { - oldMessage = state!.messages[oldIndex]; + final oldMessage = state!.messages[oldIndex]; state!.updateChannelState(state!._channelState.copyWith( messages: state?.messages?..remove(oldMessage), )); } else { - oldMessage = state!.threads.values + // remove thread message if present + // also reduces total reply count + final oldMessage = state!.threads.values .expand((messages) => messages) .firstWhereOrNull((m) => m.id == messageId); if (oldMessage?.parentId != null) { @@ -858,18 +863,11 @@ class Channel { } /// Loads the initial channel state and watches for changes - Future watch([Map options = const {}]) async { - final watchOptions = Map.from({ - 'state': true, - 'watch': true, - 'presence': false, - }) - ..addAll(options); - + Future watch() async { ChannelState response; try { - response = await query(options: watchOptions); + response = await query(watch: true); } catch (error, stackTrace) { if (!_initializedCompleter.isCompleted) { _initializedCompleter.completeError(error, stackTrace); @@ -956,17 +954,15 @@ class Channel { ); /// Creates a new channel - Future create() async => query(options: { - 'watch': false, - 'state': false, - 'presence': false, - }); + Future create() async => query(state: false); /// Query the API, get messages, members or other channel fields /// Set [preferOffline] to true to avoid the api call if the data is already /// in the offline storage Future query({ - Map options = const {}, + bool state = true, + bool watch = false, + bool presence = false, PaginationParams? messagesPagination, PaginationParams? membersPagination, PaginationParams? watchersPagination, @@ -976,10 +972,10 @@ class Channel { final updatedState = await _client.chatPersistenceClient ?.getChannelStateByCid(cid!, messagePagination: messagesPagination); if (updatedState != null && updatedState.messages.isNotEmpty) { - if (state == null) { + if (this.state == null) { _initState(updatedState); } else { - state?.updateChannelState(updatedState); + this.state?.updateChannelState(updatedState); } return updatedState; } @@ -990,6 +986,9 @@ class Channel { type, channelId: id, channelData: _extraData, + state: state, + watch: watch, + presence: presence, messagesPagination: messagesPagination, membersPagination: membersPagination, watchersPagination: watchersPagination, @@ -1000,7 +999,7 @@ class Channel { _cid = updatedState.channel!.cid; } - state?.updateChannelState(updatedState); + this.state?.updateChannelState(updatedState); return updatedState; } catch (e) { if (!_client.persistenceEnabled) { @@ -1775,7 +1774,7 @@ class ChannelClientState { ); } - late Timer _cleaningTimer; + Timer? _cleaningTimer; void _startCleaning() { if (_channelState.channel?.config.typingEvents == false) { @@ -1842,7 +1841,7 @@ class ChannelClientState { _channelStateController.close(); _isUpToDateController.close(); _threadsController.close(); - _cleaningTimer.cancel(); + _cleaningTimer?.cancel(); _pinnedMessagesTimer.cancel(); _typingEventsController.close(); } diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 9ecc780b..87e471e6 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -757,7 +757,7 @@ class StreamChatClient { Future updateChannel( String channelId, String channelType, - Map data, { + Map data, { Message? message, }) => _chatApi.channel.updateChannel( diff --git a/packages/stream_chat/lib/src/core/api/channel_api.dart b/packages/stream_chat/lib/src/core/api/channel_api.dart index 761f10da..bdf2a401 100644 --- a/packages/stream_chat/lib/src/core/api/channel_api.dart +++ b/packages/stream_chat/lib/src/core/api/channel_api.dart @@ -92,7 +92,7 @@ class ChannelApi { Future updateChannel( String channelId, String channelType, - Map data, { + Map data, { Message? message, }) async { final response = await _client.post( diff --git a/packages/stream_chat/test/src/api/channel_test.dart b/packages/stream_chat/test/src/api/channel_test.dart new file mode 100644 index 00000000..85f83db5 --- /dev/null +++ b/packages/stream_chat/test/src/api/channel_test.dart @@ -0,0 +1,1872 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/client/channel.dart'; +import 'package:stream_chat/src/client/retry_policy.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../fakes.dart'; +import '../matchers.dart'; +import '../mocks.dart'; + +void main() { + ChannelState _generateChannelState( + String channelId, + String channelType, { + bool mockChannelConfig = false, + }) { + ChannelConfig? config; + if (mockChannelConfig) { + config = MockChannelConfig(); + when(() => config!.readEvents).thenReturn(true); + when(() => config!.typingEvents).thenReturn(true); + } + final channel = ChannelModel( + id: channelId, + type: channelType, + config: config, + ); + final state = ChannelState(channel: channel); + return state; + } + + Logger _createLogger(String name) { + final logger = Logger.detached(name)..level = Level.ALL; + logger.onRecord.listen(print); + return logger; + } + + // TODO : test all persistence related logic in this group + group('Initialized Channel with Persistence', () { + late final client = MockStreamChatClientWithPersistence(); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const channelCid = '$channelType:$channelId'; + late Channel channel; + + setUpAll(() { + // Fallback values + registerFallbackValue(FakeMessage()); + registerFallbackValue>([]); + registerFallbackValue(FakeAttachmentFile()); + + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + retryTimeout: (_, __, ___) => Duration.zero, + ); + when(() => client.retryPolicy).thenReturn(retryPolicy); + + final event = Event(type: 'event.local'); + when(() => client.on(any(), any(), any(), any())) + .thenAnswer((_) => Stream.value(event)); + + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); + + // mock persistence client + final channelThreads = >{}; + when(() => client.chatPersistenceClient.getChannelThreads(channelCid)) + .thenAnswer((_) async => channelThreads); + final channelState = _generateChannelState(channelId, channelType); + when(() => client.chatPersistenceClient.getChannelStateByCid(channelCid)) + .thenAnswer((_) async => channelState); + when(() => client.chatPersistenceClient.updateMessages(channelCid, any())) + .thenAnswer((_) => Future.value()); + + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + }); + + // Setting up a initialized channel + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + }); + + group('Initialized Channel', () { + late final client = MockStreamChatClient(); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const channelCid = '$channelType:$channelId'; + late Channel channel; + + setUpAll(() { + // Fallback values + registerFallbackValue(FakeMessage()); + registerFallbackValue(FakeAttachmentFile()); + registerFallbackValue(FakeEvent()); + + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + retryTimeout: (_, __, ___) => Duration.zero, + ); + when(() => client.retryPolicy).thenReturn(retryPolicy); + + final event = Event(type: 'event.local'); + when(() => client.on(any(), any(), any(), any())) + .thenAnswer((_) => Stream.value(event)); + + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); + + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + }); + + // Setting up a initialized channel + setUp(() { + final channelState = _generateChannelState( + channelId, + channelType, + mockChannelConfig: true, + ); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + + group('`.sendMessage`', () { + test('should work fine', () async { + final message = Message(id: 'test-message-id'); + + final sendMessageResponse = SendMessageResponse()..message = message; + + when(() => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + )).thenAnswer((_) async => sendMessageResponse); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sending), + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.sendMessage(message); + + expect(res, isNotNull); + expect(res.message.id, message.id); + + verify(() => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + )).called(1); + }); + + test('with attachments should work just fine', () async { + final attachments = List.generate( + 3, + (index) => Attachment( + id: 'test-attachment-id-$index', + type: index.isEven ? 'image' : 'file', + file: AttachmentFile(size: 33 * index, path: 'test-file-path'), + ), + ); + + final message = Message( + id: 'test-message-id', + attachments: attachments, + ); + + final sendImageResponse = SendImageResponse()..file = 'test-image-url'; + final sendFileResponse = SendFileResponse()..file = 'test-file-url'; + + when(() => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).thenAnswer((_) async => sendImageResponse); + + when(() => client.sendFile( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).thenAnswer((_) async => sendFileResponse); + + when(() => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + )).thenAnswer((_) async => SendMessageResponse() + ..message = message.copyWith( + attachments: attachments + .map((it) => + it.copyWith(uploadState: const UploadState.success())) + .toList(growable: false), + )); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sending), + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.sendMessage(message); + + expect(res, isNotNull); + expect(res.message.id, message.id); + expect(res.message.attachments.length, message.attachments.length); + expect( + res.message.attachments.every( + (it) => it.uploadState == const UploadState.success(), + ), + isTrue, + ); + + verify(() => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).called(2); + + verify(() => client.sendFile( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).called(1); + + verify(() => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + )).called(1); + }); + }); + + group('`.updateMessage`', () { + test('should work fine', () async { + final message = Message(id: 'test-message-id'); + + final updateMessageResponse = UpdateMessageResponse() + ..message = message; + + when(() => client.updateMessage(any(that: isSameMessageAs(message)))) + .thenAnswer((_) async => updateMessageResponse); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.updating), + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.updateMessage(message); + + expect(res, isNotNull); + expect(res.message.id, message.id); + + verify(() => client.updateMessage( + any(that: isSameMessageAs(message)), + )).called(1); + }); + + test('with attachments should work just fine', () async { + final attachments = List.generate( + 3, + (index) => Attachment( + id: 'test-attachment-id-$index', + type: index.isEven ? 'image' : 'file', + file: AttachmentFile(size: 33 * index, path: 'test-file-path'), + ), + ); + + final message = Message( + id: 'test-message-id', + attachments: attachments, + ); + + final sendImageResponse = SendImageResponse()..file = 'test-image-url'; + final sendFileResponse = SendFileResponse()..file = 'test-file-url'; + + when(() => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).thenAnswer((_) async => sendImageResponse); + + when(() => client.sendFile( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).thenAnswer((_) async => sendFileResponse); + + when(() => client.updateMessage( + any(that: isSameMessageAs(message)), + )).thenAnswer((_) async => UpdateMessageResponse() + ..message = message.copyWith( + attachments: attachments + .map((it) => + it.copyWith(uploadState: const UploadState.success())) + .toList(growable: false), + )); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.updating), + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.updateMessage(message); + + expect(res, isNotNull); + expect(res.message.id, message.id); + expect(res.message.attachments.length, message.attachments.length); + expect( + res.message.attachments.every( + (it) => it.uploadState == const UploadState.success(), + ), + isTrue, + ); + + verify(() => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).called(2); + + verify(() => client.sendFile( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).called(1); + + verify(() => client.updateMessage( + any(that: isSameMessageAs(message)), + )).called(1); + }); + }); + + group('`.deleteMessage`', () { + test('should work fine', () async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + + when(() => client.deleteMessage(messageId)) + .thenAnswer((_) async => EmptyResponse()); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.deleting), + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.deleteMessage(message); + + expect(res, isNotNull); + + verify(() => client.deleteMessage(messageId)).called(1); + }); + + test( + 'should directly update the state with message as deleted if the state is sending or failed', + () async { + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + status: MessageSendingStatus.sending, + ); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.deleteMessage(message); + + expect(res, isNotNull); + }, + ); + }); + + group('`.pinMessage`', () { + test('should work fine without passing timeoutOrExpirationDate', + () async { + final message = Message(id: 'test-message-id'); + + when(() => client.updateMessage(any(that: isSameMessageAs(message)))) + .thenAnswer((invocation) async => UpdateMessageResponse() + ..message = (invocation.positionalArguments.first as Message) + .copyWith(status: MessageSendingStatus.sent)); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.updating), + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.pinMessage(message); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNull); + + verify(() => client.updateMessage(any(that: isSameMessageAs(message)))) + .called(1); + }); + + test( + 'should work fine if passed timeoutOrExpirationDate as num(seconds)', + () async { + final message = Message(id: 'test-message-id'); + const timeoutOrExpirationDate = 300; // 300 seconds + + when(() => client.updateMessage(any(that: isSameMessageAs(message)))) + .thenAnswer((invocation) async => UpdateMessageResponse() + ..message = (invocation.positionalArguments.first as Message) + .copyWith(status: MessageSendingStatus.sent)); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.updating), + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.pinMessage( + message, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNotNull); + + verify(() => + client.updateMessage(any(that: isSameMessageAs(message)))) + .called(1); + }, + ); + + test( + 'should work fine if passed timeoutOrExpirationDate as DateTime', + () async { + final message = Message(id: 'test-message-id'); + final timeoutOrExpirationDate = + DateTime.now().add(const Duration(days: 3)); // 3 days + + when(() => client.updateMessage(any(that: isSameMessageAs(message)))) + .thenAnswer((invocation) async => UpdateMessageResponse() + ..message = (invocation.positionalArguments.first as Message) + .copyWith(status: MessageSendingStatus.sent)); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.updating), + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.pinMessage( + message, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNotNull); + expect(res.message.pinExpires, timeoutOrExpirationDate.toUtc()); + + verify( + () => client.updateMessage(any(that: isSameMessageAs(message))), + ).called(1); + }, + ); + + test( + 'should throw if invalid timeoutOrExpirationDate is passed', + () async { + final message = Message(id: 'test-message-id'); + const timeoutOrExpirationDate = 'invalid-value'; + + try { + await channel.pinMessage( + message, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + } catch (e) { + expect(e, isA()); + } + }, + ); + }); + + test('`.unpinMessage`', () async { + final message = Message(id: 'test-message-id', pinned: true); + + when(() => client.updateMessage(any(that: isSameMessageAs(message)))) + .thenAnswer((invocation) async => UpdateMessageResponse() + ..message = (invocation.positionalArguments.first as Message) + .copyWith(status: MessageSendingStatus.sent)); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.updating), + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.unpinMessage(message); + + expect(res, isNotNull); + expect(res.message.pinned, isFalse); + + verify( + () => client.updateMessage(any(that: isSameMessageAs(message))), + ).called(1); + }); + + // + // /// Send a file to this channel + // Future sendFile( + // AttachmentFile file, { + // ProgressCallback? onSendProgress, + // CancelToken? cancelToken, + // }) { + // _checkInitialized(); + // return _client.sendFile( + // file, + // id!, + // type, + // onSendProgress: onSendProgress, + // cancelToken: cancelToken, + // ); + // } + // + // /// Send an image to this channel + // Future sendImage( + // AttachmentFile file, { + // ProgressCallback? onSendProgress, + // CancelToken? cancelToken, + // }) { + // _checkInitialized(); + // return _client.sendImage( + // file, + // id!, + // type, + // onSendProgress: onSendProgress, + // cancelToken: cancelToken, + // ); + // } + + group('`.search`', () { + final filter = Filter.in_('cid', const [channelCid]); + + test('should work fine with `query`', () async { + const query = 'test-search-query'; + const sort = [SortOption('test-sort-field')]; + const pagination = PaginationParams(); + + final results = List.generate(3, (index) => GetMessageResponse()); + + when(() => client.search( + filter, + query: query, + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( + (_) async => SearchMessagesResponse()..results = results, + ); + + final res = await channel.search( + query: query, + sort: sort, + paginationParams: pagination, + ); + + expect(res, isNotNull); + expect(res.results.length, results.length); + + verify(() => client.search( + filter, + query: query, + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + )).called(1); + }); + + test('should work fine with `messageFilters`', () async { + final messageFilters = Filter.query('key', 'text'); + const sort = [SortOption('test-sort-field')]; + const pagination = PaginationParams(); + + final results = List.generate(3, (index) => GetMessageResponse()); + + when(() => client.search( + filter, + messageFilters: messageFilters, + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( + (_) async => SearchMessagesResponse()..results = results, + ); + + final res = await channel.search( + sort: sort, + paginationParams: pagination, + messageFilters: messageFilters, + ); + + expect(res, isNotNull); + expect(res.results.length, results.length); + + verify(() => client.search( + filter, + messageFilters: messageFilters, + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + )).called(1); + }); + }); + + test('`.deleteFile`', () async { + const url = 'test-file-url'; + + when(() => client.deleteFile(url, channelId, channelType, + cancelToken: any(named: 'cancelToken'))) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.deleteFile(url); + + expect(res, isNotNull); + + verify(() => client.deleteFile(url, channelId, channelType, + cancelToken: any(named: 'cancelToken'))).called(1); + }); + + test('`.deleteImage`', () async { + const url = 'test-image-url'; + + when(() => client.deleteImage(url, channelId, channelType, + cancelToken: any(named: 'cancelToken'))) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.deleteImage(url); + + expect(res, isNotNull); + + verify(() => client.deleteImage(url, channelId, channelType, + cancelToken: any(named: 'cancelToken'))).called(1); + }); + + test('`.sendEvent`', () async { + final event = Event(type: 'event.local'); + + when(() => client.sendEvent(channelId, channelType, event)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.sendEvent(event); + + expect(res, isNotNull); + + verify(() => client.sendEvent(channelId, channelType, event)).called(1); + }); + + group('`.sendReaction`', () { + test('should work fine', () async { + const type = 'test-reaction-type'; + final message = Message(id: 'test-message-id'); + + final reaction = Reaction(type: type, messageId: message.id); + + when(() => client.sendReaction(message.id, type)).thenAnswer( + (_) async => SendReactionResponse() + ..message = message + ..reaction = reaction, + ); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + status: MessageSendingStatus.sent, + reactionCounts: {type: 1}, + reactionScores: {type: 1}, + latestReactions: [reaction], + ownReactions: [reaction], + ), + matchReactions: true, + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.sendReaction(message, type); + + expect(res, isNotNull); + expect(res.reaction.type, type); + expect(res.reaction.messageId, message.id); + + verify(() => client.sendReaction(message.id, type)).called(1); + }); + + test( + 'should restore previous message if `client.sendReaction` throws', + () async { + const type = 'test-reaction-type'; + final message = Message(id: 'test-message-id'); + + final reaction = Reaction(type: type, messageId: message.id); + + when(() => client.sendReaction(message.id, type)) + .thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + status: MessageSendingStatus.sent, + reactionCounts: {type: 1}, + reactionScores: {type: 1}, + latestReactions: [reaction], + ownReactions: [reaction], + ), + matchReactions: true, + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message, + matchReactions: true, + matchSendingStatus: true, + ), + ], + ]), + ); + + try { + await channel.sendReaction(message, type); + } catch (e) { + expect(e, isA()); + } + + verify(() => client.sendReaction(message.id, type)).called(1); + }, + ); + + test( + 'should override previous reaction if present and `enforceUnique` is true', + () async { + const userId = 'test-user-id'; + const messageId = 'test-message-id'; + const prevType = 'test-reaction-type'; + final prevReaction = Reaction( + type: prevType, + messageId: messageId, + userId: userId, + ); + final message = Message( + id: messageId, + ownReactions: [prevReaction], + latestReactions: [prevReaction], + reactionScores: const {prevType: 1}, + reactionCounts: const {prevType: 1}, + ); + + const type = 'test-reaction-type-2'; + final newReaction = Reaction( + type: type, + messageId: messageId, + userId: userId, + ); + final newMessage = message.copyWith( + ownReactions: [newReaction], + latestReactions: [newReaction], + ); + + const enforceUnique = true; + + when(() => client.sendReaction( + messageId, + type, + enforceUnique: enforceUnique, + )).thenAnswer( + (_) async => SendReactionResponse() + ..message = newMessage + ..reaction = newReaction, + ); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + newMessage.copyWith(status: MessageSendingStatus.sent), + matchReactions: true, + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.sendReaction( + message, + type, + enforceUnique: enforceUnique, + ); + + expect(res, isNotNull); + expect(res.reaction.type, type); + expect(res.reaction.messageId, messageId); + + verify(() => client.sendReaction( + messageId, + type, + enforceUnique: enforceUnique, + )).called(1); + }, + ); + }); + + group('`.deleteReaction`', () { + test('should work fine', () async { + const userId = 'test-user-id'; + const messageId = 'test-message-id'; + const type = 'test-reaction-type'; + final reaction = Reaction( + type: type, + messageId: messageId, + userId: userId, + ); + final message = Message( + id: messageId, + ownReactions: [reaction], + latestReactions: [reaction], + reactionScores: const {type: 1}, + reactionCounts: const {type: 1}, + ); + + when(() => client.deleteReaction(messageId, type)) + .thenAnswer((_) async => EmptyResponse()); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + status: MessageSendingStatus.sent, + latestReactions: [], + ownReactions: [], + ), + matchReactions: true, + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.deleteReaction(message, reaction); + + expect(res, isNotNull); + + verify(() => client.deleteReaction(messageId, type)).called(1); + }); + + test( + 'should restore prev message state if `client.deleteReaction` throws', + () async { + const userId = 'test-user-id'; + const messageId = 'test-message-id'; + const type = 'test-reaction-type'; + final reaction = Reaction( + type: type, + messageId: messageId, + userId: userId, + ); + final message = Message( + id: messageId, + ownReactions: [reaction], + latestReactions: [reaction], + reactionScores: const {type: 1}, + reactionCounts: const {type: 1}, + ); + + when(() => client.deleteReaction(messageId, type)) + .thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + status: MessageSendingStatus.sent, + latestReactions: [], + ownReactions: [], + ), + matchReactions: true, + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message, + matchReactions: true, + matchSendingStatus: true, + ), + ], + ]), + ); + + try { + await channel.deleteReaction(message, reaction); + } catch (e) { + expect(e, isA()); + } + + verify(() => client.deleteReaction(messageId, type)).called(1); + }, + ); + }); + + test('`.update`', () async { + const channelData = { + 'name': 'Stream Team', + 'profile_image': 'test-profile-image', + }; + final updateMessage = Message( + id: 'test-message-id', + text: 'updated channel', + ); + + final channelModel = ChannelModel( + cid: channelCid, + extraData: channelData, + ); + + when(() => client.updateChannel(channelId, channelType, channelData, + message: any(named: 'message'))).thenAnswer( + (_) async => UpdateChannelResponse() + ..channel = channelModel + ..message = updateMessage, + ); + + final res = await channel.update(channelData, updateMessage); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.channel.extraData, channelData); + expect(res.message?.id, updateMessage.id); + + verify(() => client.updateChannel(channelId, channelType, channelData, + message: any(named: 'message'))).called(1); + }); + + test('`.updatePartial`', () async { + const channelData = { + 'name': 'Stream Team', + 'profile_image': 'test-profile-image', + }; + + final channelModel = ChannelModel( + cid: channelCid, + extraData: { + 'coolness': 999, + ...channelData, + }, + ); + + when(() => client.updateChannelPartial( + channelId, + channelType, + channelData, + )).thenAnswer( + (_) async => PartialUpdateChannelResponse()..channel = channelModel, + ); + + final res = await channel.updatePartial(channelData); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect( + res.channel.extraData, + {'coolness': 999, ...channelData}, + ); + + verify(() => client.updateChannelPartial( + channelId, + channelType, + channelData, + )).called(1); + }); + + test('`.delete`', () async { + when(() => client.deleteChannel(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.delete(); + + expect(res, isNotNull); + + verify(() => client.deleteChannel(channelId, channelType)).called(1); + }); + + test('`.truncate`', () async { + when(() => client.truncateChannel(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.truncate(); + + expect(res, isNotNull); + + verify(() => client.truncateChannel(channelId, channelType)).called(1); + }); + + test('`.acceptInvite`', () async { + final message = Message(id: 'test-message-id', text: 'Invite Accepted'); + + final channelModel = ChannelModel(cid: channelCid); + + when(() => client.acceptChannelInvite(channelId, channelType, + message: any(named: 'message'))).thenAnswer( + (_) async => AcceptInviteResponse() + ..channel = channelModel + ..message = message, + ); + + final res = await channel.acceptInvite(message); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); + + verify(() => client.acceptChannelInvite(channelId, channelType, + message: any(named: 'message'))).called(1); + }); + + test('`.rejectInvite`', () async { + final message = Message(id: 'test-message-id', text: 'Invite Rejected'); + + final channelModel = ChannelModel(cid: channelCid); + + when(() => client.rejectChannelInvite(channelId, channelType, + message: any(named: 'message'))).thenAnswer( + (_) async => RejectInviteResponse() + ..channel = channelModel + ..message = message, + ); + + final res = await channel.rejectInvite(message); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); + + verify(() => client.rejectChannelInvite(channelId, channelType, + message: any(named: 'message'))).called(1); + }); + + test('`.addMembers`', () async { + final members = List.generate( + 3, + (index) => Member(userId: 'test-member-id-$index'), + ); + final memberIds = members + .map((it) => it.userId) + .whereType() + .toList(growable: false); + final message = Message(id: 'test-message-id', text: 'Members Added'); + + final channelModel = ChannelModel(cid: channelCid); + + when(() => client.addChannelMembers(channelId, channelType, memberIds, + message: any(named: 'message'))).thenAnswer( + (_) async => AddMembersResponse() + ..channel = channelModel + ..members = members + ..message = message, + ); + + final res = await channel.addMembers(memberIds, message); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.members.length, members.length); + expect(res.message?.id, message.id); + + verify(() => client.addChannelMembers(channelId, channelType, memberIds, + message: any(named: 'message'))).called(1); + }); + + test('`.inviteMembers`', () async { + final members = List.generate( + 3, + (index) => Member(userId: 'test-member-id-$index'), + ); + final memberIds = members + .map((it) => it.userId) + .whereType() + .toList(growable: false); + final message = Message(id: 'test-message-id', text: 'Members Invited'); + + final channelModel = ChannelModel(cid: channelCid); + + when(() => client.inviteChannelMembers(channelId, channelType, memberIds, + message: any(named: 'message'))).thenAnswer( + (_) async => InviteMembersResponse() + ..channel = channelModel + ..members = members + ..message = message, + ); + + final res = await channel.inviteMembers(memberIds, message); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.members.length, members.length); + expect(res.message?.id, message.id); + + verify(() => client.inviteChannelMembers( + channelId, channelType, memberIds, + message: any(named: 'message'))).called(1); + }); + + test('`.removeMembers`', () async { + final members = List.generate( + 3, + (index) => Member(userId: 'test-member-id-$index'), + ); + final memberIds = members + .map((it) => it.userId) + .whereType() + .toList(growable: false); + final message = Message(id: 'test-message-id', text: 'Members Removed'); + + final channelModel = ChannelModel(cid: channelCid); + + when(() => client.removeChannelMembers(channelId, channelType, memberIds, + message: any(named: 'message'))).thenAnswer( + (_) async => RemoveMembersResponse() + ..channel = channelModel + ..members = members + ..message = message, + ); + + final res = await channel.removeMembers(memberIds, message); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.members.length, members.length); + expect(res.message?.id, message.id); + + verify(() => client.removeChannelMembers( + channelId, channelType, memberIds, + message: any(named: 'message'))).called(1); + }); + + group('`.sendAction`', () { + test('should work fine', () async { + final message = Message(id: 'test-message-id', text: 'Action Sent'); + const formData = {'key': 'value'}; + + when( + () => client.sendAction(channelId, channelType, message.id, formData), + ).thenAnswer((_) async => SendActionResponse()); + + final res = await channel.sendAction(message, formData); + + expect(res, isNotNull); + + verify( + () => client.sendAction(channelId, channelType, message.id, formData), + ).called(1); + }); + + test('should emit received message if not null', () async { + final message = Message(id: 'test-message-id', text: 'Action Sent'); + const formData = {'key': 'value'}; + + when( + () => client.sendAction(channelId, channelType, message.id, formData), + ).thenAnswer((_) async => SendActionResponse()..message = message); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message, + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.sendAction(message, formData); + + expect(res, isNotNull); + expect(res.message?.id, message.id); + + verify( + () => client.sendAction(channelId, channelType, message.id, formData), + ).called(1); + }); + }); + + test('`.markRead`', () async { + const messageId = 'test-message-id'; + + when(() => client.markChannelRead(channelId, channelType, + messageId: messageId)).thenAnswer((_) async => EmptyResponse()); + + expectLater( + // skipping first seed unread count -> 0 unread count + channel.state?.unreadCountStream.skip(1), + emitsInOrder([0]), + ); + + final res = await channel.markRead(messageId: messageId); + + expect(res, isNotNull); + expect(client.state.totalUnreadCount, 0); + + verify(() => client.markChannelRead(channelId, channelType, + messageId: messageId)).called(1); + }); + + group('`.watch`', () { + test('should work fine', () async { + when(() => client.queryChannel( + channelType, + channelId: channelId, + watch: true, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).thenAnswer( + (_) async => _generateChannelState(channelId, channelType), + ); + + final res = await channel.watch(); + + expect(res, isNotNull); + expect(res.channel, isNotNull); + expect(res.channel?.cid, channelCid); + + verify(() => client.queryChannel( + channelType, + channelId: channelId, + watch: true, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).called(1); + }); + + test('should rethrow if `.query` throws', () async { + when(() => client.queryChannel( + channelType, + channelId: channelId, + watch: true, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + try { + await channel.watch(); + } catch (e) { + expect(e, isA()); + } + + verify(() => client.queryChannel( + channelType, + channelId: channelId, + watch: true, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).called(1); + }); + }); + + test('`.stopWatching`', () async { + when(() => client.stopChannelWatching(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.stopWatching(); + + expect(res, isNotNull); + + verify(() => client.stopChannelWatching(channelId, channelType)) + .called(1); + }); + + test('`.getReplies`', () async { + const parentId = 'test-parent-id'; + const options = PaginationParams(); + + final messages = List.generate( + 3, + (index) => Message( + id: 'test-message-id-$index', + parentId: parentId, + ), + ); + + when(() => client.getReplies(parentId, options)).thenAnswer( + (_) async => QueryRepliesResponse()..messages = messages, + ); + + final res = await channel.getReplies(parentId, options); + + expect(res, isNotNull); + expect(res.messages.length, messages.length); + expect(res.messages.every((it) => it.parentId == parentId), isTrue); + + verify(() => client.getReplies(parentId, options)).called(1); + }); + + test('`.getReactions`', () async { + const messageId = 'test-message-id'; + const options = PaginationParams(); + + final reactions = List.generate( + 3, + (index) => Reaction( + type: 'test-reaction-type-$index', + messageId: messageId, + ), + ); + + when(() => client.getReactions(messageId, options)).thenAnswer( + (_) async => QueryReactionsResponse()..reactions = reactions, + ); + + final res = await channel.getReactions(messageId, options); + + expect(res, isNotNull); + expect(res.reactions.length, reactions.length); + expect(res.reactions.every((it) => it.messageId == messageId), isTrue); + + verify(() => client.getReactions(messageId, options)).called(1); + }); + + test('`.getMessagesById`', () async { + final messages = List.generate( + 3, + (index) => Message(id: 'test-message-id-$index'), + ); + + final messageIds = messages.map((it) => it.id).toList(growable: false); + + when(() => client.getMessagesById(channelId, channelType, messageIds)) + .thenAnswer( + (_) async => GetMessagesByIdResponse()..messages = messages, + ); + + final res = await channel.getMessagesById(messageIds); + + expect(res, isNotNull); + expect(res.messages.length, messageIds.length); + + verify( + () => client.getMessagesById(channelId, channelType, messageIds), + ).called(1); + }); + + test('`.translateMessage`', () async { + const messageId = 'test-message-id'; + const language = 'hi'; // Hindi + const translatedMessageText = 'नमस्ते'; + final translatedMessage = TranslatedMessage(const { + language: translatedMessageText, + }); + + when(() => client.translateMessage(messageId, language)).thenAnswer( + (_) async => TranslateMessageResponse()..message = translatedMessage, + ); + + final res = await channel.translateMessage(messageId, language); + + expect(res, isNotNull); + expect(res.message.i18n, translatedMessage.i18n); + + verify(() => client.translateMessage(messageId, language)).called(1); + }); + + group('`.query`', () { + test('should work fine', () async { + final channelState = _generateChannelState(channelId, channelType); + + when( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenAnswer((_) async => channelState); + + final res = await channel.query(); + + expect(res, isNotNull); + + verify( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).called(1); + }); + + test('should rethrow if `client.queryChannel` throws', () async { + when( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + try { + await channel.query(); + } catch (e) { + expect(e, isA()); + } + + verify( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).called(1); + }); + }); + + test('`.queryMembers`', () async { + final filter = Filter.in_('cid', const [channelCid]); + + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); + + when(() => client.queryMembers( + channelType, + channelId: channelId, + filter: filter, + members: any(named: 'members'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async => QueryMembersResponse()..members = members); + + final res = await channel.queryMembers(filter: filter); + + expect(res, isNotNull); + expect(res.members.length, members.length); + + verify(() => client.queryMembers( + channelType, + channelId: channelId, + filter: filter, + members: any(named: 'members'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).called(1); + }); + + test('`.mute`', () async { + when(() => client.muteChannel( + channelCid, + expiration: any(named: 'expiration'), + )).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.mute(); + + expect(res, isNotNull); + + verify(() => client.muteChannel( + channelCid, + expiration: any(named: 'expiration'), + )).called(1); + }); + + test('`.unmute`', () async { + when( + () => client.unmuteChannel(channelCid), + ).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.unmute(); + + expect(res, isNotNull); + + verify( + () => client.unmuteChannel(channelCid), + ).called(1); + }); + + test('`.banUser`', () async { + const userId = 'test-user-id'; + const options = {'key': 'value'}; + + when(() => client.banUser( + userId, + {'type': channelType, 'id': channelId, ...options}, + )).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.banUser(userId, options); + + expect(res, isNotNull); + + verify(() => client.banUser( + userId, + {'type': channelType, 'id': channelId, ...options}, + )).called(1); + }); + + test('`.unbanUser`', () async { + const userId = 'test-user-id'; + + when(() => client.unbanUser(userId, any())) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.unbanUser(userId); + + expect(res, isNotNull); + + verify(() => client.unbanUser(userId, any())).called(1); + }); + + test('`.shadowBan`', () async { + const userId = 'test-user-id'; + const options = {'key': 'value'}; + + when(() => client.shadowBan( + userId, + {'type': channelType, 'id': channelId, ...options}, + )).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.shadowBan(userId, options); + + expect(res, isNotNull); + + verify(() => client.shadowBan( + userId, + {'type': channelType, 'id': channelId, ...options}, + )).called(1); + }); + + test('`.removeShadowBan`', () async { + const userId = 'test-user-id'; + + when(() => client.removeShadowBan(userId, any())) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.removeShadowBan(userId); + + expect(res, isNotNull); + + verify(() => client.removeShadowBan(userId, any())).called(1); + }); + + test('`.hide`', () async { + const clearHistory = true; + + when(() => client.hideChannel( + channelId, + channelType, + clearHistory: clearHistory, + )).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.hide(clearHistory: clearHistory); + + expect(res, isNotNull); + + verify(() => client.hideChannel( + channelId, + channelType, + clearHistory: clearHistory, + )).called(1); + }); + + test('`.show`', () async { + when(() => client.showChannel(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.show(); + + expect(res, isNotNull); + + verify(() => client.showChannel(channelId, channelType)).called(1); + }); + + test('`.on`', () async { + const eventType = 'test.event'; + final event = Event(type: eventType, cid: channelCid); + + when(() => client.on(eventType, any(), any(), any())) + .thenAnswer((_) => Stream.value(event)); + + expectLater(channel.on(eventType), emitsInOrder([event])); + + verify(() => client.on(eventType, any(), any(), any())).called(1); + }); + + group( + '`.keyStroke`', + () { + test('should return if `config.typingEvents` is false', () async { + when(() => channel.config?.typingEvents).thenReturn(false); + + final typingEvent = Event(type: EventType.typingStart); + + await channel.keyStroke(); + + verifyNever(() => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingEvent)), + )); + }); + + test( + 'should send `typingStart` event if there is not already a typingEvent or the difference between the two is >= 2 seconds', + () async { + final typingEvent = Event(type: EventType.typingStart); + + when(() => channel.config?.typingEvents).thenReturn(true); + + when(() => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingEvent)), + )).thenAnswer((_) async => EmptyResponse()); + + await channel.keyStroke(); + + verify(() => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingEvent)), + )).called(1); + }, + ); + }, + ); + + group('`.stopTyping`', () { + test('should return if `config.typingEvents` is false', () async { + when(() => channel.config?.typingEvents).thenReturn(false); + + final typingStopEvent = Event(type: EventType.typingStop); + + await channel.keyStroke(); + + verifyNever(() => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStopEvent)), + )); + }); + + test('should send `typingStop` successfully', () async { + final typingStopEvent = Event(type: EventType.typingStop); + + when(() => channel.config?.typingEvents).thenReturn(true); + + when(() => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStopEvent)), + )).thenAnswer((_) async => EmptyResponse()); + + await channel.stopTyping(); + + verify(() => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStopEvent)), + )).called(1); + }); + }); + }); +} diff --git a/packages/stream_chat/test/src/api/requests_test.dart b/packages/stream_chat/test/src/core/api/requests_test.dart similarity index 100% rename from packages/stream_chat/test/src/api/requests_test.dart rename to packages/stream_chat/test/src/core/api/requests_test.dart diff --git a/packages/stream_chat/test/src/api/responses_test.dart b/packages/stream_chat/test/src/core/api/responses_test.dart similarity index 100% rename from packages/stream_chat/test/src/api/responses_test.dart rename to packages/stream_chat/test/src/core/api/responses_test.dart diff --git a/packages/stream_chat/test/src/fakes.dart b/packages/stream_chat/test/src/fakes.dart index b7e431b1..f1468271 100644 --- a/packages/stream_chat/test/src/fakes.dart +++ b/packages/stream_chat/test/src/fakes.dart @@ -82,3 +82,24 @@ class FakeChatApi extends Fake implements StreamChatApi { AttachmentFileUploader get fileUploader => _fileUploader ??= MockAttachmentFileUploader(); } + +class FakeClientState extends Fake implements ClientState { + @override + OwnUser? get user => OwnUser(id: 'test-user-id'); + + var _totalUnreadCount = 0; + + @override + int? get totalUnreadCount => _totalUnreadCount; + + @override + set totalUnreadCount(int? unreadCount) { + _totalUnreadCount += unreadCount ?? 0; + } +} + +class FakeMessage extends Fake implements Message {} + +class FakeAttachmentFile extends Fake implements AttachmentFile {} + +class FakeEvent extends Fake implements Event {} diff --git a/packages/stream_chat/test/src/matchers.dart b/packages/stream_chat/test/src/matchers.dart index a2be680f..c958ff5f 100644 --- a/packages/stream_chat/test/src/matchers.dart +++ b/packages/stream_chat/test/src/matchers.dart @@ -1,4 +1,7 @@ +import 'package:collection/collection.dart'; import 'package:dio/dio.dart' show MultipartFile; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/message.dart'; import 'package:test/test.dart'; Matcher isSameMultipartFileAs(MultipartFile targetFile) => @@ -17,3 +20,72 @@ class _IsSameMultipartFileAs extends Matcher { bool matches(covariant MultipartFile file, Map matchState) => file.length == targetFile.length; } + +Matcher isSameEventAs(Event targetEvent) => + _IsSameEventAs(targetEvent: targetEvent); + +class _IsSameEventAs extends Matcher { + const _IsSameEventAs({required this.targetEvent}); + + final Event targetEvent; + + @override + Description describe(Description description) => + description.add('is same event as $targetEvent'); + + @override + bool matches(covariant Event event, Map matchState) => + event.type == targetEvent.type; +} + +Matcher isSameMessageAs( + Message targetMessage, { + bool matchReactions = false, + bool matchSendingStatus = false, +}) => + _IsSameMessageAs( + targetMessage: targetMessage, + matchReactions: matchReactions, + matchSendingStatus: matchSendingStatus, + ); + +class _IsSameMessageAs extends Matcher { + const _IsSameMessageAs({ + required this.targetMessage, + this.matchReactions = false, + this.matchSendingStatus = false, + }); + + final Message targetMessage; + final bool matchReactions; + final bool matchSendingStatus; + + @override + Description describe(Description description) => + description.add('is same message as $targetMessage'); + + @override + bool matches(covariant Message message, Map matchState) { + var matches = message.id == targetMessage.id; + if (matchSendingStatus) { + matches &= message.status == targetMessage.status; + } + if (matchReactions) { + matches &= const ListEquality().equals( + message.ownReactions + ?.map((it) => '${it.type}-${it.messageId}') + .toList(), + targetMessage.ownReactions + ?.map((it) => '${it.type}-${it.messageId}') + .toList()); + matches &= const ListEquality().equals( + message.latestReactions + ?.map((it) => '${it.type}-${it.messageId}') + .toList(), + targetMessage.latestReactions + ?.map((it) => '${it.type}-${it.messageId}') + .toList()); + } + return matches; + } +} diff --git a/packages/stream_chat/test/src/mocks.dart b/packages/stream_chat/test/src/mocks.dart index 411f06a3..9c20be7c 100644 --- a/packages/stream_chat/test/src/mocks.dart +++ b/packages/stream_chat/test/src/mocks.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:logging/logging.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/client/client.dart'; import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; import 'package:stream_chat/src/core/api/channel_api.dart'; import 'package:stream_chat/src/core/api/device_api.dart'; @@ -12,8 +13,13 @@ import 'package:stream_chat/src/core/api/user_api.dart'; import 'package:stream_chat/src/core/http/connection_id_manager.dart'; import 'package:stream_chat/src/core/http/stream_http_client.dart'; import 'package:stream_chat/src/core/http/token_manager.dart'; +import 'package:stream_chat/src/core/models/channel_config.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/db/chat_persistence_client.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; +import 'db/chat_persistence_client_test.dart'; + class MockWebSocketChannel extends Mock implements WebSocketChannel {} class MockWebSocketSink extends Mock implements WebSocketSink {} @@ -57,3 +63,24 @@ class MockGeneralApi extends Mock implements GeneralApi {} class MockAttachmentFileUploader extends Mock implements AttachmentFileUploader {} + +class MockPersistenceClient extends Mock implements ChatPersistenceClient {} + +class MockStreamChatClient extends Mock implements StreamChatClient { + @override + bool get persistenceEnabled => false; +} + +class MockStreamChatClientWithPersistence extends Mock + implements StreamChatClient { + ChatPersistenceClient? _persistenceClient; + + @override + ChatPersistenceClient get chatPersistenceClient => + _persistenceClient ??= MockPersistenceClient(); + + @override + bool get persistenceEnabled => true; +} + +class MockChannelConfig extends Mock implements ChannelConfig {}