diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 84dec6dc7e..d8d505d91f 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -997,6 +997,10 @@ functions called inside each test block. For cleanup, prefer `addTearDown` over global `tearDown` callback — `addTearDown` registers cleanup at the exact point a resource is created, ensuring it only runs if initialization succeeded. +The `setUp:` parameter of `chatClientTest` / `channelTest` is a different thing and is +fine to use: it runs once per test against that test's own tester, and shares nothing +with the tests around it. + ### Prefer more test files, avoid long test files Organize tests into smaller files grouped by feature, widget, or behavior. It's easier @@ -1029,6 +1033,52 @@ persistence client, WebSocket). Do not mock every collaborator. mock.method(any(named: 'foo')))` pattern; do not hand-roll a fake that duplicates the full interface. +For `stream_chat` those seams are already replaced for you — see +[Use the `stream_chat_test` harness for `stream_chat` tests](#use-the-stream_chat_test-harness-for-stream_chat-tests). + +### Use the `stream_chat_test` harness for `stream_chat` tests + +`chatClientTest` / `channelTest`, from the `stream_chat_test` package, build a **real** +`StreamChatClient` with only the REST API, the WebSocket transport and — when asked +for — the persistence client swapped out, so event decoding, state, retries and +reconnection all run production code. Do not hand-roll a mocked `StreamChatClient` +for these tests. See the package's README for the full API. + +```dart +// BAD — mocking the subject itself; nothing underneath it is exercised. +final client = MockStreamChatClient(); +when(() => client.markAllRead()).thenAnswer((_) async => EmptyResponse()); + +// GOOD — the client, its state and the event pipeline are real; only the +// REST call is stubbed. +chatClientTest( + '`.markAllRead` marks every channel as read', + body: (tester) async { + tester.mockApi( + (api) => api.channel.markAllRead(), + result: createDefaultEmptyResponse(), + ); + + await tester.client.markAllRead(); + + tester.verifyApi((api) => api.channel.markAllRead()); + }, +); +``` + +Three conventions keep that harness trustworthy. Breaking them tends to produce a +confusing mismatch rather than a clear failure: + +- **Stub with exact argument values wherever the value is known.** A mocktail stub + only answers on a match, so an exact stub doubles as verification of the request + the SDK actually sent. Reach for `any()` only when the SDK stamps the argument + itself (e.g. a `Message`, which gets a local timestamp and sender attached). +- **Keep fixtures deterministic.** Build them with the `createDefaultXxx` factories + and pass timestamps explicitly — a `DateTime.now()` in test data makes + equality-based matching differ from run to run. +- **Use UTC for any `DateTime` inside an emitted event.** Emitted events go through + real JSON decoding, and local times do not survive that round-trip intact. + ### Golden tests Widget tests that verify pixel-level rendering use the `alchemist` package. Golden diff --git a/TESTING.md b/TESTING.md index 56be291133..333d4d510e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -138,6 +138,10 @@ The helpers (`_emptyChannelListController`, `_pumpChannelListView`) live at the of the test file. Their names tell the reader what they do; the reader doesn't need to look inside unless something breaks. +When testing `stream_chat`, the `connect:` and `setUp:` phases of `chatClientTest` / +`channelTest` are where this setup belongs, so the body holds only the behavior under +test. + ## Optimize tests for comprehension Even a well-factored test benefits from small edits that separate "the thing under @@ -241,5 +245,7 @@ your machine — see [Golden tests in STYLE_GUIDE.md](STYLE_GUIDE.md#golden-test - [STYLE_GUIDE.md — Testing](STYLE_GUIDE.md#testing) — repo-level testing conventions (mocktail, alchemist golden tests, self-contained tests, `addTearDown`). +- [`packages/stream_chat_test/README.md`](packages/stream_chat_test/README.md) — the + `chatClientTest` / `channelTest` harness used by the `stream_chat` test suite. - Flutter's [Writing-Effective-Tests](https://github.com/flutter/flutter/blob/master/docs/contributing/testing/Writing-Effective-Tests.md) — the source this guide was adapted from. diff --git a/melos.yaml b/melos.yaml index ae206a15a1..eb0d1bcc24 100644 --- a/melos.yaml +++ b/melos.yaml @@ -84,6 +84,7 @@ command: media_kit_video: ^2.0.1 meta: ^1.9.1 mime: ^2.0.0 + mocktail: ^1.0.5 package_info_plus: ">=9.0.1 <11.0.0" path: ^1.9.1 path_provider: ^2.1.5 @@ -131,6 +132,7 @@ command: ref: 2aea1413997af886ee951921d71e6b8cb8c5cfbb path: packages/stream_thumbnail synchronized: ^3.4.0 + test: ^1.31.1 url_launcher: ^6.3.2 uuid: ^4.5.3 video_player: ^2.11.1 diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index eb7323bf5a..720d00ac88 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -88,6 +88,7 @@ - Fixed reconnect catch-up covering an arbitrary subset of channels when more are active than one request holds; the most recently active are now covered first. - Fixed `CurrentPlatform` throwing `UnimplementedError` on WebAssembly builds. - Fixed live location expiry emitting repeated `location.expired` events for the same expired location. +- Fixed members removed from a channel keeping their read state in the channel state. 🔄 Internal / Non-breaking diff --git a/packages/stream_chat/lib/src/client/channel/channel_client_state.dart b/packages/stream_chat/lib/src/client/channel/channel_client_state.dart index c905f39772..cb4ee7b0cc 100644 --- a/packages/stream_chat/lib/src/client/channel/channel_client_state.dart +++ b/packages/stream_chat/lib/src/client/channel/channel_client_state.dart @@ -33,6 +33,7 @@ class ChannelClientState { upsertTypingEvent: _upsertTypingEvent, removeTypingEvent: _removeTypingEvent, removeWatcher: _removeWatcher, + removeMember: _removeMember, updateMember: _updateMember, deleteMessagesFromUser: _deleteMessagesFromUser, ); @@ -71,6 +72,18 @@ class ChannelClientState { ); } + // Removes the member and their read state from the channel state. + void _removeMember(User user) { + // Writes the state directly: the read list merge in [updateChannelState] + // would undo the read removal. + final existingMembers = channelState.members ?? const []; + final existingReads = channelState.read ?? const []; + _channelState = channelState.copyWith( + members: existingMembers.where((member) => member.userId != user.id).toList(), + read: existingReads.where((read) => read.user.id != user.id).toList(), + ); + } + // Replaces the member matching the given member's user id in the channel // state. Does nothing if no member with the same user id exists. void _updateMember(Member member) { diff --git a/packages/stream_chat/lib/src/client/channel/channel_state_mutations.dart b/packages/stream_chat/lib/src/client/channel/channel_state_mutations.dart index d629198424..e7abb05848 100644 --- a/packages/stream_chat/lib/src/client/channel/channel_state_mutations.dart +++ b/packages/stream_chat/lib/src/client/channel/channel_state_mutations.dart @@ -16,6 +16,7 @@ class ChannelStateMutations { required this._upsertTypingEvent, required this._removeTypingEvent, required this._removeWatcher, + required this._removeMember, required this._updateMember, required this._deleteMessagesFromUser, }); @@ -26,6 +27,7 @@ class ChannelStateMutations { final void Function(User user, Event event) _upsertTypingEvent; final void Function(User user) _removeTypingEvent; final void Function(User watcher, {int? watcherCount}) _removeWatcher; + final void Function(User user) _removeMember; final void Function(Member member) _updateMember; final Future Function({ required String userId, @@ -430,17 +432,7 @@ class ChannelStateMutations { } /// Removes the [user]'s membership and read state. - void onMemberRemoved(User user) { - final existingRead = _state.channelState.read ?? []; - final existingMembers = _state.channelState.members ?? []; - - _state.updateChannelState( - _state.channelState.copyWith( - read: [...existingRead.where((r) => r.user.id != user.id)], - members: [...existingMembers.where((m) => m.userId != user.id)], - ), - ); - } + void onMemberRemoved(User user) => _removeMember(user); /// Merges the updated [user] into the matching member and membership. /// diff --git a/packages/stream_chat/pubspec.yaml b/packages/stream_chat/pubspec.yaml index 2fbadbe2d6..54f04235dd 100644 --- a/packages/stream_chat/pubspec.yaml +++ b/packages/stream_chat/pubspec.yaml @@ -51,4 +51,6 @@ dev_dependencies: json_serializable: ^6.13.2 mocktail: ^1.0.5 retrofit_generator: ^10.2.6 + stream_chat_test: + path: ../stream_chat_test test: ^1.31.1 diff --git a/packages/stream_chat/test/src/client/channel/channel_capability_check_test.dart b/packages/stream_chat/test/src/client/channel/channel_capability_check_test.dart index 2082aefc5e..bc2dbccbdd 100644 --- a/packages/stream_chat/test/src/client/channel/channel_capability_check_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_capability_check_test.dart @@ -1,53 +1,35 @@ // ignore_for_file: deprecated_member_use_from_same_package -import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/stream_chat.dart'; -import 'package:test/test.dart'; - -import '../../fakes.dart'; -import '../../mocks.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; void main() { group('ChannelCapabilityCheck', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); - - setUpAll(() { - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - - // client logger - }); - - /// Parameterized test for channel capability extension properties + /// Parameterized test for channel capability extension properties. void testCapability( String capabilityName, ChannelCapability capability, bool Function(Channel) getterMethod, ) { - test('can$capabilityName returns false when capability is absent', () { - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - expect(getterMethod(channel), false); - }); - - test('can$capabilityName returns true when capability is present', () { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [capability], - ); - final channel = Channel.fromState(client, channelState); - expect(getterMethod(channel), true); - }); + channelTest( + 'can$capabilityName returns false when capability is absent', + setUp: (tester) => tester.watch(), + body: (tester) async { + expect(getterMethod(tester.channel), false); + }, + ); + + channelTest( + 'can$capabilityName returns true when capability is present', + setUp: (tester) => tester.watch( + modifyResponse: (state) => state.copyWith( + channel: createDefaultChannelModel(ownCapabilities: [capability]), + ), + ), + body: (tester) async { + expect(getterMethod(tester.channel), true); + }, + ); } // Test all channel capabilities using the parameterized function @@ -303,95 +285,73 @@ void main() { (channel) => channel.canNotifyGroup, ); - test('returns correct values with multiple capabilities', () { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ - ChannelCapability.sendMessage, - ChannelCapability.sendReply, - ChannelCapability.deleteOwnMessage, - ], + channelTest( + 'returns correct values with multiple capabilities', + setUp: (tester) => tester.watch( + modifyResponse: (state) => state.copyWith( + channel: createDefaultChannelModel( + ownCapabilities: [ + ChannelCapability.sendMessage, + ChannelCapability.sendReply, + ChannelCapability.deleteOwnMessage, + ], + ), + ), + ), + body: (tester) async { + expect(tester.channel.canSendMessage, true); + expect(tester.channel.canSendReply, true); + expect(tester.channel.canDeleteOwnMessage, true); + expect(tester.channel.canDeleteAnyMessage, false); + expect(tester.channel.canUpdateChannel, false); + }, + ); + group('usesLocalUnreadCount', () { + channelTest( + 'is false when disabled and read receipts are unavailable', + setUp: (tester) => tester.watch(), + body: (tester) async { + expect(tester.channel.usesLocalUnreadCount, false); + }, + ); + + channelTest( + 'is false when disabled and read receipts are available', + setUp: (tester) => tester.watch( + modifyResponse: (state) => state.copyWith( + channel: createDefaultChannelModel( + ownCapabilities: [ChannelCapability.readEvents], + ), + ), + ), + body: (tester) async { + expect(tester.channel.usesLocalUnreadCount, false); + }, ); - final channel = Channel.fromState(client, channelState); - expect(channel.canSendMessage, true); - expect(channel.canSendReply, true); - expect(channel.canDeleteOwnMessage, true); - expect(channel.canDeleteAnyMessage, false); - expect(channel.canUpdateChannel, false); - }); + channelTest( + 'is false when enabled but the channel supports read receipts', + isLocalUnreadCountEnabled: true, + setUp: (tester) => tester.watch( + modifyResponse: (state) => state.copyWith( + channel: createDefaultChannelModel( + ownCapabilities: [ChannelCapability.readEvents], + ), + ), + ), + body: (tester) async { + expect(tester.channel.usesLocalUnreadCount, false); + }, + ); - group('usesLocalUnreadCount', () { - // `isLocalUnreadCountEnabled` is a settable field on the mock and the - // client is shared across the group, so reset it between tests. - tearDown(() => client.isLocalUnreadCountEnabled = false); - - Channel channelWithReadEvents({required bool available}) { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ - if (available) ChannelCapability.readEvents, - ], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - return channel; - } - - test('is false when disabled and read receipts are unavailable', () { - client.isLocalUnreadCountEnabled = false; - final channel = channelWithReadEvents(available: false); - expect(channel.usesLocalUnreadCount, false); - }); - - test('is false when disabled and read receipts are available', () { - client.isLocalUnreadCountEnabled = false; - final channel = channelWithReadEvents(available: true); - expect(channel.usesLocalUnreadCount, false); - }); - - test('is false when enabled but the channel supports read receipts', () { - client.isLocalUnreadCountEnabled = true; - final channel = channelWithReadEvents(available: true); - expect(channel.usesLocalUnreadCount, false); - }); - - test('is true when enabled and read receipts are unavailable', () { - client.isLocalUnreadCountEnabled = true; - final channel = channelWithReadEvents(available: false); - expect(channel.usesLocalUnreadCount, true); - }); + channelTest( + 'is true when enabled and read receipts are unavailable', + isLocalUnreadCountEnabled: true, + setUp: (tester) => tester.watch(), + body: (tester) async { + expect(tester.channel.usesLocalUnreadCount, true); + }, + ); }); }); } - -// region Test Helpers - -ChannelState _generateChannelState( - String channelId, - String channelType, { - DateTime? lastMessageAt, - List? ownCapabilities, - 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, - ownCapabilities: ownCapabilities, - lastMessageAt: lastMessageAt, - ); - return ChannelState(channel: channel); -} - -// endregion diff --git a/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart b/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart index 1b3153bdb1..1d5a2dd820 100644 --- a/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart @@ -1,1374 +1,1377 @@ -// ignore_for_file: lines_longer_than_80_chars, cascade_invocations, deprecated_member_use_from_same_package, avoid_redundant_argument_values - -import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/stream_chat.dart'; -import 'package:test/test.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; -import '../../fakes.dart'; -import '../../mocks.dart'; +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; void main() { group('WS events', () { - late final client = MockStreamChatClient(); + group('Typing events', () { + channelTest( + '${EventType.typingStart} from another user is added to typingEvents', + channelType: 'test-channel-type', + channelId: 'test-channel-id', + setUp: (tester) => tester.watch(), + body: (tester) async { + final otherUser = User(id: 'other-user'); - setUpAll(() { - // Fallback values - registerFallbackValue(FakeMessage()); - registerFallbackValue(FakeAttachmentFile()); - registerFallbackValue(FakeEvent()); + await tester.emitEvent( + createDefaultEvent(type: EventType.typingStart, cid: tester.channel.cid, user: otherUser), + ); - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, + final typingEvents = tester.channelState!.typingEvents; + expect(typingEvents.keys.map((u) => u.id), ['other-user']); + expect(typingEvents.values.single.type, EventType.typingStart); + }, ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); + channelTest( + '${EventType.typingStop} removes only the stopping user', + channelType: 'test-channel-type', + channelId: 'test-channel-id', + setUp: (tester) => tester.watch(), + body: (tester) async { + final user1 = User(id: 'other-user-1'); + final user2 = User(id: 'other-user-2'); - // client logger + await tester.emitEvent(createDefaultEvent(type: EventType.typingStart, cid: tester.channel.cid, user: user1)); + await tester.emitEvent(createDefaultEvent(type: EventType.typingStart, cid: tester.channel.cid, user: user2)); + expect(tester.channelState!.typingEvents, hasLength(2)); - // mock channel delivery reporter - when( - () => client.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); - }); + await tester.emitEvent(createDefaultEvent(type: EventType.typingStop, cid: tester.channel.cid, user: user1)); - group('Typing events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; + expect(tester.channelState!.typingEvents.keys.map((u) => u.id), ['other-user-2']); + }, + ); - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); + channelTest( + '${EventType.typingStart} from the current user is ignored', + channelType: 'test-channel-type', + channelId: 'test-channel-id', + setUp: (tester) => tester.watch(), + body: (tester) async { + final currentUser = User(id: tester.currentUser!.id); - tearDown(() { - channel.dispose(); - }); + await tester.emitEvent( + createDefaultEvent(type: EventType.typingStart, cid: tester.channel.cid, user: currentUser), + ); + + expect(tester.channelState!.typingEvents, isEmpty); + }, + ); + + channelTest( + '${EventType.typingStart} without a user is ignored', + channelType: 'test-channel-type', + channelId: 'test-channel-id', + setUp: (tester) => tester.watch(), + body: (tester) async { + await tester.emitEvent(createDefaultEvent(type: EventType.typingStart, cid: tester.channel.cid)); + + expect(tester.channelState!.typingEvents, isEmpty); + }, + ); + + channelTest( + '${EventType.typingStop} from the current user is ignored', + channelType: 'test-channel-type', + channelId: 'test-channel-id', + setUp: (tester) => tester.watch(), + body: (tester) async { + final currentUser = User(id: tester.currentUser!.id); + + await tester.emitEvent( + createDefaultEvent(type: EventType.typingStop, cid: tester.channel.cid, user: currentUser), + ); + + expect(tester.channelState!.typingEvents, isEmpty); + }, + ); - test('${EventType.typingStart} from another user is added to typingEvents', () async { - final otherUser = User(id: 'other-user'); + channelTest( + '${EventType.typingStop} without a user is ignored', + channelType: 'test-channel-type', + channelId: 'test-channel-id', + setUp: (tester) => tester.watch(), + body: (tester) async { + await tester.emitEvent(createDefaultEvent(type: EventType.typingStop, cid: tester.channel.cid)); - client.addEvent( - Event(cid: channel.cid, type: EventType.typingStart, user: otherUser), + expect(tester.channelState!.typingEvents, isEmpty); + }, + ); + }); + + group('${EventType.messageNew} or ${EventType.notificationMessageNew}', () { + final _initialLastMessageAt = DateTime.utc(2021, 3); + + ChannelState Function(ChannelState) _seedChannel({ + List ownCapabilities = const [ChannelCapability.readEvents], + ChannelConfig? config, + }) { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: config ?? createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: ownCapabilities, + lastMessageAt: _initialLastMessageAt, + ), ); - await Future.delayed(Duration.zero); + } - expect(channel.state!.typingEvents.keys.map((u) => u.id), ['other-user']); - expect(channel.state!.typingEvents[otherUser]?.type, EventType.typingStart); - }); + Event _newMessageEvent(Message message) => createDefaultEvent( + type: EventType.messageNew, + cid: _channelCid, + message: message, + ); - test('${EventType.typingStop} removes only the stopping user', () async { - final user1 = User(id: 'other-user-1'); - final user2 = User(id: 'other-user-2'); + channelTest( + "should update 'channel.lastMessageAt'", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + expect(tester.channel.lastMessageAt, equals(_initialLastMessageAt)); - client.addEvent(Event(cid: channel.cid, type: EventType.typingStart, user: user1)); - client.addEvent(Event(cid: channel.cid, type: EventType.typingStart, user: user2)); - await Future.delayed(Duration.zero); - expect(channel.state!.typingEvents, hasLength(2)); + final message = Message( + id: 'test-message-id', + user: tester.currentUser, + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); - client.addEvent(Event(cid: channel.cid, type: EventType.typingStop, user: user1)); - await Future.delayed(Duration.zero); + await tester.emitEvent(_newMessageEvent(message)); - expect(channel.state!.typingEvents.keys.map((u) => u.id), ['other-user-2']); - }); + expect(tester.channel.lastMessageAt, equals(message.createdAt)); + expect(tester.channel.lastMessageAt, isNot(_initialLastMessageAt)); + }, + ); - test('${EventType.typingStart} from the current user is ignored', () async { - final currentUser = User(id: client.state.currentUser!.id); + channelTest( + "should update 'channel.lastMessageAt' when Message has restricted visibility only for the current user", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + expect(tester.channel.lastMessageAt, equals(_initialLastMessageAt)); - client.addEvent(Event(cid: channel.cid, type: EventType.typingStart, user: currentUser)); - await Future.delayed(Duration.zero); + final message = Message( + id: 'test-message-id', + user: tester.currentUser, + // Message is visible to the current user. + restrictedVisibility: [tester.currentUser!.id], + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); - expect(channel.state!.typingEvents, isEmpty); - }); + await tester.emitEvent(_newMessageEvent(message)); - test('${EventType.typingStart} without a user is ignored', () async { - client.addEvent(Event(cid: channel.cid, type: EventType.typingStart)); - await Future.delayed(Duration.zero); + expect(tester.channel.lastMessageAt, equals(message.createdAt)); + expect(tester.channel.lastMessageAt, isNot(_initialLastMessageAt)); + }, + ); - expect(channel.state!.typingEvents, isEmpty); - }); + channelTest( + "should not update 'channel.lastMessageAt' when 'message.createdAt' is older", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + expect(tester.channel.lastMessageAt, equals(_initialLastMessageAt)); + + final message = Message( + id: 'test-message-id', + user: tester.currentUser, + // Older than the current 'channel.lastMessageAt'. + createdAt: _initialLastMessageAt.subtract(const Duration(days: 1)), + ); + + await tester.emitEvent(_newMessageEvent(message)); - test('${EventType.typingStop} from the current user is ignored', () async { - final currentUser = User(id: client.state.currentUser!.id); + expect(tester.channel.lastMessageAt, isNot(message.createdAt)); + expect(tester.channel.lastMessageAt, equals(_initialLastMessageAt)); + }, + ); - client.addEvent(Event(cid: channel.cid, type: EventType.typingStop, user: currentUser)); - await Future.delayed(Duration.zero); + channelTest( + "should not update 'channel.lastMessageAt' when Message is shadowed", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + expect(tester.channel.lastMessageAt, equals(_initialLastMessageAt)); - expect(channel.state!.typingEvents, isEmpty); - }); + final message = Message( + id: 'test-message-id', + user: tester.currentUser, + shadowed: true, + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); - test('${EventType.typingStop} without a user is ignored', () async { - client.addEvent(Event(cid: channel.cid, type: EventType.typingStop)); - await Future.delayed(Duration.zero); + await tester.emitEvent(_newMessageEvent(message)); - expect(channel.state!.typingEvents, isEmpty); - }); - }); + expect(tester.channel.lastMessageAt, isNot(message.createdAt)); + expect(tester.channel.lastMessageAt, equals(_initialLastMessageAt)); + }, + ); - group( - '${EventType.messageNew} or ${EventType.notificationMessageNew}', - () { - final initialLastMessageAt = DateTime.now(); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ownCapabilities: const [ChannelCapability.readEvents], - lastMessageAt: initialLastMessageAt, + channelTest( + "should not update 'channel.lastMessageAt' when Message is ephemeral", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + expect(tester.channel.lastMessageAt, equals(_initialLastMessageAt)); + + final message = Message( + type: MessageType.ephemeral, + id: 'test-message-id', + user: tester.currentUser, + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), ); - channel = Channel.fromState(client, channelState); - }); + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channel.lastMessageAt, isNot(message.createdAt)); + expect(tester.channel.lastMessageAt, equals(_initialLastMessageAt)); + }, + ); - tearDown(() => channel.dispose()); + channelTest( + "should not update 'channel.lastMessageAt' when Message has restricted visibility but not for the current user", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + expect(tester.channel.lastMessageAt, equals(_initialLastMessageAt)); - Event createNewMessageEvent(Message message) { - return Event( - cid: channel.cid, - type: EventType.messageNew, - message: message, + final message = Message( + id: 'test-message-id', + user: tester.currentUser, + // Message is only visible to user-1 not the current user. + restrictedVisibility: const ['user-1'], + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), ); - } - test( - "should update 'channel.lastMessageAt'", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channel.lastMessageAt, isNot(message.createdAt)); + expect(tester.channel.lastMessageAt, equals(_initialLastMessageAt)); + }, + ); + + channelTest( + "should not update 'channel.lastMessageAt' when Message is system and skip is enabled", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + config: createDefaultChannelConfig( + readEvents: true, + typingEvents: true, + skipLastMsgUpdateForSystemMsgs: true, + ), + ), + ), + body: (tester) async { + expect(tester.channel.lastMessageAt, equals(_initialLastMessageAt)); + + final message = Message( + type: MessageType.system, + id: 'test-message-id', + user: tester.currentUser, + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channel.lastMessageAt, isNot(message.createdAt)); + expect(tester.channel.lastMessageAt, equals(_initialLastMessageAt)); + }, + ); + + channelTest( + "should update 'unreadCount'", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + expect(tester.channelState?.unreadCount, equals(0)); + + final message = Message( + id: 'test-message-id', + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channelState?.unreadCount, equals(1)); + + final message2 = Message( + id: 'test-message-id-2', + user: User(id: 'other-user'), + createdAt: message.createdAt.add(const Duration(seconds: 3)), + ); + + await tester.emitEvent(_newMessageEvent(message2)); + + expect(tester.channelState?.unreadCount, equals(2)); + }, + ); + + group("should not update 'unreadCount'", () { + channelTest( + 'when the message is silent', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + expect(tester.channelState?.unreadCount, equals(0)); final message = Message( id: 'test-message-id', - user: client.state.currentUser, - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + silent: true, + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); + await tester.emitEvent(_newMessageEvent(message)); - expect(channel.lastMessageAt, equals(message.createdAt)); - expect(channel.lastMessageAt, isNot(initialLastMessageAt)); + expect(tester.channelState?.unreadCount, equals(0)); }, ); - test( - "should update 'channel.lastMessageAt' when Message has restricted visibility only for the current user", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); + channelTest( + 'when the message is shadowed', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + expect(tester.channelState?.unreadCount, equals(0)); final message = Message( id: 'test-message-id', - user: client.state.currentUser, - // Message is visible to the current user. - restrictedVisibility: [client.state.currentUser!.id], - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + shadowed: true, + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); + await tester.emitEvent(_newMessageEvent(message)); - expect(channel.lastMessageAt, equals(message.createdAt)); - expect(channel.lastMessageAt, isNot(initialLastMessageAt)); + expect(tester.channelState?.unreadCount, equals(0)); }, ); - test( - "should not update 'channel.lastMessageAt' when 'message.createdAt' is older", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); + channelTest( + 'when the message type is ephemeral', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + expect(tester.channelState?.unreadCount, equals(0)); final message = Message( id: 'test-message-id', - user: client.state.currentUser, - // Older than the current 'channel.lastMessageAt'. - createdAt: initialLastMessageAt.subtract(const Duration(days: 1)), + type: MessageType.ephemeral, + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); + await tester.emitEvent(_newMessageEvent(message)); - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); + expect(tester.channelState?.unreadCount, equals(0)); }, ); - test( - "should not update 'channel.lastMessageAt' when Message is shadowed", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); + channelTest( + 'when the message is a thread reply', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + expect(tester.channelState?.unreadCount, equals(0)); final message = Message( id: 'test-message-id', - user: client.state.currentUser, - shadowed: true, - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + parentId: 'test-parent-id', + showInChannel: false, + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); + await tester.emitEvent(_newMessageEvent(message)); - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); + expect(tester.channelState?.unreadCount, equals(0)); }, ); - test( - "should not update 'channel.lastMessageAt' when Message is ephemeral", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); + channelTest( + 'when the message is a thread reply', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + expect(tester.channelState?.unreadCount, equals(0)); final message = Message( - type: MessageType.ephemeral, id: 'test-message-id', - user: client.state.currentUser, - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + parentId: 'test-parent-id', + showInChannel: false, + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); + await tester.emitEvent(_newMessageEvent(message)); - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); + expect(tester.channelState?.unreadCount, equals(0)); }, ); - test( - "should not update 'channel.lastMessageAt' when Message has restricted visibility but not for the current user", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); + channelTest( + 'when the message is from the current user', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + expect(tester.channelState?.unreadCount, equals(0)); final message = Message( id: 'test-message-id', - user: client.state.currentUser, - // Message is only visible to user-1 not the current user. - restrictedVisibility: const ['user-1'], - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + user: tester.currentUser, + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); + await tester.emitEvent(_newMessageEvent(message)); - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); + expect(tester.channelState?.unreadCount, equals(0)); }, ); - test( - "should not update 'channel.lastMessageAt' when Message is system and skip is enabled", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - - when( - () => channel.config?.skipLastMsgUpdateForSystemMsgs, - ).thenReturn(true); + channelTest( + 'when the message is not restricted for the current user', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + expect(tester.channelState?.unreadCount, equals(0)); final message = Message( - type: MessageType.system, id: 'test-message-id', - user: client.state.currentUser, - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + restrictedVisibility: const ['other-user-2'], ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); + await tester.emitEvent(_newMessageEvent(message)); - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); + expect(tester.channelState?.unreadCount, equals(0)); }, ); + }); - test("should update 'unreadCount'", () async { - expect(channel.state?.unreadCount, equals(0)); + channelTest( + 'should submit channel for delivery when message is received', + channelType: _channelType, + channelId: _channelId, + // A real watch response carries the member's read state; without it the + // first unread increment synthesizes one anchored at the incoming + // message, which suppresses the delivery receipt. + setUp: (tester) => tester.watch( + modifyResponse: (state) => _seedChannel( + ownCapabilities: const [ChannelCapability.readEvents, ChannelCapability.deliveryEvents], + )(state).copyWith(read: [createDefaultRead()]), + ), + body: (tester) async { + registerFallbackValue([]); + tester.mockApi( + (api) => api.channel.markChannelsDelivered(any()), + result: createDefaultEmptyResponse(), + ); final message = Message( id: 'test-message-id', user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + await tester.emitEvent(_newMessageEvent(message)); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // The delivery reporter batches receipts behind a 1s trailing throttle. + await Future.delayed(const Duration(milliseconds: 1100)); - expect(channel.state?.unreadCount, equals(1)); - - final message2 = Message( - id: 'test-message-id-2', - user: User(id: 'other-user'), - createdAt: message.createdAt.add(const Duration(seconds: 3)), + final captured = tester.captureApi( + (api) => api.channel.markChannelsDelivered(captureAny()), ); + final deliveries = captured.single! as List; + expect(deliveries.single.channelCid, _channelCid); + expect(deliveries.single.messageId, 'test-message-id'); + }, + ); - final newMessage2Event = createNewMessageEvent(message2); - client.addEvent(newMessage2Event); + channelTest( + 'should not duplicate when server echoes back an optimistically ' + 'inserted message with a later createdAt', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + // Local message used as the input to `channel.sendMessage`. + final localCreatedAt = _initialLastMessageAt.add(const Duration(seconds: 3)); + final localMessage = Message( + id: 'test-message-id', + text: 'Hello world!', + user: tester.currentUser, + createdAt: localCreatedAt, + ); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Mock the network send to return the message unchanged so the + // optimistic insert + sent-state update both land on the same + // `createdAt`. The bug fires later, on the WS echo. + tester.mockApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(localMessage))), + result: createDefaultSendMessageResponse(message: localMessage.copyWith(state: MessageState.sent)), + ); - expect(channel.state?.unreadCount, equals(2)); - }); + await tester.channel.sendMessage(localMessage); - group("should not update 'unreadCount'", () { - test( - 'when the message is silent', - () async { - expect(channel.state?.unreadCount, equals(0)); + expect(tester.channelState!.messages, hasLength(1)); - final message = Message( - id: 'test-message-id', - silent: true, - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + // Server then broadcasts the same message via a `message.new` + // event with a slightly later `createdAt` (server-assigned + // timestamp). + final serverMessage = localMessage.copyWith( + createdAt: localCreatedAt.add(const Duration(milliseconds: 50)), + ); + await tester.emitEvent(_newMessageEvent(serverMessage)); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + // The state should contain exactly one message with that id, + // not a duplicate. + final matching = tester.channelState!.messages.where((it) => it.id == localMessage.id); + expect(matching, hasLength(1)); + expect(tester.channelState!.messages, hasLength(1)); + }, + ); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + channelTest( + 'should not duplicate when the locally-sent message is no longer ' + 'the latest (retry-after-offline scenario)', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + // Mirrors the offline-retry flow: a local message is sent, then + // another message arrives via WS while the local one is still + // pending. When the retry finally succeeds the server response's + // `createdAt` is later than the intervening message, so the + // locally-sent copy is no longer `messages.last`. + final localCreatedAt = _initialLastMessageAt.add(const Duration(seconds: 1)); + final localMessage = Message( + id: 'local-message-id', + text: 'Hello world!', + user: tester.currentUser, + createdAt: localCreatedAt, + ); + + tester.mockApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(localMessage))), + result: createDefaultSendMessageResponse(message: localMessage.copyWith(state: MessageState.sent)), + ); + + await tester.channel.sendMessage(localMessage); + + // Another message arrives via WS with a later `createdAt`, + // pushing the locally-sent message off the tail. + final otherMessage = Message( + id: 'other-message-id', + user: User(id: 'other-user'), + createdAt: localCreatedAt.add(const Duration(seconds: 2)), + ); + await tester.emitEvent(_newMessageEvent(otherMessage)); - expect(channel.state?.unreadCount, equals(0)); - }, + // Server then broadcasts the locally-sent message via + // `message.new` with a `createdAt` that is later than the + // intervening message — exactly the shape produced by a + // successful retry after another message arrived in between. + final serverEcho = localMessage.copyWith( + createdAt: otherMessage.createdAt.add(const Duration(seconds: 1)), ); + await tester.emitEvent(_newMessageEvent(serverEcho)); + + final localMatches = tester.channelState!.messages.where((it) => it.id == localMessage.id); + expect(localMatches, hasLength(1)); + expect(tester.channelState!.messages, hasLength(2)); + }, + ); - test( - 'when the message is shadowed', - () async { - expect(channel.state?.unreadCount, equals(0)); + channelTest( + '${EventType.notificationMessageNew} adds the message and counts unread', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'notified-message-id', + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); - final message = Message( - id: 'test-message-id', - shadowed: true, - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.notificationMessageNew, + message: message, + ), + ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + expect(tester.channelState!.messages.map((m) => m.id), ['notified-message-id']); + expect(tester.channelState!.unreadCount, 1); + }, + ); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + channelTest( + 'a channel message is not appended while the channel is not up to date', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + tester.channelState!.isUpToDate = false; - expect(channel.state?.unreadCount, equals(0)); - }, + final message = Message( + id: 'below-window-message-id', + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), ); - test( - 'when the message type is ephemeral', - () async { - expect(channel.state?.unreadCount, equals(0)); - - final message = Message( - id: 'test-message-id', - type: MessageType.ephemeral, - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + await tester.emitEvent(_newMessageEvent(message)); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + expect(tester.channelState!.messages, isEmpty); + expect(tester.channelState!.unreadCount, 1); + }, + ); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + channelTest( + 'a thread-only reply is stored even while the channel is not up to date', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + tester.channelState!.isUpToDate = false; - expect(channel.state?.unreadCount, equals(0)); - }, + final reply = Message( + id: 'thread-reply-id', + parentId: 'parent-message-id', + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), ); - test( - 'when the message is a thread reply', - () async { - expect(channel.state?.unreadCount, equals(0)); + await tester.emitEvent(_newMessageEvent(reply)); - final message = Message( - id: 'test-message-id', - parentId: 'test-parent-id', - showInChannel: false, - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + expect(tester.channelState!.messages, isEmpty); + expect(tester.channelState!.threads['parent-message-id']?.map((m) => m.id), ['thread-reply-id']); + }, + ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + channelTest( + 'an event without a message is ignored', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + await tester.emitEvent(createDefaultEvent(cid: _channelCid, type: EventType.messageNew)); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + expect(tester.channelState!.messages, isEmpty); + expect(tester.channel.lastMessageAt, _initialLastMessageAt); + }, + ); + }); - expect(channel.state?.unreadCount, equals(0)); - }, - ); + group(EventType.messageUpdated, () { + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); + } - test( - 'when the message is a thread reply', - () async { - expect(channel.state?.unreadCount, equals(0)); + Event _updateMessageEvent(Message message) => createDefaultEvent( + type: EventType.messageUpdated, + cid: _channelCid, + message: message, + ); - final message = Message( - id: 'test-message-id', - parentId: 'test-parent-id', - showInChannel: false, - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + // A `message.updated` event for a message outside the loaded window + // would otherwise upsert into the sorted list — creating a phantom + // entry with a gap. The guard is "id not in the loaded list", and + // is independent of `isUpToDate` — even at the latest page we may + // have paginated past older history and receive an event for a + // message no longer in memory. + + channelTest( + "should update 'channel.state.pinnedMessages' and should add message to pinned messages only once if updatedMessage.pinned is true", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + user: tester.currentUser, + pinned: true, + ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + await tester.emitEvent(_updateMessageEvent(message)); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + expect(tester.channelState?.pinnedMessages.length, equals(1)); + expect(tester.channelState?.pinnedMessages.first.id, equals(messageId)); + }, + ); - expect(channel.state?.unreadCount, equals(0)); - }, + channelTest( + 'should update pinned message itself if updatedMessage.pinned is true and message is already pinned', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const messageId = 'test-message-id'; + const oldText = 'Old text'; + const newText = 'New text'; + final message = Message( + id: messageId, + user: tester.currentUser, + text: oldText, + pinned: true, ); - test( - 'when the message is from the current user', - () async { - expect(channel.state?.unreadCount, equals(0)); + await tester.emitEvent(_updateMessageEvent(message)); - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + expect(tester.channelState?.pinnedMessages.length, equals(1)); + expect(tester.channelState?.pinnedMessages.first.id, equals(messageId)); + expect(tester.channelState?.pinnedMessages.first.text, equals(oldText)); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + final updatedMessage = message.copyWith(text: newText); + await tester.emitEvent(_updateMessageEvent(updatedMessage)); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + expect(tester.channelState?.pinnedMessages.length, equals(1)); + expect(tester.channelState?.pinnedMessages.first.id, equals(messageId)); + expect(tester.channelState?.pinnedMessages.first.text, equals(newText)); + }, + ); - expect(channel.state?.unreadCount, equals(0)); - }, + channelTest( + "should update 'channel.state.pinnedMessages' and should add message to pinned messages " + 'and not unpin previous pinned message if updatedMessage.pinned is true and there is already another pinned message', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const firstMessageId = 'first-test-message-id'; + const secondMessageId = 'second-test-message-id'; + final firstMessage = Message( + id: firstMessageId, + user: tester.currentUser, + pinned: true, ); + final secondMessage = firstMessage.copyWith(id: secondMessageId); - test( - 'when the message is not restricted for the current user', - () async { - expect(channel.state?.unreadCount, equals(0)); + await tester.emitEvent(_updateMessageEvent(firstMessage)); - final message = Message( - id: 'test-message-id', - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - restrictedVisibility: const ['other-user-2'], - ); + expect(tester.channelState?.pinnedMessages.length, equals(1)); + expect( + tester.channelState?.pinnedMessages.first.id, + equals(firstMessageId), + ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + await tester.emitEvent(_updateMessageEvent(secondMessage)); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + expect(tester.channelState?.pinnedMessages.length, equals(2)); + expect( + tester.channelState?.pinnedMessages.first.id, + equals(firstMessageId), + ); + expect( + tester.channelState?.pinnedMessages[1].id, + equals(secondMessageId), + ); + }, + ); - expect(channel.state?.unreadCount, equals(0)); - }, + channelTest( + "should update 'channel.state.pinnedMessages' and should remove message from pinned messages if updatedMessage.pinned is false", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const messageId = 'test-message-id'; + final pinnedMessage = Message( + id: messageId, + user: tester.currentUser, + pinned: true, ); - }); - test( - 'should submit channel for delivery when message is received', - () async { - final message = Message( - id: 'test-message-id', - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + await tester.emitEvent(_updateMessageEvent(pinnedMessage)); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + expect(tester.channelState?.pinnedMessages.length, equals(1)); + expect(tester.channelState?.pinnedMessages.first.id, equals(messageId)); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + final unpinnedMessage = pinnedMessage.copyWith(pinned: false); + await tester.emitEvent(_updateMessageEvent(unpinnedMessage)); - // Verify submitForDelivery was called - verify( - () => client.channelDeliveryReporter.submitForDelivery([channel]), - ).called(1); - }, - ); + expect(tester.channelState?.pinnedMessages, isEmpty); + }, + ); - test( - 'should not duplicate when server echoes back an optimistically ' - 'inserted message with a later createdAt', - () async { - // Local message used as the input to `channel.sendMessage`. - final localCreatedAt = initialLastMessageAt.add(const Duration(seconds: 3)); - final localMessage = Message( - id: 'test-message-id', - text: 'Hello world!', - user: client.state.currentUser, - createdAt: localCreatedAt, + group('when message is outside the loaded window', () { + channelTest( + 'should NOT insert unknown message into `messages` list', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + // Simulate "we have the latest page but not older history": + // seed the tail messages. + final tail = List.generate( + 3, + (i) => Message( + id: 'tail-$i', + user: tester.currentUser, + text: 'tail $i', + createdAt: DateTime.utc(2026, 6, 1).add(Duration(seconds: i)), + ), ); - - // Mock the network send to return the message unchanged so the - // optimistic insert + sent-state update both land on the same - // `createdAt`. The bug fires later, on the WS echo. - final sendMessageResponse = SendMessageResponse() - ..message = localMessage.copyWith(state: MessageState.sent); - when(() => client.sendMessage(any(), channelId, channelType)).thenAnswer((_) async => sendMessageResponse); - - await channel.sendMessage(localMessage); - - expect(channel.state!.messages, hasLength(1)); - - // Server then broadcasts the same message via a `message.new` - // event with a slightly later `createdAt` (server-assigned - // timestamp). - final serverMessage = localMessage.copyWith( - createdAt: localCreatedAt.add(const Duration(milliseconds: 50)), + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith(messages: tail), ); - client.addEvent(createNewMessageEvent(serverMessage)); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); + expect(tester.channelState!.messages, hasLength(3)); + + // Event for a message on an older page we don't have loaded. + final olderPageEdit = Message( + id: 'older-page-msg', + user: tester.currentUser, + text: 'edited on older page', + createdAt: DateTime.utc(2025, 1, 1), + ); + await tester.emitEvent(_updateMessageEvent(olderPageEdit)); - // The state should contain exactly one message with that id, - // not a duplicate. - final matching = channel.state!.messages.where((it) => it.id == localMessage.id); - expect(matching, hasLength(1)); - expect(channel.state!.messages, hasLength(1)); + // Tail is unchanged, no phantom entry inserted at position 0. + expect(tester.channelState!.messages.map((m) => m.id), ['tail-0', 'tail-1', 'tail-2']); + expect(tester.channelState!.pinnedMessages, isEmpty); }, ); - test( - 'should not duplicate when the locally-sent message is no longer ' - 'the latest (retry-after-offline scenario)', - () async { - // Mirrors the offline-retry flow: a local message is sent, then - // another message arrives via WS while the local one is still - // pending. When the retry finally succeeds the server response's - // `createdAt` is later than the intervening message, so the - // locally-sent copy is no longer `messages.last`. - final localCreatedAt = initialLastMessageAt.add(const Duration(seconds: 1)); - final localMessage = Message( - id: 'local-message-id', - text: 'Hello world!', - user: client.state.currentUser, - createdAt: localCreatedAt, + channelTest( + 'should update message in place when it IS in the loaded window', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const messageId = 'known'; + final seeded = Message( + id: messageId, + user: tester.currentUser, + text: 'old', + createdAt: DateTime.utc(2026), + ); + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith(messages: [seeded]), ); + tester.channelState!.isUpToDate = false; - final sendMessageResponse = SendMessageResponse() - ..message = localMessage.copyWith(state: MessageState.sent); - when(() => client.sendMessage(any(), channelId, channelType)).thenAnswer((_) async => sendMessageResponse); + final edited = seeded.copyWith(text: 'new'); + await tester.emitEvent(_updateMessageEvent(edited)); - await channel.sendMessage(localMessage); + final stored = tester.channelState!.messages.singleWhere((m) => m.id == messageId); + expect(stored.text, equals('new')); + }, + ); - // Another message arrives via WS with a later `createdAt`, - // pushing the locally-sent message off the tail. - final otherMessage = Message( - id: 'other-message-id', - user: User(id: 'other-user'), - createdAt: localCreatedAt.add(const Duration(seconds: 2)), - ); - client.addEvent(createNewMessageEvent(otherMessage)); - await Future.delayed(Duration.zero); + channelTest( + 'should still add to pinnedMessages when pinned:true even if not in loaded window', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + tester.channelState!.isUpToDate = false; + expect(tester.channelState!.messages, isEmpty); + expect(tester.channelState!.pinnedMessages, isEmpty); - // Server then broadcasts the locally-sent message via - // `message.new` with a `createdAt` that is later than the - // intervening message — exactly the shape produced by a - // successful retry after another message arrived in between. - final serverEcho = localMessage.copyWith( - createdAt: otherMessage.createdAt.add(const Duration(seconds: 1)), + const messageId = 'pin-me'; + final pinned = Message( + id: messageId, + user: tester.currentUser, + pinned: true, ); - client.addEvent(createNewMessageEvent(serverEcho)); - await Future.delayed(Duration.zero); + await tester.emitEvent(_updateMessageEvent(pinned)); - final localMatches = channel.state!.messages.where((it) => it.id == localMessage.id); - expect(localMatches, hasLength(1)); - expect(channel.state!.messages, hasLength(2)); + expect(tester.channelState!.messages, isEmpty); + expect(tester.channelState!.pinnedMessages.length, equals(1)); + expect(tester.channelState!.pinnedMessages.first.id, equals(messageId)); }, ); - test( - '${EventType.notificationMessageNew} adds the message and counts unread', - () async { - final message = Message( - id: 'notified-message-id', - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + channelTest( + 'should NOT insert unknown reply into threads[parentId]', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const parentId = 'parent-1'; + final knownReply = Message( + id: 'known-reply', + parentId: parentId, + user: tester.currentUser, + createdAt: DateTime.utc(2026), ); + // Populate threads[parentId] via addNewMessage's thread-only path. + tester.channelState!.addNewMessage(knownReply); + await Future.delayed(Duration.zero); + expect(tester.channelState!.threads[parentId], hasLength(1)); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.notificationMessageNew, - message: message, - ), + tester.channelState!.isUpToDate = false; + + final phantomReply = Message( + id: 'other-reply', + parentId: parentId, + user: tester.currentUser, + text: 'edited', + createdAt: DateTime.utc(2026, 1, 2), ); - await Future.delayed(Duration.zero); + await tester.emitEvent(_updateMessageEvent(phantomReply)); - expect(channel.state!.messages.map((m) => m.id), ['notified-message-id']); - expect(channel.state!.unreadCount, 1); + expect(tester.channelState!.threads[parentId]!.map((m) => m.id), ['known-reply']); }, ); - test( - 'a channel message is not appended while the channel is not up to date', - () async { - channel.state!.isUpToDate = false; + channelTest( + 'should NOT create phantom threads[parentId] entry for unloaded thread', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const parentId = 'unloaded-parent'; + // The thread was never paged in, so there's no entry for it. + expect(tester.channelState!.threads.containsKey(parentId), isFalse); - final message = Message( - id: 'below-window-message-id', - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + tester.channelState!.isUpToDate = false; - client.addEvent(createNewMessageEvent(message)); - await Future.delayed(Duration.zero); + final phantomReply = Message( + id: 'phantom-reply', + parentId: parentId, + user: tester.currentUser, + text: 'edited', + createdAt: DateTime.utc(2026, 1, 2), + ); + await tester.emitEvent(_updateMessageEvent(phantomReply)); - expect(channel.state!.messages, isEmpty); - expect(channel.state!.unreadCount, 1); + // The dropped reply must not leave behind an empty thread entry. + expect(tester.channelState!.threads.containsKey(parentId), isFalse); }, ); - test( - 'a thread-only reply is stored even while the channel is not up to date', - () async { - channel.state!.isUpToDate = false; - - final reply = Message( - id: 'thread-reply-id', - parentId: 'parent-message-id', - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + channelTest( + 'should still expire activeLiveLocations for out-of-window message', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final liveLocation = Location( + channelCid: tester.channel.cid, + userId: 'user1', + messageId: 'loc-msg', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), ); - client.addEvent(createNewMessageEvent(reply)); + // Seed only activeLiveLocations, keeping `messages` empty — + // the exact "message is outside the loaded window" scenario. + tester.channelState!.updateChannelState( + ChannelState( + channel: tester.channelState!.channelState.channel, + activeLiveLocations: [liveLocation], + ), + ); + tester.channelState!.isUpToDate = false; + expect(tester.channelState!.messages, isEmpty); + expect(tester.channelState!.activeLiveLocations, hasLength(1)); + + // A message.updated that expires the live location. + final expiredMessage = Message( + id: 'loc-msg', + text: 'Live location shared', + sharedLocation: liveLocation.copyWith( + endAt: DateTime.now().subtract(const Duration(minutes: 1)), + ), + ); + // Applied directly, the way the `message.updated` listener would: + // a WS `message.updated` carrying an expired live location is + // rerouted by `locationExpiredResolver` to `location.expired` before + // channel state sees it, and that handler no-ops when the message is + // not loaded. This keeps the state-layer guard itself pinned. + tester.channelState!.updateMessage(expiredMessage, upsert: false); await Future.delayed(Duration.zero); - expect(channel.state!.messages, isEmpty); - expect(channel.state!.threads['parent-message-id']?.map((m) => m.id), ['thread-reply-id']); + expect(tester.channelState!.messages, isEmpty); + expect(tester.channelState!.activeLiveLocations, isEmpty); }, ); + }); + }); - test('an event without a message is ignored', () async { - client.addEvent(Event(cid: channel.cid, type: EventType.messageNew)); - await Future.delayed(Duration.zero); + group('reply events with `show_in_channel = true` and unloaded thread', () { + const _replyId = 'mirrored-reply-id'; - expect(channel.state!.messages, isEmpty); - expect(channel.lastMessageAt, initialLastMessageAt); - }); - }, - ); + const _parentId = 'parent-message-id'; - group( - EventType.messageUpdated, - () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ownCapabilities: const [ChannelCapability.readEvents], - ); - - channel = Channel.fromState(client, channelState); - }); + // Pinned createdAt keeps oldIndex lookups stable in `updateMessage`. + final _createdAt = DateTime.utc(2026); - tearDown(() => channel.dispose()); - - Event createUpdateMessageEvent(Message message) { - return Event( - cid: channel.cid, - type: EventType.messageUpdated, - message: message, - ); - } - - test( - "should update 'channel.state.pinnedMessages' and should add message to pinned messages only once if updatedMessage.pinned is true", - () async { - const messageId = 'test-message-id'; - final message = Message( - id: messageId, - user: client.state.currentUser, - pinned: true, - ); - - final newMessageEvent = createUpdateMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.pinnedMessages.length, equals(1)); - expect(channel.state?.pinnedMessages.first.id, equals(messageId)); - }, - ); - - test( - 'should update pinned message itself if updatedMessage.pinned is true and message is already pinned', - () async { - const messageId = 'test-message-id'; - const oldText = 'Old text'; - const newText = 'New text'; - final message = Message( - id: messageId, - user: client.state.currentUser, - text: oldText, - pinned: true, - ); - - final firstUpdateEvent = createUpdateMessageEvent(message); - client.addEvent(firstUpdateEvent); - - // Wait for the first event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.pinnedMessages.length, equals(1)); - expect(channel.state?.pinnedMessages.first.id, equals(messageId)); - expect(channel.state?.pinnedMessages.first.text, equals(oldText)); - - final updatedMessage = message.copyWith(text: newText); - final secondUpdateEvent = createUpdateMessageEvent(updatedMessage); - client.addEvent(secondUpdateEvent); - - // Wait for the second event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.pinnedMessages.length, equals(1)); - expect(channel.state?.pinnedMessages.first.id, equals(messageId)); - expect(channel.state?.pinnedMessages.first.text, equals(newText)); - }, + ChannelState _seedChannel(ChannelState _) { + return createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), ); + } - test( - "should update 'channel.state.pinnedMessages' and should add message to pinned messages " - 'and not unpin previous pinned message if updatedMessage.pinned is true and there is already another pinned message', - () async { - const firstMessageId = 'first-test-message-id'; - const secondMessageId = 'second-test-message-id'; - final firstMessage = Message( - id: firstMessageId, - user: client.state.currentUser, - pinned: true, - ); - final secondMessage = firstMessage.copyWith(id: secondMessageId); - - final firstUpdateEvent = createUpdateMessageEvent(firstMessage); - client.addEvent(firstUpdateEvent); - - // Wait for the first event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.pinnedMessages.length, equals(1)); - expect( - channel.state?.pinnedMessages.first.id, - equals(firstMessageId), - ); - - final secondUpdateEvent = createUpdateMessageEvent(secondMessage); - client.addEvent(secondUpdateEvent); - - // Wait for the second event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.pinnedMessages.length, equals(2)); - expect( - channel.state?.pinnedMessages.first.id, - equals(firstMessageId), - ); - expect( - channel.state?.pinnedMessages[1].id, - equals(secondMessageId), - ); - }, + // Seeds a single reply into the channel-level `messages` while leaving + // `threads[parentId]` empty — the exact regression scenario. + Message _seedMirroredReply( + ChannelTester tester, { + List ownReactions = const [], + Poll? poll, + }) { + final reply = Message( + id: _replyId, + parentId: _parentId, + showInChannel: true, + user: tester.currentUser, + createdAt: _createdAt, + ownReactions: ownReactions, + poll: poll, + pollId: poll?.id, ); - - test( - "should update 'channel.state.pinnedMessages' and should remove message from pinned messages if updatedMessage.pinned is false", - () async { - const messageId = 'test-message-id'; - final pinnedMessage = Message( - id: messageId, - user: client.state.currentUser, - pinned: true, - ); - - final pinEvent = createUpdateMessageEvent(pinnedMessage); - client.addEvent(pinEvent); - - // Wait for the pin event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.pinnedMessages.length, equals(1)); - expect(channel.state?.pinnedMessages.first.id, equals(messageId)); - - final unpinnedMessage = pinnedMessage.copyWith(pinned: false); - final unpinEvent = createUpdateMessageEvent(unpinnedMessage); - client.addEvent(unpinEvent); - - // Wait for the unpin event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.pinnedMessages, isEmpty); - }, + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith(messages: [reply]), ); + return reply; + } - // A `message.updated` event for a message outside the loaded window - // would otherwise upsert into the sorted list — creating a phantom - // entry with a gap. The guard is "id not in the loaded list", and - // is independent of `isUpToDate` — even at the latest page we may - // have paginated past older history and receive an event for a - // message no longer in memory. - group('when message is outside the loaded window', () { - test( - 'should NOT insert unknown message into `messages` list', - () async { - // Simulate "we have the latest page but not older history": - // seed the tail messages. - final tail = List.generate( - 3, - (i) => Message( - id: 'tail-$i', - user: client.state.currentUser, - text: 'tail $i', - createdAt: DateTime.utc(2026, 6, 1).add(Duration(seconds: i)), - ), - ); - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(messages: tail), - ); - expect(channel.state!.messages, hasLength(3)); - - // Event for a message on an older page we don't have loaded. - final olderPageEdit = Message( - id: 'older-page-msg', - user: client.state.currentUser, - text: 'edited on older page', - createdAt: DateTime.utc(2025, 1, 1), - ); - client.addEvent(createUpdateMessageEvent(olderPageEdit)); - await Future.delayed(Duration.zero); - - // Tail is unchanged, no phantom entry inserted at position 0. - expect(channel.state!.messages.map((m) => m.id), ['tail-0', 'tail-1', 'tail-2']); - expect(channel.state!.pinnedMessages, isEmpty); - }, - ); - - test( - 'should update message in place when it IS in the loaded window', - () async { - const messageId = 'known'; - final seeded = Message( - id: messageId, - user: client.state.currentUser, - text: 'old', - createdAt: DateTime.utc(2026), - ); - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(messages: [seeded]), - ); - channel.state!.isUpToDate = false; - - final edited = seeded.copyWith(text: 'new'); - client.addEvent(createUpdateMessageEvent(edited)); - await Future.delayed(Duration.zero); - - final stored = channel.state!.messages.singleWhere((m) => m.id == messageId); - expect(stored.text, equals('new')); - }, - ); - - test( - 'should still add to pinnedMessages when pinned:true even if not in loaded window', - () async { - channel.state!.isUpToDate = false; - expect(channel.state!.messages, isEmpty); - expect(channel.state!.pinnedMessages, isEmpty); - - const messageId = 'pin-me'; - final pinned = Message( - id: messageId, - user: client.state.currentUser, - pinned: true, - ); - client.addEvent(createUpdateMessageEvent(pinned)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages, isEmpty); - expect(channel.state!.pinnedMessages.length, equals(1)); - expect(channel.state!.pinnedMessages.first.id, equals(messageId)); - }, - ); - - test( - 'should NOT insert unknown reply into threads[parentId]', - () async { - const parentId = 'parent-1'; - final knownReply = Message( - id: 'known-reply', - parentId: parentId, - user: client.state.currentUser, - createdAt: DateTime.utc(2026), - ); - // Populate threads[parentId] via addNewMessage's thread-only path. - channel.state!.addNewMessage(knownReply); - await Future.delayed(Duration.zero); - expect(channel.state!.threads[parentId], hasLength(1)); - - channel.state!.isUpToDate = false; - - final phantomReply = Message( - id: 'other-reply', - parentId: parentId, - user: client.state.currentUser, - text: 'edited', - createdAt: DateTime.utc(2026, 1, 2), - ); - client.addEvent(createUpdateMessageEvent(phantomReply)); - await Future.delayed(Duration.zero); - - expect(channel.state!.threads[parentId]!.map((m) => m.id), ['known-reply']); - }, - ); - - test( - 'should NOT create phantom threads[parentId] entry for unloaded thread', - () async { - const parentId = 'unloaded-parent'; - // The thread was never paged in, so there's no entry for it. - expect(channel.state!.threads.containsKey(parentId), isFalse); - - channel.state!.isUpToDate = false; - - final phantomReply = Message( - id: 'phantom-reply', - parentId: parentId, - user: client.state.currentUser, - text: 'edited', - createdAt: DateTime.utc(2026, 1, 2), - ); - client.addEvent(createUpdateMessageEvent(phantomReply)); - await Future.delayed(Duration.zero); - - // The dropped reply must not leave behind an empty thread entry. - expect(channel.state!.threads.containsKey(parentId), isFalse); - }, - ); - - test( - 'should still expire activeLiveLocations for out-of-window message', - () async { - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'loc-msg', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - // Seed only activeLiveLocations, keeping `messages` empty — - // the exact "message is outside the loaded window" scenario. - channel.state!.updateChannelState( - ChannelState( - channel: channel.state!.channelState.channel, - activeLiveLocations: [liveLocation], - ), - ); - channel.state!.isUpToDate = false; - expect(channel.state!.messages, isEmpty); - expect(channel.state!.activeLiveLocations, hasLength(1)); - - // A message.updated that expires the live location. - final expiredMessage = Message( - id: 'loc-msg', - text: 'Live location shared', - sharedLocation: liveLocation.copyWith( - endAt: DateTime.now().subtract(const Duration(minutes: 1)), - ), - ); - client.addEvent(createUpdateMessageEvent(expiredMessage)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages, isEmpty); - expect(channel.state!.activeLiveLocations, isEmpty); - }, + // A reply with `show_in_channel = true` is mirrored into both `messages` + // and `threads[parentId]`. When the thread isn't loaded (fresh hydration, + // user never opened the thread) the channel-level copy is the only place + // locally-cached fields like `ownReactions`/`poll` survive — so reaction + // and message-update events for such replies must still find it. + + channelTest( + '`reaction.new` from another user preserves `ownReactions`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + final ownReaction = Reaction( + type: 'like', + messageId: _replyId, + user: tester.currentUser, + ); + _seedMirroredReply(tester, ownReactions: [ownReaction]); + // Pre-condition: thread is not loaded. + expect(tester.channelState!.threads, isEmpty); + + // Server reaction events don't echo back the recipient's own + // reactions, so the listener must pull them from the cached copy. + final otherUserReaction = Reaction( + type: 'love', + messageId: _replyId, + user: User(id: 'other-user'), ); - }); - }, - ); - - // A reply with `show_in_channel = true` is mirrored into both `messages` - // and `threads[parentId]`. When the thread isn't loaded (fresh hydration, - // user never opened the thread) the channel-level copy is the only place - // locally-cached fields like `ownReactions`/`poll` survive — so reaction - // and message-update events for such replies must still find it. - group( - 'reply events with `show_in_channel = true` and unloaded thread', - () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - const replyId = 'mirrored-reply-id'; - const parentId = 'parent-message-id'; - // Pinned createdAt keeps oldIndex lookups stable in `updateMessage`. - final createdAt = DateTime.utc(2026, 1, 1); - late Channel channel; - - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ownCapabilities: const [ChannelCapability.readEvents], + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reactionNew, + reaction: otherUserReaction, + message: Message( + id: _replyId, + parentId: _parentId, + showInChannel: true, + user: tester.currentUser, + createdAt: _createdAt, + latestReactions: [otherUserReaction], + ), + ), ); - channel = Channel.fromState(client, channelState); - }); - tearDown(() => channel.dispose()); - - // Seeds a single reply into the channel-level `messages` while leaving - // `threads[parentId]` empty — the exact regression scenario. - Message seedMirroredReply({ - List ownReactions = const [], - Poll? poll, - }) { - final reply = Message( - id: replyId, - parentId: parentId, - showInChannel: true, - user: client.state.currentUser, - createdAt: createdAt, - ownReactions: ownReactions, - poll: poll, - pollId: poll?.id, - ); - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(messages: [reply]), - ); - return reply; - } + final stored = tester.channelState!.messages.firstWhere((it) => it.id == _replyId); + expect(stored.ownReactions, [ownReaction]); + }, + ); - test( - '`reaction.new` from another user preserves `ownReactions`', - () async { - final ownReaction = Reaction( - type: 'like', - messageId: replyId, - user: client.state.currentUser, - ); - seedMirroredReply(ownReactions: [ownReaction]); - // Pre-condition: thread is not loaded. - expect(channel.state!.threads, isEmpty); - - // Server reaction events don't echo back the recipient's own - // reactions, so the listener must pull them from the cached copy. - final otherUserReaction = Reaction( - type: 'love', - messageId: replyId, - user: User(id: 'other-user'), - ); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.reactionNew, - reaction: otherUserReaction, - message: Message( - id: replyId, - parentId: parentId, - showInChannel: true, - user: client.state.currentUser, - createdAt: createdAt, - latestReactions: [otherUserReaction], - ), + channelTest( + '`reaction.deleted` strips only the removed reaction', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + final kept = Reaction( + type: 'like', + messageId: _replyId, + user: tester.currentUser, + ); + final removed = Reaction( + type: 'love', + messageId: _replyId, + user: tester.currentUser, + ); + _seedMirroredReply(tester, ownReactions: [kept, removed]); + expect(tester.channelState!.threads, isEmpty); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reactionDeleted, + reaction: removed, + message: Message( + id: _replyId, + parentId: _parentId, + showInChannel: true, + user: tester.currentUser, + createdAt: _createdAt, ), - ); + ), + ); - await Future.delayed(Duration.zero); + final stored = tester.channelState!.messages.firstWhere((it) => it.id == _replyId); + expect(stored.ownReactions, [kept]); + }, + ); - final stored = channel.state!.messages.firstWhere((it) => it.id == replyId); - expect(stored.ownReactions, [ownReaction]); - }, - ); + channelTest( + '`message.updated` preserves `poll`, `pollId`, and `ownReactions`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + final ownReaction = Reaction( + type: 'like', + messageId: _replyId, + user: tester.currentUser, + ); + // Partial server updates can omit poll/pollId/ownReactions; the + // cached copy is what backfills them. + final poll = Poll( + id: 'poll-1', + name: 'Pick one', + options: const [ + PollOption(text: 'A'), + PollOption(text: 'B'), + ], + ); + _seedMirroredReply(tester, ownReactions: [ownReaction], poll: poll); + expect(tester.channelState!.threads, isEmpty); - test( - '`reaction.deleted` strips only the removed reaction', - () async { - final kept = Reaction( - type: 'like', - messageId: replyId, - user: client.state.currentUser, - ); - final removed = Reaction( - type: 'love', - messageId: replyId, - user: client.state.currentUser, - ); - seedMirroredReply(ownReactions: [kept, removed]); - expect(channel.state!.threads, isEmpty); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.reactionDeleted, - reaction: removed, - message: Message( - id: replyId, - parentId: parentId, - showInChannel: true, - user: client.state.currentUser, - createdAt: createdAt, - ), + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageUpdated, + message: Message( + id: _replyId, + parentId: _parentId, + showInChannel: true, + user: tester.currentUser, + createdAt: _createdAt, + text: 'edited', ), - ); - - await Future.delayed(Duration.zero); + ), + ); - final stored = channel.state!.messages.firstWhere((it) => it.id == replyId); - expect(stored.ownReactions, [kept]); - }, - ); + final stored = tester.channelState!.messages.firstWhere((it) => it.id == _replyId); + expect(stored.ownReactions, [ownReaction]); + expect(stored.poll?.id, poll.id); + expect(stored.pollId, poll.id); + }, + ); + }); - test( - '`message.updated` preserves `poll`, `pollId`, and `ownReactions`', - () async { - final ownReaction = Reaction( - type: 'like', - messageId: replyId, - user: client.state.currentUser, - ); - // Partial server updates can omit poll/pollId/ownReactions; the - // cached copy is what backfills them. - final poll = Poll( - id: 'poll-1', - name: 'Pick one', - options: const [ - PollOption(text: 'A'), - PollOption(text: 'B'), - ], - ); - seedMirroredReply(ownReactions: [ownReaction], poll: poll); - expect(channel.state!.threads, isEmpty); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.messageUpdated, - message: Message( - id: replyId, - parentId: parentId, - showInChannel: true, - user: client.state.currentUser, - createdAt: createdAt, - text: 'edited', - ), - ), - ); + group('Reaction events', () { + const _messageId = 'reaction-message-id'; - await Future.delayed(Duration.zero); + // Pinned createdAt keeps message index lookups stable in `updateMessage`. + final _createdAt = DateTime.utc(2021, 3); - final stored = channel.state!.messages.firstWhere((it) => it.id == replyId); - expect(stored.ownReactions, [ownReaction]); - expect(stored.poll?.id, poll.id); - expect(stored.pollId, poll.id); - }, + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), ); - }, - ); - - group('Reaction events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - const messageId = 'reaction-message-id'; - final createdAt = DateTime.now(); - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); + } - Message seedMessage({String? parentId, List ownReactions = const []}) { + Message _seedMessage(ChannelTester tester, {String? parentId, List ownReactions = const []}) { final message = Message( - id: messageId, + id: _messageId, parentId: parentId, user: User(id: 'other-user'), text: 'react to me', - createdAt: createdAt, + createdAt: _createdAt, ownReactions: ownReactions, ); - channel.state!.updateMessage(message); + tester.channelState!.updateMessage(message); return message; } - test('${EventType.reactionNew} from the current user is added to ownReactions', () async { - seedMessage(); - - final reaction = Reaction( - type: 'like', - messageId: messageId, - user: client.state.currentUser, - ); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.reactionNew, - reaction: reaction, - message: Message( - id: messageId, - user: User(id: 'other-user'), - createdAt: createdAt, - latestReactions: [reaction], + channelTest( + '${EventType.reactionNew} from the current user is added to ownReactions', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + _seedMessage(tester); + + final reaction = Reaction( + type: 'like', + messageId: _messageId, + user: tester.currentUser, + ); + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reactionNew, + reaction: reaction, + message: Message( + id: _messageId, + user: User(id: 'other-user'), + createdAt: _createdAt, + latestReactions: [reaction], + ), ), - ), - ); - await Future.delayed(Duration.zero); - - final stored = channel.state!.messages.firstWhere((it) => it.id == messageId); - expect(stored.ownReactions?.map((r) => r.type), ['like']); - }); + ); - test('${EventType.reactionNew} updates a thread message', () async { - const parentId = 'reaction-parent-id'; - seedMessage(parentId: parentId); + final stored = tester.channelState!.messages.firstWhere((it) => it.id == _messageId); + expect(stored.ownReactions?.map((r) => r.type), ['like']); + }, + ); - final reaction = Reaction( - type: 'like', - messageId: messageId, - user: client.state.currentUser, - ); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.reactionNew, - reaction: reaction, - message: Message( - id: messageId, - parentId: parentId, - user: User(id: 'other-user'), - createdAt: createdAt, + channelTest( + '${EventType.reactionNew} updates a thread message', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const parentId = 'reaction-parent-id'; + _seedMessage(tester, parentId: parentId); + + final reaction = Reaction( + type: 'like', + messageId: _messageId, + user: tester.currentUser, + ); + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reactionNew, + reaction: reaction, + message: Message( + id: _messageId, + parentId: parentId, + user: User(id: 'other-user'), + createdAt: _createdAt, + ), ), - ), - ); - await Future.delayed(Duration.zero); + ); - final stored = channel.state!.threads[parentId]!.firstWhere((it) => it.id == messageId); - expect(stored.ownReactions?.map((r) => r.type), ['like']); - }); + final stored = tester.channelState!.threads[parentId]!.firstWhere((it) => it.id == _messageId); + expect(stored.ownReactions?.map((r) => r.type), ['like']); + }, + ); - test('${EventType.reactionUpdated} from the current user replaces ownReactions', () async { - final existing = Reaction( - type: 'love', - messageId: messageId, - user: client.state.currentUser, - ); - seedMessage(ownReactions: [existing]); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.reactionUpdated, - reaction: Reaction( - type: 'like', - messageId: messageId, - user: client.state.currentUser, - ), - message: Message( - id: messageId, - user: User(id: 'other-user'), - createdAt: createdAt, + channelTest( + '${EventType.reactionUpdated} from the current user replaces ownReactions', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final existing = Reaction( + type: 'love', + messageId: _messageId, + user: tester.currentUser, + ); + _seedMessage(tester, ownReactions: [existing]); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reactionUpdated, + reaction: Reaction( + type: 'like', + messageId: _messageId, + user: tester.currentUser, + ), + message: Message( + id: _messageId, + user: User(id: 'other-user'), + createdAt: _createdAt, + ), ), - ), - ); - await Future.delayed(Duration.zero); + ); - final stored = channel.state!.messages.firstWhere((it) => it.id == messageId); - expect(stored.ownReactions?.map((r) => r.type), ['like']); - }); + final stored = tester.channelState!.messages.firstWhere((it) => it.id == _messageId); + expect(stored.ownReactions?.map((r) => r.type), ['like']); + }, + ); - test('${EventType.reactionDeleted} updates a thread message', () async { - const parentId = 'reaction-parent-id'; - final removed = Reaction( - type: 'love', - messageId: messageId, - user: client.state.currentUser, - ); - seedMessage(parentId: parentId, ownReactions: [removed]); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.reactionDeleted, - reaction: removed, - message: Message( - id: messageId, - parentId: parentId, - user: User(id: 'other-user'), - createdAt: createdAt, + channelTest( + '${EventType.reactionDeleted} updates a thread message', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const parentId = 'reaction-parent-id'; + final removed = Reaction( + type: 'love', + messageId: _messageId, + user: tester.currentUser, + ); + _seedMessage(tester, parentId: parentId, ownReactions: [removed]); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reactionDeleted, + reaction: removed, + message: Message( + id: _messageId, + parentId: parentId, + user: User(id: 'other-user'), + createdAt: _createdAt, + ), ), - ), - ); - await Future.delayed(Duration.zero); + ); - final stored = channel.state!.threads[parentId]!.firstWhere((it) => it.id == messageId); - expect(stored.ownReactions, isEmpty); - }); + final stored = tester.channelState!.threads[parentId]!.firstWhere((it) => it.id == _messageId); + expect(stored.ownReactions, isEmpty); + }, + ); }); group('Poll events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - const pollMessageId = 'poll-message-id'; - const pollId = 'poll-id'; - final createdAt = DateTime.now(); - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); + const _pollMessageId = 'poll-message-id'; - tearDown(() { - channel.dispose(); - }); + const _pollId = 'poll-id'; + + final _createdAt = DateTime.utc(2021, 3); - Poll createPoll({ + ChannelState _seedChannel(ChannelState _) => + createDefaultChannelState(channel: createDefaultChannelModel(cid: _channelCid)); + + Poll _createPoll({ String name = 'Favorite color?', List latestAnswers = const [], List ownVotesAndAnswers = const [], }) { return Poll( - id: pollId, + id: _pollId, name: name, options: const [ PollOption(id: 'option-a', text: 'A'), @@ -1379,691 +1382,773 @@ void main() { ); } - Message seedPollMessage({ + Message _seedPollMessage( + ChannelTester tester, { String? parentId, List latestAnswers = const [], List ownVotesAndAnswers = const [], }) { final message = Message( - id: pollMessageId, + id: _pollMessageId, parentId: parentId, user: User(id: 'other-user'), - createdAt: createdAt, - poll: createPoll( + createdAt: _createdAt, + poll: _createPoll( latestAnswers: latestAnswers, ownVotesAndAnswers: ownVotesAndAnswers, ), ); - channel.state!.updateMessage(message); + tester.channelState!.updateMessage(message); return message; } - Message storedPollMessage() { - return channel.state!.messages.firstWhere((it) => it.id == pollMessageId); + Message _storedPollMessage(ChannelTester tester) { + return tester.channelState!.messages.firstWhere((it) => it.id == _pollMessageId); } - test('${EventType.pollCreated} adds the poll message', () async { - client.addEvent( - Event( - cid: channel.cid, - type: EventType.pollCreated, - message: Message( - id: pollMessageId, - user: User(id: 'other-user'), - createdAt: createdAt, - poll: createPoll(), + channelTest( + '${EventType.pollCreated} adds the poll message', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.pollCreated, + message: Message( + id: _pollMessageId, + user: User(id: 'other-user'), + createdAt: _createdAt, + poll: _createPoll(), + ), ), - ), - ); - await Future.delayed(Duration.zero); + ); - expect(storedPollMessage().poll?.id, pollId); - }); + expect(_storedPollMessage(tester).poll?.id, _pollId); + }, + ); - test('${EventType.pollCreated} without a poll is ignored', () async { - client.addEvent( - Event( - cid: channel.cid, - type: EventType.pollCreated, - message: Message( - id: pollMessageId, - user: User(id: 'other-user'), - createdAt: createdAt, + channelTest( + '${EventType.pollCreated} without a poll is ignored', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.pollCreated, + message: Message( + id: _pollMessageId, + user: User(id: 'other-user'), + createdAt: _createdAt, + ), ), - ), - ); - await Future.delayed(Duration.zero); + ); - expect(channel.state!.messages, isEmpty); - }); + expect(tester.channelState!.messages, isEmpty); + }, + ); - test('${EventType.pollUpdated} updates the poll but preserves own votes', () async { - final ownVote = PollVote( - id: 'own-vote-id', - optionId: 'option-a', - userId: client.state.currentUser!.id, - ); - seedPollMessage(ownVotesAndAnswers: [ownVote]); + channelTest( + '${EventType.pollUpdated} updates the poll but preserves own votes', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + final ownVote = PollVote( + id: 'own-vote-id', + optionId: 'option-a', + userId: tester.currentUser!.id, + ); + _seedPollMessage(tester, ownVotesAndAnswers: [ownVote]); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.pollUpdated, + poll: _createPoll(name: 'Renamed'), + ), + ); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.pollUpdated, - poll: createPoll(name: 'Renamed'), - ), - ); - await Future.delayed(Duration.zero); + final stored = _storedPollMessage(tester); + expect(stored.poll?.name, 'Renamed'); + expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-vote-id']); + }, + ); - final stored = storedPollMessage(); - expect(stored.poll?.name, 'Renamed'); - expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-vote-id']); - }); + channelTest( + '${EventType.pollUpdated} for an unknown poll is ignored', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + _seedPollMessage(tester); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.pollUpdated, + poll: Poll( + id: 'unknown-poll-id', + name: 'Renamed', + options: const [PollOption(text: 'A')], + ), + ), + ); - test('${EventType.pollUpdated} for an unknown poll is ignored', () async { - seedPollMessage(); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.pollUpdated, - poll: Poll( - id: 'unknown-poll-id', - name: 'Renamed', - options: const [PollOption(text: 'A')], + expect(_storedPollMessage(tester).poll?.name, 'Favorite color?'); + }, + ); + + channelTest( + '${EventType.pollUpdated} without a poll is ignored', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + _seedPollMessage(tester); + + await tester.emitEvent(createDefaultEvent(cid: tester.channel.cid, type: EventType.pollUpdated)); + + expect(_storedPollMessage(tester).poll?.name, 'Favorite color?'); + }, + ); + + channelTest( + '${EventType.pollUpdated} updates a poll on a thread message', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + const parentId = 'poll-parent-id'; + _seedPollMessage(tester, parentId: parentId); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.pollUpdated, + poll: _createPoll(name: 'Renamed'), ), - ), - ); - await Future.delayed(Duration.zero); + ); - expect(storedPollMessage().poll?.name, 'Favorite color?'); - }); + final stored = tester.channelState!.threads[parentId]!.firstWhere((it) => it.id == _pollMessageId); + expect(stored.poll?.name, 'Renamed'); + }, + ); - test('${EventType.pollUpdated} without a poll is ignored', () async { - seedPollMessage(); + channelTest( + '${EventType.pollClosed} closes the poll and keeps the cached data', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + _seedPollMessage(tester); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.pollClosed, + poll: _createPoll(name: 'Renamed'), + ), + ); - client.addEvent(Event(cid: channel.cid, type: EventType.pollUpdated)); - await Future.delayed(Duration.zero); + final stored = _storedPollMessage(tester); + expect(stored.poll?.isClosed, isTrue); + expect(stored.poll?.name, 'Favorite color?'); + }, + ); - expect(storedPollMessage().poll?.name, 'Favorite color?'); - }); + channelTest( + '${EventType.pollAnswerCasted} adds own answers only for the current user', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + _seedPollMessage(tester); + + final ownAnswer = PollVote( + id: 'own-answer-id', + answerText: 'my answer', + userId: tester.currentUser!.id, + ); + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.pollAnswerCasted, + poll: _createPoll(), + pollVote: ownAnswer, + ), + ); - test('${EventType.pollUpdated} updates a poll on a thread message', () async { - const parentId = 'poll-parent-id'; - seedPollMessage(parentId: parentId); + var stored = _storedPollMessage(tester); + expect(stored.poll?.latestAnswers.map((v) => v.id), ['own-answer-id']); + expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-answer-id']); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.pollUpdated, - poll: createPoll(name: 'Renamed'), - ), - ); - await Future.delayed(Duration.zero); + final otherAnswer = PollVote( + id: 'other-answer-id', + answerText: 'their answer', + userId: 'other-user', + ); + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.pollAnswerCasted, + poll: _createPoll(), + pollVote: otherAnswer, + ), + ); - final stored = channel.state!.threads[parentId]!.firstWhere((it) => it.id == pollMessageId); - expect(stored.poll?.name, 'Renamed'); - }); + stored = _storedPollMessage(tester); + expect(stored.poll?.latestAnswers.map((v) => v.id), contains('other-answer-id')); + expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-answer-id']); + }, + ); - test('${EventType.pollClosed} closes the poll and keeps the cached data', () async { - seedPollMessage(); + channelTest( + '${EventType.pollVoteCasted} adds own votes only for the current user', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + _seedPollMessage(tester); + + final ownVote = PollVote( + id: 'own-vote-id', + optionId: 'option-a', + userId: tester.currentUser!.id, + ); + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.pollVoteCasted, + poll: _createPoll(), + pollVote: ownVote, + ), + ); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.pollClosed, - poll: createPoll(name: 'Renamed'), - ), - ); - await Future.delayed(Duration.zero); + var stored = _storedPollMessage(tester); + expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-vote-id']); - final stored = storedPollMessage(); - expect(stored.poll?.isClosed, isTrue); - expect(stored.poll?.name, 'Favorite color?'); - }); + final otherVote = PollVote( + id: 'other-vote-id', + optionId: 'option-b', + userId: 'other-user', + ); + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.pollVoteCasted, + poll: _createPoll(), + pollVote: otherVote, + ), + ); - test('${EventType.pollAnswerCasted} adds own answers only for the current user', () async { - seedPollMessage(); + stored = _storedPollMessage(tester); + expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-vote-id']); + }, + ); - final ownAnswer = PollVote( - id: 'own-answer-id', - answerText: 'my answer', - userId: client.state.currentUser!.id, - ); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.pollAnswerCasted, - poll: createPoll(), - pollVote: ownAnswer, - ), - ); - await Future.delayed(Duration.zero); + channelTest( + '${EventType.pollVoteChanged} upserts the current user vote', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + _seedPollMessage(tester); + + final changedVote = PollVote( + id: 'changed-vote-id', + optionId: 'option-b', + userId: tester.currentUser!.id, + ); + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.pollVoteChanged, + poll: _createPoll(), + pollVote: changedVote, + ), + ); - var stored = storedPollMessage(); - expect(stored.poll?.latestAnswers.map((v) => v.id), ['own-answer-id']); - expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-answer-id']); + expect( + _storedPollMessage(tester).poll?.ownVotesAndAnswers.map((v) => v.id), + contains('changed-vote-id'), + ); + }, + ); - final otherAnswer = PollVote( - id: 'other-answer-id', - answerText: 'their answer', - userId: 'other-user', - ); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.pollAnswerCasted, - poll: createPoll(), - pollVote: otherAnswer, - ), - ); - await Future.delayed(Duration.zero); + channelTest( + '${EventType.pollAnswerRemoved} removes the answer from both lists', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + final answer = PollVote( + id: 'answer-id', + answerText: 'my answer', + userId: tester.currentUser!.id, + ); + _seedPollMessage(tester, latestAnswers: [answer], ownVotesAndAnswers: [answer]); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.pollAnswerRemoved, + poll: _createPoll(), + pollVote: answer, + ), + ); - stored = storedPollMessage(); - expect(stored.poll?.latestAnswers.map((v) => v.id), contains('other-answer-id')); - expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-answer-id']); - }); + final stored = _storedPollMessage(tester); + expect(stored.poll?.latestAnswers, isEmpty); + expect(stored.poll?.ownVotesAndAnswers, isEmpty); + }, + ); + + channelTest( + '${EventType.pollVoteRemoved} removes the vote from own votes', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + final vote = PollVote( + id: 'vote-id', + optionId: 'option-a', + userId: tester.currentUser!.id, + ); + _seedPollMessage(tester, ownVotesAndAnswers: [vote]); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.pollVoteRemoved, + poll: _createPoll(), + pollVote: vote, + ), + ); + + expect(_storedPollMessage(tester).poll?.ownVotesAndAnswers, isEmpty); + }, + ); + }); - test('${EventType.pollVoteCasted} adds own votes only for the current user', () async { - seedPollMessage(); + group(EventType.messageDeleted, () { + final _deletedAt = DateTime.utc(2026, 7); - final ownVote = PollVote( - id: 'own-vote-id', - optionId: 'option-a', - userId: client.state.currentUser!.id, + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), ); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.pollVoteCasted, - poll: createPoll(), - pollVote: ownVote, + } + + Event _deleteMessageEvent(Message message, {bool hardDelete = false}) { + return createDefaultEvent( + cid: _channelCid, + type: EventType.messageDeleted, + message: message.copyWith( + type: MessageType.deleted, + deletedAt: _deletedAt, ), + hardDelete: hardDelete, ); - await Future.delayed(Duration.zero); + } + + // A `message.deleted` event for a message outside the loaded window + // must not upsert a "deleted" record into the sorted list — that would + // create a phantom entry with a gap. Pinned + live-location + // side-effects must still fire. + + // Same design as the `messageUpdated` guards: the check is + // "message-in-loaded-window" and is independent of `isUpToDate` — + // an event for a message on an older, unloaded page must not be + // turned into a phantom "deleted" record inserted into the sorted + // list. + + group('when message is outside the loaded window', () { + channelTest( + 'soft delete does NOT insert phantom "deleted" record into messages', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final tail = List.generate( + 3, + (i) => Message( + id: 'tail-$i', + user: tester.currentUser, + text: 'tail $i', + createdAt: DateTime.utc(2026, 6, 1).add(Duration(seconds: i)), + ), + ); + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith(messages: tail), + ); + expect(tester.channelState!.messages, hasLength(3)); - var stored = storedPollMessage(); - expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-vote-id']); + final olderPage = Message( + id: 'older-page-msg', + user: tester.currentUser, + text: 'gone', + createdAt: DateTime.utc(2025, 1, 1), + ); + await tester.emitEvent(_deleteMessageEvent(olderPage)); - final otherVote = PollVote( - id: 'other-vote-id', - optionId: 'option-b', - userId: 'other-user', - ); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.pollVoteCasted, - poll: createPoll(), - pollVote: otherVote, - ), + expect(tester.channelState!.messages.map((m) => m.id), ['tail-0', 'tail-1', 'tail-2']); + }, ); - await Future.delayed(Duration.zero); - stored = storedPollMessage(); - expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-vote-id']); - }); + channelTest( + 'soft delete marks message as deleted when it IS in the loaded window', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const messageId = 'known'; + final seeded = Message( + id: messageId, + user: tester.currentUser, + text: 'hi', + createdAt: DateTime.utc(2026), + ); + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith(messages: [seeded]), + ); + tester.channelState!.isUpToDate = false; - test('${EventType.pollVoteChanged} upserts the current user vote', () async { - seedPollMessage(); + await tester.emitEvent(_deleteMessageEvent(seeded)); - final changedVote = PollVote( - id: 'changed-vote-id', - optionId: 'option-b', - userId: client.state.currentUser!.id, + final stored = tester.channelState!.messages.singleWhere((m) => m.id == messageId); + expect(stored.type, equals(MessageType.deleted)); + expect(stored.deletedAt, isNotNull); + }, ); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.pollVoteChanged, - poll: createPoll(), - pollVote: changedVote, - ), + + channelTest( + 'soft delete unpins a pinned-but-not-in-window message via _pinIsValid', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const messageId = 'pinned-msg'; + final pinned = Message( + id: messageId, + user: tester.currentUser, + pinned: true, + createdAt: DateTime.utc(2026), + ); + // Seed only the pinnedMessages list — message absent from + // the main `messages` window. + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith(pinnedMessages: [pinned]), + ); + tester.channelState!.isUpToDate = false; + expect(tester.channelState!.messages, isEmpty); + expect(tester.channelState!.pinnedMessages, hasLength(1)); + + await tester.emitEvent(_deleteMessageEvent(pinned)); + + expect(tester.channelState!.messages, isEmpty); + expect(tester.channelState!.pinnedMessages, isEmpty); + }, ); - await Future.delayed(Duration.zero); - expect( - storedPollMessage().poll?.ownVotesAndAnswers.map((v) => v.id), - contains('changed-vote-id'), - ); - }); + channelTest( + 'soft delete still clears activeLiveLocations even when message not in window', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final liveLocation = Location( + channelCid: tester.channel.cid, + userId: 'user1', + messageId: 'loc-msg', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.timestamp().add(const Duration(hours: 1)), + ); - test('${EventType.pollAnswerRemoved} removes the answer from both lists', () async { - final answer = PollVote( - id: 'answer-id', - answerText: 'my answer', - userId: client.state.currentUser!.id, - ); - seedPollMessage(latestAnswers: [answer], ownVotesAndAnswers: [answer]); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.pollAnswerRemoved, - poll: createPoll(), - pollVote: answer, - ), + // Seed only activeLiveLocations, keeping `messages` empty. + tester.channelState!.updateChannelState( + ChannelState( + channel: tester.channelState!.channelState.channel, + activeLiveLocations: [liveLocation], + ), + ); + tester.channelState!.isUpToDate = false; + expect(tester.channelState!.messages, isEmpty); + expect(tester.channelState!.activeLiveLocations, hasLength(1)); + + final locationMessage = Message( + id: 'loc-msg', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + await tester.emitEvent(_deleteMessageEvent(locationMessage)); + + expect(tester.channelState!.messages, isEmpty); + expect(tester.channelState!.activeLiveLocations, isEmpty); + }, ); - await Future.delayed(Duration.zero); - final stored = storedPollMessage(); - expect(stored.poll?.latestAnswers, isEmpty); - expect(stored.poll?.ownVotesAndAnswers, isEmpty); - }); + channelTest( + 'hard delete is a no-op when message is not in the loaded window', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + tester.channelState!.isUpToDate = false; + expect(tester.channelState!.messages, isEmpty); + + final phantom = Message( + id: 'phantom', + user: tester.currentUser, + text: 'gone', + createdAt: DateTime.utc(2026), + ); + await tester.emitEvent(_deleteMessageEvent(phantom, hardDelete: true)); - test('${EventType.pollVoteRemoved} removes the vote from own votes', () async { - final vote = PollVote( - id: 'vote-id', - optionId: 'option-a', - userId: client.state.currentUser!.id, - ); - seedPollMessage(ownVotesAndAnswers: [vote]); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.pollVoteRemoved, - poll: createPoll(), - pollVote: vote, - ), + expect(tester.channelState!.messages, isEmpty); + expect(tester.channelState!.pinnedMessages, isEmpty); + }, ); - await Future.delayed(Duration.zero); - - expect(storedPollMessage().poll?.ownVotesAndAnswers, isEmpty); }); - }); - - // A `message.deleted` event for a message outside the loaded window - // must not upsert a "deleted" record into the sorted list — that would - // create a phantom entry with a gap. Pinned + live-location - // side-effects must still fire. - group( - EventType.messageDeleted, - () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ownCapabilities: const [ChannelCapability.readEvents], - ); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() => channel.dispose()); - - Event createDeleteMessageEvent(Message message, {bool hardDelete = false}) { - return Event( - cid: channel.cid, - type: EventType.messageDeleted, - message: message.copyWith( - type: MessageType.deleted, - deletedAt: DateTime.timestamp(), - ), - hardDelete: hardDelete, - ); - } - // Same design as the `messageUpdated` guards: the check is - // "message-in-loaded-window" and is independent of `isUpToDate` — - // an event for a message on an older, unloaded page must not be - // turned into a phantom "deleted" record inserted into the sorted - // list. - group('when message is outside the loaded window', () { - test( - 'soft delete does NOT insert phantom "deleted" record into messages', - () async { - final tail = List.generate( - 3, - (i) => Message( - id: 'tail-$i', - user: client.state.currentUser, - text: 'tail $i', - createdAt: DateTime.utc(2026, 6, 1).add(Duration(seconds: i)), - ), - ); - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(messages: tail), - ); - expect(channel.state!.messages, hasLength(3)); - - final olderPage = Message( - id: 'older-page-msg', - user: client.state.currentUser, - text: 'gone', - createdAt: DateTime.utc(2025, 1, 1), - ); - client.addEvent(createDeleteMessageEvent(olderPage)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages.map((m) => m.id), ['tail-0', 'tail-1', 'tail-2']); - }, - ); - - test( - 'soft delete marks message as deleted when it IS in the loaded window', - () async { - const messageId = 'known'; - final seeded = Message( - id: messageId, - user: client.state.currentUser, - text: 'hi', - createdAt: DateTime.utc(2026), - ); - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(messages: [seeded]), - ); - channel.state!.isUpToDate = false; - - client.addEvent(createDeleteMessageEvent(seeded)); - await Future.delayed(Duration.zero); - - final stored = channel.state!.messages.singleWhere((m) => m.id == messageId); - expect(stored.type, equals(MessageType.deleted)); - expect(stored.deletedAt, isNotNull); - }, - ); - - test( - 'soft delete unpins a pinned-but-not-in-window message via _pinIsValid', - () async { - const messageId = 'pinned-msg'; - final pinned = Message( - id: messageId, - user: client.state.currentUser, - pinned: true, - createdAt: DateTime.utc(2026), - ); - // Seed only the pinnedMessages list — message absent from - // the main `messages` window. - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(pinnedMessages: [pinned]), - ); - channel.state!.isUpToDate = false; - expect(channel.state!.messages, isEmpty); - expect(channel.state!.pinnedMessages, hasLength(1)); - - client.addEvent(createDeleteMessageEvent(pinned)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages, isEmpty); - expect(channel.state!.pinnedMessages, isEmpty); - }, - ); - - test( - 'soft delete still clears activeLiveLocations even when message not in window', - () async { - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'loc-msg', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - // Seed only activeLiveLocations, keeping `messages` empty. - channel.state!.updateChannelState( - ChannelState( - channel: channel.state!.channelState.channel, - activeLiveLocations: [liveLocation], - ), - ); - channel.state!.isUpToDate = false; - expect(channel.state!.messages, isEmpty); - expect(channel.state!.activeLiveLocations, hasLength(1)); - - final locationMessage = Message( - id: 'loc-msg', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - client.addEvent(createDeleteMessageEvent(locationMessage)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages, isEmpty); - expect(channel.state!.activeLiveLocations, isEmpty); - }, - ); - - test( - 'hard delete is a no-op when message is not in the loaded window', - () async { - channel.state!.isUpToDate = false; - expect(channel.state!.messages, isEmpty); - - final phantom = Message( - id: 'phantom', - user: client.state.currentUser, - text: 'gone', - createdAt: DateTime.utc(2026), - ); - client.addEvent(createDeleteMessageEvent(phantom, hardDelete: true)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages, isEmpty); - expect(channel.state!.pinnedMessages, isEmpty); - }, - ); - }); - - test('an in-window hard delete removes the message', () async { + channelTest( + 'an in-window hard delete removes the message', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final message = Message( id: 'doomed-message-id', user: User(id: 'other-user'), - createdAt: DateTime.now(), + createdAt: DateTime.utc(2026), ); - channel.state!.updateMessage(message); - expect(channel.state!.messages, hasLength(1)); + tester.channelState!.updateMessage(message); + expect(tester.channelState!.messages, hasLength(1)); - client.addEvent(createDeleteMessageEvent(message, hardDelete: true)); - await Future.delayed(Duration.zero); + await tester.emitEvent(_deleteMessageEvent(message, hardDelete: true)); - expect(channel.state!.messages, isEmpty); - }); + expect(tester.channelState!.messages, isEmpty); + }, + ); - test('deletedForMe is propagated to the stored message', () async { + channelTest( + 'deletedForMe is propagated to the stored message', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final message = Message( id: 'deleted-for-me-message-id', user: User(id: 'other-user'), - createdAt: DateTime.now(), + createdAt: DateTime.utc(2026), ); - channel.state!.updateMessage(message); + tester.channelState!.updateMessage(message); - client.addEvent( - Event( - cid: channel.cid, + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, type: EventType.messageDeleted, deletedForMe: true, message: message.copyWith( type: MessageType.deleted, - deletedAt: DateTime.timestamp(), + deletedAt: _deletedAt, ), ), ); - await Future.delayed(Duration.zero); - final stored = channel.state!.messages.single; + final stored = tester.channelState!.messages.single; expect(stored.deletedForMe, isTrue); expect(stored.type, MessageType.deleted); - }); - }, - ); + }, + ); + }); group('Channel updated events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); + ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); - test('merges the event channel into the current channel model', () async { - client.addEvent( - Event( - cid: channel.cid, - type: EventType.channelUpdated, - channel: ChannelModel( - id: channelId, - type: channelType, - memberCount: 42, - extraData: const {'name': 'updated-name'}, + channelTest( + 'merges the event channel into the current channel model', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.channelUpdated, + channel: createDefaultChannelModel( + cid: _channelCid, + memberCount: 42, + extraData: const {'name': 'updated-name'}, + ), ), - ), - ); - await Future.delayed(Duration.zero); + ); - expect(channel.memberCount, 42); - expect(channel.extraData['name'], 'updated-name'); - }); + expect(tester.channel.memberCount, 42); + expect(tester.channel.extraData['name'], 'updated-name'); + }, + ); - test('replaces the member list with the event members', () async { - channel.state!.updateChannelState( - channel.state!.channelState.copyWith( - members: [ - Member(userId: 'member-1'), - Member(userId: 'member-2'), - ], - ), - ); + channelTest( + 'replaces the member list with the event members', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith( + members: [ + Member(userId: 'member-1'), + Member(userId: 'member-2'), + ], + ), + ); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.channelUpdated, - channel: ChannelModel( - id: channelId, - type: channelType, - members: [Member(userId: 'member-3')], + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.channelUpdated, + channel: createDefaultChannelModel( + cid: _channelCid, + members: [Member(userId: 'member-3')], + ), ), - ), - ); - await Future.delayed(Duration.zero); + ); - expect(channel.state!.channelState.members?.map((m) => m.userId), ['member-3']); - }); + expect(tester.channelState!.channelState.members?.map((m) => m.userId), ['member-3']); + }, + ); }); group('Channel truncated events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - late MockPersistenceClient persistenceClient; - - setUp(() { - persistenceClient = MockPersistenceClient(); - when(() => client.chatPersistenceClient).thenReturn(persistenceClient); - when(() => persistenceClient.deleteMessageByCid(any())).thenAnswer((_) async {}); - when(() => persistenceClient.getChannelThreads(any())).thenAnswer((_) async => {}); - - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); + final _seededCreatedAt = DateTime.utc(2021, 3); - tearDown(() { - channel.dispose(); - // Reset so the remaining groups run without a persistence layer. - when(() => client.chatPersistenceClient).thenReturn(null); - }); + ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); + + // Stubs every persistence call the harness makes on the way to a truncation: + // `updateConnectionInfo` on connect, `getChannelThreads` when the channel + // state initializes, and `deleteMessageByCid` from the truncation handler. + MockPersistenceClient _createPersistenceClient() { + registerFallbackValue(createDefaultEvent()); + final persistenceClient = MockPersistenceClient(); + when(() => persistenceClient.updateConnectionInfo(any())).thenAnswer((_) async {}); + when(() => persistenceClient.getChannelThreads(_channelCid)).thenAnswer((_) async => {}); + when(() => persistenceClient.deleteMessageByCid(_channelCid)).thenAnswer((_) async {}); + return persistenceClient; + } - Message seedMessage(String id) { + Message _seedMessage(ChannelTester tester, String id, {DateTime? createdAt}) { final message = Message( id: id, user: User(id: 'other-user'), text: 'to be truncated', - createdAt: DateTime.now(), + createdAt: createdAt ?? _seededCreatedAt, ); - channel.state!.updateMessage(message); + tester.channelState!.updateMessage(message); return message; } - test('${EventType.channelTruncated} clears messages and wipes persistence', () async { - seedMessage('truncated-message-1'); - seedMessage('truncated-message-2'); - expect(channel.state!.messages, hasLength(2)); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.channelTruncated, - channel: ChannelModel(id: channelId, type: channelType), - ), - ); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages, isEmpty); - verify(() => persistenceClient.deleteMessageByCid(channel.cid!)).called(1); - }); + final truncatedPersistence = _createPersistenceClient(); + + final notifiedPersistence = _createPersistenceClient(); + + channelTest( + '${EventType.channelTruncated} clears messages and wipes persistence', + channelType: _channelType, + channelId: _channelId, + chatPersistenceClient: truncatedPersistence, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + _seedMessage(tester, 'truncated-message-1'); + _seedMessage(tester, 'truncated-message-2', createdAt: _seededCreatedAt.add(const Duration(seconds: 1))); + expect(tester.channelState!.messages, hasLength(2)); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.channelTruncated, + channel: createDefaultChannelModel(cid: _channelCid), + ), + ); - test('${EventType.notificationChannelTruncated} keeps the event system message', () async { - seedMessage('truncated-message-1'); + expect(tester.channelState!.messages, isEmpty); + verify(() => truncatedPersistence.deleteMessageByCid(tester.channel.cid!)).called(1); + }, + ); - final systemMessage = Message( - id: 'system-message-id', - type: MessageType.system, - text: 'Channel truncated', - createdAt: DateTime.now(), - ); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.notificationChannelTruncated, - channel: ChannelModel(id: channelId, type: channelType), - message: systemMessage, - ), - ); - await Future.delayed(Duration.zero); + channelTest( + '${EventType.notificationChannelTruncated} keeps the event system message', + channelType: _channelType, + channelId: _channelId, + chatPersistenceClient: notifiedPersistence, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + _seedMessage(tester, 'truncated-message-1'); + + final systemMessage = Message( + id: 'system-message-id', + type: MessageType.system, + text: 'Channel truncated', + createdAt: _seededCreatedAt.add(const Duration(seconds: 1)), + ); + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.notificationChannelTruncated, + channel: createDefaultChannelModel(cid: _channelCid), + message: systemMessage, + ), + ); - expect(channel.state!.messages.map((m) => m.id), ['system-message-id']); - }); + expect(tester.channelState!.messages.map((m) => m.id), ['system-message-id']); + }, + ); }); group('Member Events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); + } - test( + channelTest( 'should update membership when member is updated and is current user', - () async { - final currentUser = client.state.currentUser; + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final currentUser = tester.currentUser; final currentMember = Member(user: currentUser); - final now = DateTime.now(); + final now = DateTime.utc(2021, 3); // Setup initial membership - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( members: [currentMember], membership: currentMember, ), ); // Verify initial state - expect(channel.membership, isNotNull); - expect(channel.membership?.channelRole, isNull); - expect(channel.membership?.isModerator, false); - expect(channel.isPinned, isFalse); - expect(channel.isArchived, isFalse); + expect(tester.channel.membership, isNotNull); + expect(tester.channel.membership?.channelRole, isNull); + expect(tester.channel.membership?.isModerator, false); + expect(tester.channel.isPinned, isFalse); + expect(tester.channel.isArchived, isFalse); // Create updated member with same userId but updated properties final updatedMember = currentMember.copyWith( @@ -2074,155 +2159,220 @@ void main() { ); // Create member updated event - final memberUpdatedEvent = Event( - cid: channel.cid, + final memberUpdatedEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.memberUpdated, user: currentUser, member: updatedMember, ); // Dispatch event - client.addEvent(memberUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); + await tester.emitEvent(memberUpdatedEvent); // Verify membership is updated with new properties - expect(channel.membership, isNotNull); - expect(channel.membership?.userId, equals(currentUser?.id)); - expect(channel.membership?.channelRole, equals('moderator')); - expect(channel.membership?.isModerator, isTrue); - expect(channel.isPinned, isTrue); - expect(channel.isArchived, isTrue); + expect(tester.channel.membership, isNotNull); + expect(tester.channel.membership?.userId, equals(currentUser?.id)); + expect(tester.channel.membership?.channelRole, equals('moderator')); + expect(tester.channel.membership?.isModerator, isTrue); + expect(tester.channel.isPinned, isTrue); + expect(tester.channel.isArchived, isTrue); }, ); - test( + channelTest( 'should update membership user when any event containing user is updated', - () async { - final currentUser = client.state.currentUser; + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final currentUser = tester.currentUser; final currentMember = Member(user: currentUser); // Setup initial membership - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( members: [currentMember], membership: currentMember, ), ); // Verify initial state - expect(channel.membership, isNotNull); - expect(channel.membership?.user?.id, equals(currentUser?.id)); - expect(channel.membership?.user?.role, equals(currentUser?.role)); + expect(tester.channel.membership, isNotNull); + expect(tester.channel.membership?.user?.id, equals(currentUser?.id)); + expect(tester.channel.membership?.user?.role, equals(currentUser?.role)); // Create updated user with same userId but updated properties final updatedUser = currentUser?.copyWith(role: 'moderator'); // Create any event with same updated user as membership. - final anyEvent = Event( - cid: channel.cid, + final anyEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.any, user: updatedUser, ); // Dispatch event - client.addEvent(anyEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); + await tester.emitEvent(anyEvent); // Verify membership is updated with new properties - expect(channel.membership, isNotNull); - expect(channel.membership?.user?.id, equals(updatedUser?.id)); - expect(channel.membership?.user?.role, equals(updatedUser?.role)); + expect(tester.channel.membership, isNotNull); + expect(tester.channel.membership?.user?.id, equals(updatedUser?.id)); + expect(tester.channel.membership?.user?.role, equals(updatedUser?.role)); }, ); - test('${EventType.memberAdded} appends the member', () async { - client.addEvent( - Event( - cid: channel.cid, - type: EventType.memberAdded, - member: Member(userId: 'new-member'), - ), - ); - await Future.delayed(Duration.zero); + channelTest( + '${EventType.memberAdded} appends the member', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberAdded, + member: Member(userId: 'new-member'), + ), + ); - expect(channel.state!.channelState.members?.map((m) => m.userId), ['new-member']); - }); + expect(tester.channelState!.channelState.members?.map((m) => m.userId), ['new-member']); + }, + ); - test('${EventType.memberRemoved} removes the member', () async { - channel.state!.updateChannelState( - channel.state!.channelState.copyWith( - members: [ - Member(userId: 'member-1'), - Member(userId: 'member-2'), - ], - ), - ); + channelTest( + '${EventType.memberRemoved} removes the member and its read state', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith( + members: [ + Member(userId: 'member-1'), + Member(userId: 'member-2'), + ], + read: [ + Read( + user: User(id: 'member-1'), + lastRead: DateTime.utc(2020), + ), + Read( + user: User(id: 'member-2'), + lastRead: DateTime.utc(2020), + ), + ], + ), + ); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.memberRemoved, - user: User(id: 'member-1'), - ), - ); - await Future.delayed(Duration.zero); + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberRemoved, + user: User(id: 'member-1'), + ), + ); - expect(channel.state!.channelState.members?.map((m) => m.userId), ['member-2']); - }); + expect(tester.channelState!.channelState.members?.map((m) => m.userId), ['member-2']); + expect(tester.channelState!.channelState.read?.map((r) => r.user.id), ['member-2']); + }, + ); - test('${EventType.memberUpdated} replaces the member entry', () async { - channel.state!.updateChannelState( - channel.state!.channelState.copyWith( - members: [Member(userId: 'member-1', channelRole: 'channel_member')], - ), - ); + channelTest( + '${EventType.memberRemoved} clears the read state of the only member', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith( + members: [Member(userId: 'member-1')], + read: [ + Read( + user: User(id: 'member-1'), + lastRead: DateTime.utc(2020), + ), + ], + ), + ); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.memberUpdated, - member: Member(userId: 'member-1', channelRole: 'channel_moderator'), - ), - ); - await Future.delayed(Duration.zero); + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberRemoved, + user: User(id: 'member-1'), + ), + ); - expect(channel.state!.channelState.members?.single.channelRole, 'channel_moderator'); - }); + expect(tester.channelState!.channelState.members, isEmpty); + expect(tester.channelState!.channelState.read, isEmpty); + }, + ); - test('an event user that is not a member is ignored', () async { - channel.state!.updateChannelState( - channel.state!.channelState.copyWith( - members: [ - Member( - userId: 'member-1', - user: User(id: 'member-1', name: 'old-name'), - ), - ], - ), - ); + channelTest( + '${EventType.memberUpdated} replaces the member entry', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith( + members: [Member(userId: 'member-1', channelRole: 'channel_member')], + ), + ); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.userUpdated, - user: User(id: 'stranger', name: 'new-name'), - ), - ); - await Future.delayed(Duration.zero); + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberUpdated, + member: Member(userId: 'member-1', channelRole: 'channel_moderator'), + ), + ); - expect(channel.state!.channelState.members?.single.user?.name, 'old-name'); - }); + expect(tester.channelState!.channelState.members?.single.channelRole, 'channel_moderator'); + }, + ); + + channelTest( + 'an event user that is not a member is ignored', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith( + members: [ + Member( + userId: 'member-1', + user: User(id: 'member-1', name: 'old-name'), + ), + ], + ), + ); + + // The merge runs unfiltered on every event carrying a user, so a + // non-member must not write state at all — an equal-but-new state + // would still notify every listener. + final before = tester.channelState!.channelState; + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userUpdated, + user: User(id: 'stranger', name: 'new-name'), + ), + ); + + expect(tester.channelState!.channelState.members?.single.user?.name, 'old-name'); + expect(identical(before, tester.channelState!.channelState), isTrue); + }, + ); group('user banned/unbanned events', () { - setUp(() { - clearInteractions(client); + Future setUpBannedMember(ChannelTester tester) async { + await tester.watch(modifyResponse: _seedChannel()); - channel.state!.updateChannelState( - channel.state!.channelState.copyWith( + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith( members: [ Member( userId: 'bad-user', @@ -2233,421 +2383,448 @@ void main() { ), ); - when( - () => client.queryMembers( - channelType, - channelId: channelId, - filter: any(named: 'filter'), + tester.mockApi( + (api) => api.general.queryMembers( + _channelType, + channelId: _channelId, + filter: any( + named: 'filter', + that: isSameFilterAs( + MemberFilter.equal(MemberFilterField.userId, 'bad-user'), + ), + ), members: any(named: 'members'), sort: any(named: 'sort'), pagination: any(named: 'pagination'), ), - ).thenAnswer( - (_) async => QueryMembersResponse()..members = [Member(userId: 'bad-user', channelRole: 'channel_banned')], + result: QueryMembersResponse()..members = [Member(userId: 'bad-user', channelRole: 'channel_banned')], ); - }); + } - test('${EventType.userBanned} refreshes the member from the server', () async { - client.addEvent( - Event( - cid: channel.cid, - type: EventType.userBanned, - user: User(id: 'bad-user'), - ), - ); - await Future.delayed(Duration.zero); - await Future.delayed(Duration.zero); + channelTest( + '${EventType.userBanned} refreshes the member from the server', + channelType: _channelType, + channelId: _channelId, + setUp: setUpBannedMember, + body: (tester) async { + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userBanned, + user: User(id: 'bad-user'), + ), + ); - expect(channel.state!.channelState.members?.single.channelRole, 'channel_banned'); - }); + expect(tester.channelState!.channelState.members?.single.channelRole, 'channel_banned'); + }, + ); - test('${EventType.userUnbanned} refreshes the member from the server', () async { - client.addEvent( - Event( - cid: channel.cid, - type: EventType.userUnbanned, - user: User(id: 'bad-user'), - ), - ); - await Future.delayed(Duration.zero); - await Future.delayed(Duration.zero); + channelTest( + '${EventType.userUnbanned} refreshes the member from the server', + channelType: _channelType, + channelId: _channelId, + setUp: setUpBannedMember, + body: (tester) async { + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userUnbanned, + user: User(id: 'bad-user'), + ), + ); - expect(channel.state!.channelState.members?.single.channelRole, 'channel_banned'); - }); + expect(tester.channelState!.channelState.members?.single.channelRole, 'channel_banned'); + }, + ); - test('an app-level ban without a cid is ignored', () async { - client.addEvent( - Event( - type: EventType.userBanned, - user: User(id: 'bad-user'), - ), - ); - await Future.delayed(Duration.zero); - await Future.delayed(Duration.zero); + channelTest( + 'an app-level ban without a cid is ignored', + channelType: _channelType, + channelId: _channelId, + setUp: setUpBannedMember, + body: (tester) async { + await tester.emitEvent( + createDefaultEvent( + type: EventType.userBanned, + user: User(id: 'bad-user'), + ), + ); - expect(channel.state!.channelState.members?.single.channelRole, 'channel_member'); - verifyNever( - () => client.queryMembers( - any(), - channelId: any(named: 'channelId'), - filter: any(named: 'filter'), - members: any(named: 'members'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - ), - ); - }); + expect(tester.channelState!.channelState.members?.single.channelRole, 'channel_member'); + tester.verifyNeverCalled( + (api) => api.general.queryMembers( + any(), + channelId: any(named: 'channelId'), + filter: any(named: 'filter'), + members: any(named: 'members'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ); + }, + ); }); }); group('Watching Events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ownCapabilities: const [ChannelCapability.readEvents], - ); - channel = Channel.fromState(client, channelState); - }); + final _messageCreatedAt = DateTime.utc(2021, 3); - tearDown(() => channel.dispose()); + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); + } - test( + channelTest( '${EventType.userWatchingStart} adds the watcher and updates watcherCount', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final watcher = User(id: 'watcher-1'); - client.addEvent( - Event( - cid: channel.cid, + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, type: EventType.userWatchingStart, user: watcher, watcherCount: 3, ), ); - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state!.watcherCount, 3); + expect(tester.channelState!.watcherCount, 3); expect( - channel.state!.channelState.watchers?.map((it) => it.id), + tester.channelState!.channelState.watchers?.map((it) => it.id), contains('watcher-1'), ); }, ); - test( + channelTest( '${EventType.userWatchingStop} removes the watcher and updates watcherCount', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final watcher = User(id: 'watcher-1'); // The watcher starts watching first (count = 2). - client.addEvent( - Event( - cid: channel.cid, + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, type: EventType.userWatchingStart, user: watcher, watcherCount: 2, ), ); - await Future.delayed(Duration.zero); - expect(channel.state!.watcherCount, 2); + expect(tester.channelState!.watcherCount, 2); expect( - channel.state!.channelState.watchers?.map((it) => it.id), + tester.channelState!.channelState.watchers?.map((it) => it.id), contains('watcher-1'), ); // Then stops watching (count = 1). - client.addEvent( - Event( - cid: channel.cid, + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, type: EventType.userWatchingStop, user: watcher, watcherCount: 1, ), ); - await Future.delayed(Duration.zero); - expect(channel.state!.watcherCount, 1); + expect(tester.channelState!.watcherCount, 1); expect( - channel.state!.channelState.watchers?.map((it) => it.id), + tester.channelState!.channelState.watchers?.map((it) => it.id), isNot(contains('watcher-1')), ); }, ); - test( + channelTest( 'watching event without watcherCount preserves the existing count', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { // Seed an initial watcher count. - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(watcherCount: 5), + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith(watcherCount: 5), ); - expect(channel.state!.watcherCount, 5); + expect(tester.channelState!.watcherCount, 5); // A watching event that omits watcher_count must not wipe the count. - client.addEvent( - Event( - cid: channel.cid, + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, type: EventType.userWatchingStart, user: User(id: 'watcher-2'), ), ); - await Future.delayed(Duration.zero); - expect(channel.state!.watcherCount, 5); + expect(tester.channelState!.watcherCount, 5); expect( - channel.state!.channelState.watchers?.map((it) => it.id), + tester.channelState!.channelState.watchers?.map((it) => it.id), contains('watcher-2'), ); }, ); - test( + channelTest( '${EventType.messageNew} updates watcherCount from the event', - () async { - expect(channel.state!.watcherCount, isNull); + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + expect(tester.channelState!.watcherCount, isNull); final message = Message( id: 'test-message-id', - user: client.state.currentUser, - createdAt: DateTime.now(), + user: tester.currentUser, + createdAt: _messageCreatedAt, ); - client.addEvent( - Event( - cid: channel.cid, + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, type: EventType.messageNew, message: message, watcherCount: 7, ), ); - await Future.delayed(Duration.zero); - expect(channel.state!.watcherCount, 7); + expect(tester.channelState!.watcherCount, 7); }, ); - test( + channelTest( '${EventType.messageNew} without watcherCount preserves the existing count', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { // Seed an initial watcher count. - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(watcherCount: 4), + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith(watcherCount: 4), ); - expect(channel.state!.watcherCount, 4); + expect(tester.channelState!.watcherCount, 4); // A local/optimistic message.new without watcher_count must not // reset the count. - client.addEvent( - Event( - cid: channel.cid, + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, type: EventType.messageNew, message: Message( id: 'test-message-id-2', - user: client.state.currentUser, - createdAt: DateTime.now(), + user: tester.currentUser, + createdAt: _messageCreatedAt, ), ), ); - await Future.delayed(Duration.zero); - expect(channel.state!.watcherCount, 4); + expect(tester.channelState!.watcherCount, 4); }, ); - test( + channelTest( '${EventType.notificationMessageNew} does not overwrite watcherCount', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { // Seed a known watcher count. - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(watcherCount: 5), + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith(watcherCount: 5), ); - expect(channel.state!.watcherCount, 5); + expect(tester.channelState!.watcherCount, 5); // notification.message_new is delivered to non-watchers and reports // watcher_count: 0; it must not clobber the real count. - client.addEvent( - Event( - cid: channel.cid, + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, type: EventType.notificationMessageNew, message: Message( id: 'notif-message-id', user: User(id: 'other-user'), - createdAt: DateTime.now(), + createdAt: _messageCreatedAt, ), watcherCount: 0, ), ); - await Future.delayed(Duration.zero); - expect(channel.state!.watcherCount, 5); + expect(tester.channelState!.watcherCount, 5); }, ); - test('${EventType.userWatchingStop} without a watcher count preserves the count', () async { - client.addEvent( - Event( - cid: channel.cid, - type: EventType.userWatchingStart, - user: User(id: 'watcher-1'), - watcherCount: 5, - ), - ); - await Future.delayed(Duration.zero); - expect(channel.state!.watcherCount, 5); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.userWatchingStop, - user: User(id: 'watcher-1'), - ), - ); - await Future.delayed(Duration.zero); + channelTest( + '${EventType.userWatchingStop} without a watcher count preserves the count', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userWatchingStart, + user: User(id: 'watcher-1'), + watcherCount: 5, + ), + ); + expect(tester.channelState!.watcherCount, 5); - expect(channel.state!.channelState.watchers, isEmpty); - expect(channel.state!.watcherCount, 5); - }); + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userWatchingStop, + user: User(id: 'watcher-1'), + ), + ); + + expect(tester.channelState!.channelState.watchers, isEmpty); + expect(tester.channelState!.watcherCount, 5); + }, + ); }); group('Read Events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, + ChannelState Function(ChannelState) _seedChannel({ + List? ownCapabilities, + List read = const [], + }) { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: ownCapabilities, + ), + read: read, ); + } - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test('should update read state on message read event', () async { - final currentUser = User(id: 'test-user'); - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, - ); + channelTest( + 'should update read state on message read event', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final currentUser = User(id: 'test-user'); + final currentRead = Read( + user: currentUser, + lastRead: DateTime.utc(2020), + unreadMessages: 10, + ); - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); + // Setup initial read state + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [currentRead], + ), + ); - // Verify initial state - final read = channel.state?.read.first; - expect(read?.user.id, 'test-user'); - expect(read?.unreadMessages, 10); - expect(read?.lastReadMessageId, isNull); - expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); - - // Create message read event - final messageReadEvent = Event( - cid: channel.cid, - type: EventType.messageRead, - user: currentUser, - createdAt: DateTime(2022), - unreadMessages: 0, - lastReadMessageId: 'message-123', - ); + // Verify initial state + final read = tester.channelState?.read.first; + expect(read?.user.id, 'test-user'); + expect(read?.unreadMessages, 10); + expect(read?.lastReadMessageId, isNull); + expect(read?.lastRead.isAtSameMomentAs(DateTime.utc(2020)), isTrue); - // Dispatch event - client.addEvent(messageReadEvent); + // Create message read event + final messageReadEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageRead, + user: currentUser, + createdAt: DateTime.utc(2022), + unreadMessages: 0, + lastReadMessageId: 'message-123', + ); - // Wait for event to be processed - await Future.delayed(Duration.zero); + // Dispatch event + await tester.emitEvent(messageReadEvent); - // Verify read state is updated - final updatedRead = channel.state?.read.first; - expect(updatedRead?.user.id, 'test-user'); - expect(updatedRead?.unreadMessages, 0); - expect(updatedRead?.lastReadMessageId, 'message-123'); - expect(updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)), isTrue); - }); + // Verify read state is updated + final updatedRead = tester.channelState?.read.first; + expect(updatedRead?.user.id, 'test-user'); + expect(updatedRead?.unreadMessages, 0); + expect(updatedRead?.lastReadMessageId, 'message-123'); + expect(updatedRead?.lastRead.isAtSameMomentAs(DateTime.utc(2022)), isTrue); + }, + ); - test( + channelTest( 'should add a new read state if not exist on message read event', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { // Create the current read state final currentUser = User(id: 'test-user'); // Verify initial state - final read = channel.state?.read; + final read = tester.channelState?.read; expect(read, isEmpty); // Create mark read notification event - final markReadEvent = Event( - cid: channel.cid, + final markReadEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.messageRead, user: currentUser, - createdAt: DateTime(2022), + createdAt: DateTime.utc(2022), unreadMessages: 0, lastReadMessageId: 'message-123', ); // Dispatch event - client.addEvent(markReadEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); + await tester.emitEvent(markReadEvent); // Verify read list has not changed - final updated = channel.state?.read; + final updated = tester.channelState?.read; expect(updated?.length, 1); expect(updated?.any((r) => r.user.id == currentUser.id), isTrue); }, ); - test( + channelTest( 'should not update channel read state on thread message read event', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final currentUser = User(id: 'test-user'); final currentRead = Read( user: currentUser, - lastRead: DateTime(2020), + lastRead: DateTime.utc(2020), unreadMessages: 10, lastReadMessageId: 'channel-msg-1', ); // Setup initial channel read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( read: [currentRead], ), ); // Verify initial state - final read = channel.state?.read.first; + final read = tester.channelState?.read.first; expect(read?.unreadMessages, 10); expect(read?.lastReadMessageId, 'channel-msg-1'); - expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); + expect(read?.lastRead.isAtSameMomentAs(DateTime.utc(2020)), isTrue); // Create a thread-scoped message.read event (thread != null) - final threadMessageReadEvent = Event( - cid: channel.cid, + final threadMessageReadEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.messageRead, user: currentUser, - createdAt: DateTime(2022), + createdAt: DateTime.utc(2022), lastReadMessageId: 'thread-reply-99', thread: Thread( - channelCid: channel.cid!, + channelCid: tester.channel.cid!, parentMessageId: 'parent-msg-1', createdByUserId: currentUser.id, replyCount: 3, @@ -2656,318 +2833,335 @@ void main() { ); // Dispatch event - client.addEvent(threadMessageReadEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); + await tester.emitEvent(threadMessageReadEvent); // Channel read state must be untouched — thread reads // must not clobber the channel-level Read. - final after = channel.state?.read.first; + final after = tester.channelState?.read.first; expect(after?.unreadMessages, 10); expect(after?.lastReadMessageId, 'channel-msg-1'); - expect(after?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); + expect(after?.lastRead.isAtSameMomentAs(DateTime.utc(2020)), isTrue); }, ); - test('should update read state on notification mark unread event', () async { - // Create the current read state - final currentUser = User(id: 'test-user'); - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, - ); + channelTest( + 'should update read state on notification mark unread event', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + // Create the current read state + final currentUser = User(id: 'test-user'); + final currentRead = Read( + user: currentUser, + lastRead: DateTime.utc(2020), + unreadMessages: 10, + ); - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); + // Setup initial read state + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [currentRead], + ), + ); - // Verify initial state - final read = channel.state?.read.first; - expect(read?.user.id, 'test-user'); - expect(read?.unreadMessages, 10); - expect(read?.lastReadMessageId, isNull); - expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); - - // Create mark unread notification event - final markUnreadEvent = Event( - cid: channel.cid, - type: EventType.notificationMarkUnread, - user: currentUser, - lastReadAt: DateTime(2019), - unreadMessages: 15, - lastReadMessageId: 'message-100', - ); + // Verify initial state + final read = tester.channelState?.read.first; + expect(read?.user.id, 'test-user'); + expect(read?.unreadMessages, 10); + expect(read?.lastReadMessageId, isNull); + expect(read?.lastRead.isAtSameMomentAs(DateTime.utc(2020)), isTrue); - // Dispatch event - client.addEvent(markUnreadEvent); + // Create mark unread notification event + final markUnreadEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.notificationMarkUnread, + user: currentUser, + lastReadAt: DateTime.utc(2019), + unreadMessages: 15, + lastReadMessageId: 'message-100', + ); - // Wait for event to be processed - await Future.delayed(Duration.zero); + // Dispatch event + await tester.emitEvent(markUnreadEvent); - // Verify read state is updated - final updatedRead = channel.state?.read.first; - expect(updatedRead?.user.id, 'test-user'); - expect(updatedRead?.unreadMessages, 15); - expect(updatedRead?.lastReadMessageId, 'message-100'); - expect(updatedRead?.lastRead.isAtSameMomentAs(DateTime(2019)), isTrue); - }); + // Verify read state is updated + final updatedRead = tester.channelState?.read.first; + expect(updatedRead?.user.id, 'test-user'); + expect(updatedRead?.unreadMessages, 15); + expect(updatedRead?.lastReadMessageId, 'message-100'); + expect(updatedRead?.lastRead.isAtSameMomentAs(DateTime.utc(2019)), isTrue); + }, + ); - test( + channelTest( 'should add a new read state if not exist on notification mark unread', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { // Verify initial state - final read = channel.state?.read; + final read = tester.channelState?.read; expect(read, isEmpty); // Create event for non-existing user - final markUnreadEvent = Event( - cid: channel.cid, + final markUnreadEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.notificationMarkUnread, user: User(id: 'non-existing-user'), - lastReadAt: DateTime(2019), + lastReadAt: DateTime.utc(2019), unreadMessages: 15, lastReadMessageId: 'message-100', ); // Dispatch event - client.addEvent(markUnreadEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); + await tester.emitEvent(markUnreadEvent); // Verify read list has not changed - final updated = channel.state?.read; + final updated = tester.channelState?.read; expect(updated?.length, 1); expect(updated?.any((r) => r.user.id == 'non-existing-user'), isTrue); }, ); - test( + channelTest( 'should preserve delivery info on message read event', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final currentUser = User(id: 'test-user'); final currentRead = Read( user: currentUser, - lastRead: DateTime(2020), + lastRead: DateTime.utc(2020), unreadMessages: 10, - lastDeliveredAt: DateTime(2021), + lastDeliveredAt: DateTime.utc(2021), lastDeliveredMessageId: 'delivered-msg-456', ); // Setup initial read state with delivery info - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( read: [currentRead], ), ); // Verify initial state - final read = channel.state?.read.first; + final read = tester.channelState?.read.first; expect(read?.lastDeliveredAt, isNotNull); expect( - read?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)), + read?.lastDeliveredAt?.isAtSameMomentAs(DateTime.utc(2021)), isTrue, ); expect(read?.lastDeliveredMessageId, 'delivered-msg-456'); // Create message read event (doesn't include delivery info) - final messageReadEvent = Event( - cid: channel.cid, + final messageReadEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.messageRead, user: currentUser, - createdAt: DateTime(2022), + createdAt: DateTime.utc(2022), unreadMessages: 0, lastReadMessageId: 'message-123', ); // Dispatch event - client.addEvent(messageReadEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); + await tester.emitEvent(messageReadEvent); // Verify read state is updated but delivery info is preserved - final updatedRead = channel.state?.read.first; + final updatedRead = tester.channelState?.read.first; expect(updatedRead?.user.id, 'test-user'); expect(updatedRead?.unreadMessages, 0); expect(updatedRead?.lastReadMessageId, 'message-123'); expect( - updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)), + updatedRead?.lastRead.isAtSameMomentAs(DateTime.utc(2022)), isTrue, ); // Delivery info should be preserved expect(updatedRead?.lastDeliveredAt, isNotNull); expect( - updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)), + updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime.utc(2021)), isTrue, ); expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); }, ); - test( + channelTest( 'should reconcile delivery when message read event is from current user', - () async { - final currentUser = client.state.currentUser; - final updatedUser = currentUser?.copyWith(id: 'current-user-id'); - - client.state.updateUser(updatedUser); - addTearDown(() => client.state.updateUser(currentUser)); + channelType: _channelType, + channelId: _channelId, + // The delivery reporter is real here, so the reconciliation is observed + // through its effect: a pending delivery receipt — armed by an incoming + // message on a delivery-capable channel with a stale read — is dropped + // once the current user's read supersedes it. + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: const [ChannelCapability.readEvents, ChannelCapability.deliveryEvents], + read: [createDefaultRead()], + ), + ), + body: (tester) async { + registerFallbackValue([]); + tester.mockApi( + (api) => api.channel.markChannelsDelivered(any()), + result: createDefaultEmptyResponse(), + ); - when( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).thenAnswer((_) => Future.value()); + // An incoming message from another user arms a pending delivery + // receipt for this channel. + final message = Message( + id: 'message-123', + user: User(id: 'other-user'), + createdAt: DateTime.utc(2021, 2), + ); + await tester.emitEvent( + createDefaultEvent(cid: tester.channel.cid, type: EventType.messageNew, message: message), + ); // Create message read event from current user - final messageReadEvent = Event( - cid: channel.cid, + final messageReadEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.messageRead, - user: currentUser, - createdAt: DateTime(2022), + user: tester.currentUser, + createdAt: DateTime.utc(2022), unreadMessages: 0, lastReadMessageId: 'message-123', ); // Dispatch event - client.addEvent(messageReadEvent); + await tester.emitEvent(messageReadEvent); - // Wait for event to be processed - await Future.delayed(Duration.zero); + // The delivery reporter batches receipts behind a 1s trailing + // throttle; by then reconciliation must have dropped the receipt. + await Future.delayed(const Duration(milliseconds: 1100)); - // Verify reconcileDelivery was called - verify( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).called(1); + // Verify the reconciled receipt was never sent + tester.verifyNeverCalled((api) => api.channel.markChannelsDelivered(any())); }, ); - test( + channelTest( 'should reset unread count on notification mark read event', - () async { - final currentUser = client.state.currentUser!; + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final currentUser = tester.currentUser!; final currentRead = Read( user: currentUser, - lastRead: DateTime(2020), + lastRead: DateTime.utc(2020), unreadMessages: 10, ); // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( read: [currentRead], ), ); - when( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).thenAnswer((_) => Future.value()); - // Verify initial state - expect(channel.state?.unreadCount, 10); + expect(tester.channelState?.unreadCount, 10); // notification.mark_read is delivered on the reading user's own // connection, so it reaches non-watched channels as well. - client.addEvent( - Event( - cid: channel.cid, + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, type: EventType.notificationMarkRead, user: currentUser, - createdAt: DateTime(2022), + createdAt: DateTime.utc(2022), lastReadMessageId: 'message-123', ), ); - // Wait for event to be processed - await Future.delayed(Duration.zero); - // Verify read state is updated - final updatedRead = channel.state?.read.first; + final updatedRead = tester.channelState?.read.first; expect(updatedRead?.user.id, currentUser.id); - expect(channel.state?.unreadCount, 0); + expect(tester.channelState?.unreadCount, 0); expect(updatedRead?.lastReadMessageId, 'message-123'); expect( - updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)), + updatedRead?.lastRead.isAtSameMomentAs(DateTime.utc(2022)), isTrue, ); }, ); - test( + channelTest( 'should preserve delivery info on notification mark read event', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final currentUser = User(id: 'test-user'); final currentRead = Read( user: currentUser, - lastRead: DateTime(2020), + lastRead: DateTime.utc(2020), unreadMessages: 10, - lastDeliveredAt: DateTime(2021), + lastDeliveredAt: DateTime.utc(2021), lastDeliveredMessageId: 'delivered-msg-456', ); // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( read: [currentRead], ), ); - client.addEvent( - Event( - cid: channel.cid, + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, type: EventType.notificationMarkRead, user: currentUser, - createdAt: DateTime(2022), + createdAt: DateTime.utc(2022), lastReadMessageId: 'message-123', ), ); - // Wait for event to be processed - await Future.delayed(Duration.zero); - // Verify read state is updated but delivery info is preserved - final updatedRead = channel.state?.read.first; + final updatedRead = tester.channelState?.read.first; expect(updatedRead?.unreadMessages, 0); expect( - updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)), + updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime.utc(2021)), isTrue, ); expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); }, ); - test( + channelTest( 'should not update channel read state on thread notification mark ' 'read event', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final currentUser = User(id: 'test-user'); final currentRead = Read( user: currentUser, - lastRead: DateTime(2020), + lastRead: DateTime.utc(2020), unreadMessages: 10, lastReadMessageId: 'channel-msg-1', ); // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( read: [currentRead], ), ); - client.addEvent( - Event( - cid: channel.cid, + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, type: EventType.notificationMarkRead, user: currentUser, - createdAt: DateTime(2022), + createdAt: DateTime.utc(2022), lastReadMessageId: 'thread-reply-99', thread: Thread( - channelCid: channel.cid!, + channelCid: tester.channel.cid!, parentMessageId: 'parent-msg-1', createdByUserId: currentUser.id, replyCount: 3, @@ -2976,129 +3170,151 @@ void main() { ), ); - // Wait for event to be processed - await Future.delayed(Duration.zero); - // Channel read state must be untouched — thread reads // must not clobber the channel-level Read. - final after = channel.state?.read.first; + final after = tester.channelState?.read.first; expect(after?.unreadMessages, 10); expect(after?.lastReadMessageId, 'channel-msg-1'); - expect(after?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); + expect(after?.lastRead.isAtSameMomentAs(DateTime.utc(2020)), isTrue); }, ); - test( + channelTest( 'should reconcile delivery when notification mark read event is from ' 'current user', - () async { - final currentUser = client.state.currentUser; + channelType: _channelType, + channelId: _channelId, + // The delivery reporter is real here, so the reconciliation is observed + // through its effect: a pending delivery receipt — armed by an incoming + // message on a delivery-capable channel with a stale read — is dropped + // once the current user's read supersedes it. + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: const [ChannelCapability.readEvents, ChannelCapability.deliveryEvents], + read: [createDefaultRead()], + ), + ), + body: (tester) async { + registerFallbackValue([]); + tester.mockApi( + (api) => api.channel.markChannelsDelivered(any()), + result: createDefaultEmptyResponse(), + ); - when( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).thenAnswer((_) => Future.value()); + // An incoming message from another user arms a pending delivery + // receipt for this channel. + final message = Message( + id: 'message-123', + user: User(id: 'other-user'), + createdAt: DateTime.utc(2021, 2), + ); + await tester.emitEvent( + createDefaultEvent(cid: tester.channel.cid, type: EventType.messageNew, message: message), + ); - client.addEvent( - Event( - cid: channel.cid, + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, type: EventType.notificationMarkRead, - user: currentUser, - createdAt: DateTime(2022), + user: tester.currentUser, + createdAt: DateTime.utc(2022), lastReadMessageId: 'message-123', ), ); - // Wait for event to be processed - await Future.delayed(Duration.zero); + // The delivery reporter batches receipts behind a 1s trailing + // throttle; by then reconciliation must have dropped the receipt. + await Future.delayed(const Duration(milliseconds: 1100)); - // Verify reconcileDelivery was called - verify( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).called(1); + // Verify the reconciled receipt was never sent + tester.verifyNeverCalled((api) => api.channel.markChannelsDelivered(any())); }, ); - test('should update read state on message delivered event', () async { - final currentUser = User(id: 'test-user'); - final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); - final currentRead = Read( - user: currentUser, - lastRead: distantPast, - unreadMessages: 5, - ); + channelTest( + 'should update read state on message delivered event', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final currentUser = User(id: 'test-user'); + final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + final currentRead = Read( + user: currentUser, + lastRead: distantPast, + unreadMessages: 5, + ); - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); + // Setup initial read state + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [currentRead], + ), + ); - // Verify initial state has no delivery info - final read = channel.state?.read.first; - expect(read?.user.id, 'test-user'); - expect(read?.lastDeliveredAt, isNull); - expect(read?.lastDeliveredMessageId, isNull); - - // Create message delivered event - final messageDeliveredEvent = Event( - cid: channel.cid, - type: EventType.messageDelivered, - user: currentUser, - lastDeliveredAt: DateTime(2022), - lastDeliveredMessageId: 'message-456', - ); + // Verify initial state has no delivery info + final read = tester.channelState?.read.first; + expect(read?.user.id, 'test-user'); + expect(read?.lastDeliveredAt, isNull); + expect(read?.lastDeliveredMessageId, isNull); - // Dispatch event - client.addEvent(messageDeliveredEvent); + // Create message delivered event + final messageDeliveredEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageDelivered, + user: currentUser, + lastDeliveredAt: DateTime.utc(2022), + lastDeliveredMessageId: 'message-456', + ); - // Wait for event to be processed - await Future.delayed(Duration.zero); + // Dispatch event + await tester.emitEvent(messageDeliveredEvent); - // Verify delivery state is updated - final updatedRead = channel.state?.read.first; - expect(updatedRead?.user.id, 'test-user'); - expect(updatedRead?.lastDeliveredAt, isNotNull); - expect( - updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), - isTrue, - ); - expect(updatedRead?.lastDeliveredMessageId, 'message-456'); - }); + // Verify delivery state is updated + final updatedRead = tester.channelState?.read.first; + expect(updatedRead?.user.id, 'test-user'); + expect(updatedRead?.lastDeliveredAt, isNotNull); + expect( + updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime.utc(2022)), + isTrue, + ); + expect(updatedRead?.lastDeliveredMessageId, 'message-456'); + }, + ); - test( + channelTest( 'should add a new read state if not exist on message delivered event', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final newUser = User(id: 'new-user'); final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); // Verify initial state - final read = channel.state?.read; + final read = tester.channelState?.read; expect(read, isEmpty); // Create message delivered event for new user - final messageDeliveredEvent = Event( - cid: channel.cid, + final messageDeliveredEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.messageDelivered, user: newUser, - lastDeliveredAt: DateTime(2022), + lastDeliveredAt: DateTime.utc(2022), lastDeliveredMessageId: 'message-789', ); // Dispatch event - client.addEvent(messageDeliveredEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); + await tester.emitEvent(messageDeliveredEvent); // Verify read state was created with delivery info - final updated = channel.state?.read; + final updated = tester.channelState?.read; expect(updated?.length, 1); final newRead = updated?.first; expect(newRead?.user.id, 'new-user'); expect(newRead?.lastDeliveredAt, isNotNull); expect( - newRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), + newRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime.utc(2022)), isTrue, ); expect(newRead?.lastDeliveredMessageId, 'message-789'); @@ -3110,56 +3326,56 @@ void main() { }, ); - test( + channelTest( 'should preserve read info on message delivered event', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final currentUser = User(id: 'test-user'); final currentRead = Read( user: currentUser, - lastRead: DateTime(2020), + lastRead: DateTime.utc(2020), unreadMessages: 10, lastReadMessageId: 'read-msg-123', ); // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( read: [currentRead], ), ); // Verify initial state - final read = channel.state?.read.first; - expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); + final read = tester.channelState?.read.first; + expect(read?.lastRead.isAtSameMomentAs(DateTime.utc(2020)), isTrue); expect(read?.unreadMessages, 10); expect(read?.lastReadMessageId, 'read-msg-123'); // Create message delivered event (doesn't include read info) - final messageDeliveredEvent = Event( - cid: channel.cid, + final messageDeliveredEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.messageDelivered, user: currentUser, - lastDeliveredAt: DateTime(2022), + lastDeliveredAt: DateTime.utc(2022), lastDeliveredMessageId: 'delivered-msg-456', ); // Dispatch event - client.addEvent(messageDeliveredEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); + await tester.emitEvent(messageDeliveredEvent); // Verify delivery state is updated but read info is preserved - final updatedRead = channel.state?.read.first; + final updatedRead = tester.channelState?.read.first; expect(updatedRead?.user.id, 'test-user'); expect( - updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), + updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime.utc(2022)), isTrue, ); expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); // Read info should be preserved expect( - updatedRead?.lastRead.isAtSameMomentAs(DateTime(2020)), + updatedRead?.lastRead.isAtSameMomentAs(DateTime.utc(2020)), isTrue, ); expect(updatedRead?.unreadMessages, 10); @@ -3167,266 +3383,287 @@ void main() { }, ); - test( + channelTest( 'should reconcile delivery when message delivered event is from current user', - () async { - final currentUser = client.state.currentUser; - final updatedUser = currentUser?.copyWith(id: 'current-user-id'); - - client.state.updateUser(updatedUser); - addTearDown(() => client.state.updateUser(currentUser)); + channelType: _channelType, + channelId: _channelId, + // The delivery reporter is real here, so the reconciliation is observed + // through its effect: a pending delivery receipt — armed by an incoming + // message on a delivery-capable channel with a stale read — is dropped + // once the current user's delivery marker supersedes it. + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: const [ChannelCapability.readEvents, ChannelCapability.deliveryEvents], + read: [createDefaultRead()], + ), + ), + body: (tester) async { + registerFallbackValue([]); + tester.mockApi( + (api) => api.channel.markChannelsDelivered(any()), + result: createDefaultEmptyResponse(), + ); - when( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).thenAnswer((_) => Future.value()); + // An incoming message from another user arms a pending delivery + // receipt for this channel. + final message = Message( + id: 'message-456', + user: User(id: 'other-user'), + createdAt: DateTime.utc(2021, 2), + ); + await tester.emitEvent( + createDefaultEvent(cid: tester.channel.cid, type: EventType.messageNew, message: message), + ); // Create message delivered event from current user - final messageDeliveredEvent = Event( - cid: channel.cid, + final messageDeliveredEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.messageDelivered, - user: currentUser, - lastDeliveredAt: DateTime(2022), + user: tester.currentUser, + lastDeliveredAt: DateTime.utc(2022), lastDeliveredMessageId: 'message-456', ); // Dispatch event - client.addEvent(messageDeliveredEvent); + await tester.emitEvent(messageDeliveredEvent); - // Wait for event to be processed - await Future.delayed(Duration.zero); + // The delivery reporter batches receipts behind a 1s trailing + // throttle; by then reconciliation must have dropped the receipt. + await Future.delayed(const Duration(milliseconds: 1100)); - // Verify reconcileDelivery was called - verify( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).called(1); + // Verify the reconciled receipt was never sent + tester.verifyNeverCalled((api) => api.channel.markChannelsDelivered(any())); }, ); }); group('Draft events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test('should handle draft.updated event for channel drafts', () async { - // Verify initial state - expect(channel.state?.draft, isNull); - - // Create Draft - final draft = Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - message: DraftMessage(text: 'test message'), - ); - - // Create draft.updated event - final draftUpdatedEvent = Event( - cid: channel.cid, - type: EventType.draftUpdated, - draft: draft, - ); - - // Dispatch event - client.addEvent(draftUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify channel draft was updated - expect(channel.state?.draft, isNotNull); - expect(channel.state?.draft?.message.text, 'test message'); - }); - - test('should handle draft.updated event for thread drafts', () async { - const threadParentMessageId = 'thread-parent-id'; - - // Setup initial state with a regular message - channel.state?.updateMessage( - Message( - id: threadParentMessageId, - user: client.state.currentUser, - ), - ); + final _draftCreatedAt = DateTime.utc(2021, 3); - // Verify initial state - expect(channel.state?.threadDraft(threadParentMessageId), isNull); + ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); - // Create thread Draft - final draft = Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - parentId: threadParentMessageId, - message: DraftMessage(text: 'thread reply'), - ); + channelTest( + 'should handle draft.updated event for channel drafts', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + // Verify initial state + expect(tester.channelState?.draft, isNull); - // Create draft.updated event - final draftUpdatedEvent = Event( - cid: channel.cid, - type: EventType.draftUpdated, - draft: draft, - ); + // Create Draft + final draft = Draft( + channelCid: tester.channel.cid!, + createdAt: _draftCreatedAt, + message: DraftMessage(text: 'test message'), + ); - // Dispatch event - client.addEvent(draftUpdatedEvent); + // Create and dispatch draft.updated event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.draftUpdated, + draft: draft, + ), + ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Verify channel draft was updated + expect(tester.channelState?.draft, isNotNull); + expect(tester.channelState?.draft?.message.text, 'test message'); + }, + ); - // Verify thread draft was updated - final threadDraft = channel.state?.threadDraft(threadParentMessageId); - expect(threadDraft, isNotNull); - expect(threadDraft?.message.text, 'thread reply'); - }); + channelTest( + 'should handle draft.updated event for thread drafts', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + const threadParentMessageId = 'thread-parent-id'; - test('should handle draft.deleted event for channel drafts', () async { - // Setup initial state with a draft - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - draft: Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - message: DraftMessage(text: 'test message'), + // Setup initial state with a regular message + tester.channelState?.updateMessage( + Message( + id: threadParentMessageId, + user: tester.currentUser, ), - ), - ); - - // Verify initial state - final draft = channel.state?.draft; - expect(draft, isNotNull); - expect(draft?.message.text, 'test message'); + ); - // Create draft.deleted event - final draftUpdatedEvent = Event( - cid: channel.cid, - type: EventType.draftDeleted, - draft: draft, - ); + // Verify initial state + expect(tester.channelState?.threadDraft(threadParentMessageId), isNull); - // Dispatch event - client.addEvent(draftUpdatedEvent); + // Create thread Draft + final draft = Draft( + channelCid: tester.channel.cid!, + createdAt: _draftCreatedAt, + parentId: threadParentMessageId, + message: DraftMessage(text: 'thread reply'), + ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Create and dispatch draft.updated event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.draftUpdated, + draft: draft, + ), + ); - // Verify channel draft was updated - expect(channel.state?.draft, isNull); - }); + // Verify thread draft was updated + final threadDraft = tester.channelState?.threadDraft(threadParentMessageId); + expect(threadDraft, isNotNull); + expect(threadDraft?.message.text, 'thread reply'); + }, + ); - test('should handle draft.deleted event for thread drafts', () async { - const threadParentMessageId = 'thread-parent-id'; + channelTest( + 'should handle draft.deleted event for channel drafts', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + // Setup initial state with a draft + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + draft: Draft( + channelCid: tester.channel.cid!, + createdAt: _draftCreatedAt, + message: DraftMessage(text: 'test message'), + ), + ), + ); - // Setup initial state with a thread draft - channel.state?.updateMessage( - Message( - id: threadParentMessageId, - user: client.state.currentUser, - draft: Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - parentId: threadParentMessageId, - message: DraftMessage(text: 'thread reply'), + // Verify initial state + final draft = tester.channelState?.draft; + expect(draft, isNotNull); + expect(draft?.message.text, 'test message'); + + // Create and dispatch draft.deleted event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.draftDeleted, + draft: draft, ), - ), - ); + ); - // Verify initial state - final threadDraft = channel.state?.threadDraft(threadParentMessageId); - expect(threadDraft, isNotNull); - expect(threadDraft?.message.text, 'thread reply'); + // Verify channel draft was updated + expect(tester.channelState?.draft, isNull); + }, + ); - // Create draft.deleted event - final draftDeletedEvent = Event( - cid: channel.cid, - type: EventType.draftDeleted, - draft: threadDraft, - ); + channelTest( + 'should handle draft.deleted event for thread drafts', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + const threadParentMessageId = 'thread-parent-id'; - // Dispatch event - client.addEvent(draftDeletedEvent); + // Setup initial state with a thread draft + tester.channelState?.updateMessage( + Message( + id: threadParentMessageId, + user: tester.currentUser, + draft: Draft( + channelCid: tester.channel.cid!, + createdAt: _draftCreatedAt, + parentId: threadParentMessageId, + message: DraftMessage(text: 'thread reply'), + ), + ), + ); - // Allow event to be processed - await Future.delayed(Duration.zero); + // Verify initial state + final threadDraft = tester.channelState?.threadDraft(threadParentMessageId); + expect(threadDraft, isNotNull); + expect(threadDraft?.message.text, 'thread reply'); + + // Create and dispatch draft.deleted event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.draftDeleted, + draft: threadDraft, + ), + ); - // Verify thread draft was removed - expect(channel.state?.threadDraft(threadParentMessageId), isNull); - }); + // Verify thread draft was removed + expect(tester.channelState?.threadDraft(threadParentMessageId), isNull); + }, + ); - test( + channelTest( 'should update current channel draft if draft.updated event is emitted', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { // Setup initial state with a draft final initialDraft = Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), + channelCid: tester.channel.cid!, + createdAt: _draftCreatedAt, message: DraftMessage(text: 'test message'), ); - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( draft: initialDraft, ), ); // Verify initial state - expect(channel.state?.draft, isNotNull); - expect(channel.state?.draft?.message.text, 'test message'); + expect(tester.channelState?.draft, isNotNull); + expect(tester.channelState?.draft?.message.text, 'test message'); // Create Draft final updatedDraft = initialDraft.copyWith( message: DraftMessage(text: 'updated message'), ); - // Create draft.updated event - final draftUpdatedEvent = Event( - cid: channel.cid, - type: EventType.draftUpdated, - draft: updatedDraft, + // Create and dispatch draft.updated event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.draftUpdated, + draft: updatedDraft, + ), ); - // Dispatch event - client.addEvent(draftUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - // Verify channel draft was updated - expect(channel.state?.draft, isNotNull); - expect(channel.state?.draft?.message.text, 'updated message'); + expect(tester.channelState?.draft, isNotNull); + expect(tester.channelState?.draft?.message.text, 'updated message'); }, ); - test( + channelTest( 'should update current thread draft if draft.updated event is emitted', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { const threadParentMessageId = 'thread-parent-id'; // Setup initial state with a thread draft final initialDraft = Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), + channelCid: tester.channel.cid!, + createdAt: _draftCreatedAt, parentId: threadParentMessageId, message: DraftMessage(text: 'thread reply'), ); - channel.state?.updateMessage( + tester.channelState?.updateMessage( Message( id: threadParentMessageId, - user: client.state.currentUser, + user: tester.currentUser, draft: initialDraft, ), ); // Verify initial state - final draft = channel.state?.threadDraft(threadParentMessageId); + final draft = tester.channelState?.threadDraft(threadParentMessageId); expect(draft, isNotNull); expect(draft?.message.text, 'thread reply'); @@ -3435,685 +3672,733 @@ void main() { message: DraftMessage(text: 'updated thread reply'), ); - // Create draft.updated event - final draftUpdatedEvent = Event( - cid: channel.cid, - type: EventType.draftUpdated, - draft: updatedDraft, + // Create and dispatch draft.updated event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.draftUpdated, + draft: updatedDraft, + ), ); - // Dispatch event - client.addEvent(draftUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - // Verify thread draft was updated - final threadDraft = channel.state?.threadDraft(threadParentMessageId); + final threadDraft = tester.channelState?.threadDraft(threadParentMessageId); expect(threadDraft, isNotNull); expect(threadDraft?.message.text, 'updated thread reply'); }, ); - test('an event without a draft is ignored', () async { - client.addEvent(Event(cid: channel.cid, type: EventType.draftUpdated)); - await Future.delayed(Duration.zero); + channelTest( + 'an event without a draft is ignored', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + await tester.emitEvent(createDefaultEvent(cid: tester.channel.cid, type: EventType.draftUpdated)); - expect(channel.state?.draft, isNull); - }); + expect(tester.channelState?.draft, isNull); + }, + ); }); group('Reminder events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test('should handle reminder.created event', () async { - const messageId = 'test-message-id'; - - // Setup initial state with a message without reminder - final message = Message( - id: messageId, - user: client.state.currentUser, - text: 'Test message', - ); - - channel.state?.updateMessage(message); - - // Verify initial state - no reminder - final initialMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNull); - - // Create reminder - final reminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: DateTime.now().add(const Duration(days: 30)), - ); - - // Create reminder.created event - final reminderCreatedEvent = Event( - cid: channel.cid, - type: EventType.reminderCreated, - reminder: reminder, - ); - - // Dispatch event - client.addEvent(reminderCreatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify message reminder was added - final updatedMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNotNull); - expect(updatedMessage?.reminder?.messageId, messageId); - expect(updatedMessage?.reminder?.remindAt, reminder.remindAt); - }); + // Deterministic stand-in for `DateTime.now()`: emitted reminders round-trip + // through JSON, and only UTC values survive that round-trip unchanged. + final _now = DateTime.utc(2021, 3); - test('should handle reminder.updated event', () async { - const messageId = 'test-message-id'; - - // Setup initial state with a message with existing reminder - final remindAt = DateTime.now().add(const Duration(days: 30)); - final initialReminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: remindAt, - ); - - final message = Message( - id: messageId, - user: client.state.currentUser, - text: 'Test message', - reminder: initialReminder, - ); + ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); - channel.state?.updateMessage(message); + channelTest( + 'should handle reminder.created event', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + const messageId = 'test-message-id'; - // Verify initial state - final initialMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNotNull); - expect(initialMessage?.reminder?.remindAt, remindAt); - - // Create updated reminder - final updatedRemindAt = remindAt.add(const Duration(days: 15)); - final updatedReminder = initialReminder.copyWith( - remindAt: updatedRemindAt, - updatedAt: DateTime.now(), - ); + // Setup initial state with a message without reminder + final message = Message( + id: messageId, + user: tester.currentUser, + text: 'Test message', + ); - // Create reminder.updated event - final reminderUpdatedEvent = Event( - cid: channel.cid, - type: EventType.reminderUpdated, - reminder: updatedReminder, - ); + tester.channelState?.updateMessage(message); - // Dispatch event - client.addEvent(reminderUpdatedEvent); + // Verify initial state - no reminder + final initialMessage = tester.channelState?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNull); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Create reminder + final reminder = createDefaultMessageReminder( + messageId: messageId, + channelCid: tester.channel.cid!, + userId: 'test-user-id', + remindAt: _now.add(const Duration(days: 30)), + ); - // Verify message reminder was updated - final updatedMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNotNull); - expect(updatedMessage?.reminder?.messageId, messageId); - expect(updatedMessage?.reminder?.remindAt, updatedRemindAt); - }); + // Emit reminder.created event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reminderCreated, + reminder: reminder, + ), + ); - test('should handle reminder.deleted event', () async { - const messageId = 'test-message-id'; + // Verify message reminder was added + final updatedMessage = tester.channelState?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNotNull); + expect(updatedMessage?.reminder?.messageId, messageId); + expect(updatedMessage?.reminder?.remindAt, reminder.remindAt); + }, + ); - // Setup initial state with a message with existing reminder - final remindAt = DateTime.now().add(const Duration(days: 30)); - final initialReminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: remindAt, - ); + channelTest( + 'should handle reminder.updated event', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + const messageId = 'test-message-id'; - final message = Message( - id: messageId, - user: client.state.currentUser, - text: 'Test message', - reminder: initialReminder, - ); + // Setup initial state with a message with existing reminder + final remindAt = _now.add(const Duration(days: 30)); + final initialReminder = createDefaultMessageReminder( + messageId: messageId, + channelCid: tester.channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); - channel.state?.updateMessage(message); + final message = Message( + id: messageId, + user: tester.currentUser, + text: 'Test message', + reminder: initialReminder, + ); - // Verify initial state - final initialMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNotNull); + tester.channelState?.updateMessage(message); - // Create reminder.deleted event - final reminderDeletedEvent = Event( - cid: channel.cid, - type: EventType.reminderDeleted, - reminder: initialReminder, - ); + // Verify initial state + final initialMessage = tester.channelState?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); + expect(initialMessage?.reminder?.remindAt, remindAt); - // Dispatch event - client.addEvent(reminderDeletedEvent); + // Create updated reminder + final updatedRemindAt = remindAt.add(const Duration(days: 15)); + final updatedReminder = initialReminder.copyWith( + remindAt: updatedRemindAt, + updatedAt: _now, + ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Emit reminder.updated event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reminderUpdated, + reminder: updatedReminder, + ), + ); - // Verify message reminder was removed - final updatedMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNull); - }); + // Verify message reminder was updated + final updatedMessage = tester.channelState?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNotNull); + expect(updatedMessage?.reminder?.messageId, messageId); + expect(updatedMessage?.reminder?.remindAt, updatedRemindAt); + }, + ); - test('should handle reminder.created event for thread messages', () async { - const messageId = 'test-message-id'; - const parentId = 'test-parent-id'; + channelTest( + 'should handle reminder.deleted event', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + const messageId = 'test-message-id'; - // Setup initial state with a thread message without reminder - final threadMessage = Message( - id: messageId, - parentId: parentId, - user: client.state.currentUser, - text: 'Thread message', - // `Message.createdAt` falls back to `DateTime.now()` per call when - // not provided, which breaks merge/sort keyed on createdAt. - createdAt: DateTime.now(), - ); + // Setup initial state with a message with existing reminder + final remindAt = _now.add(const Duration(days: 30)); + final initialReminder = createDefaultMessageReminder( + messageId: messageId, + channelCid: tester.channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); - channel.state?.updateMessage(threadMessage); + final message = Message( + id: messageId, + user: tester.currentUser, + text: 'Test message', + reminder: initialReminder, + ); - // Verify initial state - no reminder - final initialMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNull); - - // Create reminder - final remindAt = DateTime.now().add(const Duration(days: 30)); - final reminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: remindAt, - ); + tester.channelState?.updateMessage(message); - // Create reminder.created event - final reminderCreatedEvent = Event( - cid: channel.cid, - type: EventType.reminderCreated, - reminder: reminder, - ); + // Verify initial state + final initialMessage = tester.channelState?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); - // Dispatch event - client.addEvent(reminderCreatedEvent); + // Emit reminder.deleted event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reminderDeleted, + reminder: initialReminder, + ), + ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Verify message reminder was removed + final updatedMessage = tester.channelState?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNull); + }, + ); - // Verify thread message reminder was added - final updatedMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNotNull); - expect(updatedMessage?.reminder?.messageId, messageId); - expect(updatedMessage?.reminder?.remindAt, reminder.remindAt); - }); + channelTest( + 'should handle reminder.created event for thread messages', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; + + // Setup initial state with a thread message without reminder + final threadMessage = Message( + id: messageId, + parentId: parentId, + user: tester.currentUser, + text: 'Thread message', + // `Message.createdAt` falls back to `DateTime.now()` per call when + // not provided, which breaks merge/sort keyed on createdAt. + createdAt: _now, + ); - test('should handle reminder.updated event for thread messages', () async { - const messageId = 'test-message-id'; - const parentId = 'test-parent-id'; - - // Setup initial state with a thread message with existing reminder - final remindAt = DateTime.now().add(const Duration(days: 30)); - final initialReminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: remindAt, - ); + tester.channelState?.updateMessage(threadMessage); - final threadMessage = Message( - id: messageId, - parentId: parentId, - user: client.state.currentUser, - text: 'Thread message', - reminder: initialReminder, - // `Message.createdAt` falls back to `DateTime.now()` per call when - // not provided, which breaks merge/sort keyed on createdAt. - createdAt: DateTime.now(), - ); + // Verify initial state - no reminder + final initialMessage = tester.channelState?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNull); - channel.state?.updateMessage(threadMessage); + // Create reminder + final remindAt = _now.add(const Duration(days: 30)); + final reminder = createDefaultMessageReminder( + messageId: messageId, + channelCid: tester.channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); - // Verify initial state - final initialMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNotNull); - expect(initialMessage?.reminder?.remindAt, remindAt); - - // Create updated reminder - final updatedRemindAt = remindAt.add(const Duration(days: 15)); - final updatedReminder = initialReminder.copyWith( - remindAt: updatedRemindAt, - updatedAt: DateTime.now(), - ); + // Emit reminder.created event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reminderCreated, + reminder: reminder, + ), + ); - // Create reminder.updated event - final reminderUpdatedEvent = Event( - cid: channel.cid, - type: EventType.reminderUpdated, - reminder: updatedReminder, - ); + // Verify thread message reminder was added + final updatedMessage = tester.channelState?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNotNull); + expect(updatedMessage?.reminder?.messageId, messageId); + expect(updatedMessage?.reminder?.remindAt, reminder.remindAt); + }, + ); - // Dispatch event - client.addEvent(reminderUpdatedEvent); + channelTest( + 'should handle reminder.updated event for thread messages', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; + + // Setup initial state with a thread message with existing reminder + final remindAt = _now.add(const Duration(days: 30)); + final initialReminder = createDefaultMessageReminder( + messageId: messageId, + channelCid: tester.channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); + + final threadMessage = Message( + id: messageId, + parentId: parentId, + user: tester.currentUser, + text: 'Thread message', + reminder: initialReminder, + // `Message.createdAt` falls back to `DateTime.now()` per call when + // not provided, which breaks merge/sort keyed on createdAt. + createdAt: _now, + ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + tester.channelState?.updateMessage(threadMessage); - // Verify thread message reminder was updated - final updatedMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNotNull); - expect(updatedMessage?.reminder?.messageId, messageId); - expect(updatedMessage?.reminder?.remindAt, updatedRemindAt); - }); + // Verify initial state + final initialMessage = tester.channelState?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); + expect(initialMessage?.reminder?.remindAt, remindAt); - test('should handle reminder.deleted event for thread messages', () async { - const messageId = 'test-message-id'; - const parentId = 'test-parent-id'; - - // Setup initial state with a thread message with existing reminder - final remindAt = DateTime.now().add(const Duration(days: 30)); - final initialReminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: remindAt, - ); + // Create updated reminder + final updatedRemindAt = remindAt.add(const Duration(days: 15)); + final updatedReminder = initialReminder.copyWith( + remindAt: updatedRemindAt, + updatedAt: _now, + ); - final threadMessage = Message( - id: messageId, - parentId: parentId, - user: client.state.currentUser, - text: 'Thread message', - reminder: initialReminder, - // Explicit `createdAt` so `Message.createdAt` is deterministic - // across reads — without one it falls back to `DateTime.now()` - // on every call, which breaks any sort/merge keyed on createdAt. - createdAt: DateTime.now(), - ); + // Emit reminder.updated event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reminderUpdated, + reminder: updatedReminder, + ), + ); - channel.state?.updateMessage(threadMessage); + // Verify thread message reminder was updated + final updatedMessage = tester.channelState?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNotNull); + expect(updatedMessage?.reminder?.messageId, messageId); + expect(updatedMessage?.reminder?.remindAt, updatedRemindAt); + }, + ); - // Verify initial state - final initialMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNotNull); + channelTest( + 'should handle reminder.deleted event for thread messages', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; + + // Setup initial state with a thread message with existing reminder + final remindAt = _now.add(const Duration(days: 30)); + final initialReminder = createDefaultMessageReminder( + messageId: messageId, + channelCid: tester.channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); + + final threadMessage = Message( + id: messageId, + parentId: parentId, + user: tester.currentUser, + text: 'Thread message', + reminder: initialReminder, + // Explicit `createdAt` so `Message.createdAt` is deterministic + // across reads — without one it falls back to `DateTime.now()` + // on every call, which breaks any sort/merge keyed on createdAt. + createdAt: _now, + ); - // Create reminder.deleted event - final reminderDeletedEvent = Event( - cid: channel.cid, - type: EventType.reminderDeleted, - reminder: initialReminder, - ); + tester.channelState?.updateMessage(threadMessage); - // Dispatch event - client.addEvent(reminderDeletedEvent); + // Verify initial state + final initialMessage = tester.channelState?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Emit reminder.deleted event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reminderDeleted, + reminder: initialReminder, + ), + ); - // Verify thread message reminder was removed - final updatedMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNull); - }); + // Verify thread message reminder was removed + final updatedMessage = tester.channelState?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNull); + }, + ); - test('an event without a reminder is ignored', () async { - const messageId = 'test-message-id'; + channelTest( + 'an event without a reminder is ignored', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + const messageId = 'test-message-id'; - final message = Message( - id: messageId, - user: client.state.currentUser, - text: 'Test message', - ); - channel.state?.updateMessage(message); + final message = Message( + id: messageId, + user: tester.currentUser, + text: 'Test message', + ); + tester.channelState?.updateMessage(message); - client.addEvent(Event(cid: channel.cid, type: EventType.reminderCreated)); - await Future.delayed(Duration.zero); + await tester.emitEvent(createDefaultEvent(cid: tester.channel.cid, type: EventType.reminderCreated)); - final storedMessage = channel.state?.messages.firstWhere((m) => m.id == messageId); - expect(storedMessage?.reminder, isNull); - }); + final storedMessage = tester.channelState?.messages.firstWhere((m) => m.id == messageId); + expect(storedMessage?.reminder, isNull); + }, + ); }); group('Location events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; + ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); + channelTest( + 'should handle location.shared event', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + // Verify initial state + expect(tester.channelState?.activeLiveLocations, isEmpty); - tearDown(() { - channel.dispose(); - }); + // Create live location + final liveLocation = Location( + channelCid: _channelCid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.timestamp().add(const Duration(hours: 1)), + ); - test('should handle location.shared event', () async { - // Verify initial state - expect(channel.state?.activeLiveLocations, isEmpty); - - // Create live location - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); + // Dispatch location.shared event + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.locationShared, + message: locationMessage, + ), + ); - // Create location.shared event - final locationSharedEvent = Event( - cid: channel.cid, - type: EventType.locationShared, - message: locationMessage, - ); + // Check if message was added + final messages = tester.channelState?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message, isNotNull); - // Dispatch event - client.addEvent(locationSharedEvent); + // Check if active live location was updated + final activeLiveLocations = tester.channelState?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.messageId, equals('msg1')); + }, + ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + channelTest( + 'should handle location.updated event', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + // Setup initial state with location message + final liveLocation = Location( + channelCid: _channelCid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.timestamp().add(const Duration(hours: 1)), + ); - // Check if message was added - final messages = channel.state?.messages; - final message = messages?.firstWhere((m) => m.id == 'msg1'); - expect(message, isNotNull); + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); - // Check if active live location was updated - final activeLiveLocations = channel.state?.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations?.first.messageId, equals('msg1')); - }); + // Add initial message + tester.channelState?.addNewMessage(locationMessage); - test('should handle location.updated event', () async { - // Setup initial state with location message - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); + // Create updated location + final updatedLocation = liveLocation.copyWith( + latitude: 40.7500, // Updated latitude + longitude: -74.1000, // Updated longitude + ); - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); + final updatedMessage = locationMessage.copyWith( + sharedLocation: updatedLocation, + ); - // Add initial message - channel.state?.addNewMessage(locationMessage); + // Dispatch location.updated event + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.locationUpdated, + message: updatedMessage, + ), + ); - // Create updated location - final updatedLocation = liveLocation.copyWith( - latitude: 40.7500, // Updated latitude - longitude: -74.1000, // Updated longitude - ); + // Check if message was updated + final messages = tester.channelState?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message?.sharedLocation?.latitude, equals(40.7500)); + expect(message?.sharedLocation?.longitude, equals(-74.1000)); - final updatedMessage = locationMessage.copyWith( - sharedLocation: updatedLocation, - ); + // Check if active live location was updated + final activeLiveLocations = tester.channelState?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.latitude, equals(40.7500)); + expect(activeLiveLocations?.first.longitude, equals(-74.1000)); + }, + ); - // Create location.updated event - final locationUpdatedEvent = Event( - cid: channel.cid, - type: EventType.locationUpdated, - message: updatedMessage, - ); + channelTest( + 'should handle location.expired event', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + // Setup initial state with location message + final liveLocation = Location( + channelCid: _channelCid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.timestamp().add(const Duration(hours: 1)), + ); - // Dispatch event - client.addEvent(locationUpdatedEvent); + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Add initial message + tester.channelState?.addNewMessage(locationMessage); + expect(tester.channelState?.activeLiveLocations, hasLength(1)); - // Check if message was updated - final messages = channel.state?.messages; - final message = messages?.firstWhere((m) => m.id == 'msg1'); - expect(message?.sharedLocation?.latitude, equals(40.7500)); - expect(message?.sharedLocation?.longitude, equals(-74.1000)); + // Create expired location + final expiredLocation = liveLocation.copyWith( + endAt: DateTime.timestamp().subtract(const Duration(hours: 1)), + ); - // Check if active live location was updated - final activeLiveLocations = channel.state?.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations?.first.latitude, equals(40.7500)); - expect(activeLiveLocations?.first.longitude, equals(-74.1000)); - }); + final expiredMessage = locationMessage.copyWith( + sharedLocation: expiredLocation, + ); - test('should handle location.expired event', () async { - // Setup initial state with location message - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); + // Dispatch location.expired event + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.locationExpired, + message: expiredMessage, + ), + ); - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); + // Check if message was updated + final messages = tester.channelState?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message?.sharedLocation?.isExpired, isTrue); - // Add initial message - channel.state?.addNewMessage(locationMessage); - expect(channel.state?.activeLiveLocations, hasLength(1)); + // Check if active live location was removed + expect(tester.channelState?.activeLiveLocations, isEmpty); + }, + ); - // Create expired location - final expiredLocation = liveLocation.copyWith( - endAt: DateTime.now().subtract(const Duration(hours: 1)), - ); + channelTest( + "should auto-expire another user's live location once at endAt", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + final liveLocation = Location( + channelCid: _channelCid, + userId: 'user1', // Another user. + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.timestamp().add(const Duration(milliseconds: 800)), + ); - final expiredMessage = locationMessage.copyWith( - sharedLocation: expiredLocation, - ); + // The real client processes every event it handles, so collect the + // handled events the way the old mock captured `client.handleEvent`. + final captured = []; + final subscription = tester.client.on().listen(captured.add); - // Create location.expired event - final locationExpiredEvent = Event( - cid: channel.cid, - type: EventType.locationExpired, - message: expiredMessage, - ); + tester.channelState?.addNewMessage( + Message(id: 'msg1', sharedLocation: liveLocation), + ); + expect(tester.channelState?.activeLiveLocations, hasLength(1)); - // Dispatch event - client.addEvent(locationExpiredEvent); + // Before endAt no expiry event is emitted. + await Future.delayed(const Duration(milliseconds: 200)); + expect(captured, isEmpty); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // After endAt the scheduler emits exactly one location.expired event. + await Future.delayed(const Duration(milliseconds: 900)); + expect(captured, hasLength(1)); + final event = captured.single; + expect(event.type, EventType.locationExpired); + expect(event.message?.id, 'msg1'); - // Check if message was updated - final messages = channel.state?.messages; - final message = messages?.firstWhere((m) => m.id == 'msg1'); - expect(message?.sharedLocation?.isExpired, isTrue); + await subscription.cancel(); + }, + ); - // Check if active live location was removed - expect(channel.state?.activeLiveLocations, isEmpty); - }); + channelTest( + "should not auto-expire the current user's own live location", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + final ownLocation = Location( + channelCid: _channelCid, + userId: tester.currentUser!.id, // The current user (handled by the client). + messageId: 'msg-own', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.timestamp().add(const Duration(milliseconds: 150)), + ); - test("should auto-expire another user's live location once at endAt", () async { - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', // Another user. - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(milliseconds: 800)), - ); + final captured = []; + final subscription = tester.client.on().listen(captured.add); - channel.state?.addNewMessage( - Message(id: 'msg1', sharedLocation: liveLocation), - ); - expect(channel.state?.activeLiveLocations, hasLength(1)); - - // Before endAt no expiry event is emitted. - await Future.delayed(const Duration(milliseconds: 200)); - verifyNever(() => client.handleEvent(any())); - - // After endAt the scheduler emits exactly one location.expired event. - await Future.delayed(const Duration(milliseconds: 900)); - final captured = verify(() => client.handleEvent(captureAny())).captured; - expect(captured, hasLength(1)); - final event = captured.single as Event; - expect(event.type, EventType.locationExpired); - expect(event.message?.id, 'msg1'); - }); + tester.channelState?.addNewMessage( + Message(id: 'msg-own', sharedLocation: ownLocation), + ); + expect(tester.channelState?.activeLiveLocations, hasLength(1)); - test("should not auto-expire the current user's own live location", () async { - final ownLocation = Location( - channelCid: channel.cid, - userId: 'test-user-id', // The current user (handled by the client). - messageId: 'msg-own', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(milliseconds: 150)), - ); + // The channel scheduler skips the current user's own locations, so no + // expiry event is emitted even after endAt passes. + await Future.delayed(const Duration(milliseconds: 300)); + expect(captured, isEmpty); - channel.state?.addNewMessage( - Message(id: 'msg-own', sharedLocation: ownLocation), - ); - expect(channel.state?.activeLiveLocations, hasLength(1)); + await subscription.cancel(); + }, + ); - // The channel scheduler skips the current user's own locations, so no - // expiry event is emitted even after endAt passes. - await Future.delayed(const Duration(milliseconds: 300)); - verifyNever(() => client.handleEvent(any())); - }); + channelTest( + "should auto-expire another user's location that arrives expired", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + final expiredLocation = Location( + channelCid: _channelCid, + userId: 'user1', // Another user. + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.timestamp().subtract(const Duration(minutes: 5)), + ); - test("should auto-expire another user's location that arrives expired", () async { - final expiredLocation = Location( - channelCid: channel.cid, - userId: 'user1', // Another user. - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().subtract(const Duration(minutes: 5)), - ); + final captured = []; + final subscription = tester.client.on().listen(captured.add); - // Mirrors a query/watch response whose live location is already past - // endAt by the local clock, e.g. when the device clock runs ahead of - // the server or endAt passed while the response was in flight. - channel.state?.updateChannelState( - ChannelState(messages: const [], activeLiveLocations: [expiredLocation]), - ); - expect(channel.state?.activeLiveLocations, hasLength(1)); - - // The scheduler fires straight away and emits exactly one event. - await Future.delayed(const Duration(milliseconds: 100)); - final captured = verify(() => client.handleEvent(captureAny())).captured; - expect(captured, hasLength(1)); - final event = captured.single as Event; - expect(event.type, EventType.locationExpired); - expect(event.message?.id, 'msg1'); - }); + // Mirrors a query/watch response whose live location is already past + // endAt by the local clock, e.g. when the device clock runs ahead of + // the server or endAt passed while the response was in flight. + tester.channelState?.updateChannelState( + ChannelState(messages: const [], activeLiveLocations: [expiredLocation]), + ); + expect(tester.channelState?.activeLiveLocations, hasLength(1)); - test('should not add static location to active locations', () async { - final staticLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - // No endAt - static location - ); + // The scheduler fires straight away and emits exactly one event. + await Future.delayed(const Duration(milliseconds: 100)); + expect(captured, hasLength(1)); + final event = captured.single; + expect(event.type, EventType.locationExpired); + expect(event.message?.id, 'msg1'); - final staticMessage = Message( - id: 'msg1', - text: 'Static location shared', - sharedLocation: staticLocation, - ); + await subscription.cancel(); + }, + ); - // Create location.shared event - final locationSharedEvent = Event( - cid: channel.cid, - type: EventType.locationShared, - message: staticMessage, - ); + channelTest( + 'should not add static location to active locations', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + final staticLocation = Location( + channelCid: _channelCid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + // No endAt - static location + ); - // Dispatch event - client.addEvent(locationSharedEvent); + final staticMessage = Message( + id: 'msg1', + text: 'Static location shared', + sharedLocation: staticLocation, + ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Dispatch location.shared event + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.locationShared, + message: staticMessage, + ), + ); - // Check if message was added - final messages = channel.state?.messages; - final message = messages?.firstWhere((m) => m.id == 'msg1'); - expect(message?.sharedLocation, isNotNull); + // Check if message was added + final messages = tester.channelState?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message?.sharedLocation, isNotNull); - // Check if active live location was NOT updated (should remain empty) - expect(channel.state?.activeLiveLocations, isEmpty); - }); + // Check if active live location was NOT updated (should remain empty) + expect(tester.channelState?.activeLiveLocations, isEmpty); + }, + ); - test( + channelTest( 'should update active locations when location message is deleted', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { final liveLocation = Location( - channelCid: channel.cid, + channelCid: _channelCid, userId: 'user1', messageId: 'msg1', latitude: 40.7128, longitude: -74.0060, createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), + endAt: DateTime.timestamp().add(const Duration(hours: 1)), ); final locationMessage = Message( @@ -4123,98 +4408,100 @@ void main() { ); // Verify initial state - channel.state?.addNewMessage(locationMessage); - expect(channel.state?.activeLiveLocations, hasLength(1)); + tester.channelState?.addNewMessage(locationMessage); + expect(tester.channelState?.activeLiveLocations, hasLength(1)); - final messageDeletedEvent = Event( - type: EventType.messageDeleted, - cid: channel.cid, - message: locationMessage.copyWith( - type: MessageType.deleted, - deletedAt: DateTime.timestamp(), + // Dispatch message.deleted event + await tester.emitEvent( + createDefaultEvent( + type: EventType.messageDeleted, + cid: _channelCid, + message: locationMessage.copyWith( + type: MessageType.deleted, + deletedAt: DateTime.timestamp(), + ), ), ); - // Dispatch event - client.addEvent(messageDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - // Verify active locations are updated - expect(channel.state?.activeLiveLocations, isEmpty); + expect(tester.channelState?.activeLiveLocations, isEmpty); }, ); - test('should merge locations with same key', () async { - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); + channelTest( + 'should merge locations with same key', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + final liveLocation = Location( + channelCid: _channelCid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.timestamp().add(const Duration(hours: 1)), + ); - // Add initial location for setup - channel.state?.addNewMessage(locationMessage); - expect(channel.state?.activeLiveLocations, hasLength(1)); - - // Create new location with same user, channel, and device - final newLocation = Location( - channelCid: channel.cid, - userId: 'user1', // Same user - messageId: 'msg2', // Different message - latitude: 40.7500, - longitude: -74.1000, - createdByDeviceId: 'device1', // Same device - endAt: DateTime.now().add(const Duration(hours: 2)), - ); + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); - final newMessage = Message( - id: 'msg2', - text: 'Updated location', - sharedLocation: newLocation, - ); + // Add initial location for setup + tester.channelState?.addNewMessage(locationMessage); + expect(tester.channelState?.activeLiveLocations, hasLength(1)); - // Create location.shared event for the new message - final locationSharedEvent = Event( - cid: channel.cid, - type: EventType.locationShared, - message: newMessage, - ); + // Create new location with same user, channel, and device + final newLocation = Location( + channelCid: _channelCid, + userId: 'user1', // Same user + messageId: 'msg2', // Different message + latitude: 40.7500, + longitude: -74.1000, + createdByDeviceId: 'device1', // Same device + endAt: DateTime.timestamp().add(const Duration(hours: 2)), + ); - // Dispatch event - client.addEvent(locationSharedEvent); + final newMessage = Message( + id: 'msg2', + text: 'Updated location', + sharedLocation: newLocation, + ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Dispatch location.shared event for the new message + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.locationShared, + message: newMessage, + ), + ); - // Should still have only one active location (merged) - final activeLiveLocations = channel.state?.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations?.first.messageId, equals('msg2')); - expect(activeLiveLocations?.first.latitude, equals(40.7500)); - }); + // Should still have only one active location (merged) + final activeLiveLocations = tester.channelState?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.messageId, equals('msg2')); + expect(activeLiveLocations?.first.latitude, equals(40.7500)); + }, + ); - test( + channelTest( 'should handle multiple active locations from different devices', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { final liveLocation = Location( - channelCid: channel.cid, + channelCid: _channelCid, userId: 'user1', messageId: 'msg1', latitude: 40.7128, longitude: -74.0060, createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), + endAt: DateTime.timestamp().add(const Duration(hours: 1)), ); final locationMessage = Message( @@ -4224,18 +4511,18 @@ void main() { ); // Add first location for setup - channel.state?.addNewMessage(locationMessage); - expect(channel.state?.activeLiveLocations, hasLength(1)); + tester.channelState?.addNewMessage(locationMessage); + expect(tester.channelState?.activeLiveLocations, hasLength(1)); // Create location from different device final location2 = Location( - channelCid: channel.cid, + channelCid: _channelCid, userId: 'user1', // Same user messageId: 'msg2', latitude: 34.0522, longitude: -118.2437, createdByDeviceId: 'device2', // Different device - endAt: DateTime.now().add(const Duration(hours: 1)), + endAt: DateTime.timestamp().add(const Duration(hours: 1)), ); final message2 = Message( @@ -4244,246 +4531,261 @@ void main() { sharedLocation: location2, ); - // Create location.shared event for the second message - final locationSharedEvent = Event( - cid: channel.cid, - type: EventType.locationShared, - message: message2, + // Dispatch location.shared event for the second message + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.locationShared, + message: message2, + ), ); - // Dispatch event - client.addEvent(locationSharedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - // Should have two active locations - expect(channel.state?.activeLiveLocations, hasLength(2)); + expect(tester.channelState?.activeLiveLocations, hasLength(2)); }, ); - test('should handle location messages in threads', () async { - final parentMessage = Message( - id: 'parent1', - text: 'Thread parent', - ); - - // Add parent message first for setup - channel.state?.addNewMessage(parentMessage); - - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'thread-msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final threadLocationMessage = Message( - id: 'thread-msg1', - text: 'Live location in thread', - parentId: 'parent1', - sharedLocation: liveLocation, - ); - - // Create location.shared event for the thread message - final locationSharedEvent = Event( - cid: channel.cid, - type: EventType.locationShared, - message: threadLocationMessage, - ); - - // Dispatch event - client.addEvent(locationSharedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Check if thread message was added - final thread = channel.state?.threads['parent1']; - expect(thread, contains(threadLocationMessage)); - - // Check if location was added to active locations - final activeLiveLocations = channel.state?.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations?.first.messageId, equals('thread-msg1')); - }); - - test('should update thread location messages', () async { - final parentMessage = Message( - id: 'parent1', - text: 'Thread parent', - ); - - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'thread-msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final threadLocationMessage = Message( - id: 'thread-msg1', - text: 'Live location in thread', - parentId: 'parent1', - sharedLocation: liveLocation, - ); - - // Add messages - channel.state?.addNewMessage(parentMessage); - channel.state?.addNewMessage(threadLocationMessage); + channelTest( + 'should handle location messages in threads', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + final parentMessage = Message( + id: 'parent1', + text: 'Thread parent', + ); - // Update the location - final updatedLocation = liveLocation.copyWith( - latitude: 40.7500, - longitude: -74.1000, - ); + // Add parent message first for setup + tester.channelState?.addNewMessage(parentMessage); - final updatedThreadMessage = threadLocationMessage.copyWith( - sharedLocation: updatedLocation, - ); + final liveLocation = Location( + channelCid: _channelCid, + userId: 'user1', + messageId: 'thread-msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.timestamp().add(const Duration(hours: 1)), + ); - // Create location.updated event for the thread message - final locationUpdatedEvent = Event( - cid: channel.cid, - type: EventType.locationUpdated, - message: updatedThreadMessage, - ); + final threadLocationMessage = Message( + id: 'thread-msg1', + text: 'Live location in thread', + parentId: 'parent1', + sharedLocation: liveLocation, + ); - // Dispatch event - client.addEvent(locationUpdatedEvent); + // Dispatch location.shared event for the thread message + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.locationShared, + message: threadLocationMessage, + ), + ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Check if thread message was added. The wire round-trip rewrites the + // local-only message fields (state, local timestamps), so match on id + // and the location payload instead of whole-message equality. + final thread = tester.channelState?.threads['parent1']; + final threadMessage = thread?.firstWhere((m) => m.id == 'thread-msg1'); + expect(threadMessage, isNotNull); + expect(threadMessage?.sharedLocation, equals(liveLocation)); - // Check if thread message was updated - final thread = channel.state?.threads['parent1']; - final threadMessage = thread?.firstWhere((m) => m.id == 'thread-msg1'); - expect(threadMessage?.sharedLocation?.latitude, equals(40.7500)); - expect(threadMessage?.sharedLocation?.longitude, equals(-74.1000)); + // Check if location was added to active locations + final activeLiveLocations = tester.channelState?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.messageId, equals('thread-msg1')); + }, + ); - // Check if active location was updated - final activeLiveLocations = channel.state?.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations?.first.latitude, equals(40.7500)); - expect(activeLiveLocations?.first.longitude, equals(-74.1000)); - }); - }); + channelTest( + 'should update thread location messages', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + final parentMessage = Message( + id: 'parent1', + text: 'Thread parent', + ); - group('Channel push preference events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; + final liveLocation = Location( + channelCid: _channelCid, + userId: 'user1', + messageId: 'thread-msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.timestamp().add(const Duration(hours: 1)), + ); - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); + final threadLocationMessage = Message( + id: 'thread-msg1', + text: 'Live location in thread', + parentId: 'parent1', + sharedLocation: liveLocation, + ); - tearDown(() { - channel.dispose(); - }); + // Add messages + tester.channelState?.addNewMessage(parentMessage); + tester.channelState?.addNewMessage(threadLocationMessage); - test('should handle channel.push_preference.updated event', () async { - // Verify initial state - expect(channel.state?.channelState.pushPreferences, isNull); + // Update the location + final updatedLocation = liveLocation.copyWith( + latitude: 40.7500, + longitude: -74.1000, + ); - // Create channel push preference - final channelPushPreference = ChannelPushPreference( - chatLevel: ChatLevel.mentions, - disabledUntil: DateTime.now().add(const Duration(hours: 1)), - ); + final updatedThreadMessage = threadLocationMessage.copyWith( + sharedLocation: updatedLocation, + ); - // Create channel.push_preference.updated event - final channelPushPreferenceUpdatedEvent = Event( - cid: channel.cid, - type: EventType.channelPushPreferenceUpdated, - channelPushPreference: channelPushPreference, - ); + // Dispatch location.updated event for the thread message + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.locationUpdated, + message: updatedThreadMessage, + ), + ); - // Dispatch event - client.addEvent(channelPushPreferenceUpdatedEvent); + // Check if thread message was updated + final thread = tester.channelState?.threads['parent1']; + final threadMessage = thread?.firstWhere((m) => m.id == 'thread-msg1'); + expect(threadMessage?.sharedLocation?.latitude, equals(40.7500)); + expect(threadMessage?.sharedLocation?.longitude, equals(-74.1000)); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Check if active location was updated + final activeLiveLocations = tester.channelState?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.latitude, equals(40.7500)); + expect(activeLiveLocations?.first.longitude, equals(-74.1000)); + }, + ); + }); - // Verify channel push preferences were updated - final updatedPreferences = channel.state?.channelState.pushPreferences; - expect(updatedPreferences, isNotNull); - expect(updatedPreferences?.chatLevel, ChatLevel.mentions); - expect( - updatedPreferences?.disabledUntil, - channelPushPreference.disabledUntil, - ); - }); + group('Channel push preference events', () { + ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); - test('should update existing channel push preferences', () async { - // Set initial push preferences - const initialPushPreference = ChannelPushPreference( - chatLevel: ChatLevel.all, - ); + channelTest( + 'should handle channel.push_preference.updated event', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + // Verify initial state + expect(tester.channelState?.channelState.pushPreferences, isNull); - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - pushPreferences: initialPushPreference, - ), - ); + // Create channel push preference + final channelPushPreference = ChannelPushPreference( + chatLevel: ChatLevel.mentions, + disabledUntil: DateTime.utc(2021, 3), + ); - // Verify initial state - final pushPreferences = channel.state?.channelState.pushPreferences; - expect(pushPreferences?.chatLevel, ChatLevel.all); - expect(pushPreferences?.disabledUntil, isNull); + // Dispatch channel.push_preference.updated event + await tester.emitEvent( + createDefaultEvent( + type: EventType.channelPushPreferenceUpdated, + cid: tester.channel.cid, + channelPushPreference: channelPushPreference, + ), + ); - // Create updated channel push preference - final updatedPushPreference = ChannelPushPreference( - chatLevel: ChatLevel.none, - disabledUntil: DateTime.now().add(const Duration(hours: 2)), - ); + // Verify channel push preferences were updated + final updatedPreferences = tester.channelState?.channelState.pushPreferences; + expect(updatedPreferences, isNotNull); + expect(updatedPreferences?.chatLevel, ChatLevel.mentions); + expect( + updatedPreferences?.disabledUntil, + channelPushPreference.disabledUntil, + ); + }, + ); - // Create channel.push_preference.updated event - final channelPushPreferenceUpdatedEvent = Event( - cid: channel.cid, - type: EventType.channelPushPreferenceUpdated, - channelPushPreference: updatedPushPreference, - ); + channelTest( + 'should update existing channel push preferences', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + // Set initial push preferences + const initialPushPreference = ChannelPushPreference( + chatLevel: ChatLevel.all, + ); - // Dispatch event - client.addEvent(channelPushPreferenceUpdatedEvent); + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + pushPreferences: initialPushPreference, + ), + ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Verify initial state + final pushPreferences = tester.channelState?.channelState.pushPreferences; + expect(pushPreferences?.chatLevel, ChatLevel.all); + expect(pushPreferences?.disabledUntil, isNull); + + // Create updated channel push preference + final updatedPushPreference = ChannelPushPreference( + chatLevel: ChatLevel.none, + disabledUntil: DateTime.utc(2021, 4), + ); + + // Dispatch channel.push_preference.updated event + await tester.emitEvent( + createDefaultEvent( + type: EventType.channelPushPreferenceUpdated, + cid: tester.channel.cid, + channelPushPreference: updatedPushPreference, + ), + ); - // Verify channel push preferences were updated - final updatedPreferences = channel.state?.channelState.pushPreferences; - expect(updatedPreferences?.chatLevel, ChatLevel.none); - expect( - updatedPreferences?.disabledUntil, - updatedPushPreference.disabledUntil, - ); - }); + // Verify channel push preferences were updated + final updatedPreferences = tester.channelState?.channelState.pushPreferences; + expect(updatedPreferences?.chatLevel, ChatLevel.none); + expect( + updatedPreferences?.disabledUntil, + updatedPushPreference.disabledUntil, + ); + }, + ); - test('an event without a push preference is ignored', () async { - client.addEvent(Event(cid: channel.cid, type: EventType.channelPushPreferenceUpdated)); - await Future.delayed(Duration.zero); + channelTest( + 'an event without a push preference is ignored', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + await tester.emitEvent( + createDefaultEvent(type: EventType.channelPushPreferenceUpdated, cid: tester.channel.cid), + ); - expect(channel.state?.channelState.pushPreferences, isNull); - }); + expect(tester.channelState?.channelState.pushPreferences, isNull); + }, + ); }); group('User messages deleted event', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - late MockPersistenceClient persistenceClient; - - setUp(() { - persistenceClient = MockPersistenceClient(); - when(() => client.chatPersistenceClient).thenReturn(persistenceClient); + ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); + + // Stubs every persistence call the harness makes on the way to a user-level + // deletion: `updateConnectionInfo` on connect, `getChannelThreads` when the + // channel state initializes, the debounced channel-state/threads writes (they + // only fire when a run is slow enough for the 1s debounce to elapse + // mid-test), and the three calls the `user.messages.deleted` handler makes. + MockPersistenceClient _createPersistenceClient() { + registerFallbackValue(createDefaultEvent()); + registerFallbackValue(createDefaultChannelState()); + final persistenceClient = MockPersistenceClient(); + when(() => persistenceClient.updateConnectionInfo(any())).thenAnswer((_) async {}); + when(() => persistenceClient.getChannelThreads(_channelCid)).thenAnswer((_) async => {}); + when(() => persistenceClient.updateChannelState(any())).thenAnswer((_) async {}); + when(() => persistenceClient.updateChannelThreads(any(), any())).thenAnswer((_) async {}); when( () => persistenceClient.deleteMessagesFromUser( cid: any(named: 'cid'), @@ -4494,19 +4796,34 @@ void main() { ).thenAnswer((_) async {}); when(() => persistenceClient.deleteMessageByIds(any())).thenAnswer((_) async {}); when(() => persistenceClient.deletePinnedMessageByIds(any())).thenAnswer((_) async {}); - when(() => persistenceClient.getChannelThreads(any())).thenAnswer((_) async => >{}); + return persistenceClient; + } - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); + final softDeletePersistence = _createPersistenceClient(); - tearDown(() { - channel.dispose(); - }); + final hardDeletePersistence = _createPersistenceClient(); + + final threadMessagesPersistence = _createPersistenceClient(); + + final nullUserPersistence = _createPersistenceClient(); - test( + final emptyChannelPersistence = _createPersistenceClient(); + + final hardDeleteStoragePersistence = _createPersistenceClient(); + + final softDeleteStoragePersistence = _createPersistenceClient(); + + final storageWidePersistence = _createPersistenceClient(); + + final crossThreadPersistence = _createPersistenceClient(); + + channelTest( 'should soft delete all messages from user when hardDelete is false', - () async { + channelType: _channelType, + channelId: _channelId, + chatPersistenceClient: softDeletePersistence, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { // Setup: Add messages from different users final user1 = User(id: 'user-1', name: 'User 1'); final user2 = User(id: 'user-2', name: 'User 2'); @@ -4527,40 +4844,37 @@ void main() { user: user2, ); - channel.state?.addNewMessage(message1); - channel.state?.addNewMessage(message2); - channel.state?.addNewMessage(message3); + tester.channelState?.addNewMessage(message1); + tester.channelState?.addNewMessage(message2); + tester.channelState?.addNewMessage(message3); // Verify initial state - expect(channel.state?.messages.length, equals(3)); + expect(tester.channelState?.messages.length, equals(3)); expect( - channel.state?.messages.where((m) => m.user?.id == 'user-1').length, + tester.channelState?.messages.where((m) => m.user?.id == 'user-1').length, equals(2), ); expect( - channel.state?.messages.where((m) => m.user?.id == 'user-2').length, + tester.channelState?.messages.where((m) => m.user?.id == 'user-2').length, equals(1), ); // Create user.messages.deleted event (soft delete) - final deletedAt = DateTime.now(); - final userMessagesDeletedEvent = Event( - cid: channel.cid, + final deletedAt = DateTime.utc(2021, 3); + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.userMessagesDeleted, user: user1, hardDelete: false, createdAt: deletedAt, ); - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Dispatch event and wait for it to be processed + await tester.emitEvent(userMessagesDeletedEvent); // Verify user1's messages are soft deleted - expect(channel.state?.messages.length, equals(3)); - final deletedMessages = channel.state?.messages.where((m) => m.user?.id == 'user-1').toList(); + expect(tester.channelState?.messages.length, equals(3)); + final deletedMessages = tester.channelState?.messages.where((m) => m.user?.id == 'user-1').toList(); expect(deletedMessages?.length, equals(2)); for (final message in deletedMessages!) { expect(message.type, equals(MessageType.deleted)); @@ -4569,15 +4883,19 @@ void main() { } // Verify user2's message is unaffected - final user2Message = channel.state?.messages.firstWhere((m) => m.id == 'msg-3'); + final user2Message = tester.channelState?.messages.firstWhere((m) => m.id == 'msg-3'); expect(user2Message?.type, isNot(MessageType.deleted)); expect(user2Message?.deletedAt, isNull); }, ); - test( + channelTest( 'should hard delete all messages from user when hardDelete is true', - () async { + channelType: _channelType, + channelId: _channelId, + chatPersistenceClient: hardDeletePersistence, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { // Setup: Add messages from different users final user1 = User(id: 'user-1', name: 'User 1'); final user2 = User(id: 'user-2', name: 'User 2'); @@ -4598,44 +4916,45 @@ void main() { user: user2, ); - channel.state?.addNewMessage(message1); - channel.state?.addNewMessage(message2); - channel.state?.addNewMessage(message3); + tester.channelState?.addNewMessage(message1); + tester.channelState?.addNewMessage(message2); + tester.channelState?.addNewMessage(message3); // Verify initial state - expect(channel.state?.messages.length, equals(3)); + expect(tester.channelState?.messages.length, equals(3)); // Create user.messages.deleted event (hard delete) - final userMessagesDeletedEvent = Event( - cid: channel.cid, + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.userMessagesDeleted, user: user1, hardDelete: true, ); - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Dispatch event and wait for it to be processed + await tester.emitEvent(userMessagesDeletedEvent); // Verify user1's messages are removed - expect(channel.state?.messages.length, equals(1)); + expect(tester.channelState?.messages.length, equals(1)); expect( - channel.state?.messages.any((m) => m.user?.id == 'user-1'), + tester.channelState?.messages.any((m) => m.user?.id == 'user-1'), isFalse, ); // Verify user2's message still exists - final user2Message = channel.state?.messages.firstWhere((m) => m.id == 'msg-3'); + final user2Message = tester.channelState?.messages.firstWhere((m) => m.id == 'msg-3'); expect(user2Message, isNotNull); expect(user2Message?.user?.id, equals('user-2')); }, ); - test( + channelTest( 'should handle thread messages from user', - () async { + channelType: _channelType, + channelId: _channelId, + chatPersistenceClient: threadMessagesPersistence, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { // Setup: Add parent and thread messages final user1 = User(id: 'user-1', name: 'User 1'); final user2 = User(id: 'user-2', name: 'User 2'); @@ -4658,30 +4977,27 @@ void main() { parentId: 'parent-msg', ); - channel.state?.addNewMessage(parentMessage); - channel.state?.addNewMessage(threadMessage1); - channel.state?.addNewMessage(threadMessage2); + tester.channelState?.addNewMessage(parentMessage); + tester.channelState?.addNewMessage(threadMessage1); + tester.channelState?.addNewMessage(threadMessage2); // Verify initial state - expect(channel.state?.messages.length, equals(1)); - expect(channel.state?.threads['parent-msg']?.length, equals(2)); + expect(tester.channelState?.messages.length, equals(1)); + expect(tester.channelState?.threads['parent-msg']?.length, equals(2)); // Create user.messages.deleted event (soft delete) - final userMessagesDeletedEvent = Event( - cid: channel.cid, + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.userMessagesDeleted, user: user1, hardDelete: false, ); - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Dispatch event and wait for it to be processed + await tester.emitEvent(userMessagesDeletedEvent); // Verify thread messages are soft deleted - final threadMessages = channel.state?.threads['parent-msg']; + final threadMessages = tester.channelState?.threads['parent-msg']; expect(threadMessages?.length, equals(2)); for (final message in threadMessages!) { expect(message.type, equals(MessageType.deleted)); @@ -4689,14 +5005,18 @@ void main() { } // Verify parent message is unaffected - final parent = channel.state?.messages.first; + final parent = tester.channelState?.messages.first; expect(parent?.type, isNot(MessageType.deleted)); }, ); - test( + channelTest( 'should do nothing when user is null', - () async { + channelType: _channelType, + channelId: _channelId, + chatPersistenceClient: nullUserPersistence, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { // Setup: Add messages final user1 = User(id: 'user-1', name: 'User 1'); final message1 = Message( @@ -4705,61 +5025,63 @@ void main() { user: user1, ); - channel.state?.addNewMessage(message1); + tester.channelState?.addNewMessage(message1); // Verify initial state - expect(channel.state?.messages.length, equals(1)); + expect(tester.channelState?.messages.length, equals(1)); // Create user.messages.deleted event without user - final userMessagesDeletedEvent = Event( - cid: channel.cid, + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.userMessagesDeleted, hardDelete: false, ); - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Dispatch event and wait for it to be processed + await tester.emitEvent(userMessagesDeletedEvent); // Verify messages are unaffected - expect(channel.state?.messages.length, equals(1)); + expect(tester.channelState?.messages.length, equals(1)); expect( - channel.state?.messages.first.type, + tester.channelState?.messages.first.type, isNot(MessageType.deleted), ); }, ); - test( + channelTest( 'should handle empty message list', - () async { + channelType: _channelType, + channelId: _channelId, + chatPersistenceClient: emptyChannelPersistence, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { // Setup: Empty channel - expect(channel.state?.messages.length, equals(0)); + expect(tester.channelState?.messages.length, equals(0)); // Create user.messages.deleted event - final userMessagesDeletedEvent = Event( - cid: channel.cid, + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.userMessagesDeleted, user: User(id: 'user-1'), hardDelete: false, ); // Dispatch event - should not throw - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); + await tester.emitEvent(userMessagesDeletedEvent); // Verify state is still empty - expect(channel.state?.messages.length, equals(0)); + expect(tester.channelState?.messages.length, equals(0)); }, ); - test( + channelTest( 'should delete messages from persistence when hardDelete is true', - () async { + channelType: _channelType, + channelId: _channelId, + chatPersistenceClient: hardDeleteStoragePersistence, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { // Setup: Add messages from different users final user1 = User(id: 'user-1', name: 'User 1'); final user2 = User(id: 'user-2', name: 'User 2'); @@ -4780,47 +5102,48 @@ void main() { user: user2, ); - channel.state?.addNewMessage(message1); - channel.state?.addNewMessage(message2); - channel.state?.addNewMessage(message3); + tester.channelState?.addNewMessage(message1); + tester.channelState?.addNewMessage(message2); + tester.channelState?.addNewMessage(message3); // Verify initial state - expect(channel.state?.messages.length, equals(3)); + expect(tester.channelState?.messages.length, equals(3)); // Create user.messages.deleted event (hard delete) - final userMessagesDeletedEvent = Event( - cid: channel.cid, + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.userMessagesDeleted, user: user1, hardDelete: true, ); - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Dispatch event and wait for it to be processed + await tester.emitEvent(userMessagesDeletedEvent); // Verify messages are removed from persistence verify( - () => persistenceClient.deleteMessageByIds(['msg-1', 'msg-2']), + () => hardDeleteStoragePersistence.deleteMessageByIds(['msg-1', 'msg-2']), ).called(1); verify( - () => persistenceClient.deletePinnedMessageByIds(['msg-1', 'msg-2']), + () => hardDeleteStoragePersistence.deletePinnedMessageByIds(['msg-1', 'msg-2']), ).called(1); // Verify user1's messages are removed from state - expect(channel.state?.messages.length, equals(1)); + expect(tester.channelState?.messages.length, equals(1)); expect( - channel.state?.messages.any((m) => m.user?.id == 'user-1'), + tester.channelState?.messages.any((m) => m.user?.id == 'user-1'), isFalse, ); }, ); - test( + channelTest( 'should not delete from persistence when hardDelete is false', - () async { + channelType: _channelType, + channelId: _channelId, + chatPersistenceClient: softDeleteStoragePersistence, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { // Setup: Add messages final user1 = User(id: 'user-1', name: 'User 1'); final message1 = Message( @@ -4829,35 +5152,36 @@ void main() { user: user1, ); - channel.state?.addNewMessage(message1); + tester.channelState?.addNewMessage(message1); // Create user.messages.deleted event (soft delete) - final userMessagesDeletedEvent = Event( - cid: channel.cid, + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.userMessagesDeleted, user: user1, hardDelete: false, ); - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Dispatch event and wait for it to be processed + await tester.emitEvent(userMessagesDeletedEvent); // Verify persistence deletion methods were NOT called - verifyNever(() => persistenceClient.deleteMessageByIds(any())); - verifyNever(() => persistenceClient.deletePinnedMessageByIds(any())); + verifyNever(() => softDeleteStoragePersistence.deleteMessageByIds(any())); + verifyNever(() => softDeleteStoragePersistence.deletePinnedMessageByIds(any())); // Verify message is soft deleted (still in state) - expect(channel.state?.messages.length, equals(1)); - expect(channel.state?.messages.first.type, equals(MessageType.deleted)); + expect(tester.channelState?.messages.length, equals(1)); + expect(tester.channelState?.messages.first.type, equals(MessageType.deleted)); }, ); - test( + channelTest( 'should delete all user messages including those only in storage', - () async { + channelType: _channelType, + channelId: _channelId, + chatPersistenceClient: storageWidePersistence, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { final user1 = User(id: 'user-1', name: 'User 1'); final user2 = User(id: 'user-2', name: 'User 2'); @@ -4889,48 +5213,45 @@ void main() { // Note: In reality, storage may contain many more user1 messages // (e.g., older messages not loaded into state yet), but the delete // operation should remove ALL of them from storage. - channel.state?.addNewMessage(stateMessage1); - channel.state?.addNewMessage(stateMessage2); - channel.state?.addNewMessage(stateThreadMessage1); - channel.state?.addNewMessage(stateThreadMessage2); + tester.channelState?.addNewMessage(stateMessage1); + tester.channelState?.addNewMessage(stateMessage2); + tester.channelState?.addNewMessage(stateThreadMessage1); + tester.channelState?.addNewMessage(stateThreadMessage2); // Verify initial state has only 2 messages and 1 thread with 2 replies - expect(channel.state?.messages.length, equals(2)); - expect(channel.state?.threads['msg-1']?.length, equals(2)); + expect(tester.channelState?.messages.length, equals(2)); + expect(tester.channelState?.threads['msg-1']?.length, equals(2)); // Create user.messages.deleted event (hard delete) - final userMessagesDeletedEvent = Event( - cid: channel.cid, + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.userMessagesDeleted, user: user1, hardDelete: true, ); - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Dispatch event and wait for it to be processed + await tester.emitEvent(userMessagesDeletedEvent); // Verify user1's messages are removed from state - expect(channel.state?.messages.length, equals(1)); - expect(channel.state?.threads['msg-1']?.length, equals(1)); + expect(tester.channelState?.messages.length, equals(1)); + expect(tester.channelState?.threads['msg-1']?.length, equals(1)); expect( - channel.state?.messages.any((m) => m.user?.id == 'user-1'), + tester.channelState?.messages.any((m) => m.user?.id == 'user-1'), isFalse, ); expect( - channel.state?.threads['msg-1']?.any((m) => m.user?.id == 'user-1'), + tester.channelState?.threads['msg-1']?.any((m) => m.user?.id == 'user-1'), isFalse, ); // Verify persistence delete was called - this handles ALL messages // in storage (both those in state AND those only in storage) verify( - () => persistenceClient.deleteMessagesFromUser( - cid: channel.cid, + () => storageWidePersistence.deleteMessagesFromUser( + cid: tester.channel.cid, userId: user1.id, hardDelete: true, deletedAt: any(named: 'deletedAt'), @@ -4940,7 +5261,7 @@ void main() { // Verify in-state messages were also removed from state's persistence final capturedIds = verify( - () => persistenceClient.deleteMessageByIds(captureAny()), + () => storageWidePersistence.deleteMessageByIds(captureAny()), ).captured.first as List; @@ -4954,10 +5275,14 @@ void main() { }, ); - test( + channelTest( 'should delete every authored message across threads without ' 'cross-thread leakage (regression: _updateThreadMessages)', - () async { + channelType: _channelType, + channelId: _channelId, + chatPersistenceClient: crossThreadPersistence, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { // user-1 authors a top-level message AND replies in two different // threads (owned by user-2). The user.messages.deleted flow // collects everything from user-1 across channel + threads and @@ -4988,41 +5313,40 @@ void main() { parentId: 'parent-B', ); - channel.state?.addNewMessage(parentA); - channel.state?.addNewMessage(parentB); - channel.state?.addNewMessage(topLevelFromUser1); - channel.state?.addNewMessage(replyA); - channel.state?.addNewMessage(replyB); + tester.channelState?.addNewMessage(parentA); + tester.channelState?.addNewMessage(parentB); + tester.channelState?.addNewMessage(topLevelFromUser1); + tester.channelState?.addNewMessage(replyA); + tester.channelState?.addNewMessage(replyB); // Initial state: each thread has exactly its own reply. expect( - channel.state?.threads['parent-A']?.map((m) => m.id), + tester.channelState?.threads['parent-A']?.map((m) => m.id), equals(['reply-A']), ); expect( - channel.state?.threads['parent-B']?.map((m) => m.id), + tester.channelState?.threads['parent-B']?.map((m) => m.id), equals(['reply-B']), ); // Trigger the multi-thread batch via user.messages.deleted. - final userMessagesDeletedEvent = Event( - cid: channel.cid, + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.userMessagesDeleted, user: user1, hardDelete: false, ); - client.addEvent(userMessagesDeletedEvent); - await Future.delayed(Duration.zero); + await tester.emitEvent(userMessagesDeletedEvent); // 1) Thread membership is preserved — no cross-thread leakage. // Without the fix, replyB would leak into thread A and v.v. expect( - channel.state?.threads['parent-A']?.map((m) => m.id), + tester.channelState?.threads['parent-A']?.map((m) => m.id), equals(['reply-A']), reason: 'thread A must not contain replies from thread B', ); expect( - channel.state?.threads['parent-B']?.map((m) => m.id), + tester.channelState?.threads['parent-B']?.map((m) => m.id), equals(['reply-B']), reason: 'thread B must not contain replies from thread A', ); @@ -5030,28 +5354,28 @@ void main() { // 2) Every message authored by user-1 is soft-deleted — top-level // AND in both threads. The fix must not narrow this scope. expect( - channel.state?.messages.firstWhere((m) => m.id == 'top-1').type, + tester.channelState?.messages.firstWhere((m) => m.id == 'top-1').type, equals(MessageType.deleted), reason: 'top-level user-1 message must be deleted', ); expect( - channel.state?.threads['parent-A']?.first.type, + tester.channelState?.threads['parent-A']?.first.type, equals(MessageType.deleted), reason: 'thread A reply from user-1 must be deleted', ); expect( - channel.state?.threads['parent-B']?.first.type, + tester.channelState?.threads['parent-B']?.first.type, equals(MessageType.deleted), reason: 'thread B reply from user-1 must be deleted', ); // 3) Other users' messages are unaffected. expect( - channel.state?.messages.firstWhere((m) => m.id == 'parent-A').type, + tester.channelState?.messages.firstWhere((m) => m.id == 'parent-A').type, isNot(MessageType.deleted), ); expect( - channel.state?.messages.firstWhere((m) => m.id == 'parent-B').type, + tester.channelState?.messages.firstWhere((m) => m.id == 'parent-B').type, isNot(MessageType.deleted), ); }, @@ -5059,299 +5383,305 @@ void main() { }); group('Dispatch error isolation', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test('a throwing handler still lets the later stages apply', () async { - expect(channel.memberCount, equals(0)); - - // A message.deleted without a message throws on `event.message!` in - // the first dispatch stage. The unfiltered count refresh that runs - // after it must still apply. - client.addEvent( - Event( - cid: channel.cid, - type: EventType.messageDeleted, - channelMemberCount: 9, - ), + // A bare channel state (no config, no capabilities). + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), ); + } - await Future.delayed(Duration.zero); + channelTest( + 'a throwing handler still lets the later stages apply', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + expect(tester.channel.memberCount, equals(0)); + + // A message.deleted without a message throws on `event.message!` in + // the first dispatch stage. The unfiltered count refresh that runs + // after it must still apply. + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageDeleted, + channelMemberCount: 9, + ), + ); - expect(channel.memberCount, equals(9)); - }); + expect(tester.channel.memberCount, equals(9)); + }, + ); }); }); group('Local unread count', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - final currentUser = OwnUser(id: 'current-user-id'); - - late final client = MockStreamChatClient(); - - setUpAll(() { - when(() => client.retryPolicy).thenReturn( - RetryPolicy(shouldRetry: (_, __, ___) => false, delayFactor: Duration.zero), - ); - when(() => client.state).thenReturn(FakeClientState(currentUser: currentUser)); - when( - () => client.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); - when( - () => client.channelDeliveryReporter.reconcileDelivery(any()), - ).thenAnswer((_) async {}); - client.isLocalUnreadCountEnabled = true; - }); - - // A "livestream-like" channel: read events are disabled, both via the - // channel-type config and the current user's own capabilities. - Channel _createLivestreamChannel({ - StreamChatClient? overrideClient, - List? messages, - List? reads, + /// A "livestream-like" channel state: read events are disabled, both via the + /// channel-type config and the current user's own capabilities. + ChannelState _livestreamChannelState({ + String cid = _channelCid, + List messages = const [], + List read = const [], + List ownCapabilities = const [], // No readEvents capability. }) { - final channelState = ChannelState( - channel: ChannelModel( - id: channelId, - type: channelType, - config: ChannelConfig(readEvents: false), - ownCapabilities: const [], // No readEvents capability. + return createDefaultChannelState( + channel: createDefaultChannelModel( + cid: cid, + config: createDefaultChannelConfig(readEvents: false), + ownCapabilities: ownCapabilities, ), messages: messages, - read: reads, + read: read, ); - - final channel = Channel.fromState(overrideClient ?? client, channelState); - addTearDown(channel.dispose); - return channel; } - test( + // A message from another user that counts as unread once seeded together + // with a read state older than the message. + final countedMessage = Message( + id: 'message-1', + text: 'Hello', + user: User(id: 'other-user'), + createdAt: DateTime.utc(2024), + ); + + // The three messages test 'markUnreadByTimestamp recomputes ...' seeds. + final now = DateTime.utc(2024); + final timestampedMessages = [ + Message( + id: 'm1', + text: '1', + user: User(id: 'other-user'), + createdAt: now, + ), + Message( + id: 'm2', + text: '2', + user: User(id: 'other-user'), + createdAt: now.add(const Duration(minutes: 1)), + ), + Message( + id: 'm3', + text: '3', + user: User(id: 'other-user'), + createdAt: now.add(const Duration(minutes: 2)), + ), + ]; + + channelTest( 'increments unreadCount locally for new messages when the channel has ' 'no read events capability', - () async { - final channel = _createLivestreamChannel(); - expect(channel.state?.unreadCount, equals(0)); + channelType: _channelType, + channelId: _channelId, + isLocalUnreadCountEnabled: true, + setUp: (tester) => tester.watch(modifyResponse: (_) => _livestreamChannelState()), + body: (tester) async { + expect(tester.channelState?.unreadCount, equals(0)); final message = Message( id: 'message-1', text: 'Hello', user: User(id: 'other-user'), - createdAt: DateTime(2024, 1, 1), + createdAt: DateTime.utc(2024), ); - client.addEvent( - Event(cid: channel.cid, type: EventType.messageNew, message: message), + await tester.emitEvent( + createDefaultEvent(cid: tester.channel.cid, type: EventType.messageNew, message: message), ); - await Future.delayed(Duration.zero); - expect(channel.state?.unreadCount, equals(1)); + expect(tester.channelState?.unreadCount, equals(1)); }, ); - test( + channelTest( 'does not increment unreadCount when local unread count tracking is ' 'disabled', - () async { - final disabledClient = MockStreamChatClient(); - when(() => disabledClient.retryPolicy).thenReturn( - RetryPolicy(shouldRetry: (_, __, ___) => false), - ); - when(() => disabledClient.state).thenReturn(FakeClientState(currentUser: currentUser)); - when( - () => disabledClient.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); - // `isLocalUnreadCountEnabled` defaults to `false` on the mock. - - final channel = _createLivestreamChannel(overrideClient: disabledClient); - + channelType: _channelType, + channelId: _channelId, + // Local unread count tracking stays at its default, disabled. + isLocalUnreadCountEnabled: false, + setUp: (tester) => tester.watch(modifyResponse: (_) => _livestreamChannelState()), + body: (tester) async { final message = Message( id: 'message-1', text: 'Hello', user: User(id: 'other-user'), - createdAt: DateTime(2024, 1, 1), + createdAt: DateTime.utc(2024), ); - disabledClient.addEvent( - Event(cid: channel.cid, type: EventType.messageNew, message: message), + await tester.emitEvent( + createDefaultEvent(cid: tester.channel.cid, type: EventType.messageNew, message: message), ); - await Future.delayed(Duration.zero); - expect(channel.state?.unreadCount, equals(0)); + expect(tester.channelState?.unreadCount, equals(0)); }, ); - test('decrements unreadCount when a counted message is hard-deleted', () async { - final message = Message( - id: 'message-1', - text: 'Hello', - user: User(id: 'other-user'), - createdAt: DateTime(2024, 1, 1), - ); - final channel = _createLivestreamChannel( - messages: [message], - reads: [ - Read( - user: currentUser, - lastRead: message.createdAt.subtract(const Duration(days: 1)), + channelTest( + 'decrements unreadCount when a counted message is hard-deleted', + channelType: _channelType, + channelId: _channelId, + isLocalUnreadCountEnabled: true, + setUp: (tester) => tester.watch( + modifyResponse: (_) => _livestreamChannelState( + messages: [countedMessage], + read: [ + createDefaultRead(lastRead: countedMessage.createdAt.subtract(const Duration(days: 1))), + ], + ), + ), + body: (tester) async { + tester.channelState!.unreadCount = 1; + expect(tester.channelState?.unreadCount, equals(1)); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageDeleted, + message: countedMessage, + hardDelete: true, ), - ], - ); - channel.state!.unreadCount = 1; - expect(channel.state?.unreadCount, equals(1)); + ); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.messageDeleted, - message: message, - hardDelete: true, - ), - ); - await Future.delayed(Duration.zero); + expect(tester.channelState?.unreadCount, equals(0)); + }, + ); - expect(channel.state?.unreadCount, equals(0)); - }); + channelTest( + 'does not decrement unreadCount when a message is soft-deleted', + channelType: _channelType, + channelId: _channelId, + isLocalUnreadCountEnabled: true, + setUp: (tester) => tester.watch( + modifyResponse: (_) => _livestreamChannelState( + messages: [countedMessage], + read: [ + createDefaultRead(lastRead: countedMessage.createdAt.subtract(const Duration(days: 1))), + ], + ), + ), + body: (tester) async { + tester.channelState!.unreadCount = 1; - test('does not decrement unreadCount when a message is soft-deleted', () async { - final message = Message( - id: 'message-1', - text: 'Hello', - user: User(id: 'other-user'), - createdAt: DateTime(2024, 1, 1), - ); - final channel = _createLivestreamChannel( - messages: [message], - reads: [ - Read( - user: currentUser, - lastRead: message.createdAt.subtract(const Duration(days: 1)), + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageDeleted, + message: countedMessage, + hardDelete: false, ), - ], - ); - channel.state!.unreadCount = 1; - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.messageDeleted, - message: message, - hardDelete: false, - ), - ); - await Future.delayed(Duration.zero); + ); - expect(channel.state?.unreadCount, equals(1)); - }); + expect(tester.channelState?.unreadCount, equals(1)); + }, + ); - test( + channelTest( 'markRead resets unreadCount locally without making a network request', - () async { - final channel = _createLivestreamChannel(); - channel.state!.unreadCount = 3; - expect(channel.state?.unreadCount, equals(3)); - - await expectLater(channel.markRead(), completes); - - expect(channel.state?.unreadCount, equals(0)); - verifyNever( - () => client.markChannelRead( - any(), - any(), - messageId: any(named: 'messageId'), - ), + channelType: _channelType, + channelId: _channelId, + isLocalUnreadCountEnabled: true, + setUp: (tester) => tester.watch(modifyResponse: (_) => _livestreamChannelState()), + body: (tester) async { + tester.channelState!.unreadCount = 3; + expect(tester.channelState?.unreadCount, equals(3)); + + await expectLater(tester.channel.markRead(), completes); + + expect(tester.channelState?.unreadCount, equals(0)); + tester.verifyNeverCalled( + (api) => api.channel.markRead(any(), any(), messageId: any(named: 'messageId')), ); }, ); - test( + channelTest( 'markUnreadByTimestamp recomputes unreadCount locally without making a ' 'network request', - () async { - final now = DateTime(2024, 1, 1); - final messages = [ - Message( - id: 'm1', - text: '1', - user: User(id: 'other-user'), - createdAt: now, - ), - Message( - id: 'm2', - text: '2', - user: User(id: 'other-user'), - createdAt: now.add(const Duration(minutes: 1)), - ), - Message( - id: 'm3', - text: '3', - user: User(id: 'other-user'), - createdAt: now.add(const Duration(minutes: 2)), - ), - ]; - final channel = _createLivestreamChannel( - messages: messages, - reads: [ - Read(user: currentUser, lastRead: now.add(const Duration(minutes: 5))), - ], - ); - expect(channel.state?.unreadCount, equals(0)); + channelType: _channelType, + channelId: _channelId, + isLocalUnreadCountEnabled: true, + setUp: (tester) => tester.watch( + modifyResponse: (_) => _livestreamChannelState( + messages: timestampedMessages, + read: [createDefaultRead(lastRead: now.add(const Duration(minutes: 5)))], + ), + ), + body: (tester) async { + expect(tester.channelState?.unreadCount, equals(0)); await expectLater( - channel.markUnreadByTimestamp(now.add(const Duration(seconds: 30))), + tester.channel.markUnreadByTimestamp(now.add(const Duration(seconds: 30))), completes, ); // Only m2 and m3 were created after the given timestamp. - expect(channel.state?.unreadCount, equals(2)); - verifyNever( - () => client.markChannelUnreadByTimestamp(any(), any(), any()), - ); + expect(tester.channelState?.unreadCount, equals(2)); + registerFallbackValue(DateTime.utc(2024)); + tester.verifyNeverCalled((api) => api.channel.markUnreadByTimestamp(any(), any(), any())); }, ); - test( + channelTest( 'markUnread throws when the message is not locally known', - () async { - final channel = _createLivestreamChannel(); - + channelType: _channelType, + channelId: _channelId, + isLocalUnreadCountEnabled: true, + setUp: (tester) => tester.watch(modifyResponse: (_) => _livestreamChannelState()), + body: (tester) async { await expectLater( - channel.markUnread('unknown-message-id'), + tester.channel.markUnread('unknown-message-id'), throwsA(isA()), ); - verifyNever( - () => client.markChannelUnread(any(), any(), any()), - ); + tester.verifyNeverCalled((api) => api.channel.markUnread(any(), any(), any())); }, ); - test( + channelTest( 'markRead reconciles pending delivery receipts', - () async { - final channel = _createLivestreamChannel(); - channel.state!.unreadCount = 2; + channelType: _channelType, + channelId: _channelId, + isLocalUnreadCountEnabled: true, + // The delivery reporter is real in this harness, so the reconciliation + // is observed through its effect: the pending receipt for the channel is + // dropped and never reaches the API. A receipt only becomes pending on a + // channel with the deliveryEvents capability and a read state older than + // the incoming message. + setUp: (tester) => tester.watch( + modifyResponse: (_) => _livestreamChannelState( + ownCapabilities: const [ChannelCapability.deliveryEvents], + read: [createDefaultRead()], + ), + ), + body: (tester) async { + registerFallbackValue([]); + tester.mockApi( + (api) => api.channel.markChannelsDelivered(any()), + result: createDefaultEmptyResponse(), + ); + + // A new message queues a delivery receipt behind the reporter's 1s + // trailing throttle. + final message = Message( + id: 'message-1', + text: 'Hello', + user: User(id: 'other-user'), + createdAt: DateTime.utc(2024), + ); + await tester.emitEvent( + createDefaultEvent(cid: tester.channel.cid, type: EventType.messageNew, message: message), + ); - await expectLater(channel.markRead(), completes); + await expectLater(tester.channel.markRead(), completes); - verify( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).called(1); + // Read supersedes delivered: once the throttle window elapses, the + // reconciled receipt must not be reported. + await Future.delayed(const Duration(milliseconds: 1100)); + tester.verifyNeverCalled((api) => api.channel.markChannelsDelivered(any())); }, ); group('local read boundary anchors', () { - final start = DateTime(2024, 1, 1); + final start = DateTime.utc(2024); final messages = [ Message( id: 'm1', @@ -5373,80 +5703,94 @@ void main() { ), ]; - test( + ChannelState seedAnchoredChannel(ChannelState _, {String cid = _channelCid}) { + return _livestreamChannelState( + cid: cid, + messages: messages, + read: [createDefaultRead(lastRead: start.add(const Duration(minutes: 5)))], + ); + } + + channelTest( 'markUnread is inclusive of the anchor and points lastReadMessageId at ' 'the previous message', - () async { - final channel = _createLivestreamChannel( - messages: messages, - reads: [ - Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), - ], - ); - - await expectLater(channel.markUnread('m2'), completes); + channelType: _channelType, + channelId: _channelId, + isLocalUnreadCountEnabled: true, + setUp: (tester) => tester.watch(modifyResponse: seedAnchoredChannel), + body: (tester) async { + await expectLater(tester.channel.markUnread('m2'), completes); // m2 (the anchor) and m3 are unread; m1 stays read. - expect(channel.state?.unreadCount, equals(2)); - expect(channel.state?.currentUserRead?.lastReadMessageId, equals('m1')); - verifyNever(() => client.markChannelUnread(any(), any(), any())); + expect(tester.channelState?.unreadCount, equals(2)); + expect(tester.channelState?.currentUserRead?.lastReadMessageId, equals('m1')); + tester.verifyNeverCalled((api) => api.channel.markUnread(any(), any(), any())); }, ); - test( + channelTest( 'markUnread leaves lastReadMessageId null when the anchor is the oldest ' 'known message', - () async { - final channel = _createLivestreamChannel( - messages: messages, - reads: [ - Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), - ], - ); - - await expectLater(channel.markUnread('m1'), completes); - - expect(channel.state?.unreadCount, equals(3)); - expect(channel.state?.currentUserRead?.lastReadMessageId, isNull); + channelType: _channelType, + channelId: _channelId, + isLocalUnreadCountEnabled: true, + setUp: (tester) => tester.watch(modifyResponse: seedAnchoredChannel), + body: (tester) async { + await expectLater(tester.channel.markUnread('m1'), completes); + + expect(tester.channelState?.unreadCount, equals(3)); + expect(tester.channelState?.currentUserRead?.lastReadMessageId, isNull); }, ); - test( + channelTest( 'markUnreadByTimestamp is exclusive of the boundary and points ' 'lastReadMessageId at the newest message at or before it', - () async { - final channel = _createLivestreamChannel( - messages: messages, - reads: [ - Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), - ], - ); - + channelType: _channelType, + channelId: _channelId, + isLocalUnreadCountEnabled: true, + setUp: (tester) => tester.watch(modifyResponse: seedAnchoredChannel), + body: (tester) async { // Exactly m2's createdAt: m2 stays read, only m3 becomes unread. - await expectLater(channel.markUnreadByTimestamp(messages[1].createdAt), completes); + await expectLater(tester.channel.markUnreadByTimestamp(messages[1].createdAt), completes); - expect(channel.state?.unreadCount, equals(1)); - expect(channel.state?.currentUserRead?.lastReadMessageId, equals('m2')); - verifyNever(() => client.markChannelUnreadByTimestamp(any(), any(), any())); + expect(tester.channelState?.unreadCount, equals(1)); + expect(tester.channelState?.currentUserRead?.lastReadMessageId, equals('m2')); + registerFallbackValue(DateTime.utc(2024)); + tester.verifyNeverCalled((api) => api.channel.markUnreadByTimestamp(any(), any(), any())); }, ); - test( + channelTest( 'markUnread(id) and markUnreadByTimestamp(createdAt) intentionally ' 'differ by the anchor message', - () async { - final byId = _createLivestreamChannel( - messages: messages, - reads: [ - Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), - ], - ); - final byTimestamp = _createLivestreamChannel( - messages: messages, - reads: [ - Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), - ], + channelType: _channelType, + channelId: _channelId, + isLocalUnreadCountEnabled: true, + setUp: (tester) => tester.watch(modifyResponse: seedAnchoredChannel), + body: (tester) async { + // The harness provides one channel per test; the second, + // identically-seeded channel is watched by hand. + const otherChannelId = 'other-channel-id'; + final byId = tester.channel; + final byTimestamp = tester.client.channel(_channelType, id: otherChannelId); + addTearDown(byTimestamp.dispose); + + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: otherChannelId, + channelData: byTimestamp.extraData, + state: true, + watch: true, + presence: false, + ), + result: seedAnchoredChannel( + createDefaultChannelState(), + cid: '$_channelType:$otherChannelId', + ), ); + await byTimestamp.watch(); await byId.markUnread('m2'); await byTimestamp.markUnreadByTimestamp(messages[1].createdAt); @@ -5465,235 +5809,218 @@ void main() { ); }); - test( + channelTest( 'server payloads do not clobber the locally-tracked read state', - () async { - final channel = _createLivestreamChannel(); - channel.state!.unreadCount = 5; + channelType: _channelType, + channelId: _channelId, + isLocalUnreadCountEnabled: true, + setUp: (tester) => tester.watch(modifyResponse: (_) => _livestreamChannelState()), + body: (tester) async { + tester.channelState!.unreadCount = 5; final serverRead = Read( - user: currentUser, - lastRead: DateTime.now(), + user: tester.currentUser!, + lastRead: DateTime.utc(2024, 1, 2), unreadMessages: 0, ); - channel.state!.updateChannelStateFromServer( - channel.state!.channelState.copyWith(read: [serverRead]), + tester.channelState!.updateChannelStateFromServer( + tester.channelState!.channelState.copyWith(read: [serverRead]), ); - expect(channel.state?.unreadCount, equals(5)); + expect(tester.channelState?.unreadCount, equals(5)); }, ); - test( + channelTest( 'local (non-remote) state updates are not affected by the server-merge ' 'guard', - () async { - final channel = _createLivestreamChannel(); - channel.state!.unreadCount = 5; + channelType: _channelType, + channelId: _channelId, + isLocalUnreadCountEnabled: true, + setUp: (tester) => tester.watch(modifyResponse: (_) => _livestreamChannelState()), + body: (tester) async { + tester.channelState!.unreadCount = 5; // A plain local mutation (via updateChannelState, not // updateChannelStateFromServer) should still be able to change the // locally-tracked read state. - await expectLater(channel.markRead(), completes); + await expectLater(tester.channel.markRead(), completes); - expect(channel.state?.unreadCount, equals(0)); + expect(tester.channelState?.unreadCount, equals(0)); }, ); }); group('updateChannelState identity guard', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); - - setUpAll(() { - when(() => client.retryPolicy).thenReturn( - RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ), - ); - when(() => client.state).thenReturn(FakeClientState()); - when( - () => client.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); - }); + // Pinned base timestamp for the seeded messages (m1, m2 +1s, m3 +2s). + final _baseCreatedAt = DateTime.utc(2021, 3); - Channel _seededChannel() { - final base = _generateChannelState(channelId, channelType); - final now = DateTime.now(); - final seeded = base.copyWith( + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), messages: [ - Message(id: 'm1', text: '1', createdAt: now), - Message(id: 'm2', text: '2', createdAt: now.add(const Duration(seconds: 1))), - Message(id: 'm3', text: '3', createdAt: now.add(const Duration(seconds: 2))), + Message(id: 'm1', text: '1', createdAt: _baseCreatedAt), + Message(id: 'm2', text: '2', createdAt: _baseCreatedAt.add(const Duration(seconds: 1))), + Message(id: 'm3', text: '3', createdAt: _baseCreatedAt.add(const Duration(seconds: 2))), ], ); - return Channel.fromState(client, seeded); } - test( + channelTest( 'preserves messages reference when updatedState.messages is null', - () { - final channel = _seededChannel(); - addTearDown(channel.dispose); - - final before = channel.state!.messages; - channel.state!.updateChannelState( - ChannelState(channel: channel.state!.channelState.channel), + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final before = tester.channelState!.messages; + tester.channelState!.updateChannelState( + ChannelState(channel: tester.channelState!.channelState.channel), ); - final after = channel.state!.messages; + final after = tester.channelState!.messages; expect(identical(before, after), isTrue); }, ); - test( + channelTest( 'preserves messages reference when updatedState.messages is identical', - () { - final channel = _seededChannel(); - addTearDown(channel.dispose); - - final before = channel.state!.messages; + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final before = tester.channelState!.messages; // copyWith without messages keeps the same `messages` reference, so // updateChannelState should hit the identity-guard fast path. - channel.state!.updateChannelState( - channel.state!.channelState.copyWith( + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith( read: [ Read( user: User(id: 'me'), - lastRead: DateTime.now(), + lastRead: DateTime.utc(2021, 3, 2), unreadMessages: 1, ), ], ), ); - final after = channel.state!.messages; + final after = tester.channelState!.messages; expect(identical(before, after), isTrue); }, ); - test( + channelTest( 'still merges messages when updatedState.messages is a different list', - () { - final channel = _seededChannel(); - addTearDown(channel.dispose); - + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final newMessage = Message( id: 'm4', text: '4', - createdAt: DateTime.now().add(const Duration(seconds: 10)), + createdAt: _baseCreatedAt.add(const Duration(seconds: 10)), ); - channel.state!.updateChannelState( + tester.channelState!.updateChannelState( ChannelState( - channel: channel.state!.channelState.channel, + channel: tester.channelState!.channelState.channel, messages: [newMessage], ), ); expect( - channel.state!.messages.map((m) => m.id), + tester.channelState!.messages.map((m) => m.id), ['m1', 'm2', 'm3', 'm4'], ); }, ); - test('cold-path merge interleaves new messages in sorted order', () { - final channel = _seededChannel(); - addTearDown(channel.dispose); - - final base = channel.state!.messages.first.createdAt; - // Incoming list is sorted ascending by createdAt and slots between - // the existing m1, m2, m3. - final incoming = [ - Message( - id: 'm1.5', - text: 'between m1 and m2', - createdAt: base.add(const Duration(milliseconds: 500)), - ), - Message( - id: 'm2.5', - text: 'between m2 and m3', - createdAt: base.add(const Duration(milliseconds: 1500)), - ), - ]; - channel.state!.updateChannelState( - ChannelState( - channel: channel.state!.channelState.channel, - messages: incoming, - ), - ); - - expect( - channel.state!.messages.map((m) => m.id), - ['m1', 'm1.5', 'm2', 'm2.5', 'm3'], - ); - }); + channelTest( + 'cold-path merge interleaves new messages in sorted order', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final base = tester.channelState!.messages.first.createdAt; + // Incoming list is sorted ascending by createdAt and slots between + // the existing m1, m2, m3. + final incoming = [ + Message( + id: 'm1.5', + text: 'between m1 and m2', + createdAt: base.add(const Duration(milliseconds: 500)), + ), + Message( + id: 'm2.5', + text: 'between m2 and m3', + createdAt: base.add(const Duration(milliseconds: 1500)), + ), + ]; + tester.channelState!.updateChannelState( + ChannelState( + channel: tester.channelState!.channelState.channel, + messages: incoming, + ), + ); - test('cold-path merge runs syncWith on overlapping ids', () { - final channel = _seededChannel(); - addTearDown(channel.dispose); + expect( + tester.channelState!.messages.map((m) => m.id), + ['m1', 'm1.5', 'm2', 'm2.5', 'm3'], + ); + }, + ); - final localStamp = DateTime.now(); - // Seed m2 with a localCreatedAt that the incoming version doesn't - // carry, so we can verify syncWith fired during the merge. - channel.state!.updateMessage( - Message( - id: 'm2', - text: '2', - createdAt: channel.state!.messages.firstWhere((m) => m.id == 'm2').createdAt, - ).copyWith(localCreatedAt: localStamp), - ); + channelTest( + 'cold-path merge runs syncWith on overlapping ids', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final localStamp = DateTime.utc(2021, 3, 5); + // Seed m2 with a localCreatedAt that the incoming version doesn't + // carry, so we can verify syncWith fired during the merge. + tester.channelState!.updateMessage( + Message( + id: 'm2', + text: '2', + createdAt: tester.channelState!.messages.firstWhere((m) => m.id == 'm2').createdAt, + ).copyWith(localCreatedAt: localStamp), + ); - final incoming = [ - Message( - id: 'm2', - text: '2 (server)', - createdAt: channel.state!.messages.firstWhere((m) => m.id == 'm2').createdAt, - ), - ]; - channel.state!.updateChannelState( - ChannelState( - channel: channel.state!.channelState.channel, - messages: incoming, - ), - ); + final incoming = [ + Message( + id: 'm2', + text: '2 (server)', + createdAt: tester.channelState!.messages.firstWhere((m) => m.id == 'm2').createdAt, + ), + ]; + tester.channelState!.updateChannelState( + ChannelState( + channel: tester.channelState!.channelState.channel, + messages: incoming, + ), + ); - final m2 = channel.state!.messages.firstWhere((m) => m.id == 'm2'); - expect(m2.text, '2 (server)'); - // Local-only field carried over by syncWith during the merge. - expect(m2.localCreatedAt, localStamp); - }); + final m2 = tester.channelState!.messages.firstWhere((m) => m.id == 'm2'); + expect(m2.text, '2 (server)'); + // Local-only field carried over by syncWith during the merge. + expect(m2.localCreatedAt, localStamp); + }, + ); }); group('updateMessage quoted-rewrite', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); - - setUpAll(() { - when(() => client.retryPolicy).thenReturn( - RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ), + ChannelState Function(ChannelState) _seedChannel({required List messages}) { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + messages: messages, ); - when(() => client.state).thenReturn(FakeClientState()); - when( - () => client.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); - }); - - Channel _seededChannel({required List messages}) { - final base = _generateChannelState(channelId, channelType); - return Channel.fromState(client, base.copyWith(messages: messages)); } - test( + channelTest( 'rewrites quotedMessage on every quoter when target is deleted', - () { - final now = DateTime.now(); + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final now = DateTime.utc(2021, 3); final target = Message(id: 'target', text: 'hi', createdAt: now); final quoter1 = Message( id: 'q1', @@ -5715,18 +6042,19 @@ void main() { createdAt: now.add(const Duration(seconds: 3)), ); - final channel = _seededChannel(messages: [target, quoter1, unrelated, quoter2]); - addTearDown(channel.dispose); + await tester.watch( + modifyResponse: _seedChannel(messages: [target, quoter1, unrelated, quoter2]), + ); - final unrelatedBefore = channel.state!.messages.firstWhere((m) => m.id == 'u1'); + final unrelatedBefore = tester.channelState!.messages.firstWhere((m) => m.id == 'u1'); final deleted = target.copyWith( type: MessageType.deleted, deletedAt: now.add(const Duration(seconds: 5)), ); - channel.state!.updateMessage(deleted); + tester.channelState!.updateMessage(deleted); - final after = channel.state!.messages; + final after = tester.channelState!.messages; final q1After = after.firstWhere((m) => m.id == 'q1'); final q2After = after.firstWhere((m) => m.id == 'q2'); final uAfter = after.firstWhere((m) => m.id == 'u1'); @@ -5740,10 +6068,12 @@ void main() { }, ); - test( + channelTest( 'preserves messages reference when no message quotes the deleted one', - () { - final now = DateTime.now(); + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final now = DateTime.utc(2021, 3); final target = Message(id: 'target', text: 'hi', createdAt: now); final unrelated = Message( id: 'u1', @@ -5751,28 +6081,29 @@ void main() { createdAt: now.add(const Duration(seconds: 1)), ); - final channel = _seededChannel(messages: [target, unrelated]); - addTearDown(channel.dispose); + await tester.watch(modifyResponse: _seedChannel(messages: [target, unrelated])); final deleted = target.copyWith( type: MessageType.deleted, deletedAt: now.add(const Duration(seconds: 5)), ); - channel.state!.updateMessage(deleted); + tester.channelState!.updateMessage(deleted); // No message quotes `target`, so `updateWhere` short-circuits and the // remaining messages keep their identities (only `target` itself was // replaced by `sortedUpsert`). - final unrelatedAfter = channel.state!.messages.firstWhere((m) => m.id == 'u1'); + final unrelatedAfter = tester.channelState!.messages.firstWhere((m) => m.id == 'u1'); expect(identical(unrelatedAfter, unrelated), isTrue); }, ); - test( + channelTest( 'does not rewrite quotes when an existing quoted target is updated ' 'without being deleted', - () { - final now = DateTime.now(); + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final now = DateTime.utc(2021, 3); final target = Message(id: 'target', text: 'original', createdAt: now); final quoter = Message( id: 'q1', @@ -5782,15 +6113,14 @@ void main() { createdAt: now.add(const Duration(seconds: 1)), ); - final channel = _seededChannel(messages: [target, quoter]); - addTearDown(channel.dispose); + await tester.watch(modifyResponse: _seedChannel(messages: [target, quoter])); - final quoterBefore = channel.state!.messages.firstWhere((m) => m.id == 'q1'); + final quoterBefore = tester.channelState!.messages.firstWhere((m) => m.id == 'q1'); // Plain text update — not a deletion. - channel.state!.updateMessage(target.copyWith(text: 'edited')); + tester.channelState!.updateMessage(target.copyWith(text: 'edited')); - final quoterAfter = channel.state!.messages.firstWhere((m) => m.id == 'q1'); + final quoterAfter = tester.channelState!.messages.firstWhere((m) => m.id == 'q1'); // `updateWhere` is gated on `message.isDeleted`, so the quoter must keep // its identity (no allocation, no quoted-message overwrite). expect(identical(quoterAfter, quoterBefore), isTrue); @@ -5799,39 +6129,25 @@ void main() { }); group('Message enrichment preservation on merge', () { - late final client = MockStreamChatClient(); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUpAll(() { - registerFallbackValue(FakeMessage()); - registerFallbackValue([]); - - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - }); - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); + } - tearDown(() { - channel.dispose(); - clearInteractions(client); - }); + Event _updateMessageEvent(Message message) => createDefaultEvent( + type: EventType.messageUpdated, + cid: _channelCid, + message: message, + ); - test( + channelTest( 'preserves the `poll` on a quotedMessage when the server omits it during ' 're-sync (regression: poll quote disappears after foregrounding)', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final pollUser = User(id: 'poll-author'); final poll = Poll( id: 'poll-1', @@ -5862,8 +6178,8 @@ void main() { // Seed channel state with the fully-enriched messages (mirrors what // the local DB load produces). - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( messages: [pollMessage, replyToPoll], ), ); @@ -5880,13 +6196,13 @@ void main() { ); final reSyncedReply = replyToPoll.copyWith(quotedMessage: strippedPollSnapshot); - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( messages: [reSyncedReply], ), ); - final mergedReply = channel.state?.messages.firstWhere((it) => it.id == replyToPoll.id); + final mergedReply = tester.channelState?.messages.firstWhere((it) => it.id == replyToPoll.id); expect(mergedReply, isNotNull); expect(mergedReply!.quotedMessage, isNotNull); @@ -5897,11 +6213,14 @@ void main() { }, ); - test( + channelTest( 'preserves a nested quotedMessage (poll) two levels deep when the ' 'server omits it during re-sync (regression: quote-of-quote of a poll ' 'disappears completely after foregrounding)', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final pollUser = User(id: 'poll-author'); final poll = Poll( id: 'poll-2', @@ -5939,8 +6258,8 @@ void main() { createdAt: DateTime.utc(2026, 4, 29, 12), ); - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( messages: [pollMessage, replyToPoll, replyToReply], ), ); @@ -5962,14 +6281,14 @@ void main() { final reSyncedReplyA = replyToPoll.copyWith(quotedMessage: strippedPollSnapshot); final reSyncedReplyB = replyToReply.copyWith(quotedMessage: strippedReplyA); - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( messages: [pollMessage, reSyncedReplyA, reSyncedReplyB], ), ); - final mergedReplyA = channel.state?.messages.firstWhere((it) => it.id == replyToPoll.id); - final mergedReplyB = channel.state?.messages.firstWhere((it) => it.id == replyToReply.id); + final mergedReplyA = tester.channelState?.messages.firstWhere((it) => it.id == replyToPoll.id); + final mergedReplyB = tester.channelState?.messages.firstWhere((it) => it.id == replyToReply.id); // First-level quote (reply A's quote of the poll) must keep the poll. expect(mergedReplyA?.quotedMessage?.poll, isNotNull); @@ -5986,10 +6305,13 @@ void main() { }, ); - test( + channelTest( 'still preserves quotedMessage when the updated payload has no ' 'quoted_message at all (existing behavior should not regress)', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final pollUser = User(id: 'poll-author'); final poll = Poll( id: 'poll-3', @@ -6018,8 +6340,8 @@ void main() { createdAt: DateTime.utc(2026, 4, 29, 11), ); - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( messages: [pollMessage, replyToPoll], ), ); @@ -6034,13 +6356,13 @@ void main() { createdAt: replyToPoll.createdAt, ); - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( messages: [reSyncedReply], ), ); - final mergedReply = channel.state?.messages.firstWhere((it) => it.id == replyToPoll.id); + final mergedReply = tester.channelState?.messages.firstWhere((it) => it.id == replyToPoll.id); expect(mergedReply, isNotNull); expect(mergedReply!.text, 'Definitely beach (edited)'); @@ -6049,11 +6371,14 @@ void main() { }, ); - test( + channelTest( 'preserves the top-level `poll` when the server emits a `message.updated`' ' that omits the `poll` object (regression: poll disappears from the ' 'parent message after a thread reply is added)', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final pollUser = User(id: 'poll-author'); final poll = Poll( id: 'poll-thread', @@ -6075,8 +6400,8 @@ void main() { ); // Seed channel state with the fully-enriched parent poll message. - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( messages: [pollMessage], ), ); @@ -6095,18 +6420,9 @@ void main() { updatedAt: DateTime.utc(2026, 4, 29, 11), ); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.messageUpdated, - message: strippedParentUpdate, - ), - ); - - // Wait for the event to be processed. - await Future.delayed(Duration.zero); + await tester.emitEvent(_updateMessageEvent(strippedParentUpdate)); - final merged = channel.state?.messages.firstWhere((it) => it.id == pollMessage.id); + final merged = tester.channelState?.messages.firstWhere((it) => it.id == pollMessage.id); // Parent poll message must remain in the channel state after a thread reply. expect(merged, isNotNull); @@ -6121,11 +6437,14 @@ void main() { }, ); - test( + channelTest( 'still uses the updated `poll` when the server includes one in ' '`message.updated` (poll edits should not be reverted to the locally ' 'cached version)', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final pollUser = User(id: 'poll-author'); final poll = Poll( id: 'poll-edit', @@ -6144,8 +6463,8 @@ void main() { createdAt: DateTime.utc(2026, 4, 29, 10), ); - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( messages: [pollMessage], ), ); @@ -6153,17 +6472,9 @@ void main() { final updatedPoll = poll.copyWith(name: 'Edited name'); final updatedParent = pollMessage.copyWith(poll: updatedPoll, updatedAt: DateTime.utc(2026, 4, 29, 12)); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.messageUpdated, - message: updatedParent, - ), - ); - - await Future.delayed(Duration.zero); + await tester.emitEvent(_updateMessageEvent(updatedParent)); - final merged = channel.state?.messages.firstWhere((it) => it.id == pollMessage.id); + final merged = tester.channelState?.messages.firstWhere((it) => it.id == pollMessage.id); // Server-echoed poll must override the locally cached one — poll edits // should not be reverted by the local-fallback merge. @@ -6173,30 +6484,3 @@ void main() { ); }); } - -// region Test Helpers - -ChannelState _generateChannelState( - String channelId, - String channelType, { - DateTime? lastMessageAt, - List? ownCapabilities, - 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, - ownCapabilities: ownCapabilities, - lastMessageAt: lastMessageAt, - ); - return ChannelState(channel: channel); -} - -// endregion diff --git a/packages/stream_chat/test/src/client/channel/channel_event_handler_test.dart b/packages/stream_chat/test/src/client/channel/channel_event_handler_test.dart index ff068dd199..d8bcf4da9a 100644 --- a/packages/stream_chat/test/src/client/channel/channel_event_handler_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_event_handler_test.dart @@ -1,13 +1,11 @@ // ignore_for_file: cascade_invocations -import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/src/client/channel/channel_event_handler.dart'; import 'package:stream_chat/src/client/channel/channel_state_mutations.dart'; import 'package:stream_chat/stream_chat.dart'; -import 'package:test/test.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; -import '../../fakes.dart'; -import '../../mocks.dart'; +import '../../mocks.dart' show FakeClientState, MockStreamChatClient, MockStreamChatClientWithPersistence; class MockChannel extends Mock implements Channel {} @@ -46,13 +44,10 @@ void main() { final otherUser = User(id: 'other-user'); setUpAll(() { - registerFallbackValue(FakeMessage()); - registerFallbackValue(FakeUser()); - registerFallbackValue(FakeEvent()); + registerChatFallbackValues(); registerFallbackValue(FakeDraft()); registerFallbackValue(FakeReaction()); registerFallbackValue(FakePoll()); - registerFallbackValue(FakePollVote()); registerFallbackValue(FakeMember()); registerFallbackValue(FakeMessageReminder()); registerFallbackValue(FakeLocation()); diff --git a/packages/stream_chat/test/src/client/channel/channel_read_helper_test.dart b/packages/stream_chat/test/src/client/channel/channel_read_helper_test.dart index 4d427345f8..ac1b5561cc 100644 --- a/packages/stream_chat/test/src/client/channel/channel_read_helper_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_read_helper_test.dart @@ -1,86 +1,72 @@ -import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/stream_chat.dart'; -import 'package:test/test.dart'; - -import '../../fakes.dart'; -import '../../mocks.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; void main() { - group('ChannelReadHelper', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); - - // A date in the distant past (Unix epoch), useful for representing old dates - final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); - - setUpAll(() { - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - - // client logger - }); - - test('userReadOf should return read for specific user', () { - final now = DateTime.now(); - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - - final reads = [ - Read(user: user1, lastRead: now), - Read(user: user2, lastRead: now.add(const Duration(minutes: 1))), - ]; - - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - channel.state!.updateChannelState( - ChannelState(channel: channelState.channel, read: reads), - ); - - final user1Read = channel.state!.userReadOf(userId: 'user-1'); - expect(user1Read, isNotNull); - expect(user1Read!.user.id, 'user-1'); - expect(user1Read.lastRead, now); - - final user2Read = channel.state!.userReadOf(userId: 'user-2'); - expect(user2Read, isNotNull); - expect(user2Read!.user.id, 'user-2'); + // A date in the distant past (Unix epoch), useful for representing old dates. + final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + + final sender = createDefaultUser(id: 'sender-id', name: 'Sender'); + final user1 = createDefaultUser(id: 'user-1', name: 'User 1'); + final user2 = createDefaultUser(id: 'user-2', name: 'User 2'); + final user3 = createDefaultUser(id: 'user-3', name: 'User 3'); + final user4 = createDefaultUser(id: 'user-4', name: 'User 4'); + + // The message all read/delivery checks are evaluated against; reads use + // timestamps relative to its creation time. + final messageSentAt = testCreatedAt; + final message = createDefaultMessage( + id: 'msg-1', + text: 'Test message', + user: sender, + createdAt: messageSentAt, + ); - final nonExistentRead = channel.state!.userReadOf(userId: 'user-3'); - expect(nonExistentRead, isNull); - }); + // ============================================================ + // FEATURE: User Reads + // ============================================================ - test('userReadOf should return null when userId is null', () { - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + group('ChannelReadHelper', () { + channelTest( + 'userReadOf should return read for specific user', + setUp: (tester) => tester.watch( + modifyResponse: (state) => state.copyWith( + read: [ + createDefaultRead(user: user1, lastRead: messageSentAt), + createDefaultRead(user: user2, lastRead: messageSentAt.add(const Duration(minutes: 1))), + ], + ), + ), + body: (tester) async { + final user1Read = tester.channelState!.userReadOf(userId: 'user-1'); + expect(user1Read, isNotNull); + expect(user1Read!.user.id, 'user-1'); + expect(user1Read.lastRead, messageSentAt); + + final user2Read = tester.channelState!.userReadOf(userId: 'user-2'); + expect(user2Read, isNotNull); + expect(user2Read!.user.id, 'user-2'); + + final nonExistentRead = tester.channelState!.userReadOf(userId: 'user-3'); + expect(nonExistentRead, isNull); + }, + ); - final read = channel.state!.userReadOf(userId: null); - expect(read, isNull); - }); + channelTest( + 'userReadOf should return null when userId is null', + setUp: (tester) => tester.watch(), + body: (tester) async { + final read = tester.channelState!.userReadOf(userId: null); + expect(read, isNull); + }, + ); - test( + channelTest( 'userReadStreamOf should emit read updates for specific user', - () async { - final now = DateTime.now(); - final user1 = User(id: 'user-1', name: 'User 1'); - - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + setUp: (tester) => tester.watch(), + body: (tester) async { + final readStream = tester.channelState!.userReadStreamOf(userId: 'user-1'); - final readStream = channel.state!.userReadStreamOf(userId: 'user-1'); - - expectLater( + final readEmitted = expectLater( readStream, emitsInOrder([ isNull, // initial state @@ -89,218 +75,143 @@ void main() { ); // Update with read - channel.state!.updateChannelState( + tester.channelState!.updateChannelState( ChannelState( - channel: channelState.channel, - read: [Read(user: user1, lastRead: now)], + read: [createDefaultRead(user: user1, lastRead: messageSentAt)], ), ); + + await readEmitted; }, ); - - test('readsOf should return reads that have marked message as read', () { - final now = DateTime.now(); - final sender = User(id: 'sender-id', name: 'Sender'); - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - final user3 = User(id: 'user-3', name: 'User 3'); - - final message = Message( - id: 'msg-1', - text: 'Test message', - user: sender, - createdAt: now, - ); - - final reads = [ - // user1 has read the message - Read(user: user1, lastRead: now.add(const Duration(seconds: 1))), - // user2 has not read the message yet - Read(user: user2, lastRead: distantPast), - // user3 has read the message - Read(user: user3, lastRead: now.add(const Duration(seconds: 2))), - // sender should be excluded - Read(user: sender, lastRead: now.add(const Duration(seconds: 10))), - ]; - - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - channel.state!.updateChannelState( - ChannelState(channel: channelState.channel, read: reads), - ); - - final messageReads = channel.state!.readsOf(message: message); - expect(messageReads.length, 2); - expect(messageReads.map((r) => r.user.id), containsAll(['user-1', 'user-3'])); - expect(messageReads.map((r) => r.user.id), isNot(contains('user-2'))); - expect(messageReads.map((r) => r.user.id), isNot(contains('sender-id'))); - }); - - test('readsOfStream should emit read updates for a message', () async { - final now = DateTime.now(); - final sender = User(id: 'sender-id', name: 'Sender'); - final user1 = User(id: 'user-1', name: 'User 1'); - - final message = Message( - id: 'msg-1', - text: 'Test message', - user: sender, - createdAt: now, - ); - - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final readsStream = channel.state!.readsOfStream(message: message); - - expectLater( - readsStream, - emitsInOrder([ - isEmpty, // initial state - hasLength(1), // after adding read - ]), - ); - - // Update with read - channel.state!.updateChannelState( - ChannelState( - channel: channelState.channel, - read: [Read(user: user1, lastRead: now.add(const Duration(seconds: 1)))], - ), - ); - }); - - test('deliveriesOf should return reads that have delivered the message', () { - final now = DateTime.now(); - final sender = User(id: 'sender-id', name: 'Sender'); - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - final user3 = User(id: 'user-3', name: 'User 3'); - final user4 = User(id: 'user-4', name: 'User 4'); - - final message = Message( - id: 'msg-1', - text: 'Test message', - user: sender, - createdAt: now, - ); - - final reads = [ - // user1 has delivered the message - Read( - user: user1, - lastRead: distantPast, - lastDeliveredAt: now.add(const Duration(seconds: 1)), - ), - // user2 has not delivered the message yet (lastDeliveredAt is before message) - Read( - user: user2, - lastRead: distantPast, - lastDeliveredAt: distantPast, - ), - // user3 has no lastDeliveredAt - Read( - user: user3, - lastRead: distantPast, - ), - // user4 has read the message (implicitly delivered) - Read( - user: user4, - lastRead: now.add(const Duration(seconds: 1)), - ), - // sender should be excluded - Read( - user: sender, - lastRead: now.add(const Duration(seconds: 10)), - lastDeliveredAt: now.add(const Duration(seconds: 10)), + channelTest( + 'readsOf should return reads that have marked message as read', + setUp: (tester) => tester.watch( + modifyResponse: (state) => state.copyWith( + read: [ + // user1 has read the message + createDefaultRead(user: user1, lastRead: messageSentAt.add(const Duration(seconds: 1))), + // user2 has not read the message yet + createDefaultRead(user: user2, lastRead: distantPast), + // user3 has read the message + createDefaultRead(user: user3, lastRead: messageSentAt.add(const Duration(seconds: 2))), + // sender should be excluded + createDefaultRead(user: sender, lastRead: messageSentAt.add(const Duration(seconds: 10))), + ], ), - ]; - - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - channel.state!.updateChannelState( - ChannelState(channel: channelState.channel, read: reads), - ); - - final deliveries = channel.state!.deliveriesOf(message: message); - expect(deliveries.length, 2); - expect(deliveries.map((r) => r.user.id), containsAll(['user-1', 'user-4'])); - expect(deliveries.map((r) => r.user.id), isNot(contains('user-2'))); - expect(deliveries.map((r) => r.user.id), isNot(contains('user-3'))); - expect(deliveries.map((r) => r.user.id), isNot(contains('sender-id'))); - }); - - test('deliveriesOfStream should emit delivery updates for a message', () async { - final now = DateTime.now(); - final sender = User(id: 'sender-id', name: 'Sender'); - final user1 = User(id: 'user-1', name: 'User 1'); - - final message = Message( - id: 'msg-1', - text: 'Test message', - user: sender, - createdAt: now, - ); + ), + body: (tester) async { + final messageReads = tester.channelState!.readsOf(message: message); + + expect(messageReads.length, 2); + expect(messageReads.map((r) => r.user.id), containsAll(['user-1', 'user-3'])); + expect(messageReads.map((r) => r.user.id), isNot(contains('user-2'))); + expect(messageReads.map((r) => r.user.id), isNot(contains('sender-id'))); + }, + ); - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + channelTest( + 'readsOfStream should emit read updates for a message', + setUp: (tester) => tester.watch(), + body: (tester) async { + final readsStream = tester.channelState!.readsOfStream(message: message); - final deliveriesStream = channel.state!.deliveriesOfStream(message: message); + final readsEmitted = expectLater( + readsStream, + emitsInOrder([ + isEmpty, // initial state + hasLength(1), // after adding read + ]), + ); - expectLater( - deliveriesStream, - emitsInOrder([ - isEmpty, // initial state - hasLength(1), // after adding delivery - ]), - ); + // Update with read + tester.channelState!.updateChannelState( + ChannelState( + read: [ + createDefaultRead(user: user1, lastRead: messageSentAt.add(const Duration(seconds: 1))), + ], + ), + ); - // Update with delivery - channel.state!.updateChannelState( - ChannelState( - channel: channelState.channel, + await readsEmitted; + }, + ); + channelTest( + 'deliveriesOf should return reads that have delivered the message', + setUp: (tester) => tester.watch( + modifyResponse: (state) => state.copyWith( read: [ - Read( + // user1 has delivered the message + createDefaultRead( user: user1, lastRead: distantPast, - lastDeliveredAt: now.add(const Duration(seconds: 1)), + lastDeliveredAt: messageSentAt.add(const Duration(seconds: 1)), + ), + // user2 has not delivered the message yet (lastDeliveredAt is before message) + createDefaultRead( + user: user2, + lastRead: distantPast, + lastDeliveredAt: distantPast, + ), + // user3 has no lastDeliveredAt + createDefaultRead( + user: user3, + lastRead: distantPast, + ), + // user4 has read the message (implicitly delivered) + createDefaultRead( + user: user4, + lastRead: messageSentAt.add(const Duration(seconds: 1)), + ), + // sender should be excluded + createDefaultRead( + user: sender, + lastRead: messageSentAt.add(const Duration(seconds: 10)), + lastDeliveredAt: messageSentAt.add(const Duration(seconds: 10)), ), ], ), - ); - }); - }); -} + ), + body: (tester) async { + final deliveries = tester.channelState!.deliveriesOf(message: message); + + expect(deliveries.length, 2); + expect(deliveries.map((r) => r.user.id), containsAll(['user-1', 'user-4'])); + expect(deliveries.map((r) => r.user.id), isNot(contains('user-2'))); + expect(deliveries.map((r) => r.user.id), isNot(contains('user-3'))); + expect(deliveries.map((r) => r.user.id), isNot(contains('sender-id'))); + }, + ); -// region Test Helpers + channelTest( + 'deliveriesOfStream should emit delivery updates for a message', + setUp: (tester) => tester.watch(), + body: (tester) async { + final deliveriesStream = tester.channelState!.deliveriesOfStream(message: message); -ChannelState _generateChannelState( - String channelId, - String channelType, { - DateTime? lastMessageAt, - List? ownCapabilities, - 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, - ownCapabilities: ownCapabilities, - lastMessageAt: lastMessageAt, - ); - return ChannelState(channel: channel); -} + final deliveriesEmitted = expectLater( + deliveriesStream, + emitsInOrder([ + isEmpty, // initial state + hasLength(1), // after adding delivery + ]), + ); + + // Update with delivery + tester.channelState!.updateChannelState( + ChannelState( + read: [ + createDefaultRead( + user: user1, + lastRead: distantPast, + lastDeliveredAt: messageSentAt.add(const Duration(seconds: 1)), + ), + ], + ), + ); -// endregion + await deliveriesEmitted; + }, + ); + }); +} diff --git a/packages/stream_chat/test/src/client/channel/channel_state_mutations_test.dart b/packages/stream_chat/test/src/client/channel/channel_state_mutations_test.dart index a73b7fe8a2..c106b39070 100644 --- a/packages/stream_chat/test/src/client/channel/channel_state_mutations_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_state_mutations_test.dart @@ -1,12 +1,10 @@ // ignore_for_file: cascade_invocations -import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/src/client/channel/channel_state_mutations.dart'; import 'package:stream_chat/stream_chat.dart'; -import 'package:test/test.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; -import '../../fakes.dart'; -import '../../mocks.dart'; +import '../../mocks.dart' show FakeClientState, MockStreamChatClient; class MockChannelClientState extends Mock implements ChannelClientState {} @@ -19,6 +17,7 @@ void main() { late List<(User, Event)> upsertTypingEventCalls; late List removeTypingEventCalls; late List<(User, int?)> removeWatcherCalls; + late List removeMemberCalls; late List updateMemberCalls; late List<(String, bool, DateTime?)> deleteMessagesFromUserCalls; @@ -27,8 +26,7 @@ void main() { final otherUser = User(id: 'other-user'); setUpAll(() { - registerFallbackValue(FakeMessage()); - registerFallbackValue(FakeChannelState()); + registerChatFallbackValues(); registerFallbackValue(0); }); @@ -45,6 +43,7 @@ void main() { upsertTypingEventCalls = []; removeTypingEventCalls = []; removeWatcherCalls = []; + removeMemberCalls = []; updateMemberCalls = []; deleteMessagesFromUserCalls = []; @@ -54,6 +53,7 @@ void main() { upsertTypingEvent: (user, event) => upsertTypingEventCalls.add((user, event)), removeTypingEvent: removeTypingEventCalls.add, removeWatcher: (watcher, {watcherCount}) => removeWatcherCalls.add((watcher, watcherCount)), + removeMember: removeMemberCalls.add, updateMember: updateMemberCalls.add, deleteMessagesFromUser: ({required userId, hardDelete = false, deletedAt}) async { deleteMessagesFromUserCalls.add((userId, hardDelete, deletedAt)); @@ -580,27 +580,10 @@ void main() { expect(capturedChannelState().members, [existingMember, member]); }); - test('onMemberRemoved removes the member and its read state', () { - final existingMember = Member(user: User(id: 'existing-user')); - final existingRead = Read( - user: User(id: 'existing-user'), - lastRead: DateTime.now(), - ); - stubChannelState( - ChannelState( - members: [existingMember, member], - read: [ - existingRead, - Read(user: otherUser, lastRead: DateTime.now()), - ], - ), - ); - + test('onMemberRemoved removes the member', () { mutations.onMemberRemoved(otherUser); - final updated = capturedChannelState(); - expect(updated.members, [existingMember]); - expect(updated.read, [existingRead]); + expect(removeMemberCalls, [otherUser]); }); test('onMemberUserUpdated merges the user into the member and membership', () { diff --git a/packages/stream_chat/test/src/client/channel/channel_test.dart b/packages/stream_chat/test/src/client/channel/channel_test.dart index 36d7d42f4f..ea63ecb279 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -1,362 +1,659 @@ -// ignore_for_file: lines_longer_than_80_chars, cascade_invocations, deprecated_member_use_from_same_package, avoid_redundant_argument_values - import 'dart:async'; -import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/stream_chat.dart'; -import 'package:test/test.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +// Builds the channel already initialized from state, so the state attaches +// while a persistence client is set. +Channel _buildInitializedChannel(StreamChatClient client) { + return Channel.fromState( + client, + createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ), + ); +} + +// Stubs the persistence calls these tests drive (`getChannelThreads`, +// `getChannelStateByCid`, `updateMessages`) plus the ones the real client +// makes on its own: `updateConnectionInfo` on connect and the debounced +// channel state/thread writes. +MockPersistenceClient _createPersistenceClient() { + registerFallbackValue(createDefaultEvent()); + registerFallbackValue(createDefaultChannelState()); + registerFallbackValue([]); + registerFallbackValue(>{}); + + final persistenceClient = MockPersistenceClient(); + when(() => persistenceClient.updateConnectionInfo(any())).thenAnswer((_) async {}); + when(() => persistenceClient.getChannelThreads(_channelCid)).thenAnswer((_) async => {}); + when(() => persistenceClient.getChannelStateByCid(_channelCid)).thenAnswer( + (_) async => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ), + ); + when(() => persistenceClient.updateMessages(_channelCid, any())).thenAnswer((_) => Future.value()); + when(() => persistenceClient.updateChannelState(any())).thenAnswer((_) async {}); + when(() => persistenceClient.updateChannelThreads(_channelCid, any())).thenAnswer((_) async {}); + return persistenceClient; +} -import '../../fakes.dart'; -import '../../matchers.dart'; -import '../../mocks.dart'; -import '../../utils.dart'; +final persistenceClient = _createPersistenceClient(); void main() { - ChannelState _generateChannelState( - String channelId, - String channelType, { - DateTime? lastMessageAt, - List? ownCapabilities, - 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, - ownCapabilities: ownCapabilities, - lastMessageAt: lastMessageAt, + group('Non-Initialized Channel', () { + channelTest( + 'should be able to set `extraData`', + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final channel = tester.channel; + + expect(channel.extraData.isEmpty, isTrue); + + expect( + () => channel.extraData = {'name': 'test-channel-name'}, + returnsNormally, + ); + + expect(channel.extraData.isEmpty, isFalse); + expect(channel.extraData.containsKey('name'), isTrue); + expect(channel.extraData['name'], 'test-channel-name'); + }, ); - final state = ChannelState(channel: channel); - return state; - } - group('Non-Initialized Channel', () { - late final client = MockStreamChatClient(); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUpAll(() { - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - }); + channelTest( + 'should be able to get and set `image`', + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final channel = tester.channel; - setUp(() { - channel = Channel(client, channelType, channelId); - }); + expect(channel.extraData.isEmpty, isTrue); - tearDown(() { - channel.dispose(); - }); + const imageUrl = 'https://getstream.io/some-image'; + channel.image = imageUrl; - test('should be able to set `extraData`', () { - expect(channel.extraData.isEmpty, isTrue); + expect(channel.image, imageUrl); + expect(channel.extraData['image'], imageUrl); - expect( - () => channel.extraData = {'name': 'test-channel-name'}, - returnsNormally, - ); + const newImage = 'https://getstream.io/new-image'; + final newChannelInstance = Channel(tester.client, _channelType, _channelId, image: newImage); - expect(channel.extraData.isEmpty, isFalse); - expect(channel.extraData.containsKey('name'), isTrue); - expect(channel.extraData['name'], 'test-channel-name'); - }); + expect(newChannelInstance.image, newImage); + expect(newChannelInstance.extraData['image'], newImage); + }, + ); - test('should be able to get and set `image`', () { - expect(channel.extraData.isEmpty, isTrue); + channelTest( + 'should be able to get and set `name`', + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final channel = tester.channel; - const imageUrl = 'https://getstream.io/some-image'; - channel.image = imageUrl; + expect(channel.extraData.isEmpty, isTrue); - expect(channel.image, imageUrl); - expect(channel.extraData['image'], imageUrl); + const name = 'Channel name'; + channel.name = name; - const newImage = 'https://getstream.io/new-image'; - final newChannelInstance = Channel(client, channelType, channelId, image: newImage); + expect(channel.name, name); + expect(channel.extraData['name'], name); - expect(newChannelInstance.image, newImage); - expect(newChannelInstance.extraData['image'], newImage); - }); + const newName = 'New channel name'; + final newChannelInstance = Channel(tester.client, _channelType, _channelId, name: newName); - test('should be able to get and set `name`', () { - expect(channel.extraData.isEmpty, isTrue); + expect(newChannelInstance.name, newName); + expect(newChannelInstance.extraData['name'], newName); + }, + ); - const name = 'Channel name'; - channel.name = name; + channelTest( + 'setters remain usable after a failed watch()', + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final channel = tester.channel; + + // Make initialization fail. + tester.mockApiFailure( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: channel.extraData, + state: true, + watch: true, + presence: false, + ), + error: createDefaultNetworkError(code: StreamErrorCode.inputError, statusCode: 400), + ); - expect(channel.name, name); - expect(channel.extraData['name'], name); + // A failed watch() also completes `initialized` with the error. Attach + // the expectation up-front so that error has a listener the moment it + // occurs and isn't reported as an unhandled async error. + final initializedFailure = expectLater( + channel.initialized, + throwsA(isA()), + ); - const newName = 'New channel name'; - final newChannelInstance = Channel(client, channelType, channelId, name: newName); + await expectLater( + channel.watch(), + throwsA(isA()), + ); + await initializedFailure; - expect(newChannelInstance.name, newName); - expect(newChannelInstance.extraData['name'], newName); - }); + // Init never *succeeded*, so the raw setters must still work. Previously + // they threw because the completer was merely `isCompleted` (it had + // completed with an error). + expect(() => channel.name = 'New name', returnsNormally); + expect(channel.name, 'New name'); + }, + ); + }); + + group('Initialized Channel with Persistence', () { + channelTest( + 'initializes from state and loads channel threads from the persistence client', + channelType: _channelType, + channelId: _channelId, + build: _buildInitializedChannel, + chatPersistenceClient: persistenceClient, + body: (tester) async { + expect(tester.channel.cid, _channelCid); + expect(tester.channelState, isNotNull); + await expectLater(tester.channel.initialized, completion(isTrue)); + + // Attaching the state loads the channel's threads from the offline + // storage; the stubbed storage has none, so the state stays empty. + verify(() => persistenceClient.getChannelThreads(_channelCid)).called(1); + expect(tester.channelState!.threads, isEmpty); + expect(tester.channelState!.messages, isEmpty); + }, + ); + }); + + group('Initialized Channel', () { + ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); + + ChannelState Function(ChannelState) _seedChannelLocationApi() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); + } - test('setters remain usable after a failed watch()', () async { - // Make initialization fail. - when( - () => client.queryChannel( - channelType, - channelId: any(named: 'channelId'), - channelData: any(named: 'channelData'), - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), + Future _seedChannelDrafts(ChannelTester tester) => tester.watch( + modifyResponse: (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], ), - ).thenThrow(apiException(code: StreamErrorCode.inputError, statusCode: 400)); + ), + ); + + Future _seedChannelReminders(ChannelTester tester) => tester.watch( + modifyResponse: (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ), + ); - // A failed watch() also completes `initialized` with the error. Attach - // the expectation up-front so that error has a listener the moment it - // occurs and isn't reported as an unhandled async error. - final initializedFailure = expectLater( - channel.initialized, - throwsA(isA()), + ChannelState Function(ChannelState) _seedChannelUpdateMessage() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), ); + } - await expectLater( - channel.watch(), - throwsA(isA()), + ChannelState Function(ChannelState) _seedChannelDeleteMessage() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), ); - await initializedFailure; + } - // Init never *succeeded*, so the raw setters must still work. Previously - // they threw because the completer was merely `isCompleted` (it had - // completed with an error). - expect(() => channel.name = 'New name', returnsNormally); - expect(channel.name, 'New name'); - }); - }); + ChannelState Function(ChannelState) _seedChannelPinMessage() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); + } - 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()); - registerFallbackValue(const ChannelFilter.raw({})); - registerFallbackValue(const BannedUserFilter.raw({})); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - - // 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()); - }); + ChannelState Function(ChannelState) _seedChannelMiscOperations() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); + } - // Setting up a initialized channel - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); + ChannelState Function(ChannelState) _seedChannelReactions() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); + } - tearDown(() { - channel.dispose(); - }); - }); + Future _seedChannelUpdateApi(ChannelTester tester) => tester.watch( + modifyResponse: (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ), + ); - 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()); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - - // mock channel delivery reporter - when( - () => client.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); - }); + Future _seedChannelMemberApi(ChannelTester tester) => tester.watch( + modifyResponse: (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ), + ); - // Setting up a initialized channel - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ownCapabilities: [ChannelCapability.readEvents], + ChannelState Function(ChannelState) _seedChannelWatch() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), ); - channel = Channel.fromState(client, channelState); - }); + } - tearDown(() { - channel.dispose(); - clearInteractions(client); - }); + ChannelState Function(ChannelState) _seedChannelMessageQueries() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); + } - test('should throw if trying to set `extraData`', () { - try { - channel.extraData = {'name': 'test-channel-name'}; - } catch (e) { - expect(e, isA()); - } - }); + // Builds the channel already initialized from state — the `.query` tests + // exercise `queryChannel` themselves, so they cannot seed through + // `tester.watch()` without polluting the call counts they verify. + Channel _buildInitializedChannel( + StreamChatClient client, { + List ownCapabilities = const [ChannelCapability.readEvents], + List messages = const [], + }) { + return Channel.fromState( + client, + createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: ownCapabilities, + ), + messages: messages, + ), + ); + } - test('should throw if trying to set `image`', () { - try { - channel.image = 'https://stream.io/some-image'; - } catch (e) { - expect(e, isA()); - } - }); + ChannelState Function(ChannelState) _seedChannelModerationApi() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); + } - test('should throw if trying to set `name`', () { - try { - channel.name = 'New name'; - } catch (e) { - expect(e, isA()); - } - }); + ChannelState Function(ChannelState) _seedChannelDisplayApi() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); + } + + // A bare channel state (no config, no capabilities) used to seed messages + // directly into the channel's client state. + ChannelState _channelStateWith({ + List messages = const [], + List pinnedMessages = const [], + }) { + return createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + messages: messages, + pinnedMessages: pinnedMessages, + ); + } + + // testing archiving + + // testing pinning + + channelTest( + 'should throw if trying to set `extraData`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + try { + tester.channel.extraData = {'name': 'test-channel-name'}; + } catch (e) { + expect(e, isA()); + } + }, + ); + + channelTest( + 'should throw if trying to set `image`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + try { + tester.channel.image = 'https://stream.io/some-image'; + } catch (e) { + expect(e, isA()); + } + }, + ); + + channelTest( + 'should throw if trying to set `name`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + try { + tester.channel.name = 'New name'; + } catch (e) { + expect(e, isA()); + } + }, + ); group('`.sendMessage`', () { - test('queues a failed send for retry when the failure is retriable', () async { - // The enqueue gate used to test a type nothing throws any more, so a - // failed send never reached the queue at all. The retry is observable - // as a second attempt, since adding to the queue processes it. - final message = Message(id: 'retriable-id', text: 'hi', user: client.state.currentUser); + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); + } - when( - () => client.sendMessage(any(that: isSameMessageAs(message)), channelId, channelType), - ).thenThrow(apiException(statusCode: 500)); + channelTest( + 'should work fine', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id', + text: 'Hello world!', + user: tester.currentUser, + ); - await expectLater(channel.sendMessage(message), throwsA(isA())); - await Future.delayed(const Duration(milliseconds: 50)); + tester.mockApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); - verify( - () => client.sendMessage(any(that: isSameMessageAs(message)), channelId, channelType), - ).called(greaterThan(1)); - }); + final messagesEmission = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.sending), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.sent), + matchMessageState: true, + ), + ], + ]), + ); - test('does not queue a failed send when the failure is not retriable', () async { - final message = Message(id: 'refused-id', text: 'hi', user: client.state.currentUser); + final res = await tester.channel.sendMessage(message); - when( - () => client.sendMessage(any(that: isSameMessageAs(message)), channelId, channelType), - ).thenThrow(apiException(code: StreamErrorCode.notAllowed, statusCode: 403)); + expect(res, isNotNull); + expect(res.message.id, message.id); - await expectLater(channel.sendMessage(message), throwsA(isA())); - await Future.delayed(const Duration(milliseconds: 50)); + tester.verifyApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + ); - verify( - () => client.sendMessage(any(that: isSameMessageAs(message)), channelId, channelType), - ).called(1); - }); + await messagesEmission; + }, + ); - test('should work fine', () async { - final message = Message( - id: 'test-message-id', - text: 'Hello world!', - user: client.state.currentUser, - ); + channelTest( + 'queues a failed send for retry when the failure is retriable', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + // The enqueue gate used to test a type nothing throws any more, so a + // failed send never reached the queue at all. The retry is observable + // as a second attempt, since adding to the queue processes it. + final message = Message(id: 'retriable-id', text: 'hi', user: tester.currentUser); + + tester.mockApiFailure( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + error: createDefaultNetworkError(statusCode: 500), + ); - final sendMessageResponse = SendMessageResponse()..message = message.copyWith(state: MessageState.sent); + await expectLater( + tester.channel.sendMessage(message), + throwsA(isA()), + ); + await tester.pumpEventQueue(); - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).thenAnswer((_) async => sendMessageResponse); + tester.verifyApiCalled( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + times: 2, + ); + }, + ); - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith(state: MessageState.sending), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith(state: MessageState.sent), - matchMessageState: true, - ), - ], - ]), - ); + channelTest( + 'does not queue a failed send when the failure is not retriable', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message(id: 'refused-id', text: 'hi', user: tester.currentUser); + + tester.mockApiFailure( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + error: createDefaultNetworkError(code: StreamErrorCode.notAllowed, statusCode: 403), + ); - final res = await channel.sendMessage(message); + await expectLater( + tester.channel.sendMessage(message), + throwsA(isA()), + ); + await tester.pumpEventQueue(); - expect(res, isNotNull); - expect(res.message.id, message.id); + tester.verifyApiCalled( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + times: 1, + ); + }, + ); - verify( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).called(1); - }); + channelTest( + 'should re-send the message through the retry queue when the failure is retriable', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id', + text: 'Hello world!', + user: tester.currentUser, + ); + + tester.mockApiFailure( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + error: createDefaultNetworkError(code: StreamErrorCode.internalError, statusCode: 500), + ); + + await expectLater( + tester.channel.sendMessage(message), + throwsA(isA()), + ); + await tester.pumpEventQueue(); + + // Once for the original send, once for the queue's retry attempt. + tester.verifyApiCalled( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + times: 2, + ); + }, + ); + + channelTest( + 'should not re-send the message when the failure is not retriable', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id', + text: 'Hello world!', + user: tester.currentUser, + ); + + tester.mockApiFailure( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + error: createDefaultNetworkError(code: StreamErrorCode.inputError, statusCode: 400), + ); + + await expectLater( + tester.channel.sendMessage(message), + throwsA(isA()), + ); + await tester.pumpEventQueue(); + + tester.verifyApiCalled( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + times: 1, + ); + }, + ); + + channelTest( + 'should report a superseded send as a cancelled request', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final attachment = Attachment( + id: 'test-attachment-id', + type: 'image', + file: AttachmentFile(size: 100, path: 'test-file-path'), + ); - test( + final message = Message(id: 'test-message-id', attachments: [attachment]); + + // Holds the first send inside its attachment upload, so the second + // one arrives while it is still in flight. + tester.mockApi( + (api) => api.fileUploader.sendImage( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + result: SendImageResponse()..file = 'url', + delay: const Duration(seconds: 1), + ); + + final superseded = tester.channel.sendMessage(message); + await tester.pumpEventQueue(); + unawaited( + tester.channel.sendMessage(message).catchError((_) => SendMessageResponse()), + ); + + // The caller stopped it, so it is a cancelled request rather than an + // SDK failure a crash tracker should hear about. + await expectLater( + superseded, + throwsA( + isA().having((it) => it.isCancelled, 'isCancelled', isTrue), + ), + ); + }, + ); + + channelTest( 'should mark a refused send as failed with skipPush: true, skipEnrichUrl: false', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final message = Message( id: 'test-message-id', text: 'Hello world!', - user: client.state.currentUser, + user: tester.currentUser, ); - when( - () => client.sendMessage( + // A 403 is the server's verdict on the request, not a transient + // failure, so the queue declines it and the send is simply refused. + tester.mockApiFailure( + (api) => api.message.sendMessage( + _channelId, + _channelType, any(that: isSameMessageAs(message)), - channelId, - channelType, skipPush: true, ), - ).thenThrow(apiException(code: StreamErrorCode.notAllowed, statusCode: 403)); + error: createDefaultNetworkError(code: StreamErrorCode.notAllowed, statusCode: 403), + ); - expectLater( + final messagesEmission = expectLater( // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), + tester.channelState?.messagesStream.skip(1), emitsInOrder([ [ isSameMessageAs( @@ -379,7 +676,7 @@ void main() { ); try { - await channel.sendMessage( + await tester.channel.sendMessage( message, skipPush: true, ); @@ -389,31 +686,50 @@ void main() { final networkError = e as StreamApiException; expect(networkError.code, equals(StreamErrorCode.notAllowed)); } + + await messagesEmission; + + // Sent once: a refused send is never handed to the retry queue. + tester.verifyApiCalled( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + ), + times: 1, + ); }, ); - test( + channelTest( 'should mark a refused send as failed with skipPush: true, skipEnrichUrl: true', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final message = Message( id: 'test-message-id-2', text: 'Hello world!', - user: client.state.currentUser, + user: tester.currentUser, ); - when( - () => client.sendMessage( + // A 403 is the server's verdict on the request, not a transient + // failure, so the queue declines it and the send is simply refused. + tester.mockApiFailure( + (api) => api.message.sendMessage( + _channelId, + _channelType, any(that: isSameMessageAs(message)), - channelId, - channelType, skipPush: true, skipEnrichUrl: true, ), - ).thenThrow(apiException(code: StreamErrorCode.notAllowed, statusCode: 403)); + error: createDefaultNetworkError(code: StreamErrorCode.notAllowed, statusCode: 403), + ); - expectLater( + final messagesEmission = expectLater( // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), + tester.channelState?.messagesStream.skip(1), emitsInOrder([ [ isSameMessageAs( @@ -436,7 +752,7 @@ void main() { ); try { - await channel.sendMessage( + await tester.channel.sendMessage( message, skipPush: true, skipEnrichUrl: true, @@ -447,30 +763,50 @@ void main() { final networkError = e as StreamApiException; expect(networkError.code, equals(StreamErrorCode.notAllowed)); } + + await messagesEmission; + + // Sent once: a refused send is never handed to the retry queue. + tester.verifyApiCalled( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + times: 1, + ); }, ); - test( + channelTest( 'should mark a refused send as failed with skipPush: false, skipEnrichUrl: true', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final message = Message( id: 'test-message-id-3', text: 'Hello world!', - user: client.state.currentUser, + user: tester.currentUser, ); - when( - () => client.sendMessage( + // A 403 is the server's verdict on the request, not a transient + // failure, so the queue declines it and the send is simply refused. + tester.mockApiFailure( + (api) => api.message.sendMessage( + _channelId, + _channelType, any(that: isSameMessageAs(message)), - channelId, - channelType, skipEnrichUrl: true, ), - ).thenThrow(apiException(code: StreamErrorCode.notAllowed, statusCode: 403)); + error: createDefaultNetworkError(code: StreamErrorCode.notAllowed, statusCode: 403), + ); - expectLater( + final messagesEmission = expectLater( // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), + tester.channelState?.messagesStream.skip(1), emitsInOrder([ [ isSameMessageAs( @@ -493,7 +829,7 @@ void main() { ); try { - await channel.sendMessage( + await tester.channel.sendMessage( message, skipEnrichUrl: true, ); @@ -503,29 +839,44 @@ void main() { final networkError = e as StreamApiException; expect(networkError.code, equals(StreamErrorCode.notAllowed)); } - }, + + await messagesEmission; + + // Sent once: a refused send is never handed to the retry queue. + tester.verifyApiCalled( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipEnrichUrl: true, + ), + times: 1, + ); + }, ); - test( + channelTest( 'should mark a refused send as failed with skipPush: false, skipEnrichUrl: false', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final message = Message( id: 'test-message-id-4', text: 'Hello world!', - user: client.state.currentUser, + user: tester.currentUser, ); - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).thenThrow(apiException(code: StreamErrorCode.notAllowed, statusCode: 403)); + // A 403 is the server's verdict on the request, not a transient + // failure, so the queue declines it and the send is simply refused. + tester.mockApiFailure( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + error: createDefaultNetworkError(code: StreamErrorCode.notAllowed, statusCode: 403), + ); - expectLater( + final messagesEmission = expectLater( // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), + tester.channelState?.messagesStream.skip(1), emitsInOrder([ [ isSameMessageAs( @@ -548,7 +899,7 @@ void main() { ); try { - await channel.sendMessage( + await tester.channel.sendMessage( message, ); } catch (e) { @@ -557,342 +908,269 @@ void main() { final networkError = e as StreamApiException; expect(networkError.code, equals(StreamErrorCode.notAllowed)); } - }, - ); - - test('should re-send the message through the retry queue when the failure is retriable', () async { - final message = Message( - id: 'test-message-id', - text: 'Hello world!', - user: client.state.currentUser, - ); - - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).thenThrow(apiException(code: StreamErrorCode.internalError, statusCode: 500)); - - await expectLater(channel.sendMessage(message), throwsA(isA())); - await pumpEventQueue(); - - // Once for the original send, once for the queue's retry attempt. - verify( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).called(2); - }); - - test('should not re-send the message when the failure is not retriable', () async { - final message = Message( - id: 'test-message-id', - text: 'Hello world!', - user: client.state.currentUser, - ); - - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).thenThrow(apiException(code: StreamErrorCode.inputError, statusCode: 400)); - - await expectLater(channel.sendMessage(message), throwsA(isA())); - await pumpEventQueue(); - - verify( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).called(1); - }); - - test('should update message state even when non-retriable error occurs', () async { - final message = Message( - id: 'test-message-id', - text: 'Hello world!', - user: client.state.currentUser, - ); - - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).thenThrow( - apiException(code: StreamErrorCode.inputError, statusCode: 400, message: 'Input error'), - ); - - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith(state: MessageState.sending), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith( - state: MessageState.sendingFailed( - skipPush: false, - skipEnrichUrl: false, - ), - ), - matchMessageState: true, - ), - ], - ]), - ); - - try { - await channel.sendMessage(message); - } catch (e) { - expect(e, isA()); - } - }); - - 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: index * 33, path: 'test-file-path'), - ), - ); - final message = Message( - id: 'test-message-id', - attachments: attachments, - ); + await messagesEmission; - final sendImageResponse = SendImageResponse()..file = 'test-image-url'; - final sendFileResponse = SendFileResponse()..file = 'test-file-url'; + // Sent once: a refused send is never handed to the retry queue. + tester.verifyApiCalled( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + times: 1, + ); + }, + ); - when( - () => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - ).thenAnswer((_) async => sendImageResponse); - - when( - () => client.sendFile( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - ).thenAnswer((_) async => sendFileResponse); + channelTest( + 'should update message state even when non-retriable error occurs', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id', + text: 'Hello world!', + user: tester.currentUser, + ); - 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), - state: MessageState.sent, - ), - ); + tester.mockApiFailure( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + error: createDefaultNetworkError(code: StreamErrorCode.inputError, statusCode: 400), + ); - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder( - [ - // preparing attachments to upload - [ - isSameMessageAs( - message.copyWith( - state: MessageState.sending, - attachments: [...attachments.map((it) => it.copyWith(uploadState: const UploadState.preparing()))], - ), - matchMessageState: true, - matchAttachments: true, - matchAttachmentsUploadState: true, - ), - ], - // 0th attachment is successfully uploaded - [ - isSameMessageAs( - message.copyWith( - state: MessageState.sending, - attachments: [...attachments] - ..[0] = attachments[0].copyWith( - uploadState: const UploadState.success(), - ), - ), - matchMessageState: true, - matchAttachments: true, - matchAttachmentsUploadState: true, - ), - ], - // 0th and 1st attachment is successfully uploaded - [ - isSameMessageAs( - message.copyWith( - state: MessageState.sending, - attachments: [...attachments] - ..[0] = attachments[0].copyWith( - uploadState: const UploadState.success(), - ) - ..[1] = attachments[1].copyWith( - uploadState: const UploadState.success(), - ), - ), - matchMessageState: true, - matchAttachments: true, - matchAttachmentsUploadState: true, - ), - ], - // all the attachments are successfully uploaded + final messagesEmission = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ [ isSameMessageAs( - message.copyWith( - state: MessageState.sending, - attachments: [...attachments.map((it) => it.copyWith(uploadState: const UploadState.success()))], - ), + message.copyWith(state: MessageState.sending), matchMessageState: true, - matchAttachments: true, - matchAttachmentsUploadState: true, ), ], [ isSameMessageAs( message.copyWith( - state: MessageState.sent, - attachments: [...attachments.map((it) => it.copyWith(uploadState: const UploadState.success()))], + state: MessageState.sendingFailed( + skipPush: false, + skipEnrichUrl: false, + ), ), matchMessageState: true, - matchAttachments: true, - matchAttachmentsUploadState: true, ), ], - ], - ), - ); + ]), + ); - final res = await channel.sendMessage(message); + try { + await tester.channel.sendMessage(message); + } catch (e) { + expect(e, isA()); + } - 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, - ); + await messagesEmission; + }, + ); - verify( - () => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - ).called(2); - - verify( - () => client.sendFile( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - ).called(1); + channelTest( + 'with attachments should work just fine', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final attachments = List.generate( + 3, + (index) => Attachment( + id: 'test-attachment-id-$index', + type: index.isEven ? 'image' : 'file', + file: AttachmentFile(size: index * 33, path: 'test-file-path'), + ), + ); - verify( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).called(1); - }); + final message = Message( + id: 'test-message-id', + attachments: attachments, + ); - test('should not send if the message is invalid', () async { - final message = Message(id: 'test-message-id'); + tester + ..mockApi( + (api) => api.fileUploader.sendImage( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + result: SendImageResponse()..file = 'test-image-url', + ) + ..mockApi( + (api) => api.fileUploader.sendFile( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + result: SendFileResponse()..file = 'test-file-url', + ) + ..mockApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + result: createDefaultSendMessageResponse( + message: message.copyWith( + attachments: attachments + .map((it) => it.copyWith(uploadState: const UploadState.success())) + .toList(growable: false), + state: MessageState.sent, + ), + ), + ); - expect( - () => channel.sendMessage(message), - throwsA(isA()), - ); + final messagesEmission = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder( + [ + // preparing attachments to upload + [ + isSameMessageAs( + message.copyWith( + state: MessageState.sending, + attachments: [ + ...attachments.map((it) => it.copyWith(uploadState: const UploadState.preparing())), + ], + ), + matchMessageState: true, + matchAttachments: true, + matchAttachmentsUploadState: true, + ), + ], + // 0th attachment is successfully uploaded + [ + isSameMessageAs( + message.copyWith( + state: MessageState.sending, + attachments: [...attachments] + ..[0] = attachments[0].copyWith( + uploadState: const UploadState.success(), + ), + ), + matchMessageState: true, + matchAttachments: true, + matchAttachmentsUploadState: true, + ), + ], + // 0th and 1st attachment is successfully uploaded + [ + isSameMessageAs( + message.copyWith( + state: MessageState.sending, + attachments: [...attachments] + ..[0] = attachments[0].copyWith( + uploadState: const UploadState.success(), + ) + ..[1] = attachments[1].copyWith( + uploadState: const UploadState.success(), + ), + ), + matchMessageState: true, + matchAttachments: true, + matchAttachmentsUploadState: true, + ), + ], + // all the attachments are successfully uploaded + [ + isSameMessageAs( + message.copyWith( + state: MessageState.sending, + attachments: [...attachments.map((it) => it.copyWith(uploadState: const UploadState.success()))], + ), + matchMessageState: true, + matchAttachments: true, + matchAttachmentsUploadState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.sent, + attachments: [...attachments.map((it) => it.copyWith(uploadState: const UploadState.success()))], + ), + matchMessageState: true, + matchAttachments: true, + matchAttachmentsUploadState: true, + ), + ], + ], + ), + ); - verifyNever( - () => client.sendMessage(any(), channelId, channelType), - ); - }); + final res = await tester.channel.sendMessage(message); - test('should report a superseded send as a cancelled request', () async { - final attachment = Attachment( - id: 'test-attachment-id', - type: 'image', - file: AttachmentFile(size: 100, path: 'test-file-path'), - ); + 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, + ); - final message = Message(id: 'test-message-id', attachments: [attachment]); + tester + ..verifyApiCalled( + (api) => api.fileUploader.sendImage( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + times: 2, + ) + ..verifyApi( + (api) => api.fileUploader.sendFile( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ) + ..verifyApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + ); - // Holds the first send inside its attachment upload, so the second one - // arrives while it is still in flight. - final upload = Completer(); - when( - () => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - ).thenAnswer((_) => upload.future); - addTearDown(() => upload.complete(SendImageResponse()..file = 'url')); + await messagesEmission; + }, + ); - final superseded = channel.sendMessage(message); - await pumpEventQueue(); - unawaited(channel.sendMessage(message).catchError((_) => SendMessageResponse())); + channelTest( + 'should not send if the message is invalid', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message(id: 'test-message-id'); - // The caller stopped it, so it is a cancelled request rather than an - // SDK failure a crash tracker should hear about. - await expectLater( - superseded, - throwsA( - isA().having((it) => it.isCancelled, 'isCancelled', isTrue), - ), - ); - }); + await expectLater( + () => tester.channel.sendMessage(message), + throwsA(isA()), + ); + + tester.verifyNeverCalled( + (api) => api.message.sendMessage(_channelId, _channelType, any()), + ); + }, + ); - test( + channelTest( 'should not send empty message when all attachments are cancelled', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final attachment = Attachment( id: 'test-attachment-id', type: 'image', @@ -904,44 +1182,46 @@ void main() { attachments: [attachment], ); - when( - () => client.sendImage( + tester.mockApiFailure( + (api) => api.fileUploader.sendImage( any(), - channelId, - channelType, + _channelId, + _channelType, onSendProgress: any(named: 'onSendProgress'), cancelToken: any(named: 'cancelToken'), extraData: any(named: 'extraData'), ), - ).thenAnswer( - (_) async => throw const StreamNetworkException(message: 'Request cancelled', isCancelled: true), + error: const StreamNetworkException(message: 'Request cancelled', isCancelled: true), ); - expect( - () => channel.sendMessage(message), + await expectLater( + () => tester.channel.sendMessage(message), throwsA(isA()), ); - verify( - () => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - ); - - verifyNever( - () => client.sendMessage(any(), channelId, channelType), - ); + tester + ..verifyApi( + (api) => api.fileUploader.sendImage( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ) + ..verifyNeverCalled( + (api) => api.message.sendMessage(_channelId, _channelType, any()), + ); }, ); - test( + channelTest( 'should send message when attachment is cancelled but text exists', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final attachment = Attachment( id: 'test-attachment-id', type: 'image', @@ -954,62 +1234,56 @@ void main() { attachments: [attachment], ); - when( - () => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - ).thenAnswer( - (_) async => throw const StreamNetworkException(message: 'Request cancelled', isCancelled: true), - ); - - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).thenAnswer( - (_) async => SendMessageResponse() - ..message = message.copyWith( - attachments: [], - state: MessageState.sent, + tester + ..mockApiFailure( + (api) => api.fileUploader.sendImage( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), ), - ); + error: const StreamNetworkException(message: 'Request cancelled', isCancelled: true), + ) + ..mockApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + result: createDefaultSendMessageResponse( + message: message.copyWith( + attachments: [], + state: MessageState.sent, + ), + ), + ); - final res = await channel.sendMessage(message); + final res = await tester.channel.sendMessage(message); expect(res, isNotNull); expect(res.message.text, 'Hello world!'); - verify( - () => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - ); - - verify( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ); + tester + ..verifyApi( + (api) => api.fileUploader.sendImage( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ) + ..verifyApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + ); }, ); - test( + channelTest( 'should send message when attachment is cancelled but quoted message exists', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final attachment = Attachment( id: 'test-attachment-id', type: 'image', @@ -1027,62 +1301,56 @@ void main() { quotedMessageId: quotedMessage.id, ); - when( - () => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - ).thenAnswer( - (_) async => throw const StreamNetworkException(message: 'Request cancelled', isCancelled: true), - ); - - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).thenAnswer( - (_) async => SendMessageResponse() - ..message = message.copyWith( - attachments: [], - state: MessageState.sent, + tester + ..mockApiFailure( + (api) => api.fileUploader.sendImage( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), ), - ); + error: const StreamNetworkException(message: 'Request cancelled', isCancelled: true), + ) + ..mockApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + result: createDefaultSendMessageResponse( + message: message.copyWith( + attachments: [], + state: MessageState.sent, + ), + ), + ); - final res = await channel.sendMessage(message); + final res = await tester.channel.sendMessage(message); expect(res, isNotNull); expect(res.message.quotedMessageId, quotedMessage.id); - verify( - () => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - ); - - verify( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ); + tester + ..verifyApi( + (api) => api.fileUploader.sendImage( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ) + ..verifyApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + ); }, ); - test( + channelTest( 'should send message when attachment is cancelled but poll exists', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { final attachment = Attachment( id: 'test-attachment-id', type: 'image', @@ -1095,56 +1363,47 @@ void main() { pollId: 'poll-123', ); - when( - () => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - ).thenAnswer( - (_) async => throw const StreamNetworkException(message: 'Request cancelled', isCancelled: true), - ); - - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).thenAnswer( - (_) async => SendMessageResponse() - ..message = message.copyWith( - attachments: [], - state: MessageState.sent, + tester + ..mockApiFailure( + (api) => api.fileUploader.sendImage( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), ), - ); + error: const StreamNetworkException(message: 'Request cancelled', isCancelled: true), + ) + ..mockApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + result: createDefaultSendMessageResponse( + message: message.copyWith( + attachments: [], + state: MessageState.sent, + ), + ), + ); - final res = await channel.sendMessage(message); + final res = await tester.channel.sendMessage(message); expect(res, isNotNull); expect(res.message.pollId, 'poll-123'); - verify( - () => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - ); - - verify( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ); + tester + ..verifyApi( + (api) => api.fileUploader.sendImage( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ) + ..verifyApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + ); }, ); }); @@ -1157,44 +1416,58 @@ void main() { longitude: -74.0060, ); - test('should create a static location and call sendMessage', () async { - when( - () => client.sendMessage(any(), channelId, channelType), - ).thenAnswer( - (_) async => SendMessageResponse() - ..message = Message( - id: locationId, - text: 'Location shared', - extraData: const {'custom': 'data'}, - sharedLocation: Location( - channelCid: channel.cid, - messageId: locationId, - userId: client.state.currentUser?.id, - latitude: coordinates.latitude, - longitude: coordinates.longitude, - createdByDeviceId: deviceId, + channelTest( + 'should create a static location and call sendMessage', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelLocationApi()), + body: (tester) async { + tester.mockApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(Message(id: locationId))), + ), + result: createDefaultSendMessageResponse( + message: Message( + id: locationId, + text: 'Location shared', + extraData: const {'custom': 'data'}, + sharedLocation: Location( + channelCid: tester.channel.cid, + messageId: locationId, + userId: tester.currentUser?.id, + latitude: coordinates.latitude, + longitude: coordinates.longitude, + createdByDeviceId: deviceId, + ), ), ), - ); + ); - final response = await channel.sendStaticLocation( - id: locationId, - messageText: 'Location shared', - createdByDeviceId: deviceId, - location: coordinates, - extraData: {'custom': 'data'}, - ); + final response = await tester.channel.sendStaticLocation( + id: locationId, + messageText: 'Location shared', + createdByDeviceId: deviceId, + location: coordinates, + extraData: {'custom': 'data'}, + ); - expect(response, isNotNull); - expect(response.message.id, locationId); - expect(response.message.text, 'Location shared'); - expect(response.message.extraData['custom'], 'data'); - expect(response.message.sharedLocation, isNotNull); + expect(response, isNotNull); + expect(response.message.id, locationId); + expect(response.message.text, 'Location shared'); + expect(response.message.extraData['custom'], 'data'); + expect(response.message.sharedLocation, isNotNull); - verify( - () => client.sendMessage(any(), channelId, channelType), - ).called(1); - }); + tester.verifyApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(Message(id: locationId))), + ), + ); + }, + ); }); group('`.startLiveLocationSharing`', () { @@ -1206,30 +1479,37 @@ void main() { longitude: -74.0060, ); - test( + channelTest( 'should create message with live location and call sendMessage', - () async { - when( - () => client.sendMessage(any(), channelId, channelType), - ).thenAnswer( - (_) async => SendMessageResponse() - ..message = Message( + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelLocationApi()), + body: (tester) async { + tester.mockApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(Message(id: locationId))), + ), + result: createDefaultSendMessageResponse( + message: Message( id: locationId, text: 'Location shared', extraData: const {'custom': 'data'}, sharedLocation: Location( - channelCid: channel.cid, + channelCid: tester.channel.cid, messageId: locationId, - userId: client.state.currentUser?.id, + userId: tester.currentUser?.id, latitude: coordinates.latitude, longitude: coordinates.longitude, createdByDeviceId: deviceId, endAt: endSharingAt, ), ), + ), ); - final response = await channel.startLiveLocationSharing( + final response = await tester.channel.startLiveLocationSharing( id: locationId, messageText: 'Location shared', createdByDeviceId: deviceId, @@ -1245,9 +1525,13 @@ void main() { expect(response.message.sharedLocation, isNotNull); expect(response.message.sharedLocation?.endAt, endSharingAt); - verify( - () => client.sendMessage(any(), channelId, channelType), - ).called(1); + tester.verifyApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(Message(id: locationId))), + ), + ); }, ); }); @@ -1255,525 +1539,747 @@ void main() { group('`.createDraft`', () { final draftMessage = DraftMessage(text: 'Draft message text'); - setUp(() { - when( - () => client.createDraft( - draftMessage, - channelId, - channelType, - ), - ).thenAnswer( - (_) async => CreateDraftResponse() - ..draft = Draft( - channelCid: channelCid, - createdAt: DateTime.now(), - message: draftMessage, + channelTest( + 'should call client.createDraft', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelDrafts, + body: (tester) async { + tester.mockApi( + (api) => api.message.createDraft(_channelId, _channelType, draftMessage), + result: createDefaultCreateDraftResponse( + draft: createDefaultDraft(channelCid: _channelCid, message: draftMessage), ), - ); - }); + ); - test('should call client.createDraft', () async { - final res = await channel.createDraft(draftMessage); + final res = await tester.channel.createDraft(draftMessage); - expect(res, isNotNull); - expect(res.draft.message, draftMessage); + expect(res, isNotNull); + expect(res.draft.message, draftMessage); - verify( - () => channel.client.createDraft( - draftMessage, - channelId, - channelType, - ), - ).called(1); - }); + tester.verifyApi( + (api) => api.message.createDraft(_channelId, _channelType, draftMessage), + ); + }, + ); }); group('`.getDraft`', () { final draftMessage = DraftMessage(text: 'Draft message text'); - setUp(() { - when( - () => client.getDraft( - channelId, - channelType, - parentId: any(named: 'parentId'), - ), - ).thenAnswer( - (_) async => GetDraftResponse() - ..draft = Draft( - channelCid: channelCid, - createdAt: DateTime.now(), - message: draftMessage, + channelTest( + 'should call client.getDraft', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelDrafts, + body: (tester) async { + tester.mockApi( + (api) => api.message.getDraft(_channelId, _channelType), + result: createDefaultGetDraftResponse( + draft: createDefaultDraft(channelCid: _channelCid, message: draftMessage), ), - ); - }); + ); - test('should call client.getDraft', () async { - final res = await channel.getDraft(); + final res = await tester.channel.getDraft(); - expect(res, isNotNull); - expect(res.draft.message, draftMessage); + expect(res, isNotNull); + expect(res.draft.message, draftMessage); - verify( - () => channel.client.getDraft( - channelId, - channelType, - ), - ).called(1); - }); + tester.verifyApi( + (api) => api.message.getDraft(_channelId, _channelType), + ); + }, + ); - test('with parentId should pass parentId to client', () async { - const parentId = 'parent-123'; - final res = await channel.getDraft(parentId: parentId); + channelTest( + 'with parentId should pass parentId to client', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelDrafts, + body: (tester) async { + const parentId = 'parent-123'; + tester.mockApi( + (api) => api.message.getDraft(_channelId, _channelType, parentId: parentId), + result: createDefaultGetDraftResponse( + draft: createDefaultDraft(channelCid: _channelCid, message: draftMessage), + ), + ); - expect(res, isNotNull); - expect(res.draft.message, draftMessage); + final res = await tester.channel.getDraft(parentId: parentId); - verify( - () => channel.client.getDraft( - channelId, - channelType, - parentId: parentId, - ), - ).called(1); - }); + expect(res, isNotNull); + expect(res.draft.message, draftMessage); + + tester.verifyApi( + (api) => api.message.getDraft(_channelId, _channelType, parentId: parentId), + ); + }, + ); }); group('`.deleteDraft`', () { - setUp(() { - when( - () => client.deleteDraft( - channelId, - channelType, - parentId: any(named: 'parentId'), - ), - ).thenAnswer((_) async => EmptyResponse()); - }); + channelTest( + 'should call client.deleteDraft', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelDrafts, + body: (tester) async { + tester.mockApi( + (api) => api.message.deleteDraft(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); - test('should call client.deleteDraft', () async { - final res = await channel.deleteDraft(); + final res = await tester.channel.deleteDraft(); - expect(res, isNotNull); + expect(res, isNotNull); - verify( - () => channel.client.deleteDraft( - channelId, - channelType, - ), - ).called(1); - }); + tester.verifyApi( + (api) => api.message.deleteDraft(_channelId, _channelType), + ); + }, + ); + + channelTest( + 'with parentId should pass parentId to client', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelDrafts, + body: (tester) async { + const parentId = 'parent-123'; + tester.mockApi( + (api) => api.message.deleteDraft(_channelId, _channelType, parentId: parentId), + result: createDefaultEmptyResponse(), + ); - test('with parentId should pass parentId to client', () async { - const parentId = 'parent-123'; - final res = await channel.deleteDraft(parentId: parentId); + final res = await tester.channel.deleteDraft(parentId: parentId); - expect(res, isNotNull); + expect(res, isNotNull); - verify( - () => channel.client.deleteDraft( - channelId, - channelType, - parentId: parentId, - ), - ).called(1); - }); + tester.verifyApi( + (api) => api.message.deleteDraft(_channelId, _channelType, parentId: parentId), + ); + }, + ); }); group('`.createReminder`', () { const messageId = 'test-message-id'; - - setUp(() { - when( - () => client.createReminder( - messageId, - remindAt: any(named: 'remindAt'), - ), - ).thenAnswer( - (_) async => CreateReminderResponse() - ..reminder = MessageReminder( - messageId: messageId, - channelCid: channelCid, - userId: 'test-user-id', - remindAt: DateTime(2024, 6, 15, 14, 30), - createdAt: DateTime.now(), - updatedAt: DateTime.now(), + final reminderRemindAt = DateTime.utc(2024, 6, 15, 14, 30); + + channelTest( + 'should call client.createReminder', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelReminders, + body: (tester) async { + tester.mockApi( + (api) => api.reminders.createReminder(messageId), + result: createDefaultCreateReminderResponse( + reminder: createDefaultMessageReminder( + messageId: messageId, + channelCid: _channelCid, + remindAt: reminderRemindAt, + ), ), - ); - }); + ); - test('should call client.createReminder', () async { - final res = await channel.createReminder(messageId); + final res = await tester.channel.createReminder(messageId); - expect(res, isNotNull); - expect(res.reminder.messageId, messageId); + expect(res, isNotNull); + expect(res.reminder.messageId, messageId); - verify(() => channel.client.createReminder(messageId)).called(1); - }); + tester.verifyApi( + (api) => api.reminders.createReminder(messageId), + ); + }, + ); - test('with remindAt should pass remindAt to client', () async { - final remindAt = DateTime(2024, 6, 15, 14, 30); - final res = await channel.createReminder(messageId, remindAt: remindAt); + channelTest( + 'with remindAt should pass remindAt to client', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelReminders, + body: (tester) async { + final remindAt = DateTime.utc(2024, 6, 15, 14, 30); + tester.mockApi( + (api) => api.reminders.createReminder(messageId, remindAt: remindAt), + result: createDefaultCreateReminderResponse( + reminder: createDefaultMessageReminder( + messageId: messageId, + channelCid: _channelCid, + remindAt: reminderRemindAt, + ), + ), + ); - expect(res, isNotNull); - expect(res.reminder.messageId, messageId); - expect(res.reminder.remindAt, remindAt); + final res = await tester.channel.createReminder(messageId, remindAt: remindAt); - verify( - () => channel.client.createReminder( - messageId, - remindAt: remindAt, - ), - ).called(1); - }); + expect(res, isNotNull); + expect(res.reminder.messageId, messageId); + expect(res.reminder.remindAt, remindAt); + + tester.verifyApi( + (api) => api.reminders.createReminder(messageId, remindAt: remindAt), + ); + }, + ); }); group('`.updateReminder`', () { const messageId = 'test-message-id'; - - setUp(() { - when( - () => client.updateReminder( - messageId, - remindAt: any(named: 'remindAt'), - ), - ).thenAnswer( - (_) async => UpdateReminderResponse() - ..reminder = MessageReminder( - messageId: messageId, - channelCid: channelCid, - userId: 'test-user-id', - remindAt: DateTime(2024, 8, 20, 16, 45), - createdAt: DateTime.now(), - updatedAt: DateTime.now(), + final reminderRemindAt = DateTime.utc(2024, 8, 20, 16, 45); + + channelTest( + 'should call client.updateReminder', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelReminders, + body: (tester) async { + tester.mockApi( + (api) => api.reminders.updateReminder(messageId), + result: createDefaultUpdateReminderResponse( + reminder: createDefaultMessageReminder( + messageId: messageId, + channelCid: _channelCid, + remindAt: reminderRemindAt, + ), ), - ); - }); + ); - test('should call client.updateReminder', () async { - final res = await channel.updateReminder(messageId); + final res = await tester.channel.updateReminder(messageId); - expect(res, isNotNull); - expect(res.reminder.messageId, messageId); + expect(res, isNotNull); + expect(res.reminder.messageId, messageId); + + tester.verifyApi( + (api) => api.reminders.updateReminder(messageId), + ); + }, + ); - verify(() => channel.client.updateReminder(messageId)).called(1); - }); + channelTest( + 'with remindAt should pass remindAt to client', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelReminders, + body: (tester) async { + final remindAt = DateTime.utc(2024, 8, 20, 16, 45); + tester.mockApi( + (api) => api.reminders.updateReminder(messageId, remindAt: remindAt), + result: createDefaultUpdateReminderResponse( + reminder: createDefaultMessageReminder( + messageId: messageId, + channelCid: _channelCid, + remindAt: reminderRemindAt, + ), + ), + ); - test('with remindAt should pass remindAt to client', () async { - final remindAt = DateTime(2024, 8, 20, 16, 45); - final res = await channel.updateReminder(messageId, remindAt: remindAt); + final res = await tester.channel.updateReminder(messageId, remindAt: remindAt); - expect(res, isNotNull); - expect(res.reminder.messageId, messageId); - expect(res.reminder.remindAt, remindAt); + expect(res, isNotNull); + expect(res.reminder.messageId, messageId); + expect(res.reminder.remindAt, remindAt); - verify( - () => channel.client.updateReminder( - messageId, - remindAt: remindAt, - ), - ).called(1); - }); + tester.verifyApi( + (api) => api.reminders.updateReminder(messageId, remindAt: remindAt), + ); + }, + ); }); group('`.deleteReminder`', () { const messageId = 'test-message-id'; - setUp(() { - when(() => client.deleteReminder(messageId)).thenAnswer( - (_) async => EmptyResponse(), - ); - }); + channelTest( + 'should call client.deleteReminder', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelReminders, + body: (tester) async { + tester.mockApi( + (api) => api.reminders.deleteReminder(messageId), + result: createDefaultEmptyResponse(), + ); - test('should call client.deleteReminder', () async { - final res = await channel.deleteReminder(messageId); + final res = await tester.channel.deleteReminder(messageId); - expect(res, isNotNull); + expect(res, isNotNull); - verify(() => channel.client.deleteReminder(messageId)).called(1); - }); + tester.verifyApi( + (api) => api.reminders.deleteReminder(messageId), + ); + }, + ); }); group('`.updateMessage`', () { - test('should work fine', () async { - final message = Message( - id: 'test-message-id', - state: MessageState.sent, - ); + channelTest( + 'should work fine', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelUpdateMessage()), + body: (tester) async { + final message = Message( + id: 'test-message-id', + state: MessageState.sent, + ); - final updateMessageResponse = UpdateMessageResponse()..message = message; + tester.mockApi( + (api) => api.message.updateMessage(any(that: isSameMessageAs(message))), + result: createDefaultUpdateMessageResponse(message: message), + ); - when( - () => client.updateMessage(any(that: isSameMessageAs(message))), - ).thenAnswer((_) async => updateMessageResponse); + final messagesEmits = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.updating), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.updated), + matchMessageState: true, + ), + ], + ]), + ); - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith(state: MessageState.updating), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith(state: MessageState.updated), - matchMessageState: true, - ), - ], - ]), - ); + final res = await tester.channel.updateMessage(message); - final res = await channel.updateMessage(message); + expect(res, isNotNull); + expect(res.message.id, message.id); - expect(res, isNotNull); - expect(res.message.id, message.id); + await messagesEmits; - verify( - () => client.updateMessage( - any(that: isSameMessageAs(message)), - ), - ).called(1); - }); + tester.verifyApi( + (api) => api.message.updateMessage(any(that: isSameMessageAs(message))), + ); + }, + ); - 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: index * 33, path: 'test-file-path'), - ), - ); + channelTest( + 'with attachments should work just fine', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelUpdateMessage()), + body: (tester) async { + final attachments = List.generate( + 3, + (index) => Attachment( + id: 'test-attachment-id-$index', + type: index.isEven ? 'image' : 'file', + file: AttachmentFile(size: index * 33, path: 'test-file-path'), + ), + ); - final message = Message( - id: 'test-message-id', - attachments: attachments, - ); + final message = Message( + id: 'test-message-id', + attachments: attachments, + ); - final sendImageResponse = SendImageResponse()..file = 'test-image-url'; - final sendFileResponse = SendFileResponse()..file = 'test-file-url'; + tester + ..mockApi( + (api) => api.fileUploader.sendImage( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + result: SendImageResponse()..file = 'test-image-url', + ) + ..mockApi( + (api) => api.fileUploader.sendFile( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + result: SendFileResponse()..file = 'test-file-url', + ) + ..mockApi( + (api) => api.message.updateMessage(any(that: isSameMessageAs(message))), + result: createDefaultUpdateMessageResponse( + message: message.copyWith( + state: MessageState.sent, + attachments: attachments + .map((it) => it.copyWith(uploadState: const UploadState.success())) + .toList(growable: false), + ), + ), + ); - when( - () => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - ).thenAnswer((_) async => sendImageResponse); - - when( - () => client.sendFile( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - ).thenAnswer((_) async => sendFileResponse); + final messagesEmits = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder( + [ + // preparing attachments to upload + [ + isSameMessageAs( + message.copyWith( + state: MessageState.updating, + attachments: [ + ...attachments.map((it) => it.copyWith(uploadState: const UploadState.preparing())), + ], + ), + matchMessageState: true, + matchAttachments: true, + matchAttachmentsUploadState: true, + ), + ], + // 0th attachment is successfully uploaded + [ + isSameMessageAs( + message.copyWith( + state: MessageState.updating, + attachments: [...attachments] + ..[0] = attachments[0].copyWith( + uploadState: const UploadState.success(), + ), + ), + matchMessageState: true, + matchAttachments: true, + matchAttachmentsUploadState: true, + ), + ], + // 0th and 1st attachment is successfully uploaded + [ + isSameMessageAs( + message.copyWith( + state: MessageState.updating, + attachments: [...attachments] + ..[0] = attachments[0].copyWith( + uploadState: const UploadState.success(), + ) + ..[1] = attachments[1].copyWith( + uploadState: const UploadState.success(), + ), + ), + matchMessageState: true, + matchAttachments: true, + matchAttachmentsUploadState: true, + ), + ], + // all the attachments are successfully uploaded + [ + isSameMessageAs( + message.copyWith( + state: MessageState.updating, + attachments: [...attachments.map((it) => it.copyWith(uploadState: const UploadState.success()))], + ), + matchMessageState: true, + matchAttachments: true, + matchAttachmentsUploadState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.updated, + attachments: [...attachments.map((it) => it.copyWith(uploadState: const UploadState.success()))], + ), + matchMessageState: true, + matchAttachments: true, + matchAttachmentsUploadState: true, + ), + ], + ], + ), + ); - when( - () => client.updateMessage( - any(that: isSameMessageAs(message)), - ), - ).thenAnswer( - (_) async => UpdateMessageResponse() - ..message = message.copyWith( - state: MessageState.sent, - attachments: attachments - .map((it) => it.copyWith(uploadState: const UploadState.success())) - .toList(growable: false), + final res = await tester.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, + ); - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder( - [ - // preparing attachments to upload + await messagesEmits; + + tester + ..verifyApiCalled( + (api) => api.fileUploader.sendImage( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + times: 2, + ) + ..verifyApi( + (api) => api.fileUploader.sendFile( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ) + ..verifyApi( + (api) => api.message.updateMessage(any(that: isSameMessageAs(message))), + ); + }, + ); + + channelTest( + 'should update message state even when the error is not a StreamChatException', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelUpdateMessage()), + body: (tester) async { + final message = Message( + id: 'test-message-id-error-1', + state: MessageState.sent, + ); + + tester.mockApiFailure( + (api) => api.message.updateMessage( + any(that: isSameMessageAs(message)), + skipEnrichUrl: true, + ), + error: ArgumentError('Invalid argument'), + ); + + final messagesEmits = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ [ isSameMessageAs( - message.copyWith( - state: MessageState.updating, - attachments: [...attachments.map((it) => it.copyWith(uploadState: const UploadState.preparing()))], - ), + message.copyWith(state: MessageState.updating), matchMessageState: true, - matchAttachments: true, - matchAttachmentsUploadState: true, ), ], - // 0th attachment is successfully uploaded [ isSameMessageAs( message.copyWith( - state: MessageState.updating, - attachments: [...attachments] - ..[0] = attachments[0].copyWith( - uploadState: const UploadState.success(), - ), + state: MessageState.updatingFailed( + skipPush: false, + skipEnrichUrl: true, + ), ), matchMessageState: true, - matchAttachments: true, - matchAttachmentsUploadState: true, ), ], - // 0th and 1st attachment is successfully uploaded + ]), + ); + + try { + await tester.channel.updateMessage(message, skipEnrichUrl: true); + } catch (e) { + expect(e, isA()); + } + + await messagesEmits; + }, + ); + + channelTest( + 'should mark the message failed and report the failure as retriable with skipPush: false, skipEnrichUrl: true', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelUpdateMessage()), + body: (tester) async { + final message = Message( + id: 'test-message-id-retry-1', + state: MessageState.sent, + ); + + // Create a retriable error (data == null). The retriable failure arms + // the live retry queue; its immediate retry attempt succeeds, so the + // queue drains without waiting on backoff timers. + tester.mockApiFailureOnce( + (api) => api.message.updateMessage( + any(that: isSameMessageAs(message)), + skipEnrichUrl: true, + ), + error: createDefaultNetworkError(code: StreamErrorCode.requestTimeout, statusCode: 408), + result: createDefaultUpdateMessageResponse(message: message), + ); + + final messagesEmits = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ [ isSameMessageAs( - message.copyWith( - state: MessageState.updating, - attachments: [...attachments] - ..[0] = attachments[0].copyWith( - uploadState: const UploadState.success(), - ) - ..[1] = attachments[1].copyWith( - uploadState: const UploadState.success(), - ), - ), + message.copyWith(state: MessageState.updating), matchMessageState: true, - matchAttachments: true, - matchAttachmentsUploadState: true, ), ], - // all the attachments are successfully uploaded [ isSameMessageAs( message.copyWith( - state: MessageState.updating, - attachments: [...attachments.map((it) => it.copyWith(uploadState: const UploadState.success()))], + state: MessageState.updatingFailed( + skipPush: false, + skipEnrichUrl: true, + ), ), matchMessageState: true, - matchAttachments: true, - matchAttachmentsUploadState: true, ), ], + // The retry queue retries the queued update with the same flags. [ isSameMessageAs( - message.copyWith( - state: MessageState.updated, - attachments: [...attachments.map((it) => it.copyWith(uploadState: const UploadState.success()))], - ), + message.copyWith(state: MessageState.updating), matchMessageState: true, - matchAttachments: true, - matchAttachmentsUploadState: true, ), ], - ], - ), - ); + [ + isSameMessageAs( + message.copyWith(state: MessageState.updated), + matchMessageState: true, + ), + ], + ]), + ); - final res = await channel.updateMessage(message); + try { + await tester.channel.updateMessage(message, skipEnrichUrl: true); + } catch (e) { + expect(e, isA()); - 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, - ); + final networkError = e as StreamApiException; + expect(networkError.code, equals(StreamErrorCode.requestTimeout)); + expect(networkError.isRetriable, isTrue); + } - verify( - () => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - ).called(2); - - verify( - () => client.sendFile( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - ).called(1); + await messagesEmits; - verify( - () => client.updateMessage( - any(that: isSameMessageAs(message)), - ), - ).called(1); - }); + // Initial attempt + the retry-queue retry, both with the same flags. + tester.verifyApiCalled( + (api) => api.message.updateMessage( + any(that: isSameMessageAs(message)), + skipEnrichUrl: true, + ), + times: 2, + ); + }, + ); - test('should update message state even when the error is not a StreamChatException', () async { - final message = Message( - id: 'test-message-id-error-1', - state: MessageState.sent, - ); + channelTest( + 'should mark the message failed and report the failure as retriable with skipPush: true, skipEnrichUrl: false', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelUpdateMessage()), + body: (tester) async { + final message = Message( + id: 'test-message-id-retry-2', + state: MessageState.sent, + ); - when( - () => client.updateMessage( - any(that: isSameMessageAs(message)), - skipEnrichUrl: true, - ), - ).thenThrow(ArgumentError('Invalid argument')); + // Create a retriable error (data == null). The retriable failure arms + // the live retry queue; its immediate retry attempt succeeds, so the + // queue drains without waiting on backoff timers. + tester.mockApiFailureOnce( + (api) => api.message.updateMessage( + any(that: isSameMessageAs(message)), + skipPush: true, + ), + error: createDefaultNetworkError(code: StreamErrorCode.internalError, statusCode: 500), + result: createDefaultUpdateMessageResponse(message: message), + ); - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith(state: MessageState.updating), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith( - state: MessageState.updatingFailed( - skipPush: false, - skipEnrichUrl: true, + final messagesEmits = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.updating), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.updatingFailed( + skipPush: true, + skipEnrichUrl: false, + ), ), + matchMessageState: true, ), - matchMessageState: true, - ), - ], - ]), - ); + ], + // The retry queue retries the queued update with the same flags. + [ + isSameMessageAs( + message.copyWith(state: MessageState.updating), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.updated), + matchMessageState: true, + ), + ], + ]), + ); - try { - await channel.updateMessage(message, skipEnrichUrl: true); - } catch (e) { - expect(e, isA()); - } - }); + try { + await tester.channel.updateMessage(message, skipPush: true); + } catch (e) { + expect(e, isA()); - test( - 'should mark the message failed and report the failure as retriable with skipPush: false, skipEnrichUrl: true', - () async { + final networkError = e as StreamApiException; + expect(networkError.code, equals(StreamErrorCode.internalError)); + expect(networkError.isRetriable, isTrue); + } + + await messagesEmits; + + // Initial attempt + the retry-queue retry, both with the same flags. + tester.verifyApiCalled( + (api) => api.message.updateMessage( + any(that: isSameMessageAs(message)), + skipPush: true, + ), + times: 2, + ); + }, + ); + + channelTest( + 'should handle a non-retriable failure with skipPush: true, skipEnrichUrl: true', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelUpdateMessage()), + body: (tester) async { final message = Message( - id: 'test-message-id-retry-1', + id: 'test-message-id-error-2', state: MessageState.sent, ); - // Create a retriable error (data == null) - when( - () => client.updateMessage( + tester.mockApiFailure( + (api) => api.message.updateMessage( any(that: isSameMessageAs(message)), + skipPush: true, skipEnrichUrl: true, ), - ).thenThrow( - apiException(code: StreamErrorCode.requestTimeout, statusCode: 408, message: 'Request timed out'), + error: createDefaultNetworkError(code: StreamErrorCode.notAllowed, statusCode: 403), ); - expectLater( + final messagesEmits = expectLater( // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), + tester.channelState?.messagesStream.skip(1), emitsInOrder([ [ isSameMessageAs( @@ -1785,7 +2291,7 @@ void main() { isSameMessageAs( message.copyWith( state: MessageState.updatingFailed( - skipPush: false, + skipPush: true, skipEnrichUrl: true, ), ), @@ -1796,38 +2302,43 @@ void main() { ); try { - await channel.updateMessage(message, skipEnrichUrl: true); + await tester.channel.updateMessage( + message, + skipPush: true, + skipEnrichUrl: true, + ); } catch (e) { expect(e, isA()); final networkError = e as StreamApiException; - expect(networkError.code, equals(StreamErrorCode.requestTimeout)); - expect(networkError.isRetriable, isTrue); + expect(networkError.code, equals(StreamErrorCode.notAllowed)); } + + await messagesEmits; }, ); - test( - 'should mark the message failed and report the failure as retriable with skipPush: true, skipEnrichUrl: false', - () async { + channelTest( + 'should handle a non-retriable failure with skipPush: false, skipEnrichUrl: false', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelUpdateMessage()), + body: (tester) async { final message = Message( - id: 'test-message-id-retry-2', + id: 'test-message-id-error-3', state: MessageState.sent, ); - // Create a retriable error (data == null) - when( - () => client.updateMessage( + tester.mockApiFailure( + (api) => api.message.updateMessage( any(that: isSameMessageAs(message)), - skipPush: true, ), - ).thenThrow( - apiException(code: StreamErrorCode.internalError, statusCode: 500, message: 'Internal system error'), + error: createDefaultNetworkError(code: StreamErrorCode.notAllowed, statusCode: 403), ); - expectLater( + final messagesEmits = expectLater( // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), + tester.channelState?.messagesStream.skip(1), emitsInOrder([ [ isSameMessageAs( @@ -1839,7 +2350,7 @@ void main() { isSameMessageAs( message.copyWith( state: MessageState.updatingFailed( - skipPush: true, + skipPush: false, skipEnrichUrl: false, ), ), @@ -1850,318 +2361,263 @@ void main() { ); try { - await channel.updateMessage(message, skipPush: true); + await tester.channel.updateMessage(message); } catch (e) { expect(e, isA()); final networkError = e as StreamApiException; - expect(networkError.code, equals(StreamErrorCode.internalError)); - expect(networkError.isRetriable, isTrue); + expect(networkError.code, equals(StreamErrorCode.notAllowed)); } + + await messagesEmits; }, ); + }); - test('should handle a non-retriable failure with skipPush: true, skipEnrichUrl: true', () async { - final message = Message( - id: 'test-message-id-error-2', - state: MessageState.sent, - ); + group('`ChannelClientState.updateMessage`', () { + channelTest( + 'upsert: true (default) adds an unknown message', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelUpdateMessage()), + body: (tester) async { + final message = Message( + id: 'unknown-message', + user: tester.currentUser, + text: 'hello', + createdAt: DateTime.utc(2026), + ); - when( - () => client.updateMessage( - any(that: isSameMessageAs(message)), - skipPush: true, - skipEnrichUrl: true, - ), - ).thenThrow(apiException(code: StreamErrorCode.notAllowed, statusCode: 403)); + expect(tester.channelState!.messages, isEmpty); - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith(state: MessageState.updating), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith( - state: MessageState.updatingFailed( - skipPush: true, - skipEnrichUrl: true, - ), - ), - matchMessageState: true, - ), - ], - ]), - ); + tester.channelState!.updateMessage(message); - try { - await channel.updateMessage( - message, - skipPush: true, - skipEnrichUrl: true, + expect(tester.channelState!.messages.map((m) => m.id), ['unknown-message']); + }, + ); + + channelTest( + 'upsert: false does NOT add an unknown message', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelUpdateMessage()), + body: (tester) async { + final message = Message( + id: 'unknown-message', + user: tester.currentUser, + text: 'hello', + createdAt: DateTime.utc(2026), ); - } catch (e) { - expect(e, isA()); - final networkError = e as StreamApiException; - expect(networkError.code, equals(StreamErrorCode.notAllowed)); - } - }); + expect(tester.channelState!.messages, isEmpty); - test('should handle a non-retriable failure with skipPush: false, skipEnrichUrl: false', () async { + tester.channelState!.updateMessage(message, upsert: false); + + expect(tester.channelState!.messages, isEmpty); + }, + ); + + channelTest( + 'upsert: false updates a message already in the window', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelUpdateMessage()), + body: (tester) async { + const messageId = 'known-message'; + final seeded = Message( + id: messageId, + user: tester.currentUser, + text: 'old', + createdAt: DateTime.utc(2026), + ); + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith(messages: [seeded]), + ); + + tester.channelState!.updateMessage( + seeded.copyWith(text: 'new'), + upsert: false, + ); + + final stored = tester.channelState!.messages.single; + expect(stored.id, equals(messageId)); + expect(stored.text, equals('new')); + }, + ); + }); + + channelTest( + '`.partialUpdateMessage`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelUpdateMessage()), + body: (tester) async { final message = Message( - id: 'test-message-id-error-3', + id: 'test-message-id', state: MessageState.sent, ); - when( - () => client.updateMessage( - any(that: isSameMessageAs(message)), - ), - ).thenThrow(apiException(code: StreamErrorCode.notAllowed, statusCode: 403)); + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + final updateMessageResponse = createDefaultUpdateMessageResponse( + message: message.copyWith(text: set['text'], pinExpires: null), + ); - expectLater( + tester.mockApi( + (api) => api.message.partialUpdateMessage(message.id, set: set, unset: unset), + result: updateMessageResponse, + ); + + final messagesEmits = expectLater( // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), + tester.channelState?.messagesStream.skip(1), emitsInOrder([ [ isSameMessageAs( - message.copyWith(state: MessageState.updating), + message.copyWith( + state: MessageState.updating, + ), + matchText: true, matchMessageState: true, ), ], [ isSameMessageAs( - message.copyWith( - state: MessageState.updatingFailed( - skipPush: false, - skipEnrichUrl: false, - ), + updateMessageResponse.message.copyWith( + state: MessageState.updated, ), + matchText: true, matchMessageState: true, ), ], ]), ); - try { - await channel.updateMessage(message); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamApiException; - expect(networkError.code, equals(StreamErrorCode.notAllowed)); - } - }); - }); - - group('`ChannelClientState.updateMessage`', () { - test('upsert: true (default) adds an unknown message', () async { - final message = Message( - id: 'unknown-message', - user: client.state.currentUser, - text: 'hello', - createdAt: DateTime.utc(2026), + final res = await tester.channel.partialUpdateMessage( + message, + set: set, + unset: unset, ); - expect(channel.state!.messages, isEmpty); - - channel.state!.updateMessage(message); + expect(res, isNotNull); + expect(res.message.id, message.id); + expect(res.message.id, message.id); + expect(res.message.text, set['text']); + expect(res.message.pinExpires, isNull); - expect(channel.state!.messages.map((m) => m.id), ['unknown-message']); - }); + await messagesEmits; - test('upsert: false does NOT add an unknown message', () async { - final message = Message( - id: 'unknown-message', - user: client.state.currentUser, - text: 'hello', - createdAt: DateTime.utc(2026), + tester.verifyApi( + (api) => api.message.partialUpdateMessage(message.id, set: set, unset: unset), ); - - expect(channel.state!.messages, isEmpty); - - channel.state!.updateMessage(message, upsert: false); - - expect(channel.state!.messages, isEmpty); - }); - - test('upsert: false updates a message already in the window', () async { - const messageId = 'known-message'; - final seeded = Message( - id: messageId, - user: client.state.currentUser, - text: 'old', - createdAt: DateTime.utc(2026), - ); - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(messages: [seeded]), - ); - - channel.state!.updateMessage( - seeded.copyWith(text: 'new'), - upsert: false, - ); - - final stored = channel.state!.messages.single; - expect(stored.id, equals(messageId)); - expect(stored.text, equals('new')); - }); - }); - - test('`.partialUpdateMessage`', () async { - final message = Message( - id: 'test-message-id', - state: MessageState.sent, - ); - - const set = {'text': 'Update Message text'}; - const unset = ['pinExpires']; - - final updateMessageResponse = UpdateMessageResponse() - ..message = message.copyWith(text: set['text'], pinExpires: null); - - when( - () => client.partialUpdateMessage(message.id, set: set, unset: unset), - ).thenAnswer((_) async => updateMessageResponse); - - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith( - state: MessageState.updating, - ), - matchText: true, - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - updateMessageResponse.message.copyWith( - state: MessageState.updated, - ), - matchText: true, - matchMessageState: true, - ), - ], - ]), - ); - - final res = await channel.partialUpdateMessage( - message, - set: set, - unset: unset, - ); - - expect(res, isNotNull); - expect(res.message.id, message.id); - expect(res.message.id, message.id); - expect(res.message.text, set['text']); - expect(res.message.pinExpires, isNull); - - verify( - () => client.partialUpdateMessage(message.id, set: set, unset: unset), - ).called(1); - }); + }, + ); group('`.partialUpdateMessage` error handling', () { - test('should update message state even when the error is not a StreamChatException', () async { - final message = Message( - id: 'test-message-id-error-partial-1', - state: MessageState.sent, - ); + channelTest( + 'should update message state even when the error is not a StreamChatException', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelUpdateMessage()), + body: (tester) async { + final message = Message( + id: 'test-message-id-error-partial-1', + state: MessageState.sent, + ); - // Add message to channel state first - channel.state?.updateMessage(message); + // Add message to channel state first + tester.channelState?.updateMessage(message); - const set = {'text': 'Update Message text'}; - const unset = ['pinExpires']; + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; - when( - () => client.partialUpdateMessage( - message.id, - set: set, - unset: unset, - ), - ).thenThrow(ArgumentError('Invalid argument')); + tester.mockApiFailure( + (api) => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + ), + error: ArgumentError('Invalid argument'), + ); - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith( - state: MessageState.updating, + final messagesEmits = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + state: MessageState.updating, + ), + matchText: true, + matchMessageState: true, ), - matchText: true, - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith( - state: MessageState.partialUpdatingFailed( - set: set, - unset: unset, - skipEnrichUrl: false, + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.partialUpdatingFailed( + set: set, + unset: unset, + skipEnrichUrl: false, + ), ), + matchText: true, + matchMessageState: true, ), - matchText: true, - matchMessageState: true, - ), - ], - ]), - ); - - try { - await channel.partialUpdateMessage( - message, - set: set, - unset: unset, + ], + ]), ); - } catch (e) { - expect(e, isA()); - } - }); - test( + try { + await tester.channel.partialUpdateMessage( + message, + set: set, + unset: unset, + ); + } catch (e) { + expect(e, isA()); + } + + await messagesEmits; + }, + ); + + channelTest( 'should mark the message failed and report the failure as retriable with skipEnrichUrl: true', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelUpdateMessage()), + body: (tester) async { final message = Message( id: 'test-message-id-retry-partial-1', state: MessageState.sent, ); // Add message to channel state first - channel.state?.updateMessage(message); + tester.channelState?.updateMessage(message); const set = {'text': 'Update Message text'}; const unset = ['pinExpires']; - // Create a retriable error (data == null) - when( - () => client.partialUpdateMessage( + // Create a retriable error (data == null). The retriable failure arms + // the live retry queue; its immediate retry attempt succeeds, so the + // queue drains without waiting on backoff timers. + tester.mockApiFailureOnce( + (api) => api.message.partialUpdateMessage( message.id, set: set, unset: unset, skipEnrichUrl: true, ), - ).thenThrow( - apiException(code: StreamErrorCode.requestTimeout, statusCode: 408, message: 'Request timed out'), + error: createDefaultNetworkError(code: StreamErrorCode.requestTimeout, statusCode: 408), + result: createDefaultUpdateMessageResponse(message: message), ); - expectLater( + final messagesEmits = expectLater( // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), + tester.channelState?.messagesStream.skip(1), emitsInOrder([ [ isSameMessageAs( @@ -2185,11 +2641,27 @@ void main() { matchMessageState: true, ), ], + // The retry queue retries the queued partial update with the same + // set/unset and flags. + [ + isSameMessageAs( + message.copyWith(state: MessageState.updating), + matchText: true, + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.updated), + matchText: true, + matchMessageState: true, + ), + ], ]), ); try { - await channel.partialUpdateMessage( + await tester.channel.partialUpdateMessage( message, set: set, unset: unset, @@ -2202,37 +2674,56 @@ void main() { expect(networkError.code, equals(StreamErrorCode.requestTimeout)); expect(networkError.isRetriable, isTrue); } + + await messagesEmits; + + // Initial attempt + the retry-queue retry, both with the same + // set/unset and flags. + tester.verifyApiCalled( + (api) => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + skipEnrichUrl: true, + ), + times: 2, + ); }, ); - test( + channelTest( 'should mark the message failed and report the failure as retriable with skipEnrichUrl: false', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelUpdateMessage()), + body: (tester) async { final message = Message( id: 'test-message-id-retry-partial-2', state: MessageState.sent, ); // Add message to channel state first - channel.state?.updateMessage(message); + tester.channelState?.updateMessage(message); const set = {'text': 'Update Message text'}; const unset = ['pinExpires']; - // Create a retriable error (data == null) - when( - () => client.partialUpdateMessage( + // Create a retriable error (data == null). The retriable failure arms + // the live retry queue; its immediate retry attempt succeeds, so the + // queue drains without waiting on backoff timers. + tester.mockApiFailureOnce( + (api) => api.message.partialUpdateMessage( message.id, set: set, unset: unset, ), - ).thenThrow( - apiException(code: StreamErrorCode.internalError, statusCode: 500, message: 'Internal system error'), + error: createDefaultNetworkError(code: StreamErrorCode.internalError, statusCode: 500), + result: createDefaultUpdateMessageResponse(message: message), ); - expectLater( + final messagesEmits = expectLater( // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), + tester.channelState?.messagesStream.skip(1), emitsInOrder([ [ isSameMessageAs( @@ -2256,11 +2747,27 @@ void main() { matchMessageState: true, ), ], + // The retry queue retries the queued partial update with the same + // set/unset and flags. + [ + isSameMessageAs( + message.copyWith(state: MessageState.updating), + matchText: true, + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.updated), + matchText: true, + matchMessageState: true, + ), + ], ]), ); try { - await channel.partialUpdateMessage( + await tester.channel.partialUpdateMessage( message, set: set, unset: unset, @@ -2272,225 +2779,286 @@ void main() { expect(networkError.code, equals(StreamErrorCode.internalError)); expect(networkError.isRetriable, isTrue); } + + await messagesEmits; + + // Initial attempt + the retry-queue retry, both with the same + // set/unset and flags. + tester.verifyApiCalled( + (api) => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + ), + times: 2, + ); }, ); - test('should handle a non-retriable failure with skipEnrichUrl: true', () async { - final message = Message( - id: 'test-message-id-error-partial-2', - state: MessageState.sent, - ); + channelTest( + 'should handle a non-retriable failure with skipEnrichUrl: true', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelUpdateMessage()), + body: (tester) async { + final message = Message( + id: 'test-message-id-error-partial-2', + state: MessageState.sent, + ); - // Add message to channel state first - channel.state?.updateMessage(message); + // Add message to channel state first + tester.channelState?.updateMessage(message); - const set = {'text': 'Update Message text'}; - const unset = ['pinExpires']; + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; - when( - () => client.partialUpdateMessage( - message.id, - set: set, - unset: unset, - skipEnrichUrl: true, - ), - ).thenThrow(apiException(code: StreamErrorCode.notAllowed, statusCode: 403)); + tester.mockApiFailure( + (api) => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + skipEnrichUrl: true, + ), + error: createDefaultNetworkError(code: StreamErrorCode.notAllowed, statusCode: 403), + ); - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith( - state: MessageState.updating, + final messagesEmits = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + state: MessageState.updating, + ), + matchText: true, + matchMessageState: true, ), - matchText: true, - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith( - state: MessageState.partialUpdatingFailed( - set: set, - unset: unset, - skipEnrichUrl: true, + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.partialUpdatingFailed( + set: set, + unset: unset, + skipEnrichUrl: true, + ), ), + matchText: true, + matchMessageState: true, ), - matchText: true, - matchMessageState: true, - ), - ], - ]), - ); - - try { - await channel.partialUpdateMessage( - message, - set: set, - unset: unset, - skipEnrichUrl: true, + ], + ]), ); - } catch (e) { - expect(e, isA()); - final networkError = e as StreamApiException; - expect(networkError.code, equals(StreamErrorCode.notAllowed)); - } - }); + try { + await tester.channel.partialUpdateMessage( + message, + set: set, + unset: unset, + skipEnrichUrl: true, + ); + } catch (e) { + expect(e, isA()); - test('should handle a non-retriable failure with skipEnrichUrl: false', () async { - final message = Message( - id: 'test-message-id-error-partial-3', - state: MessageState.sent, - ); + final networkError = e as StreamApiException; + expect(networkError.code, equals(StreamErrorCode.notAllowed)); + } - // Add message to channel state first - channel.state?.updateMessage(message); + await messagesEmits; + }, + ); - const set = {'text': 'Update Message text'}; - const unset = ['pinExpires']; + channelTest( + 'should handle a non-retriable failure with skipEnrichUrl: false', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelUpdateMessage()), + body: (tester) async { + final message = Message( + id: 'test-message-id-error-partial-3', + state: MessageState.sent, + ); - when( - () => client.partialUpdateMessage( - message.id, - set: set, - unset: unset, - ), - ).thenThrow(apiException(code: StreamErrorCode.notAllowed, statusCode: 403)); + // Add message to channel state first + tester.channelState?.updateMessage(message); - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith( - state: MessageState.updating, - ), - matchText: true, - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith( - state: MessageState.partialUpdatingFailed( - set: set, - unset: unset, - skipEnrichUrl: false, - ), - ), - matchText: true, - matchMessageState: true, - ), - ], - ]), - ); + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; - try { - await channel.partialUpdateMessage( - message, - set: set, - unset: unset, + tester.mockApiFailure( + (api) => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + ), + error: createDefaultNetworkError(code: StreamErrorCode.notAllowed, statusCode: 403), ); - } catch (e) { - expect(e, isA()); - final networkError = e as StreamApiException; - expect(networkError.code, equals(StreamErrorCode.notAllowed)); - } - }); - }); + final messagesEmits = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + state: MessageState.updating, + ), + matchText: true, + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.partialUpdatingFailed( + set: set, + unset: unset, + skipEnrichUrl: false, + ), + ), + matchText: true, + matchMessageState: true, + ), + ], + ]), + ); - group('`.deleteMessage`', () { - test('should work fine', () async { - const messageId = 'test-message-id'; - final message = Message( - id: messageId, - createdAt: DateTime.now(), - state: MessageState.sent, - ); + try { + await tester.channel.partialUpdateMessage( + message, + set: set, + unset: unset, + ); + } catch (e) { + expect(e, isA()); - when(() => client.deleteMessage(messageId)).thenAnswer((_) async => EmptyResponse()); + final networkError = e as StreamApiException; + expect(networkError.code, equals(StreamErrorCode.notAllowed)); + } - // A soft delete only updates a message already in the loaded window, - // so seed it first — a delete must never insert a phantom record. - channel.state?.addNewMessage(message); + await messagesEmits; + }, + ); + }); - expectLater( - // skip the seeded message -> [message] - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith(state: MessageState.softDeleting), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith(state: MessageState.softDeleted), - matchMessageState: true, - ), - ], - ]), - ); + group('`.deleteMessage`', () { + channelTest( + 'should work fine', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelDeleteMessage()), + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + createdAt: DateTime.utc(2021, 3), + state: MessageState.sent, + ); - final res = await channel.deleteMessage(message); + tester.mockApi( + (api) => api.message.deleteMessage(messageId, hard: false), + result: createDefaultEmptyResponse(), + ); - expect(res, isNotNull); + // A soft delete only updates a message already in the loaded window, + // so seed it first — a delete must never insert a phantom record. + tester.channelState?.addNewMessage(message); - verify(() => client.deleteMessage(messageId)).called(1); - }); + final messagesEmits = expectLater( + // skip the seeded message -> [message] + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.softDeleting), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.softDeleted), + matchMessageState: true, + ), + ], + ]), + ); - test('should delete attachments for hard delete', () async { - final attachments = List.generate( - 3, - (index) => Attachment( - id: 'test-attachment-id-$index', - type: index.isEven ? 'image' : 'file', - file: AttachmentFile(size: index * 33, path: 'test-file-path'), - imageUrl: index.isEven ? 'test-image-url-$index' : null, - assetUrl: index.isOdd ? 'test-asset-url-$index' : null, - uploadState: const UploadState.success(), - ), - ); + final res = await tester.channel.deleteMessage(message); - const messageId = 'test-message-id'; - final message = Message( - id: messageId, - attachments: attachments, - createdAt: DateTime.now(), - state: MessageState.sent, - ); + expect(res, isNotNull); - when( - () => client.deleteMessage(messageId, hard: true), - ).thenAnswer((_) async => EmptyResponse()); + await messagesEmits; - when( - () => client.deleteImage(any(), channelId, channelType), - ).thenAnswer((_) async => EmptyResponse()); + tester.verifyApi( + (api) => api.message.deleteMessage(messageId, hard: false), + ); + }, + ); - when( - () => client.deleteFile(any(), channelId, channelType), - ).thenAnswer((_) async => EmptyResponse()); + channelTest( + 'should delete attachments for hard delete', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelDeleteMessage()), + body: (tester) async { + final attachments = List.generate( + 3, + (index) => Attachment( + id: 'test-attachment-id-$index', + type: index.isEven ? 'image' : 'file', + file: AttachmentFile(size: index * 33, path: 'test-file-path'), + imageUrl: index.isEven ? 'test-image-url-$index' : null, + assetUrl: index.isOdd ? 'test-asset-url-$index' : null, + uploadState: const UploadState.success(), + ), + ); - final res = await channel.deleteMessage(message, hard: true); - expect(res, isNotNull); + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + attachments: attachments, + createdAt: DateTime.utc(2021, 3), + state: MessageState.sent, + ); - verify(() => client.deleteMessage(messageId, hard: true)).called(1); + tester + ..mockApi( + (api) => api.message.deleteMessage(messageId, hard: true), + result: createDefaultEmptyResponse(), + ) + ..mockApi( + (api) => api.fileUploader.deleteImage(any(), _channelId, _channelType), + result: createDefaultEmptyResponse(), + ) + ..mockApi( + (api) => api.fileUploader.deleteFile(any(), _channelId, _channelType), + result: createDefaultEmptyResponse(), + ); - verify(() => client.deleteImage(any(), channelId, channelType)).called(2); + final res = await tester.channel.deleteMessage(message, hard: true); + expect(res, isNotNull); - verify(() => client.deleteFile(any(), channelId, channelType)).called(1); - }); + tester + ..verifyApi( + (api) => api.message.deleteMessage(messageId, hard: true), + ) + ..verifyApiCalled( + (api) => api.fileUploader.deleteImage(any(), _channelId, _channelType), + times: 2, + ) + ..verifyApi( + (api) => api.fileUploader.deleteFile(any(), _channelId, _channelType), + ); + }, + ); - test( + channelTest( 'should hard delete the message if the state is sending or failed', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelDeleteMessage()), + body: (tester) async { const messageId = 'test-message-id'; final message = Message( id: messageId, @@ -2498,9 +3066,9 @@ void main() { state: MessageState.sending, ); - expectLater( + final messagesEmits = expectLater( // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), + tester.channelState?.messagesStream.skip(1), emitsInOrder([ [ isSameMessageAs( @@ -2513,60 +3081,81 @@ void main() { ); // Add message to channel state first - channel.state?.addNewMessage(message); + tester.channelState?.addNewMessage(message); - final res = await channel.deleteMessage(message); + final res = await tester.channel.deleteMessage(message); expect(res, isNotNull); - verifyNever(() => client.deleteMessage(messageId)); + + await messagesEmits; + + tester.verifyNeverCalled( + (api) => api.message.deleteMessage(messageId, hard: false), + ); }, ); }); group('`.deleteMessageForMe`', () { - test('should work fine', () async { - const messageId = 'test-message-id'; - final message = Message( - id: messageId, - createdAt: DateTime.now(), - state: MessageState.sent, - ); + channelTest( + 'should work fine', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelDeleteMessage()), + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + createdAt: DateTime.utc(2021, 3), + state: MessageState.sent, + ); - when(() => client.deleteMessageForMe(messageId)).thenAnswer((_) async => EmptyResponse()); + tester.mockApi( + (api) => api.message.deleteMessage(messageId, deleteForMe: true), + result: createDefaultEmptyResponse(), + ); - // A soft delete only updates a message already in the loaded window, - // so seed it first — a delete must never insert a phantom record. - channel.state?.addNewMessage(message); + // A soft delete only updates a message already in the loaded window, + // so seed it first — a delete must never insert a phantom record. + tester.channelState?.addNewMessage(message); - expectLater( - // skip the seeded message -> [message] - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith(state: MessageState.deletingForMe), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith(state: MessageState.deletedForMe), - matchMessageState: true, - ), - ], - ]), - ); + final messagesEmits = expectLater( + // skip the seeded message -> [message] + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.deletingForMe), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.deletedForMe), + matchMessageState: true, + ), + ], + ]), + ); - final res = await channel.deleteMessageForMe(message); + final res = await tester.channel.deleteMessageForMe(message); - expect(res, isNotNull); + expect(res, isNotNull); - verify(() => client.deleteMessageForMe(messageId)).called(1); - }); + await messagesEmits; - test( + tester.verifyApi( + (api) => api.message.deleteMessage(messageId, deleteForMe: true), + ); + }, + ); + + channelTest( 'should hard delete the message if the state is sending or failed', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelDeleteMessage()), + body: (tester) async { const messageId = 'test-message-id'; final message = Message( id: messageId, @@ -2574,9 +3163,9 @@ void main() { state: MessageState.sending, ); - expectLater( + final messagesEmits = expectLater( // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), + tester.channelState?.messagesStream.skip(1), emitsInOrder([ [ isSameMessageAs( @@ -2589,93 +3178,108 @@ void main() { ); // Add message to channel state first - channel.state?.addNewMessage(message); + tester.channelState?.addNewMessage(message); - final res = await channel.deleteMessageForMe(message); + final res = await tester.channel.deleteMessageForMe(message); expect(res, isNotNull); - verifyNever(() => client.deleteMessageForMe(messageId)); + + await messagesEmits; + + tester.verifyNeverCalled( + (api) => api.message.deleteMessage(messageId, deleteForMe: true), + ); }, ); }); group('`.pinMessage`', () { - test('should work fine without passing timeoutOrExpirationDate', () async { - final message = Message(id: 'test-message-id'); + channelTest( + 'should work fine without passing timeoutOrExpirationDate', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelPinMessage()), + body: (tester) async { + final message = Message(id: 'test-message-id'); - when( - () => client.partialUpdateMessage( - message.id, - set: any(named: 'set'), - unset: any(named: 'unset'), - ), - ).thenAnswer( - (_) async => UpdateMessageResponse() - ..message = message.copyWith( - pinned: true, - pinExpires: null, + tester.mockApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: {'pinned': true, 'pin_expires': null}, ), - ); - - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith(state: MessageState.updating), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith(state: MessageState.updated), - matchMessageState: true, + result: createDefaultUpdateMessageResponse( + message: message.copyWith( + pinned: true, + pinExpires: null, ), - ], - ]), - ); + ), + ); + + final messagesEmits = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.updating), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.updated), + matchMessageState: true, + ), + ], + ]), + ); - final res = await channel.pinMessage(message); + final res = await tester.channel.pinMessage(message); - expect(res, isNotNull); - expect(res.message.pinned, isTrue); - expect(res.message.pinExpires, isNull); + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNull); - verify( - () => client.partialUpdateMessage( - message.id, - set: any(named: 'set'), - unset: any(named: 'unset'), - ), - ).called(1); - }); + await messagesEmits; + + tester.verifyApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: {'pinned': true, 'pin_expires': null}, + ), + ); + }, + ); - test( + channelTest( 'should work fine if passed timeoutOrExpirationDate as num(seconds)', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelPinMessage()), + body: (tester) async { final message = Message(id: 'test-message-id'); const timeoutOrExpirationDate = 300; // 300 seconds - when( - () => client.partialUpdateMessage( + // The channel computes `pin_expires` from the current time, so the + // stub cannot match the exact `set` map. + tester.mockApi( + (api) => api.message.partialUpdateMessage( message.id, set: any(named: 'set'), - unset: any(named: 'unset'), ), - ).thenAnswer( - (_) async => UpdateMessageResponse() - ..message = message.copyWith( + result: createDefaultUpdateMessageResponse( + message: message.copyWith( pinned: true, - pinExpires: DateTime.now().add( + pinExpires: DateTime.utc(2021, 3).add( const Duration(seconds: timeoutOrExpirationDate), ), ), + ), ); - expectLater( + final messagesEmits = expectLater( // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), + tester.channelState?.messagesStream.skip(1), emitsInOrder([ [ isSameMessageAs( @@ -2692,7 +3296,7 @@ void main() { ]), ); - final res = await channel.pinMessage( + final res = await tester.channel.pinMessage( message, timeoutOrExpirationDate: timeoutOrExpirationDate, ); @@ -2701,39 +3305,45 @@ void main() { expect(res.message.pinned, isTrue); expect(res.message.pinExpires, isNotNull); - verify( - () => client.partialUpdateMessage( + await messagesEmits; + + tester.verifyApi( + (api) => api.message.partialUpdateMessage( message.id, set: any(named: 'set'), - unset: any(named: 'unset'), ), - ).called(1); + ); }, ); - test( + channelTest( 'should work fine if passed timeoutOrExpirationDate as DateTime', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelPinMessage()), + body: (tester) async { final message = Message(id: 'test-message-id'); - final timeoutOrExpirationDate = DateTime.now().add(const Duration(days: 3)); // 3 days + final timeoutOrExpirationDate = DateTime.utc(2021, 3).add(const Duration(days: 3)); // 3 days - when( - () => client.partialUpdateMessage( + tester.mockApi( + (api) => api.message.partialUpdateMessage( message.id, - set: any(named: 'set'), - unset: any(named: 'unset'), + set: { + 'pinned': true, + 'pin_expires': timeoutOrExpirationDate.toUtc().toIso8601String(), + }, ), - ).thenAnswer( - (_) async => UpdateMessageResponse() - ..message = message.copyWith( + result: createDefaultUpdateMessageResponse( + message: message.copyWith( pinned: true, pinExpires: timeoutOrExpirationDate, ), + ), ); - expectLater( + final messagesEmits = expectLater( // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), + tester.channelState?.messagesStream.skip(1), emitsInOrder([ [ isSameMessageAs( @@ -2750,7 +3360,7 @@ void main() { ]), ); - final res = await channel.pinMessage( + final res = await tester.channel.pinMessage( message, timeoutOrExpirationDate: timeoutOrExpirationDate, ); @@ -2760,25 +3370,29 @@ void main() { expect(res.message.pinExpires, isNotNull); expect(res.message.pinExpires, timeoutOrExpirationDate.toUtc()); - verify( - () => client.partialUpdateMessage( + await messagesEmits; + + tester.verifyApi( + (api) => api.message.partialUpdateMessage( message.id, - set: any(named: 'set'), - unset: any(named: 'unset'), + set: { + 'pinned': true, + 'pin_expires': timeoutOrExpirationDate.toUtc().toIso8601String(), + }, ), - ).called(1); + ); }, ); - test( + chatClientTest( 'should throw if invalid timeoutOrExpirationDate is passed', - () async { - final message = Message(id: 'test-message-id'); + body: (tester) async { + const messageId = 'test-message-id'; const timeoutOrExpirationDate = 'invalid-value'; try { - await channel.pinMessage( - message, + await tester.client.pinMessage( + messageId, timeoutOrExpirationDate: timeoutOrExpirationDate, ); } catch (e) { @@ -2788,248 +3402,330 @@ void main() { ); }); - test('`.unpinMessage`', () async { - final message = Message(id: 'test-message-id', pinned: true); + channelTest( + '`.unpinMessage`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelPinMessage()), + body: (tester) async { + final message = Message(id: 'test-message-id', pinned: true); - when( - () => client.partialUpdateMessage( - message.id, - set: {'pinned': false}, - ), - ).thenAnswer((_) async => UpdateMessageResponse()..message = message.copyWith(pinned: false)); - - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith(state: MessageState.updating), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith(state: MessageState.updated), - matchMessageState: true, - ), - ], - ]), - ); - - final res = await channel.unpinMessage(message); - - expect(res, isNotNull); - expect(res.message.pinned, isFalse); - - verify( - () => client.partialUpdateMessage( - message.id, - set: {'pinned': false}, - ), - ).called(1); - }); + tester.mockApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: {'pinned': false}, + ), + result: createDefaultUpdateMessageResponse( + message: message.copyWith(pinned: false), + ), + ); - group('`.search`', () { - final filter = ChannelFilter.in_(ChannelFilterField.cid, const [channelCid]); + final messagesEmits = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.updating), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.updated), + matchMessageState: true, + ), + ], + ]), + ); - test('should work fine with `query`', () async { - const query = 'test-search-query'; - final sort = [MessageSearchSort.asc(MessageSearchSortField.custom('test-sort-field'))]; - const pagination = PaginationParams(); + final res = await tester.channel.unpinMessage(message); - final results = List.generate(3, (index) => GetMessageResponse()); + expect(res, isNotNull); + expect(res.message.pinned, isFalse); - when( - () => client.search( - any(that: isSameFilterAs(filter)), - query: query, - sort: any(named: 'sort'), - paginationParams: any(named: 'paginationParams'), + await messagesEmits; + + tester.verifyApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: {'pinned': false}, ), - ).thenAnswer( - (_) async => SearchMessagesResponse()..results = results, ); + }, + ); - final res = await channel.search( - query: query, - sort: sort, - paginationParams: pagination, + group('`.search`', () { + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), ); + } - expect(res, isNotNull); - expect(res.results.length, results.length); + // The channel builds this filter itself, and a filter compares by + // identity, so the stubs below match on what it sends. + final filter = ChannelFilter.in_(ChannelFilterField.cid, const [_channelCid]); + + channelTest( + 'should work fine with `query`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const query = 'test-search-query'; + final sort = [MessageSearchSort.asc(MessageSearchSortField.custom('test-sort-field'))]; + const pagination = PaginationParams(); + + final results = List.generate(3, (index) => createDefaultGetMessageResponse()); + + tester.mockApi( + (api) => api.general.searchMessages( + any(that: isSameFilterAs(filter)), + query: query, + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + result: createDefaultSearchMessagesResponse(results: results), + ); - verify( - () => client.search( - any(that: isSameFilterAs(filter)), + final res = await tester.channel.search( query: query, - sort: any(named: 'sort'), - paginationParams: any(named: 'paginationParams'), - ), - ).called(1); - }); + sort: sort, + paginationParams: pagination, + ); - test('should work fine with `messageFilters`', () async { - final messageFilters = MessageSearchFilter.query(MessageSearchFilterField.text, 'text'); - final sort = [MessageSearchSort.desc(MessageSearchSortField.custom('test-sort-field'))]; - const pagination = PaginationParams(); + expect(res, isNotNull); + expect(res.results.length, results.length); + + tester.verifyApi( + (api) => api.general.searchMessages( + any(that: isSameFilterAs(filter)), + query: query, + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ); + }, + ); - final results = List.generate(3, (index) => GetMessageResponse()); + channelTest( + 'should work fine with `messageFilters`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final messageFilters = MessageSearchFilter.query(MessageSearchFilterField.text, 'text'); + final sort = [MessageSearchSort.desc(MessageSearchSortField.custom('test-sort-field'))]; + const pagination = PaginationParams(); + + final results = List.generate(3, (index) => createDefaultGetMessageResponse()); + + tester.mockApi( + (api) => api.general.searchMessages( + any(that: isSameFilterAs(filter)), + messageFilters: messageFilters, + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + result: createDefaultSearchMessagesResponse(results: results), + ); - when( - () => client.search( - any(that: isSameFilterAs(filter)), + final res = await tester.channel.search( + sort: sort, + paginationParams: pagination, 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( - any(that: isSameFilterAs(filter)), - messageFilters: messageFilters, - sort: any(named: 'sort'), - paginationParams: any(named: 'paginationParams'), - ), - ).called(1); - }); + expect(res, isNotNull); + expect(res.results.length, results.length); + + tester.verifyApi( + (api) => api.general.searchMessages( + any(that: isSameFilterAs(filter)), + messageFilters: messageFilters, + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ); + }, + ); }); - test('`.deleteFile`', () async { - const url = 'test-file-url'; - - when( - () => client.deleteFile(url, channelId, channelType, cancelToken: any(named: 'cancelToken')), - ).thenAnswer((_) async => EmptyResponse()); + channelTest( + '`.deleteFile`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelMiscOperations()), + body: (tester) async { + const url = 'test-file-url'; - final res = await channel.deleteFile(url); + tester.mockApi( + (api) => api.fileUploader.deleteFile(url, _channelId, _channelType), + result: createDefaultEmptyResponse(), + ); - expect(res, isNotNull); + final res = await tester.channel.deleteFile(url); - verify(() => client.deleteFile(url, channelId, channelType, cancelToken: any(named: 'cancelToken'))).called(1); - }); + expect(res, isNotNull); - test('`.deleteImage`', () async { - const url = 'test-image-url'; + tester.verifyApi( + (api) => api.fileUploader.deleteFile(url, _channelId, _channelType), + ); + }, + ); - when( - () => client.deleteImage(url, channelId, channelType, cancelToken: any(named: 'cancelToken')), - ).thenAnswer((_) async => EmptyResponse()); + channelTest( + '`.deleteImage`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelMiscOperations()), + body: (tester) async { + const url = 'test-image-url'; - final res = await channel.deleteImage(url); + tester.mockApi( + (api) => api.fileUploader.deleteImage(url, _channelId, _channelType), + result: createDefaultEmptyResponse(), + ); - expect(res, isNotNull); + final res = await tester.channel.deleteImage(url); - verify(() => client.deleteImage(url, channelId, channelType, cancelToken: any(named: 'cancelToken'))).called(1); - }); + expect(res, isNotNull); - test('`.stopAIResponse`', () async { - final stopAIEvent = Event(type: EventType.aiIndicatorStop); + tester.verifyApi( + (api) => api.fileUploader.deleteImage(url, _channelId, _channelType), + ); + }, + ); - when( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(stopAIEvent)), - ), - ).thenAnswer((_) async => EmptyResponse()); + channelTest( + '`.stopAIResponse`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelMiscOperations()), + body: (tester) async { + final stopAIEvent = Event(type: EventType.aiIndicatorStop); + + tester.mockApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(stopAIEvent)), + ), + result: createDefaultEmptyResponse(), + ); - final res = await channel.stopAIResponse(); + final res = await tester.channel.stopAIResponse(); - expect(res, isNotNull); + expect(res, isNotNull); - verify( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(stopAIEvent)), - ), - ).called(1); - }); + tester.verifyApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(stopAIEvent)), + ), + ); + }, + ); - test('`.sendEvent`', () async { - final event = Event(type: 'event.local'); + channelTest( + '`.sendEvent`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelMiscOperations()), + body: (tester) async { + final event = Event(type: 'event.local'); - when(() => client.sendEvent(channelId, channelType, event)).thenAnswer((_) async => EmptyResponse()); + // Pinned by value, not by matcher: `sendEvent` forwards the caller's + // event untouched, so the stub only answering on the exact instance is + // itself the assertion that nothing rewrote it on the way out. + tester.mockApi( + (api) => api.channel.sendEvent(_channelId, _channelType, event), + result: createDefaultEmptyResponse(), + ); - final res = await channel.sendEvent(event); + final res = await tester.channel.sendEvent(event); - expect(res, isNotNull); + expect(res, isNotNull); - verify(() => client.sendEvent(channelId, channelType, event)).called(1); - }); + tester.verifyApi( + (api) => api.channel.sendEvent(_channelId, _channelType, event), + ); + }, + ); group('`.sendReaction`', () { - test('should work fine', () async { - final message = Message( - id: 'test-message-id', - state: MessageState.sent, - ); + channelTest( + 'should work fine', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelReactions()), + body: (tester) async { + final message = Message( + id: 'test-message-id', + state: MessageState.sent, + ); - const type = 'like'; - const emojiCode = '👍'; - const score = 4; + const type = 'like'; + const emojiCode = '👍'; + const score = 4; - final reaction = Reaction( - type: type, - messageId: message.id, - emojiCode: emojiCode, - score: score, - user: client.state.currentUser, - ); + final reaction = Reaction( + type: type, + messageId: message.id, + emojiCode: emojiCode, + score: score, + user: tester.currentUser, + ); - when(() => client.sendReaction(message.id, reaction)).thenAnswer( - (_) async => SendReactionResponse() - ..message = message - ..reaction = reaction, - ); + tester.mockApi( + (api) => api.message.sendReaction(message.id, reaction), + result: createDefaultSendReactionResponse(message: message, reaction: reaction), + ); - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith( - state: MessageState.sent, - reactionGroups: {type: ReactionGroup(count: 1, sumScores: 1)}, - latestReactions: [reaction], - ownReactions: [reaction], + final messagesEmission = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + state: MessageState.sent, + reactionGroups: {type: ReactionGroup(count: 1, sumScores: 1)}, + latestReactions: [reaction], + ownReactions: [reaction], + ), + matchReactions: true, + matchMessageState: true, ), - matchReactions: true, - matchMessageState: true, - ), - ], - ]), - ); + ], + ]), + ); - final res = await channel.sendReaction(message, reaction); + final res = await tester.channel.sendReaction(message, reaction); - expect(res, isNotNull); - expect(res.reaction.type, type); - expect(res.reaction.messageId, message.id); - expect(res.reaction.emojiCode, emojiCode); - expect(res.reaction.score, score); + expect(res, isNotNull); + expect(res.reaction.type, type); + expect(res.reaction.messageId, message.id); + expect(res.reaction.emojiCode, emojiCode); + expect(res.reaction.score, score); + + tester.verifyApi((api) => api.message.sendReaction(message.id, reaction)); - verify(() => client.sendReaction(message.id, reaction)).called(1); - }); + await messagesEmission; + }, + ); - test( + channelTest( 'should restore previous message if `client.sendReaction` throws', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelReactions()), + body: (tester) async { const type = 'test-reaction-type'; final message = Message( id: 'test-message-id', @@ -3039,16 +3735,17 @@ void main() { final reaction = Reaction( type: type, messageId: message.id, - user: client.state.currentUser, + user: tester.currentUser, ); - when( - () => client.sendReaction(message.id, reaction), - ).thenThrow(apiException(code: StreamErrorCode.inputError, statusCode: 400)); + tester.mockApiFailure( + (api) => api.message.sendReaction(message.id, reaction), + error: createDefaultNetworkError(code: StreamErrorCode.inputError, statusCode: 400), + ); - expectLater( + final messagesEmission = expectLater( // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), + tester.channelState?.messagesStream.skip(1), emitsInOrder([ [ isSameMessageAs( @@ -3078,24 +3775,29 @@ void main() { ); try { - await channel.sendReaction(message, reaction); + await tester.channel.sendReaction(message, reaction); } catch (e) { expect(e, isA()); } - verify(() => client.sendReaction(message.id, reaction)).called(1); + tester.verifyApi((api) => api.message.sendReaction(message.id, reaction)); + + await messagesEmission; }, ); - test( + channelTest( '''should override previous reaction if present and `enforceUnique` is true''', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelReactions()), + body: (tester) async { const messageId = 'test-message-id'; const prevType = 'test-reaction-type'; final prevReaction = Reaction( type: prevType, messageId: messageId, - user: client.state.currentUser, + user: tester.currentUser, ); final message = Message( id: messageId, @@ -3114,7 +3816,7 @@ void main() { final newReaction = Reaction( type: type, messageId: messageId, - user: client.state.currentUser, + user: tester.currentUser, ); final newMessage = message.copyWith( ownReactions: [newReaction], @@ -3123,21 +3825,18 @@ void main() { const enforceUnique = true; - when( - () => client.sendReaction( + tester.mockApi( + (api) => api.message.sendReaction( messageId, newReaction, enforceUnique: enforceUnique, ), - ).thenAnswer( - (_) async => SendReactionResponse() - ..message = newMessage - ..reaction = newReaction, + result: createDefaultSendReactionResponse(message: newMessage, reaction: newReaction), ); - expectLater( + final messagesEmission = expectLater( // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), + tester.channelState?.messagesStream.skip(1), emitsInOrder([ [ isSameMessageAs( @@ -3149,7 +3848,7 @@ void main() { ]), ); - final res = await channel.sendReaction( + final res = await tester.channel.sendReaction( message, newReaction, enforceUnique: enforceUnique, @@ -3159,77 +3858,89 @@ void main() { expect(res.reaction.type, type); expect(res.reaction.messageId, messageId); - verify( - () => client.sendReaction( + tester.verifyApi( + (api) => api.message.sendReaction( messageId, newReaction, enforceUnique: enforceUnique, ), - ).called(1); + ); + + await messagesEmission; }, ); }); group('`.sendReaction in thread`', () { - test('should work fine', () async { - const type = 'test-reaction-type'; - final message = Message( - id: 'test-message-id', - parentId: 'test-parent-id', // is thread message - state: MessageState.sent, - ); + channelTest( + 'should work fine', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelReactions()), + body: (tester) async { + const type = 'test-reaction-type'; + final message = Message( + id: 'test-message-id', + parentId: 'test-parent-id', // is thread message + state: MessageState.sent, + ); - final reaction = Reaction( - type: type, - messageId: message.id, - user: client.state.currentUser, - ); + final reaction = Reaction( + type: type, + messageId: message.id, + user: tester.currentUser, + ); - when(() => client.sendReaction(message.id, reaction)).thenAnswer( - (_) async => SendReactionResponse() - ..message = message - ..reaction = reaction, - ); + tester.mockApi( + (api) => api.message.sendReaction(message.id, reaction), + result: createDefaultSendReactionResponse(message: message, reaction: reaction), + ); - expectLater( - channel.state?.threadsStream - // skipping first seed message list -> [] messages - .skip(1) - .map((event) => event['test-parent-id']), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith( - state: MessageState.sent, - reactionGroups: { - type: ReactionGroup( - count: 1, - sumScores: 1, - ), - }, - latestReactions: [reaction], - ownReactions: [reaction], + final threadsEmission = expectLater( + tester.channelState?.threadsStream + // skipping first seed message list -> [] messages + .skip(1) + .map((event) => event['test-parent-id']), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + state: MessageState.sent, + reactionGroups: { + type: ReactionGroup( + count: 1, + sumScores: 1, + ), + }, + latestReactions: [reaction], + ownReactions: [reaction], + ), + matchReactions: true, + matchMessageState: true, + matchParentId: true, ), - matchReactions: true, - matchMessageState: true, - matchParentId: true, - ), - ], - ]), - ); + ], + ]), + ); - final res = await channel.sendReaction(message, reaction); + final res = await tester.channel.sendReaction(message, reaction); - expect(res, isNotNull); - expect(res.reaction.type, type); - expect(res.reaction.messageId, message.id); + expect(res, isNotNull); + expect(res.reaction.type, type); + expect(res.reaction.messageId, message.id); + + tester.verifyApi((api) => api.message.sendReaction(message.id, reaction)); - verify(() => client.sendReaction(message.id, reaction)).called(1); - }); + await threadsEmission; + }, + ); - test( + channelTest( '''should restore previous thread message if `client.sendReaction` throws''', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelReactions()), + body: (tester) async { const type = 'test-reaction-type'; final message = Message( id: 'test-message-id', @@ -3237,22 +3948,23 @@ void main() { state: MessageState.sent, // `Message.createdAt` falls back to `DateTime.now()` per call // when not provided, which breaks merge/sort keyed on createdAt. - createdAt: DateTime.now(), + createdAt: DateTime.utc(2021, 3), ); final reaction = Reaction( type: type, messageId: message.id, - user: client.state.currentUser, + user: tester.currentUser, ); - when( - () => client.sendReaction(message.id, reaction), - ).thenThrow(apiException(code: StreamErrorCode.inputError, statusCode: 400)); + tester.mockApiFailure( + (api) => api.message.sendReaction(message.id, reaction), + error: createDefaultNetworkError(code: StreamErrorCode.inputError, statusCode: 400), + ); - expectLater( + final threadsEmission = expectLater( // skipping first seed message list -> [] messages - channel.state?.threadsStream.skip(1).map((event) => event['test-parent-id']), + tester.channelState?.threadsStream.skip(1).map((event) => event['test-parent-id']), emitsInOrder([ [ isSameMessageAs( @@ -3284,25 +3996,30 @@ void main() { ); try { - await channel.sendReaction(message, reaction); + await tester.channel.sendReaction(message, reaction); } catch (e) { expect(e, isA()); } - verify(() => client.sendReaction(message.id, reaction)).called(1); + tester.verifyApi((api) => api.message.sendReaction(message.id, reaction)); + + await threadsEmission; }, ); - test( + channelTest( '''should override previous thread reaction if present and `enforceUnique` is true''', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelReactions()), + body: (tester) async { const messageId = 'test-message-id'; const parentId = 'test-parent-id'; const prevType = 'test-reaction-type'; final prevReaction = Reaction( type: prevType, messageId: messageId, - user: client.state.currentUser, + user: tester.currentUser, ); final message = Message( id: messageId, @@ -3322,7 +4039,7 @@ void main() { final newReaction = Reaction( type: type, messageId: messageId, - user: client.state.currentUser, + user: tester.currentUser, ); final newMessage = message.copyWith( ownReactions: [newReaction], @@ -3331,21 +4048,18 @@ void main() { const enforceUnique = true; - when( - () => client.sendReaction( + tester.mockApi( + (api) => api.message.sendReaction( messageId, newReaction, enforceUnique: enforceUnique, ), - ).thenAnswer( - (_) async => SendReactionResponse() - ..message = newMessage - ..reaction = newReaction, + result: createDefaultSendReactionResponse(message: newMessage, reaction: newReaction), ); - expectLater( + final threadsEmission = expectLater( // skipping first seed message list -> [] messages - channel.state?.threadsStream.skip(1).map((event) => event['test-parent-id']), + tester.channelState?.threadsStream.skip(1).map((event) => event['test-parent-id']), emitsInOrder([ [ isSameMessageAs( @@ -3358,7 +4072,7 @@ void main() { ]), ); - final res = await channel.sendReaction( + final res = await tester.channel.sendReaction( message, newReaction, enforceUnique: enforceUnique, @@ -3368,70 +4082,86 @@ void main() { expect(res.reaction.type, type); expect(res.reaction.messageId, messageId); - verify( - () => client.sendReaction( + tester.verifyApi( + (api) => api.message.sendReaction( messageId, newReaction, enforceUnique: enforceUnique, ), - ).called(1); + ); + + await threadsEmission; }, ); }); 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], - reactionGroups: { - type: ReactionGroup( - count: 1, - sumScores: 1, - ), - }, - state: MessageState.sent, - ); + channelTest( + 'should work fine', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelReactions()), + body: (tester) 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], + reactionGroups: { + type: ReactionGroup( + count: 1, + sumScores: 1, + ), + }, + state: MessageState.sent, + ); - when(() => client.deleteReaction(messageId, type)).thenAnswer((_) async => EmptyResponse()); + tester.mockApi( + (api) => api.message.deleteReaction(messageId, type), + result: createDefaultEmptyResponse(), + ); - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith( - state: MessageState.sent, - latestReactions: [], - ownReactions: [], + final messagesEmission = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + state: MessageState.sent, + latestReactions: [], + ownReactions: [], + ), + matchReactions: true, + matchMessageState: true, ), - matchReactions: true, - matchMessageState: true, - ), - ], - ]), - ); + ], + ]), + ); - final res = await channel.deleteReaction(message, reaction); + final res = await tester.channel.deleteReaction(message, reaction); - expect(res, isNotNull); + expect(res, isNotNull); - verify(() => client.deleteReaction(messageId, type)).called(1); - }); + tester.verifyApi((api) => api.message.deleteReaction(messageId, type)); - test( + await messagesEmission; + }, + ); + + channelTest( 'should restore prev message state if `client.deleteReaction` throws', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelReactions()), + body: (tester) async { const userId = 'test-user-id'; const messageId = 'test-message-id'; const type = 'test-reaction-type'; @@ -3453,13 +4183,14 @@ void main() { state: MessageState.sent, ); - when( - () => client.deleteReaction(messageId, type), - ).thenThrow(apiException(code: StreamErrorCode.inputError, statusCode: 400)); + tester.mockApiFailure( + (api) => api.message.deleteReaction(messageId, type), + error: createDefaultNetworkError(code: StreamErrorCode.inputError, statusCode: 400), + ); - expectLater( + final messagesEmission = expectLater( // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), + tester.channelState?.messagesStream.skip(1), emitsInOrder([ [ isSameMessageAs( @@ -3483,76 +4214,92 @@ void main() { ); try { - await channel.deleteReaction(message, reaction); + await tester.channel.deleteReaction(message, reaction); } catch (e) { expect(e, isA()); } - verify(() => client.deleteReaction(messageId, type)).called(1); + tester.verifyApi((api) => api.message.deleteReaction(messageId, type)); + + await messagesEmission; }, ); }); group('`.deleteReaction in thread`', () { - test('should work fine', () async { - const userId = 'test-user-id'; - const messageId = 'test-message-id'; - const parentId = 'test-parent-id'; - const type = 'test-reaction-type'; - final reaction = Reaction( - type: type, - messageId: messageId, - userId: userId, - ); - final message = Message( - id: messageId, - parentId: parentId, - // is thread - ownReactions: [reaction], - latestReactions: [reaction], - reactionGroups: { - type: ReactionGroup( - count: 1, - sumScores: 1, - ), - }, - state: MessageState.sent, - // `Message.createdAt` falls back to `DateTime.now()` per call when - // not provided, which breaks merge/sort keyed on createdAt. - createdAt: DateTime.now(), - ); + channelTest( + 'should work fine', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelReactions()), + body: (tester) async { + const userId = 'test-user-id'; + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; + const type = 'test-reaction-type'; + final reaction = Reaction( + type: type, + messageId: messageId, + userId: userId, + ); + final message = Message( + id: messageId, + parentId: parentId, + // is thread + ownReactions: [reaction], + latestReactions: [reaction], + reactionGroups: { + type: ReactionGroup( + count: 1, + sumScores: 1, + ), + }, + state: MessageState.sent, + // `Message.createdAt` falls back to `DateTime.now()` per call when + // not provided, which breaks merge/sort keyed on createdAt. + createdAt: DateTime.utc(2021, 3), + ); - when(() => client.deleteReaction(messageId, type)).thenAnswer((_) async => EmptyResponse()); + tester.mockApi( + (api) => api.message.deleteReaction(messageId, type), + result: createDefaultEmptyResponse(), + ); - expectLater( - // skipping first seed message list -> [] messages - channel.state?.threadsStream.skip(1).map((event) => event['test-parent-id']), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith( - state: MessageState.sent, - latestReactions: [], - ownReactions: [], + final threadsEmission = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.threadsStream.skip(1).map((event) => event['test-parent-id']), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + state: MessageState.sent, + latestReactions: [], + ownReactions: [], + ), + matchReactions: true, + matchMessageState: true, + matchParentId: true, ), - matchReactions: true, - matchMessageState: true, - matchParentId: true, - ), - ], - ]), - ); + ], + ]), + ); - final res = await channel.deleteReaction(message, reaction); + final res = await tester.channel.deleteReaction(message, reaction); - expect(res, isNotNull); + expect(res, isNotNull); + + tester.verifyApi((api) => api.message.deleteReaction(messageId, type)); - verify(() => client.deleteReaction(messageId, type)).called(1); - }); + await threadsEmission; + }, + ); - test( + channelTest( 'should restore prev message state if `client.deleteReaction` throws', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelReactions()), + body: (tester) async { const userId = 'test-user-id'; const messageId = 'test-message-id'; const parentId = 'test-parent-id'; @@ -3576,16 +4323,17 @@ void main() { state: MessageState.sent, // `Message.createdAt` falls back to `DateTime.now()` per call // when not provided, which breaks merge/sort keyed on createdAt. - createdAt: DateTime.now(), + createdAt: DateTime.utc(2021, 3), ); - when( - () => client.deleteReaction(messageId, type), - ).thenThrow(apiException(code: StreamErrorCode.inputError, statusCode: 400)); + tester.mockApiFailure( + (api) => api.message.deleteReaction(messageId, type), + error: createDefaultNetworkError(code: StreamErrorCode.inputError, statusCode: 400), + ); - expectLater( + final threadsEmission = expectLater( // skipping first seed message list -> [] messages - channel.state?.threadsStream.skip(1).map((event) => event['test-parent-id']), + tester.channelState?.threadsStream.skip(1).map((event) => event['test-parent-id']), emitsInOrder([ [ isSameMessageAs( @@ -3611,1242 +4359,1658 @@ void main() { ); try { - await channel.deleteReaction(message, reaction); + await tester.channel.deleteReaction(message, reaction); } catch (e) { expect(e, isA()); } - verify(() => client.deleteReaction(messageId, type)).called(1); + tester.verifyApi((api) => api.message.deleteReaction(messageId, type)); + + await threadsEmission; }, ); }); - 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, - ); + channelTest( + '`.update`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelUpdateApi, + body: (tester) async { + const channelData = { + 'name': 'Stream Team', + 'profile_image': 'test-profile-image', + }; + final updateMessage = Message( + id: 'test-message-id', + text: 'updated channel', + ); - when(() => client.updateChannel(channelId, channelType, channelData, message: any(named: 'message'))).thenAnswer( - (_) async => UpdateChannelResponse() - ..channel = channelModel - ..message = updateMessage, - ); + final channelModel = createDefaultChannelModel( + cid: _channelCid, + extraData: channelData, + ); - final res = await channel.update( - channelData, - updateMessage: updateMessage, - ); + tester.mockApi( + (api) => api.channel.updateChannel(_channelId, _channelType, channelData, message: updateMessage), + result: UpdateChannelResponse() + ..channel = channelModel + ..message = updateMessage, + ); - expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect(res.channel.extraData, channelData); - expect(res.message?.id, updateMessage.id); + final res = await tester.channel.update( + channelData, + updateMessage: updateMessage, + ); - verify(() => client.updateChannel(channelId, channelType, channelData, message: any(named: 'message'))).called(1); - }); + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.channel.extraData, channelData); + expect(res.message?.id, updateMessage.id); - test('`.updateImage`', () async { - const image = 'https://getstream.io/new-image'; + tester.verifyApi( + (api) => api.channel.updateChannel(_channelId, _channelType, channelData, message: updateMessage), + ); + }, + ); - final channelModel = ChannelModel( - cid: channelCid, - extraData: {'image': image}, - ); + channelTest( + '`.updateImage`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelUpdateApi, + body: (tester) async { + const image = 'https://getstream.io/new-image'; - when( - () => client.updateChannelPartial( - channelId, - channelType, - set: {'image': image}, - ), - ).thenAnswer( - (_) async => PartialUpdateChannelResponse()..channel = channelModel, - ); + final channelModel = createDefaultChannelModel( + cid: _channelCid, + extraData: {'image': image}, + ); - final res = await channel.updateImage(image); + tester.mockApi( + (api) => api.channel.updateChannelPartial( + _channelId, + _channelType, + set: {'image': image}, + ), + result: createDefaultPartialUpdateChannelResponse(channel: channelModel), + ); - expect(res, isNotNull); - expect(res.channel.extraData['image'], image); + final res = await tester.channel.updateImage(image); - verify( - () => client.updateChannelPartial( - channelId, - channelType, - set: {'image': image}, - ), - ).called(1); - }); + expect(res, isNotNull); + expect(res.channel.extraData['image'], image); - test('`.updateName`', () async { - const name = 'Name'; + tester.verifyApi( + (api) => api.channel.updateChannelPartial( + _channelId, + _channelType, + set: {'image': image}, + ), + ); + }, + ); - final channelModel = ChannelModel( - cid: channelCid, - extraData: {'name': name}, - ); + channelTest( + '`.updateName`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelUpdateApi, + body: (tester) async { + const name = 'Name'; - when( - () => client.updateChannelPartial( - channelId, - channelType, - set: {'name': name}, - ), - ).thenAnswer( - (_) async => PartialUpdateChannelResponse()..channel = channelModel, - ); + final channelModel = createDefaultChannelModel( + cid: _channelCid, + extraData: {'name': name}, + ); - final res = await channel.updateName(name); + tester.mockApi( + (api) => api.channel.updateChannelPartial( + _channelId, + _channelType, + set: {'name': name}, + ), + result: createDefaultPartialUpdateChannelResponse(channel: channelModel), + ); - expect(res, isNotNull); - expect(res.channel.extraData['name'], name); + final res = await tester.channel.updateName(name); - verify( - () => client.updateChannelPartial( - channelId, - channelType, - set: {'name': name}, - ), - ).called(1); - }); + expect(res, isNotNull); + expect(res.channel.extraData['name'], name); - test('`.updatePartial`', () async { - const set = { - 'name': 'Stream Team', - 'profile_image': 'test-profile-image', - }; + tester.verifyApi( + (api) => api.channel.updateChannelPartial( + _channelId, + _channelType, + set: {'name': name}, + ), + ); + }, + ); - const unset = ['tag', 'last_name']; + channelTest( + '`.updatePartial`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelUpdateApi, + body: (tester) async { + const set = { + 'name': 'Stream Team', + 'profile_image': 'test-profile-image', + }; + + const unset = ['tag', 'last_name']; + + final channelModel = createDefaultChannelModel( + cid: _channelCid, + extraData: { + 'coolness': 999, + ...set, + }, + ); - final channelModel = ChannelModel( - cid: channelCid, - extraData: { - 'coolness': 999, - ...set, - }, - ); + tester.mockApi( + (api) => api.channel.updateChannelPartial( + _channelId, + _channelType, + set: set, + unset: unset, + ), + result: createDefaultPartialUpdateChannelResponse(channel: channelModel), + ); - when( - () => client.updateChannelPartial( - channelId, - channelType, - set: set, - unset: unset, - ), - ).thenAnswer( - (_) async => PartialUpdateChannelResponse()..channel = channelModel, - ); + final res = await tester.channel.updatePartial(set: set, unset: unset); - final res = await channel.updatePartial(set: set, unset: unset); + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect( + res.channel.extraData, + {'coolness': 999, ...set}, + ); - expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect( - res.channel.extraData, - {'coolness': 999, ...set}, - ); + tester.verifyApi( + (api) => api.channel.updateChannelPartial( + _channelId, + _channelType, + set: set, + unset: unset, + ), + ); + }, + ); - verify( - () => client.updateChannelPartial( - channelId, - channelType, - set: set, - unset: unset, - ), - ).called(1); - }); + channelTest( + '`.delete`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelUpdateApi, + body: (tester) async { + tester.mockApi( + (api) => api.channel.deleteChannel(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); - test('`.delete`', () async { - when(() => client.deleteChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); + final res = await tester.channel.delete(); - final res = await channel.delete(); + expect(res, isNotNull); - expect(res, isNotNull); + tester.verifyApi( + (api) => api.channel.deleteChannel(_channelId, _channelType), + ); + }, + ); - verify(() => client.deleteChannel(channelId, channelType)).called(1); - }); + channelTest( + '`.truncate`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelUpdateApi, + body: (tester) async { + tester.mockApi( + (api) => api.channel.truncateChannel(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); - test('`.truncate`', () async { - when(() => client.truncateChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); + final res = await tester.channel.truncate(); - final res = await channel.truncate(); + expect(res, isNotNull); - expect(res, isNotNull); + tester.verifyApi( + (api) => api.channel.truncateChannel(_channelId, _channelType), + ); + }, + ); - verify(() => client.truncateChannel(channelId, channelType)).called(1); - }); + channelTest( + '`.acceptInvite`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelMemberApi, + body: (tester) async { + final message = Message(id: 'test-message-id', text: 'Invite Accepted'); - test('`.acceptInvite`', () async { - final message = Message(id: 'test-message-id', text: 'Invite Accepted'); + final channelModel = createDefaultChannelModel(cid: _channelCid); - final channelModel = ChannelModel(cid: channelCid); + tester.mockApi( + (api) => api.channel.acceptChannelInvite(_channelId, _channelType, message: message), + result: AcceptInviteResponse() + ..channel = channelModel + ..message = message, + ); - when(() => client.acceptChannelInvite(channelId, channelType, message: any(named: 'message'))).thenAnswer( - (_) async => AcceptInviteResponse() - ..channel = channelModel - ..message = message, - ); + final res = await tester.channel.acceptInvite(message); - final res = await channel.acceptInvite(message); + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); - expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect(res.message?.id, message.id); + tester.verifyApi( + (api) => api.channel.acceptChannelInvite(_channelId, _channelType, message: message), + ); + }, + ); - verify(() => client.acceptChannelInvite(channelId, channelType, message: any(named: 'message'))).called(1); - }); + channelTest( + '`.rejectInvite`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelMemberApi, + body: (tester) async { + final message = Message(id: 'test-message-id', text: 'Invite Rejected'); - test('`.rejectInvite`', () async { - final message = Message(id: 'test-message-id', text: 'Invite Rejected'); + final channelModel = createDefaultChannelModel(cid: _channelCid); - final channelModel = ChannelModel(cid: channelCid); + tester.mockApi( + (api) => api.channel.rejectChannelInvite(_channelId, _channelType, message: message), + result: RejectInviteResponse() + ..channel = channelModel + ..message = message, + ); - when(() => client.rejectChannelInvite(channelId, channelType, message: any(named: 'message'))).thenAnswer( - (_) async => RejectInviteResponse() - ..channel = channelModel - ..message = message, - ); + final res = await tester.channel.rejectInvite(message); - final res = await channel.rejectInvite(message); + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); - expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect(res.message?.id, message.id); + tester.verifyApi( + (api) => api.channel.rejectChannelInvite(_channelId, _channelType, message: message), + ); + }, + ); - verify(() => client.rejectChannelInvite(channelId, channelType, message: any(named: 'message'))).called(1); - }); + channelTest( + '`.addMembers`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelMemberApi, + body: (tester) 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'); - 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 = createDefaultChannelModel(cid: _channelCid); - final channelModel = ChannelModel(cid: channelCid); + tester.mockApi( + (api) => api.channel.addMembers(_channelId, _channelType, memberIds, message: message), + result: createDefaultAddMembersResponse( + channel: channelModel, + members: members, + message: message, + ), + ); - when( - () => client.addChannelMembers(channelId, channelType, memberIds, message: any(named: 'message')), - ).thenAnswer( - (_) async => AddMembersResponse() - ..channel = channelModel - ..members = members - ..message = message, - ); + final res = await tester.channel.addMembers(memberIds, message: message); - final res = await channel.addMembers(memberIds, message: message); + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.members.length, members.length); + expect(res.message?.id, message.id); - expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect(res.members.length, members.length); - expect(res.message?.id, message.id); + tester.verifyApi( + (api) => api.channel.addMembers(_channelId, _channelType, memberIds, message: message), + ); + }, + ); - verify( - () => client.addChannelMembers(channelId, channelType, memberIds, message: any(named: 'message')), - ).called(1); - }); + channelTest( + '`.addMembers` with hideHistoryBefore', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelMemberApi, + body: (tester) 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 hideHistoryBefore = DateTime.parse('2024-01-01T00:00:00Z'); - test('`.addMembers` with hideHistoryBefore', () 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 hideHistoryBefore = DateTime.parse('2024-01-01T00:00:00Z'); + final channelModel = createDefaultChannelModel(cid: _channelCid); - final channelModel = ChannelModel(cid: channelCid); + tester.mockApi( + (api) => api.channel.addMembers( + _channelId, + _channelType, + memberIds, + message: message, + hideHistoryBefore: hideHistoryBefore, + ), + result: createDefaultAddMembersResponse( + channel: channelModel, + members: members, + message: message, + ), + ); - when( - () => client.addChannelMembers( - channelId, - channelType, + final res = await tester.channel.addMembers( memberIds, message: message, hideHistoryBefore: hideHistoryBefore, - ), - ).thenAnswer( - (_) async => AddMembersResponse() - ..channel = channelModel - ..members = members - ..message = message, - ); - - final res = await channel.addMembers( - memberIds, - message: message, - hideHistoryBefore: hideHistoryBefore, - ); + ); - expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect(res.members.length, members.length); - expect(res.message?.id, message.id); + 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: message, - hideHistoryBefore: hideHistoryBefore, - ), - ).called(1); - }); + tester.verifyApi( + (api) => api.channel.addMembers( + _channelId, + _channelType, + memberIds, + message: message, + hideHistoryBefore: hideHistoryBefore, + ), + ); + }, + ); - 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'); + channelTest( + '`.inviteMembers`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelMemberApi, + body: (tester) 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); + final channelModel = createDefaultChannelModel(cid: _channelCid); - when( - () => client.inviteChannelMembers(channelId, channelType, memberIds, message: any(named: 'message')), - ).thenAnswer( - (_) async => InviteMembersResponse() - ..channel = channelModel - ..members = members - ..message = message, - ); + tester.mockApi( + (api) => api.channel.inviteChannelMembers(_channelId, _channelType, memberIds, message: message), + result: InviteMembersResponse() + ..channel = channelModel + ..members = members + ..message = message, + ); - final res = await channel.inviteMembers(memberIds, message: message); + final res = await tester.channel.inviteMembers(memberIds, message: message); - expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect(res.members.length, members.length); - expect(res.message?.id, message.id); + 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); - }); + tester.verifyApi( + (api) => api.channel.inviteChannelMembers(_channelId, _channelType, memberIds, message: message), + ); + }, + ); - 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'); + channelTest( + '`.removeMembers`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelMemberApi, + body: (tester) 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); + final channelModel = createDefaultChannelModel(cid: _channelCid); - when( - () => client.removeChannelMembers(channelId, channelType, memberIds, message: any(named: 'message')), - ).thenAnswer( - (_) async => RemoveMembersResponse() - ..channel = channelModel - ..members = members - ..message = message, - ); + tester.mockApi( + (api) => api.channel.removeMembers(_channelId, _channelType, memberIds, message: message), + result: RemoveMembersResponse() + ..channel = channelModel + ..members = members + ..message = message, + ); - final res = await channel.removeMembers(memberIds, message: message); + final res = await tester.channel.removeMembers(memberIds, message: message); - expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect(res.members.length, members.length); - expect(res.message?.id, message.id); + 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); - }); + tester.verifyApi( + (api) => api.channel.removeMembers(_channelId, _channelType, memberIds, message: message), + ); + }, + ); group('`.sendAction`', () { - test('should work fine', () async { - final message = Message(id: 'test-message-id', text: 'Action Sent'); - const formData = {'key': 'value'}; + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); + } - when( - () => client.sendAction(channelId, channelType, message.id, formData), - ).thenAnswer((_) async => SendActionResponse()); + channelTest( + 'should work fine', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message(id: 'test-message-id', text: 'Action Sent'); + const formData = {'key': 'value'}; + + tester.mockApi( + (api) => api.message.sendAction(_channelId, _channelType, message.id, formData), + result: createDefaultSendActionResponse(), + ); - final res = await channel.sendAction(message, formData); + final res = await tester.channel.sendAction(message, formData); - expect(res, isNotNull); + expect(res, isNotNull); - verify( - () => client.sendAction(channelId, channelType, message.id, formData), - ).called(1); - }); + tester.verifyApi( + (api) => api.message.sendAction(_channelId, _channelType, message.id, formData), + ); + }, + ); - test('should emit received message if not null', () async { - final message = Message(id: 'test-message-id', text: 'Action Sent'); - const formData = {'key': 'value'}; + channelTest( + 'should emit received message if not null', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message(id: 'test-message-id', text: 'Action Sent'); + const formData = {'key': 'value'}; + + tester.mockApi( + (api) => api.message.sendAction(_channelId, _channelType, message.id, formData), + result: createDefaultSendActionResponse(message: message), + ); - when( - () => client.sendAction(channelId, channelType, message.id, formData), - ).thenAnswer((_) async => SendActionResponse()..message = message); + final messagesEmission = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message, + matchMessageState: true, + ), + ], + ]), + ); - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message, - matchMessageState: true, - ), - ], - ]), - ); + final res = await tester.channel.sendAction(message, formData); - final res = await channel.sendAction(message, formData); + expect(res, isNotNull); + expect(res.message?.id, message.id); - expect(res, isNotNull); - expect(res.message?.id, message.id); + tester.verifyApi( + (api) => api.message.sendAction(_channelId, _channelType, message.id, formData), + ); - verify( - () => client.sendAction(channelId, channelType, message.id, formData), - ).called(1); - }); + await messagesEmission; + }, + ); }); 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'), + // Builds the channel already initialized from state — the `.watch` tests + // exercise `queryChannel` themselves, so they cannot seed through + // `tester.watch()` without polluting the call counts they verify. + Channel _buildInitializedChannel(StreamChatClient client) { + return Channel.fromState( + client, + createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), ), - ).thenAnswer( - (_) async => _generateChannelState(channelId, channelType), ); + } - final res = await channel.watch(); + channelTest( + 'should work fine', + channelType: _channelType, + channelId: _channelId, + build: _buildInitializedChannel, + body: (tester) async { + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + state: true, + watch: true, + presence: false, + ), + result: createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ), + ); - expect(res, isNotNull); - expect(res.channel, isNotNull); - expect(res.channel?.cid, channelCid); + final res = await tester.channel.watch(); - 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('a successful retry after a failed init reconciles ' - '`initialized` and `state`', () async { - final freshChannel = Channel(client, channelType, channelId); - addTearDown(freshChannel.dispose); - - var attempts = 0; - 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 { - if (++attempts == 1) { - throw apiException(code: StreamErrorCode.inputError, statusCode: 400); - } - return _generateChannelState(channelId, channelType); - }); + expect(res, isNotNull); + expect(res.channel, isNotNull); + expect(res.channel?.cid, _channelCid); + + tester.verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + state: true, + watch: true, + presence: false, + ), + ); + }, + ); - // First init fails: `initialized` errors and `state` stays null. - // Attach the expectation before watch() so the error is handled. - final firstInit = expectLater( - freshChannel.initialized, - throwsA(isA()), - ); - await expectLater( - freshChannel.watch(), - throwsA(isA()), - ); - await firstInit; - expect(freshChannel.state, isNull); - - // Retrying resets the completer; the successful watch initializes the - // channel and `initialized`/`state` agree again. - await freshChannel.watch(); - expect(freshChannel.state, isNotNull); - await expectLater(freshChannel.initialized, completion(isTrue)); - }); - - 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(apiException(code: StreamErrorCode.inputError, statusCode: 400)); + channelTest( + 'a successful retry after a failed init reconciles ' + '`initialized` and `state`', + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final freshChannel = tester.channel; + + tester.mockApiFailureOnce( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: freshChannel.extraData, + state: true, + watch: true, + presence: false, + ), + error: createDefaultNetworkError(code: StreamErrorCode.inputError, statusCode: 400), + result: createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ), + ); - try { - await channel.watch(); - } catch (e) { - expect(e, isA()); - } + // First init fails: `initialized` errors and `state` stays null. + // Attach the expectation before watch() so the error is handled. + final firstInit = expectLater( + freshChannel.initialized, + throwsA(isA()), + ); + await expectLater( + freshChannel.watch(), + throwsA(isA()), + ); + await firstInit; + expect(freshChannel.state, isNull); + + // Retrying resets the completer; the successful watch initializes the + // channel and `initialized`/`state` agree again. + await freshChannel.watch(); + expect(freshChannel.state, isNotNull); + await expectLater(freshChannel.initialized, completion(isTrue)); + }, + ); - 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); - }); + channelTest( + 'should rethrow if `.query` throws', + channelType: _channelType, + channelId: _channelId, + build: _buildInitializedChannel, + body: (tester) async { + tester.mockApiFailure( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + state: true, + watch: true, + presence: false, + ), + error: createDefaultNetworkError(code: StreamErrorCode.inputError, statusCode: 400), + ); + + try { + await tester.channel.watch(); + } catch (e) { + expect(e, isA()); + } + + tester.verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + state: true, + watch: true, + presence: false, + ), + ); + }, + ); }); - test('`.stopWatching`', () async { - when(() => client.stopChannelWatching(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); + channelTest( + '`.stopWatching`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelWatch()), + body: (tester) async { + tester.mockApi( + (api) => api.channel.stopWatching(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); - final res = await channel.stopWatching(); + final res = await tester.channel.stopWatching(); - expect(res, isNotNull); + expect(res, isNotNull); - verify(() => client.stopChannelWatching(channelId, channelType)).called(1); - }); + tester.verifyApi( + (api) => api.channel.stopWatching(_channelId, _channelType), + ); + }, + ); - test('`.getReplies`', () async { - const parentId = 'test-parent-id'; + channelTest( + '`.getReplies`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelMessageQueries()), + body: (tester) async { + const parentId = 'test-parent-id'; - final messages = List.generate( - 3, - (index) => Message( - id: 'test-message-id-$index', - parentId: parentId, - ), - ); + final messages = List.generate( + 3, + (index) => Message( + id: 'test-message-id-$index', + parentId: parentId, + ), + ); - when(() => client.getReplies(parentId)).thenAnswer( - (_) async => QueryRepliesResponse()..messages = messages, - ); + tester.mockApi( + (api) => api.message.getReplies(parentId), + result: createDefaultQueryRepliesResponse(messages: messages), + ); - final res = await channel.getReplies(parentId); + final res = await tester.channel.getReplies(parentId); - expect(res, isNotNull); - expect(res.messages.length, messages.length); - expect(res.messages.every((it) => it.parentId == parentId), isTrue); + expect(res, isNotNull); + expect(res.messages.length, messages.length); + expect(res.messages.every((it) => it.parentId == parentId), isTrue); - verify(() => client.getReplies(parentId)).called(1); - }); + tester.verifyApi((api) => api.message.getReplies(parentId)); + }, + ); - test('`.getReplies` keeps the parent message out of the thread', () async { - const parentId = 'test-parent-id'; + channelTest( + '`.getReplies` keeps the parent message out of the thread', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelMessageQueries()), + body: (tester) async { + const parentId = 'test-parent-id'; - // Some backends return the parent as the first message of the oldest - // page. It is rendered from its own copy, so it must not also become a - // reply — otherwise the thread shows its root twice. - final messages = [ - Message(id: parentId), - ...List.generate( - 3, - (index) => Message(id: 'test-message-id-$index', parentId: parentId), - ), - ]; + // Some backends return the parent as the first message of the oldest + // page. It is rendered from its own copy, so it must not also become a + // reply — otherwise the thread shows its root twice. + final messages = [ + Message(id: parentId), + ...List.generate( + 3, + (index) => Message(id: 'test-message-id-$index', parentId: parentId), + ), + ]; - when(() => client.getReplies(parentId)).thenAnswer( - (_) async => QueryRepliesResponse()..messages = messages, - ); + tester.mockApi( + (api) => api.message.getReplies(parentId), + result: createDefaultQueryRepliesResponse(messages: messages), + ); - await channel.getReplies(parentId); + await tester.channel.getReplies(parentId); - final threadMessages = channel.state!.threads[parentId]; - expect(threadMessages, isNotNull); - expect(threadMessages!.length, messages.length - 1); - expect(threadMessages.any((it) => it.id == parentId), isFalse); - }); + final threadMessages = tester.channelState!.threads[parentId]; + expect(threadMessages, isNotNull); + expect(threadMessages!.length, messages.length - 1); + expect(threadMessages.any((it) => it.id == parentId), isFalse); + }, + ); - test('`.getReactions`', () async { - const messageId = 'test-message-id'; + channelTest( + '`.getReactions`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelMessageQueries()), + body: (tester) async { + const messageId = 'test-message-id'; - final reactions = List.generate( - 3, - (index) => Reaction( - type: 'test-reaction-type-$index', - messageId: messageId, - ), - ); + final reactions = List.generate( + 3, + (index) => Reaction( + type: 'test-reaction-type-$index', + messageId: messageId, + ), + ); - when(() => client.getReactions(messageId)).thenAnswer( - (_) async => QueryReactionsResponse()..reactions = reactions, - ); + tester.mockApi( + (api) => api.message.getReactions(messageId), + result: createDefaultQueryReactionsResponse(reactions: reactions), + ); - final res = await channel.getReactions(messageId); + final res = await tester.channel.getReactions(messageId); - expect(res, isNotNull); - expect(res.reactions.length, reactions.length); - expect(res.reactions.every((it) => it.messageId == messageId), isTrue); + expect(res, isNotNull); + expect(res.reactions.length, reactions.length); + expect(res.reactions.every((it) => it.messageId == messageId), isTrue); - verify(() => client.getReactions(messageId)).called(1); - }); + tester.verifyApi((api) => api.message.getReactions(messageId)); + }, + ); - test('`.getMessagesById`', () async { - final messages = List.generate( - 3, - (index) => Message(id: 'test-message-id-$index'), - ); + channelTest( + '`.getMessagesById`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelMessageQueries()), + body: (tester) async { + final messages = List.generate( + 3, + (index) => Message(id: 'test-message-id-$index'), + ); - final messageIds = messages.map((it) => it.id).toList(growable: false); + final messageIds = messages.map((it) => it.id).toList(growable: false); - when(() => client.getMessagesById(channelId, channelType, messageIds)).thenAnswer( - (_) async => GetMessagesByIdResponse()..messages = messages, - ); + tester.mockApi( + (api) => api.message.getMessagesById(_channelId, _channelType, messageIds), + result: createDefaultGetMessagesByIdResponse(messages: messages), + ); - final res = await channel.getMessagesById(messageIds); + final res = await tester.channel.getMessagesById(messageIds); - expect(res, isNotNull); - expect(res.messages.length, messageIds.length); + expect(res, isNotNull); + expect(res.messages.length, messageIds.length); - verify( - () => client.getMessagesById(channelId, channelType, messageIds), - ).called(1); - }); + tester.verifyApi( + (api) => api.message.getMessagesById(_channelId, _channelType, messageIds), + ); + }, + ); - test('`.translateMessage`', () async { - const messageId = 'test-message-id'; - const language = 'hi'; // Hindi - const translatedMessageText = 'नमस्ते'; + channelTest( + '`.translateMessage`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelMessageQueries()), + body: (tester) async { + const messageId = 'test-message-id'; + const language = 'hi'; // Hindi + const translatedMessageText = 'नमस्ते'; - final message = Message(id: messageId, text: 'Hello'); - channel.state!.updateMessage(message); + final message = Message(id: messageId, text: 'Hello'); + tester.channelState!.updateMessage(message); - final translatedMessage = message.copyWith( - i18n: {'language': 'en', '${language}_text': translatedMessageText}, - ); + final translatedMessage = message.copyWith( + i18n: {'language': 'en', '${language}_text': translatedMessageText}, + ); - when(() => client.translateMessage(messageId, language)).thenAnswer( - (_) async => TranslateMessageResponse()..message = translatedMessage, - ); + tester.mockApi( + (api) => api.message.translateMessage(messageId, language), + result: createDefaultTranslateMessageResponse(message: translatedMessage), + ); - final res = await channel.translateMessage(messageId, language); + final res = await tester.channel.translateMessage(messageId, language); - expect(res, isNotNull); - expect(res.message.i18n, translatedMessage.i18n); + expect(res, isNotNull); + expect(res.message.i18n, translatedMessage.i18n); - // The translation is merged into the channel state, so callers don't - // have to apply the response themselves. - final stateMessage = channel.state!.messages.firstWhere((it) => it.id == messageId); - expect(stateMessage.i18n, translatedMessage.i18n); + // The translation is merged into the channel state, so callers don't + // have to apply the response themselves. + final stateMessage = tester.channelState!.messages.firstWhere((it) => it.id == messageId); + expect(stateMessage.i18n, translatedMessage.i18n); - verify(() => client.translateMessage(messageId, language)).called(1); - }); + tester.verifyApi((api) => api.message.translateMessage(messageId, language)); + }, + ); 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); + channelTest( + 'should work fine', + channelType: _channelType, + channelId: _channelId, + build: _buildInitializedChannel, + body: (tester) async { + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + ), + result: createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ), + ); - final res = await channel.query(); + final res = await tester.channel.query(); - expect(res, isNotNull); + 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(apiException(code: StreamErrorCode.inputError, statusCode: 400)); + tester.verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + ), + ); + }, + ); - try { - await channel.query(); - } catch (e) { - expect(e, isA()); - } + channelTest( + 'should rethrow if `client.queryChannel` throws', + channelType: _channelType, + channelId: _channelId, + build: _buildInitializedChannel, + body: (tester) async { + tester.mockApiFailure( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + ), + error: createDefaultNetworkError(code: StreamErrorCode.inputError, statusCode: 400), + ); - 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 truncate state when querying around message id', () async { - final initialMessages = [ - Message(id: 'msg1', text: 'Hello 1'), - Message(id: 'msg2', text: 'Hello 2'), - Message(id: 'msg3', text: 'Hello 3'), - ]; + try { + await tester.channel.query(); + } catch (e) { + expect(e, isA()); + } - final stateWithMessages = _generateChannelState( - channelId, - channelType, - ).copyWith(messages: initialMessages); - - channel.state!.updateChannelState(stateWithMessages); - expect(channel.state!.messages, hasLength(3)); - - final newState = - _generateChannelState( - channelId, - channelType, - ).copyWith( - messages: [ - Message(id: 'msg-before-1', text: 'Message before 1'), - Message(id: 'msg-before-2', text: 'Message before 2'), - Message(id: 'target-message-id', text: 'Target message'), - Message(id: 'msg-after-1', text: 'Message after 1'), - Message(id: 'msg-after-2', text: 'Message after 2'), - ], - ); + tester.verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + ), + ); + }, + ); - when( - () => client.queryChannel( - channelType, - channelId: channelId, - channelData: any(named: 'channelData'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).thenAnswer((_) async => newState); + channelTest( + 'should truncate state when querying around message id', + channelType: _channelType, + channelId: _channelId, + build: _buildInitializedChannel, + body: (tester) async { + final initialMessages = [ + Message(id: 'msg1', text: 'Hello 1'), + Message(id: 'msg2', text: 'Hello 2'), + Message(id: 'msg3', text: 'Hello 3'), + ]; + + final stateWithMessages = createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + messages: initialMessages, + ); - const pagination = PaginationParams(idAround: 'target-message-id'); + tester.channelState!.updateChannelState(stateWithMessages); + expect(tester.channelState!.messages, hasLength(3)); + + final newState = createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + messages: [ + Message(id: 'msg-before-1', text: 'Message before 1'), + Message(id: 'msg-before-2', text: 'Message before 2'), + Message(id: 'target-message-id', text: 'Target message'), + Message(id: 'msg-after-1', text: 'Message after 1'), + Message(id: 'msg-after-2', text: 'Message after 2'), + ], + ); - final res = await channel.query(messagesPagination: pagination); + const pagination = PaginationParams(idAround: 'target-message-id'); - expect(res, isNotNull); - expect(channel.state!.messages, hasLength(5)); - expect(channel.state!.messages[2].id, 'target-message-id'); - - verify( - () => client.queryChannel( - channelType, - channelId: channelId, - channelData: any(named: 'channelData'), - messagesPagination: pagination, - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).called(1); - }); - - test('should truncate state when querying around created date', () async { - final initialMessages = [ - Message(id: 'msg1', text: 'Hello 1'), - Message(id: 'msg2', text: 'Hello 2'), - Message(id: 'msg3', text: 'Hello 3'), - ]; + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + messagesPagination: pagination, + ), + result: newState, + ); - final stateWithMessages = _generateChannelState( - channelId, - channelType, - ).copyWith(messages: initialMessages); - - channel.state!.updateChannelState(stateWithMessages); - expect(channel.state!.messages, hasLength(3)); - - final targetDate = DateTime.now(); - final newState = - _generateChannelState( - channelId, - channelType, - ).copyWith( - messages: [ - Message(id: 'msg-before-1', text: 'Message before 1'), - Message(id: 'msg-before-2', text: 'Message before 2'), - Message(id: 'target-message', text: 'Target message'), - Message(id: 'msg-after-1', text: 'Message after 1'), - Message(id: 'msg-after-2', text: 'Message after 2'), - ], - ); + final res = await tester.channel.query(messagesPagination: pagination); - when( - () => client.queryChannel( - channelType, - channelId: channelId, - channelData: any(named: 'channelData'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).thenAnswer((_) async => newState); + expect(res, isNotNull); + expect(tester.channelState!.messages, hasLength(5)); + expect(tester.channelState!.messages[2].id, 'target-message-id'); + + tester.verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + messagesPagination: pagination, + ), + ); + }, + ); - final pagination = PaginationParams(createdAtAround: targetDate); + channelTest( + 'should truncate state when querying around created date', + channelType: _channelType, + channelId: _channelId, + build: _buildInitializedChannel, + body: (tester) async { + final initialMessages = [ + Message(id: 'msg1', text: 'Hello 1'), + Message(id: 'msg2', text: 'Hello 2'), + Message(id: 'msg3', text: 'Hello 3'), + ]; + + final stateWithMessages = createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + messages: initialMessages, + ); - final res = await channel.query(messagesPagination: pagination); + tester.channelState!.updateChannelState(stateWithMessages); + expect(tester.channelState!.messages, hasLength(3)); + + final targetDate = DateTime.utc(2021, 3); + final newState = createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + messages: [ + Message(id: 'msg-before-1', text: 'Message before 1'), + Message(id: 'msg-before-2', text: 'Message before 2'), + Message(id: 'target-message', text: 'Target message'), + Message(id: 'msg-after-1', text: 'Message after 1'), + Message(id: 'msg-after-2', text: 'Message after 2'), + ], + ); - expect(res, isNotNull); - expect(channel.state!.messages, hasLength(5)); - expect(channel.state!.messages[2].id, 'target-message'); - - verify( - () => client.queryChannel( - channelType, - channelId: channelId, - channelData: any(named: 'channelData'), - messagesPagination: pagination, - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).called(1); - }); + final pagination = PaginationParams(createdAtAround: targetDate); - test( - 'should submit for delivery when querying latest messages (no pagination)', - () 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'), + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + messagesPagination: pagination, ), - ).thenAnswer((_) async => channelState); + result: newState, + ); - // Query without pagination params (fetching latest messages) - await channel.query(); + final res = await tester.channel.query(messagesPagination: pagination); - // Verify submitForDelivery was called - verify( - () => client.channelDeliveryReporter.submitForDelivery([channel]), - ).called(1); + expect(res, isNotNull); + expect(tester.channelState!.messages, hasLength(5)); + expect(tester.channelState!.messages[2].id, 'target-message'); + + tester.verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + messagesPagination: pagination, + ), + ); }, ); - test( - 'should NOT submit for delivery when querying with pagination (older messages)', - () 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'), + channelTest( + 'should submit for delivery when querying latest messages (no pagination)', + channelType: _channelType, + channelId: _channelId, + // The delivery reporter is real in the harness: it only records a + // channel whose last message qualifies for a receipt, so the channel is + // seeded with the `deliveryEvents` capability and a message from + // another user. + build: (client) => _buildInitializedChannel( + client, + ownCapabilities: const [ChannelCapability.readEvents, ChannelCapability.deliveryEvents], + messages: [ + Message( + id: 'test-message-id', + user: User(id: 'other-user'), + createdAt: DateTime.utc(2021, 1, 2), ), - ).thenAnswer((_) async => channelState); - - // Query with pagination params (fetching older messages) - await channel.query( - messagesPagination: const PaginationParams( - limit: 20, - lessThan: 'some-message-id', + ], + ), + body: (tester) async { + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + ), + result: createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), ), ); - // Verify submitForDelivery was NOT called - verifyNever( - () => client.channelDeliveryReporter.submitForDelivery([channel]), + registerFallbackValue([]); + tester.mockApi( + (api) => api.channel.markChannelsDelivered(any()), + result: createDefaultEmptyResponse(), ); - }, - ); - }); - test('`.queryMembers`', () async { - final filter = MemberFilter.in_(MemberFilterField.userId, const ['test-user-id-0']); + // Query without pagination params (fetching latest messages) + await tester.channel.query(); + + // The delivery reporter batches receipts behind a 1s trailing + // throttle. + await Future.delayed(const Duration(milliseconds: 1100)); - final members = List.generate( - 3, - (index) => Member(userId: 'test-user-id-$index'), + // Verify the channel's last message was marked as delivered + final captured = tester.captureApi( + (api) => api.channel.markChannelsDelivered(captureAny()), + ); + final deliveries = captured.single! as List; + expect(deliveries.single.channelCid, _channelCid); + expect(deliveries.single.messageId, 'test-message-id'); + }, ); - when( - () => client.queryMembers( - channelType, - channelId: channelId, - filter: filter, - members: any(named: 'members'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), + channelTest( + 'should NOT submit for delivery when querying with pagination (older messages)', + channelType: _channelType, + channelId: _channelId, + // Seeded so a receipt would be submitted on a plain query: the absence + // of one can then only come from the pagination params. + build: (client) => _buildInitializedChannel( + client, + ownCapabilities: const [ChannelCapability.readEvents, ChannelCapability.deliveryEvents], + messages: [ + Message( + id: 'test-message-id', + user: User(id: 'other-user'), + createdAt: DateTime.utc(2021, 1, 2), + ), + ], ), - ).thenAnswer((_) async => QueryMembersResponse()..members = members); + body: (tester) async { + registerFallbackValue([]); + tester.mockApi( + (api) => api.channel.markChannelsDelivered(any()), + result: createDefaultEmptyResponse(), + ); - final res = await channel.queryMembers(filter: filter); + const pagination = PaginationParams(limit: 20, lessThan: 'some-message-id'); - expect(res, isNotNull); - expect(res.members.length, members.length); + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + messagesPagination: pagination, + ), + result: createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ), + ); - verify( - () => client.queryMembers( - channelType, - channelId: channelId, - filter: filter, - members: any(named: 'members'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - ), - ).called(1); - }); + // Query with pagination params (fetching older messages) + await tester.channel.query(messagesPagination: pagination); - test('`.queryBannedUsers`', () async { - final filter = BannedUserFilter.equal(BannedUserFilterField.channelCid, channelCid); + // Wait out the reporter's 1s trailing throttle so a receipt would + // have been sent by now if the channel had been submitted. + await Future.delayed(const Duration(milliseconds: 1100)); - final bans = List.generate( - 3, - (index) => BannedUser( - user: User(id: 'test-user-id-$index'), - bannedBy: User(id: 'test-user-id-${index + 1}'), - ), + // Verify no delivery receipt was sent + tester.verifyNeverCalled( + (api) => api.channel.markChannelsDelivered(any()), + ); + }, ); + }); - when( - () => client.queryBannedUsers( - filter: any(named: 'filter', that: isSameFilterAs(filter)), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - ), - ).thenAnswer((_) async => QueryBannedUsersResponse()..bans = bans); + channelTest( + '`.queryMembers`', + channelType: _channelType, + channelId: _channelId, + build: _buildInitializedChannel, + body: (tester) async { + final filter = MemberFilter.in_(MemberFilterField.userId, const ['test-user-id-0']); - final res = await channel.queryBannedUsers(); + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); - expect(res, isNotNull); - expect(res.bans.length, bans.length); + tester.mockApi( + // The seeded channel state holds no members, so the SDK forwards an + // empty list. + (api) => api.general.queryMembers( + _channelType, + channelId: _channelId, + filter: filter, + members: const [], + ), + result: QueryMembersResponse()..members = members, + ); - verify( - () => client.queryBannedUsers( - filter: any(named: 'filter', that: isSameFilterAs(filter)), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - ), - ).called(1); - }); + final res = await tester.channel.queryMembers(filter: filter); - test('`.mute`', () async { - when( - () => client.muteChannel( - channelCid, - expiration: any(named: 'expiration'), - ), - ).thenAnswer((_) async => EmptyResponse()); + expect(res, isNotNull); + expect(res.members.length, members.length); + + tester.verifyApi( + (api) => api.general.queryMembers( + _channelType, + channelId: _channelId, + filter: filter, + members: const [], + ), + ); + }, + ); - final res = await channel.mute(); + channelTest( + '`.queryBannedUsers`', + channelType: _channelType, + channelId: _channelId, + build: _buildInitializedChannel, + body: (tester) async { + // Built by the channel rather than passed in, and a filter compares + // by identity, so the stubs below match on what it sends. + final filter = BannedUserFilter.equal(BannedUserFilterField.channelCid, _channelCid); + + final bans = List.generate( + 3, + (index) => BannedUser( + user: User(id: 'test-user-id-$index'), + bannedBy: User(id: 'test-user-id-${index + 1}'), + ), + ); - expect(res, isNotNull); + tester.mockApi( + (api) => api.moderation.queryBannedUsers( + filter: any(named: 'filter', that: isSameFilterAs(filter)), + ), + result: QueryBannedUsersResponse()..bans = bans, + ); - verify( - () => client.muteChannel( - channelCid, - expiration: any(named: 'expiration'), - ), - ).called(1); - }); + final res = await tester.channel.queryBannedUsers(); - test('`.mute with expiration`', () async { - const expiration = Duration(seconds: 3); + expect(res, isNotNull); + expect(res.bans.length, bans.length); - when( - () => client.muteChannel( - channelCid, - expiration: expiration, - ), - ).thenAnswer((_) async => EmptyResponse()); + tester.verifyApi( + (api) => api.moderation.queryBannedUsers( + filter: any(named: 'filter', that: isSameFilterAs(filter)), + ), + ); + }, + ); - when(() => client.unmuteChannel(channelCid)).thenAnswer((_) async => EmptyResponse()); + channelTest( + '`.mute`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelModerationApi()), + body: (tester) async { + tester.mockApi( + (api) => api.moderation.muteChannel(_channelCid), + result: createDefaultEmptyResponse(), + ); - final res = await channel.mute(expiration: expiration); + final res = await tester.channel.mute(); - expect(res, isNotNull); + expect(res, isNotNull); - verify( - () => client.muteChannel( - channelCid, - expiration: expiration, - ), - ).called(1); + tester.verifyApi( + (api) => api.moderation.muteChannel(_channelCid), + ); + }, + ); - // wait for expiration - await Future.delayed(expiration); - verify(() => client.unmuteChannel(channelCid)).called(1); - }); + channelTest( + '`.mute with expiration`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelModerationApi()), + body: (tester) async { + const expiration = Duration(seconds: 3); + + tester + ..mockApi( + (api) => api.moderation.muteChannel(_channelCid, expiration: expiration), + result: createDefaultEmptyResponse(), + ) + ..mockApi( + (api) => api.moderation.unmuteChannel(_channelCid), + result: createDefaultEmptyResponse(), + ); - test('`.unmute`', () async { - when( - () => client.unmuteChannel(channelCid), - ).thenAnswer((_) async => EmptyResponse()); + final res = await tester.channel.mute(expiration: expiration); - final res = await channel.unmute(); + expect(res, isNotNull); - expect(res, isNotNull); + tester.verifyApi( + (api) => api.moderation.muteChannel(_channelCid, expiration: expiration), + ); - verify( - () => client.unmuteChannel(channelCid), - ).called(1); - }); + // wait for expiration + await Future.delayed(expiration); + tester.verifyApi((api) => api.moderation.unmuteChannel(_channelCid)); + }, + ); - test('`.enableSlowMode`', () async { - const cooldown = 10; + channelTest( + '`.unmute`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelModerationApi()), + body: (tester) async { + tester.mockApi( + (api) => api.moderation.unmuteChannel(_channelCid), + result: createDefaultEmptyResponse(), + ); - final channelModel = ChannelModel( - cid: channelCid, - cooldown: cooldown, - ); + final res = await tester.channel.unmute(); - when( - () => client.enableSlowdown( - channelId, - channelType, - cooldown, - ), - ).thenAnswer((_) async => PartialUpdateChannelResponse()..channel = channelModel); + expect(res, isNotNull); - final res = await channel.enableSlowMode(cooldownInterval: 10); + tester.verifyApi( + (api) => api.moderation.unmuteChannel(_channelCid), + ); + }, + ); - expect(res, isNotNull); + channelTest( + '`.enableSlowMode`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelModerationApi()), + body: (tester) async { + const cooldown = 10; - verify( - () => client.enableSlowdown( - channelId, - channelType, - cooldown, - ), - ).called(1); - }); + final channelModel = ChannelModel( + cid: _channelCid, + cooldown: cooldown, + ); - test('`.disableSlowMode`', () async { - final channelModel = ChannelModel( - cid: channelCid, - ); + tester.mockApi( + (api) => api.channel.enableSlowdown(_channelId, _channelType, cooldown), + result: createDefaultPartialUpdateChannelResponse(channel: channelModel), + ); - when( - () => client.disableSlowdown( - channelId, - channelType, - ), - ).thenAnswer((_) async => PartialUpdateChannelResponse()..channel = channelModel); + final res = await tester.channel.enableSlowMode(cooldownInterval: 10); - final res = await channel.disableSlowMode(); + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.enableSlowdown(_channelId, _channelType, cooldown), + ); + }, + ); - expect(res, isNotNull); + channelTest( + '`.disableSlowMode`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelModerationApi()), + body: (tester) async { + final channelModel = ChannelModel( + cid: _channelCid, + ); - verify(() => client.disableSlowdown(channelId, channelType)).called(1); - }); + tester.mockApi( + (api) => api.channel.disableSlowdown(_channelId, _channelType), + result: createDefaultPartialUpdateChannelResponse(channel: channelModel), + ); - test('`.banUser`', () async { - const userId = 'test-user-id'; - const options = {'key': 'value'}; + final res = await tester.channel.disableSlowMode(); - when( - () => client.banUser( - userId, - {'type': channelType, 'id': channelId, ...options}, - ), - ).thenAnswer((_) async => EmptyResponse()); + expect(res, isNotNull); - final res = await channel.banMember(userId, options); + tester.verifyApi( + (api) => api.channel.disableSlowdown(_channelId, _channelType), + ); + }, + ); - expect(res, isNotNull); + channelTest( + '`.banUser`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelModerationApi()), + body: (tester) async { + const userId = 'test-user-id'; + const options = {'key': 'value'}; - verify( - () => client.banUser( - userId, - {'type': channelType, 'id': channelId, ...options}, - ), - ).called(1); - }); + tester.mockApi( + (api) => api.moderation.banUser( + userId, + options: {'type': _channelType, 'id': _channelId, ...options}, + ), + result: createDefaultEmptyResponse(), + ); - test('`.unbanUser`', () async { - const userId = 'test-user-id'; + final res = await tester.channel.banMember(userId, options); - when(() => client.unbanUser(userId, any())).thenAnswer((_) async => EmptyResponse()); + expect(res, isNotNull); - final res = await channel.unbanMember(userId); + tester.verifyApi( + (api) => api.moderation.banUser( + userId, + options: {'type': _channelType, 'id': _channelId, ...options}, + ), + ); + }, + ); - expect(res, isNotNull); + channelTest( + '`.unbanUser`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelModerationApi()), + body: (tester) async { + const userId = 'test-user-id'; - verify(() => client.unbanUser(userId, any())).called(1); - }); + tester.mockApi( + (api) => api.moderation.unbanUser( + userId, + options: {'type': _channelType, 'id': _channelId}, + ), + result: createDefaultEmptyResponse(), + ); - test('`.shadowBan`', () async { - const userId = 'test-user-id'; - const options = {'key': 'value'}; + final res = await tester.channel.unbanMember(userId); - when( - () => client.shadowBan( - userId, - {'type': channelType, 'id': channelId, ...options}, - ), - ).thenAnswer((_) async => EmptyResponse()); + expect(res, isNotNull); - final res = await channel.shadowBan(userId, options); + tester.verifyApi( + (api) => api.moderation.unbanUser( + userId, + options: {'type': _channelType, 'id': _channelId}, + ), + ); + }, + ); - expect(res, isNotNull); + channelTest( + '`.shadowBan`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelModerationApi()), + body: (tester) async { + const userId = 'test-user-id'; + const options = {'key': 'value'}; - verify( - () => client.shadowBan( - userId, - {'type': channelType, 'id': channelId, ...options}, - ), - ).called(1); - }); + tester.mockApi( + (api) => api.moderation.banUser( + userId, + options: {'shadow': true, 'type': _channelType, 'id': _channelId, ...options}, + ), + result: createDefaultEmptyResponse(), + ); - test('`.removeShadowBan`', () async { - const userId = 'test-user-id'; + final res = await tester.channel.shadowBan(userId, options); - when(() => client.removeShadowBan(userId, any())).thenAnswer((_) async => EmptyResponse()); + expect(res, isNotNull); - final res = await channel.removeShadowBan(userId); + tester.verifyApi( + (api) => api.moderation.banUser( + userId, + options: {'shadow': true, 'type': _channelType, 'id': _channelId, ...options}, + ), + ); + }, + ); - expect(res, isNotNull); + channelTest( + '`.removeShadowBan`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelModerationApi()), + body: (tester) async { + const userId = 'test-user-id'; - verify(() => client.removeShadowBan(userId, any())).called(1); - }); + tester.mockApi( + (api) => api.moderation.unbanUser( + userId, + options: {'shadow': true, 'type': _channelType, 'id': _channelId}, + ), + result: createDefaultEmptyResponse(), + ); - test('`.hide`', () async { - const clearHistory = true; + final res = await tester.channel.removeShadowBan(userId); - when( - () => client.hideChannel( - channelId, - channelType, - clearHistory: clearHistory, - ), - ).thenAnswer((_) async => EmptyResponse()); + expect(res, isNotNull); - final res = await channel.hide(clearHistory: clearHistory); + tester.verifyApi( + (api) => api.moderation.unbanUser( + userId, + options: {'shadow': true, 'type': _channelType, 'id': _channelId}, + ), + ); + }, + ); - expect(res, isNotNull); + channelTest( + '`.hide`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelDisplayApi()), + body: (tester) async { + const clearHistory = true; + + tester.mockApi( + (api) => api.channel.hideChannel( + _channelId, + _channelType, + clearHistory: clearHistory, + ), + result: createDefaultEmptyResponse(), + ); - verify( - () => client.hideChannel( - channelId, - channelType, - clearHistory: clearHistory, - ), - ).called(1); - }); + final res = await tester.channel.hide(clearHistory: clearHistory); - test('`.show`', () async { - when(() => client.showChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.hideChannel( + _channelId, + _channelType, + clearHistory: clearHistory, + ), + ); + }, + ); - final res = await channel.show(); + channelTest( + '`.show`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelDisplayApi()), + body: (tester) async { + tester.mockApi( + (api) => api.channel.showChannel(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); - expect(res, isNotNull); + final res = await tester.channel.show(); - verify(() => client.showChannel(channelId, channelType)).called(1); - }); + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.showChannel(_channelId, _channelType), + ); + }, + ); // testing archiving - test('`.archive`', () async { - when(() => client.archiveChannel(channelId: channelId, channelType: channelType)).thenAnswer( - (_) async => FakePartialUpdateMemberResponse(), - ); + channelTest( + '`.archive`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelDisplayApi()), + body: (tester) async { + tester.mockApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: {'archived': true}, + ), + result: createDefaultPartialUpdateMemberResponse(), + ); - final res = await channel.archive(); + final res = await tester.channel.archive(); - expect(res, isNotNull); + expect(res, isNotNull); - verify(() => client.archiveChannel(channelId: channelId, channelType: channelType)).called(1); - }); + tester.verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: {'archived': true}, + ), + ); + }, + ); - test('`.unarchive`', () async { - when(() => client.unarchiveChannel(channelId: channelId, channelType: channelType)).thenAnswer( - (_) async => FakePartialUpdateMemberResponse(), - ); + channelTest( + '`.unarchive`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelDisplayApi()), + body: (tester) async { + tester.mockApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + unset: ['archived'], + ), + result: createDefaultPartialUpdateMemberResponse(), + ); - final res = await channel.unarchive(); + final res = await tester.channel.unarchive(); - expect(res, isNotNull); + expect(res, isNotNull); - verify(() => client.unarchiveChannel(channelId: channelId, channelType: channelType)).called(1); - }); + tester.verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + unset: ['archived'], + ), + ); + }, + ); // testing pinning - test('`.pin`', () async { - when( - () => client.pinChannel(channelId: channelId, channelType: channelType), - ).thenAnswer((_) async => FakePartialUpdateMemberResponse()); + channelTest( + '`.pin`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelDisplayApi()), + body: (tester) async { + tester.mockApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: {'pinned': true}, + ), + result: createDefaultPartialUpdateMemberResponse(), + ); - final res = await channel.pin(); + final res = await tester.channel.pin(); - expect(res, isNotNull); + expect(res, isNotNull); - verify(() => client.pinChannel(channelId: channelId, channelType: channelType)).called(1); - }); + tester.verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: {'pinned': true}, + ), + ); + }, + ); - test('`.unpin`', () async { - when( - () => client.unpinChannel(channelId: channelId, channelType: channelType), - ).thenAnswer((_) async => FakePartialUpdateMemberResponse()); + channelTest( + '`.unpin`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelDisplayApi()), + body: (tester) async { + tester.mockApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + unset: ['pinned'], + ), + result: createDefaultPartialUpdateMemberResponse(), + ); - final res = await channel.unpin(); + final res = await tester.channel.unpin(); - expect(res, isNotNull); + expect(res, isNotNull); - verify(() => client.unpinChannel(channelId: channelId, channelType: channelType)).called(1); - }); + tester.verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + unset: ['pinned'], + ), + ); + }, + ); - test('`.on`', () async { - const eventType = 'test.event'; - final event = Event(type: eventType, cid: channelCid); + channelTest( + '`.on`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelDisplayApi()), + body: (tester) async { + const eventType = 'test.event'; + final event = createDefaultEvent(type: eventType, cid: _channelCid); + + final eventReceived = expectLater( + tester.channel.on(eventType), + // The event round-trips through the real wire decode, so the + // delivered instance is never identical to `event`; pin every field it + // carries instead. + emitsInOrder([ + isA() + .having((it) => it.type, 'type', event.type) + .having((it) => it.cid, 'cid', event.cid) + .having((it) => it.createdAt, 'createdAt', event.createdAt), + ]), + ); - Future.microtask(() => client.addEvent(event)); + await tester.emitEvent(event); - return expectLater(channel.on(eventType), emitsInOrder([event])); - }); + await eventReceived; + }, + ); group('stale error message cleanup', () { - final channelState = _generateChannelState(channelId, channelType); - final errorMessage = Message(type: MessageType.error); final bouncedErrorMessage = Message( type: MessageType.error, @@ -4857,310 +6021,366 @@ void main() { ); // Test case: sending a message cleans up stale error messages - test('when sending a new message', () async { + + // Test case: sending a message cleans up stale error messages + channelTest( + 'when sending a new message', + channelType: _channelType, + channelId: _channelId, // Channel with 2 error messages - final channel = Channel.fromState( - client, - channelState.copyWith( + setUp: (tester) => tester.watch( + modifyResponse: (_) => _channelStateWith( messages: [errorMessage, bouncedErrorMessage], ), - ); - - // Set up the mock response for sending message - final newMessage = Message(text: 'New message'); + ), + body: (tester) async { + // Set up the mock response for sending message + final newMessage = Message(text: 'New message'); - when( - () => client.sendMessage(any(), channelId, channelType), - ).thenAnswer((_) async => SendMessageResponse()..message = newMessage.copyWith(state: MessageState.sent)); + tester.mockApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(newMessage))), + result: createDefaultSendMessageResponse(message: newMessage.copyWith(state: MessageState.sent)), + ); - // Send a new message - await channel.sendMessage(newMessage); - final messages = channel.state!.messages; + // Send a new message + await tester.channel.sendMessage(newMessage); + final messages = tester.channelState!.messages; - // Verify the cleanup - expect(messages.length, 2); - expect(messages.any((m) => m.id == errorMessage.id), false); - expect(messages.any((m) => m.id == bouncedErrorMessage.id), true); - expect(messages.any((m) => m.id == newMessage.id), true); + // Verify the cleanup + expect(messages.length, 2); + expect(messages.any((m) => m.id == errorMessage.id), false); + expect(messages.any((m) => m.id == bouncedErrorMessage.id), true); + expect(messages.any((m) => m.id == newMessage.id), true); - verify(() => client.sendMessage(any(), channelId, channelType)); - }); + tester.verifyApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(newMessage))), + ); + }, + ); }); group('`.state.pruneOldest`', () { + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); + } + List _generateMessages(int count) => List.generate( count, (i) => Message( id: 'msg-$i', text: 'Hello $i', - createdAt: DateTime(2024).add(Duration(seconds: i)), + createdAt: DateTime.utc(2024).add(Duration(seconds: i)), ), ); - test('keeps only the [maxMessages] most recent messages', () { - final initial = _generateMessages(10); - channel.state!.updateChannelState( - _generateChannelState(channelId, channelType).copyWith(messages: initial), - ); - expect(channel.state!.messages, hasLength(10)); - - channel.state!.pruneOldest(4); - - final pruned = channel.state!.messages; - expect(pruned, hasLength(4)); - expect(pruned.map((m) => m.id), ['msg-6', 'msg-7', 'msg-8', 'msg-9']); - }); - - test('emits the pruned list on `messagesStream`', () async { - final initial = _generateMessages(6); - channel.state!.updateChannelState( - _generateChannelState(channelId, channelType).copyWith(messages: initial), - ); + channelTest( + 'keeps only the [maxMessages] most recent messages', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final initial = _generateMessages(10); + tester.channelState!.updateChannelState(_channelStateWith(messages: initial)); + expect(tester.channelState!.messages, hasLength(10)); + + tester.channelState!.pruneOldest(4); + + final pruned = tester.channelState!.messages; + expect(pruned, hasLength(4)); + expect(pruned.map((m) => m.id), ['msg-6', 'msg-7', 'msg-8', 'msg-9']); + }, + ); - final next = channel.state!.messagesStream.firstWhere((messages) => messages.length == 3); + channelTest( + 'emits the pruned list on `messagesStream`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final initial = _generateMessages(6); + tester.channelState!.updateChannelState(_channelStateWith(messages: initial)); - channel.state!.pruneOldest(3); + final next = tester.channelState!.messagesStream.firstWhere((messages) => messages.length == 3); - final emitted = await next; - expect(emitted.map((m) => m.id), ['msg-3', 'msg-4', 'msg-5']); - }); + tester.channelState!.pruneOldest(3); - test('is a no-op when message count is within the limit', () { - final initial = _generateMessages(3); - channel.state!.updateChannelState( - _generateChannelState(channelId, channelType).copyWith(messages: initial), - ); + final emitted = await next; + expect(emitted.map((m) => m.id), ['msg-3', 'msg-4', 'msg-5']); + }, + ); - channel.state!.pruneOldest(5); - expect(channel.state!.messages, hasLength(3)); + channelTest( + 'is a no-op when message count is within the limit', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final initial = _generateMessages(3); + tester.channelState!.updateChannelState(_channelStateWith(messages: initial)); - channel.state!.pruneOldest(3); - expect(channel.state!.messages, hasLength(3)); - }); + tester.channelState!.pruneOldest(5); + expect(tester.channelState!.messages, hasLength(3)); - test('is a no-op when [maxMessages] is zero or negative', () { - final initial = _generateMessages(5); - channel.state!.updateChannelState( - _generateChannelState(channelId, channelType).copyWith(messages: initial), - ); + tester.channelState!.pruneOldest(3); + expect(tester.channelState!.messages, hasLength(3)); + }, + ); - channel.state!.pruneOldest(0); - expect(channel.state!.messages, hasLength(5)); + channelTest( + 'is a no-op when [maxMessages] is zero or negative', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final initial = _generateMessages(5); + tester.channelState!.updateChannelState(_channelStateWith(messages: initial)); - channel.state!.pruneOldest(-1); - expect(channel.state!.messages, hasLength(5)); - }); + tester.channelState!.pruneOldest(0); + expect(tester.channelState!.messages, hasLength(5)); - test('is a no-op when `isUpToDate` is false', () { - final initial = _generateMessages(10); - channel.state!.updateChannelState( - _generateChannelState(channelId, channelType).copyWith(messages: initial), - ); + tester.channelState!.pruneOldest(-1); + expect(tester.channelState!.messages, hasLength(5)); + }, + ); - channel.state!.isUpToDate = false; - channel.state!.pruneOldest(3); - expect(channel.state!.messages, hasLength(10)); - }); + channelTest( + 'is a no-op when `isUpToDate` is false', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final initial = _generateMessages(10); + tester.channelState!.updateChannelState(_channelStateWith(messages: initial)); + + tester.channelState!.isUpToDate = false; + tester.channelState!.pruneOldest(3); + expect(tester.channelState!.messages, hasLength(10)); + }, + ); - test('only mutates `messages`; other channel state fields untouched', () { - final initial = _generateMessages(10); - final pinned = [ - Message( - id: 'pinned-1', - text: 'pinned message', - createdAt: DateTime(2024), - ), - ]; + channelTest( + 'only mutates `messages`; other channel state fields untouched', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final initial = _generateMessages(10); + final pinned = [ + Message( + id: 'pinned-1', + text: 'pinned message', + createdAt: DateTime.utc(2024), + ), + ]; - channel.state!.updateChannelState( - _generateChannelState(channelId, channelType).copyWith( - messages: initial, - pinnedMessages: pinned, - ), - ); + tester.channelState!.updateChannelState( + _channelStateWith(messages: initial, pinnedMessages: pinned), + ); - channel.state!.pruneOldest(3); + tester.channelState!.pruneOldest(3); - expect(channel.state!.messages, hasLength(3)); - expect(channel.state!.pinnedMessages, equals(pinned)); - }); + expect(tester.channelState!.messages, hasLength(3)); + expect(tester.channelState!.pinnedMessages, equals(pinned)); + }, + ); - test('does not emit on `messagesStream` for no-op calls', () async { - final initial = _generateMessages(5); - channel.state!.updateChannelState( - _generateChannelState(channelId, channelType).copyWith(messages: initial), - ); + channelTest( + 'does not emit on `messagesStream` for no-op calls', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final initial = _generateMessages(5); + tester.channelState!.updateChannelState(_channelStateWith(messages: initial)); - // Skip the seeded emission from updateChannelState. - await pumpEventQueue(); + // Skip the seeded emission from updateChannelState. + await pumpEventQueue(); - final emissions = >[]; - final sub = channel.state!.messagesStream.skip(1).listen(emissions.add); - addTearDown(sub.cancel); + final emissions = >[]; + final sub = tester.channelState!.messagesStream.skip(1).listen(emissions.add); + addTearDown(sub.cancel); - channel.state!.pruneOldest(0); // non-positive guard - channel.state!.pruneOldest(-1); // non-positive guard - channel.state!.pruneOldest(10); // within limit guard - channel.state!.isUpToDate = false; - channel.state!.pruneOldest(2); // !isUpToDate guard + tester.channelState!.pruneOldest(0); // non-positive guard + tester.channelState!.pruneOldest(-1); // non-positive guard + tester.channelState!.pruneOldest(10); // within limit guard + tester.channelState!.isUpToDate = false; + tester.channelState!.pruneOldest(2); // !isUpToDate guard - await pumpEventQueue(); - expect(emissions, isEmpty); - }); + await pumpEventQueue(); + expect(emissions, isEmpty); + }, + ); }); }); group('Channel State Validation and Cooldown', () { - late final client = MockStreamChatClient(); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; + // seconds - setUpAll(() { - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, + // A bare channel state (no config, no capabilities). + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); + } - // mock channel delivery reporter - when( - () => client.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); - }); + // A bare channel state (no config, no capabilities). + ChannelState Function(ChannelState) _seedChannelCountEvents() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); + } group('Non-initialized channel state validation', () { - test( + channelTest( 'should throw StateError when accessing cooldown on non-initialized channel', - () { - final channel = Channel(client, channelType, channelId); + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final channel = tester.channel; expect(() => channel.cooldown, throwsA(isA())); }, ); - test( + channelTest( 'should throw StateError when accessing getRemainingCooldown on non-initialized channel', - () { - final channel = Channel(client, channelType, channelId); + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final channel = tester.channel; expect(channel.getRemainingCooldown, throwsA(isA())); }, ); - test( + channelTest( 'should throw StateError when accessing cooldownStream on non-initialized channel', - () { - final channel = Channel(client, channelType, channelId); + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final channel = tester.channel; expect(() => channel.cooldownStream, throwsA(isA())); }, ); }); group('Initialized channel cooldown functionality', () { - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() => channel.dispose()); - - test( + channelTest( 'should return default cooldown value of 0 for initialized channel', - () => expect(channel.cooldown, equals(0)), + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) => expect(tester.channel.cooldown, equals(0)), ); - test('should return custom cooldown value when set in channel model', () { - final channelWithCooldown = ChannelModel( - id: channelId, - type: channelType, - cooldown: 30, - ); + channelTest( + 'should return custom cooldown value when set in channel model', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final channelWithCooldown = ChannelModel( + id: _channelId, + type: _channelType, + cooldown: 30, + ); - final stateWithCooldown = ChannelState(channel: channelWithCooldown); - final testChannel = Channel.fromState(client, stateWithCooldown); - addTearDown(testChannel.dispose); + final stateWithCooldown = ChannelState(channel: channelWithCooldown); + final testChannel = Channel.fromState(tester.client, stateWithCooldown); + addTearDown(testChannel.dispose); - expect(testChannel.cooldown, equals(30)); - }); + expect(testChannel.cooldown, equals(30)); + }, + ); - test('should return 0 remaining cooldown when no cooldown is set', () { - expect(channel.getRemainingCooldown(), equals(0)); - }); + channelTest( + 'should return 0 remaining cooldown when no cooldown is set', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + expect(tester.channel.getRemainingCooldown(), equals(0)); + }, + ); - test('should return cooldown stream with default value', () { - expectLater(channel.cooldownStream.take(1), emits(0)); - }); + channelTest( + 'should return cooldown stream with default value', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + await expectLater(tester.channel.cooldownStream.take(1), emits(0)); + }, + ); }); group('Thread reply cooldown', () { - const currentUserId = 'test-user-id'; // matches FakeClientState default - const cooldownDuration = 30; // seconds - - Channel _buildChannelWithCooldown() { - final channelModel = ChannelModel( - id: channelId, - type: channelType, - cooldown: cooldownDuration, - ownCapabilities: [ChannelCapability.slowMode], - ); - final state = ChannelState(channel: channelModel); - final ch = Channel.fromState(client, state); - // isUpToDate is seeded true by default - return ch; + const _cooldownDuration = 30; + + // A channel with an active cooldown and the slow-mode capability. + // `isUpToDate` is seeded true by default. + ChannelState Function(ChannelState) _seedChannelWithCooldown() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + cooldown: _cooldownDuration, + ownCapabilities: [ChannelCapability.slowMode], + ), + ); } - test( + channelTest( 'should return positive cooldown after current user sends a thread reply', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelWithCooldown()), + body: (tester) async { // Simulate a thread reply by the current user sent just now. final threadReply = Message( id: 'thread-reply-1', parentId: 'parent-msg-1', showInChannel: false, createdAt: DateTime.timestamp(), - user: User(id: currentUserId), + user: User(id: tester.currentUser!.id), ); - ch.state!.updateThreadInfo('parent-msg-1', [threadReply]); + tester.channelState!.updateThreadInfo('parent-msg-1', [threadReply]); - expect(ch.getRemainingCooldown(), greaterThan(0)); + expect(tester.channel.getRemainingCooldown(), greaterThan(0)); }, ); - test( + channelTest( 'should return 0 cooldown when thread reply was sent outside the cooldown window', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelWithCooldown()), + body: (tester) async { // Reply sent cooldownDuration+5 seconds ago — outside the window. final oldReply = Message( id: 'thread-reply-old', parentId: 'parent-msg-1', showInChannel: false, createdAt: DateTime.timestamp().subtract( - const Duration(seconds: cooldownDuration + 5), + const Duration(seconds: _cooldownDuration + 5), ), - user: User(id: currentUserId), + user: User(id: tester.currentUser!.id), ); - ch.state!.updateThreadInfo('parent-msg-1', [oldReply]); + tester.channelState!.updateThreadInfo('parent-msg-1', [oldReply]); - expect(ch.getRemainingCooldown(), equals(0)); + expect(tester.channel.getRemainingCooldown(), equals(0)); }, ); - test( + channelTest( 'should not trigger cooldown for a thread reply from another user', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelWithCooldown()), + body: (tester) async { final otherUserReply = Message( id: 'thread-reply-other', parentId: 'parent-msg-1', @@ -5168,50 +6388,50 @@ void main() { createdAt: DateTime.timestamp(), user: User(id: 'other-user-id'), ); - ch.state!.updateThreadInfo('parent-msg-1', [otherUserReply]); + tester.channelState!.updateThreadInfo('parent-msg-1', [otherUserReply]); - expect(ch.getRemainingCooldown(), equals(0)); + expect(tester.channel.getRemainingCooldown(), equals(0)); }, ); - test( + channelTest( 'should clear cooldown when the most-recent own message is hard-deleted', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelWithCooldown()), + body: (tester) async { final ownMessage = Message( id: 'msg-1', createdAt: DateTime.timestamp(), - user: User(id: currentUserId), + user: User(id: tester.currentUser!.id), ); - ch.state!.updateMessage(ownMessage); - expect(ch.getRemainingCooldown(), greaterThan(0)); + tester.channelState!.updateMessage(ownMessage); + expect(tester.channel.getRemainingCooldown(), greaterThan(0)); - ch.state!.deleteMessage(ownMessage, hardDelete: true); - expect(ch.getRemainingCooldown(), equals(0)); + tester.channelState!.deleteMessage(ownMessage, hardDelete: true); + expect(tester.channel.getRemainingCooldown(), equals(0)); }, ); - test( + channelTest( 'currentUserLastMessageAtStream emits a new timestamp when own message is added', - () async { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelWithCooldown()), + body: (tester) async { final emissions = []; - final sub = ch.currentUserLastMessageAtStream.listen(emissions.add); + final sub = tester.channel.currentUserLastMessageAtStream.listen(emissions.add); addTearDown(sub.cancel); // Let the seed emission settle. await Future.delayed(Duration.zero); final seededLast = emissions.last; - ch.state!.updateMessage( + tester.channelState!.updateMessage( Message( id: 'msg-1', createdAt: DateTime.timestamp(), - user: User(id: currentUserId), + user: User(id: tester.currentUser!.id), ), ); await Future.delayed(Duration.zero); @@ -5221,57 +6441,57 @@ void main() { }, ); - test( + channelTest( 'getRemainingCooldown uses the explicit [lastMessageAt] override', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelWithCooldown()), + body: (tester) async { // No messages in state, so the default path returns 0. - expect(ch.getRemainingCooldown(), equals(0)); + expect(tester.channel.getRemainingCooldown(), equals(0)); // Override pointing inside the cooldown window → positive remaining. final recent = DateTime.timestamp().subtract(const Duration(seconds: 5)); - expect(ch.getRemainingCooldown(lastMessageAt: recent), greaterThan(0)); + expect(tester.channel.getRemainingCooldown(lastMessageAt: recent), greaterThan(0)); // Override pointing outside the window → 0. final old = DateTime.timestamp().subtract( - const Duration(seconds: cooldownDuration + 5), + const Duration(seconds: _cooldownDuration + 5), ); - expect(ch.getRemainingCooldown(lastMessageAt: old), equals(0)); + expect(tester.channel.getRemainingCooldown(lastMessageAt: old), equals(0)); }, ); - test( + channelTest( 'currentUserLastMessageAt picks the latest across channel messages and threads', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelWithCooldown()), + body: (tester) async { final older = DateTime.timestamp().subtract(const Duration(seconds: 20)); final newer = DateTime.timestamp().subtract(const Duration(seconds: 5)); // Older message in the main channel. - ch.state!.updateMessage( + tester.channelState!.updateMessage( Message( id: 'msg-1', createdAt: older, - user: User(id: currentUserId), + user: User(id: tester.currentUser!.id), ), ); // Newer reply in a thread. - ch.state!.updateThreadInfo('parent-msg-1', [ + tester.channelState!.updateThreadInfo('parent-msg-1', [ Message( id: 'thread-reply-1', parentId: 'parent-msg-1', showInChannel: false, createdAt: newer, - user: User(id: currentUserId), + user: User(id: tester.currentUser!.id), ), ]); // Should pick the newer thread reply, not the older channel message. - final result = ch.currentUserLastMessageAt; + final result = tester.channel.currentUserLastMessageAt; expect(result, isNotNull); expect(result!.isAtSameMomentAs(newer), isTrue); }, @@ -5279,16 +6499,14 @@ void main() { }); group('Disposed channel state validation', () { - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - test( + channelTest( 'should throw StateError when accessing cooldown after disposal', - () { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final channel = tester.channel; + // First verify it works when initialized expect(channel.cooldown, equals(0)); @@ -5300,9 +6518,14 @@ void main() { }, ); - test( + channelTest( 'should throw StateError when accessing getRemainingCooldown after disposal', - () { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final channel = tester.channel; + // First verify it works when initialized expect(channel.getRemainingCooldown(), equals(0)); @@ -5314,11 +6537,16 @@ void main() { }, ); - test( + channelTest( 'should throw StateError when accessing cooldownStream after disposal', - () { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final channel = tester.channel; + // First verify it works when initialized - expectLater(channel.cooldownStream.take(1), emits(0)); + await expectLater(channel.cooldownStream.take(1), emits(0)); // Dispose the channel channel.dispose(); @@ -5328,12 +6556,17 @@ void main() { }, ); - test( + channelTest( 'should handle race condition scenario - initialization then quick disposal', - () { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { // This test simulates the race condition that was causing the production crash - final channelState = _generateChannelState(channelId, channelType); - final raceChannel = Channel.fromState(client, channelState); + final channelState = createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); + final raceChannel = Channel.fromState(tester.client, channelState); // Verify it works initially expect(raceChannel.cooldown, equals(0)); @@ -5350,51 +6583,41 @@ void main() { }); group('Channel message count events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test( + channelTest( 'should update channel messageCount when event contains channelMessageCount', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelCountEvents()), + body: (tester) async { // Verify initial state - no messageCount - expect(channel.messageCount, isNull); + expect(tester.channel.messageCount, isNull); // Create event with channelMessageCount - final messageCountEvent = Event( - cid: channel.cid, + final messageCountEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.messageNew, channelMessageCount: 42, ); - // Dispatch event - client.addEvent(messageCountEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Dispatch event and wait for it to be processed + await tester.emitEvent(messageCountEvent); // Verify channel messageCount was updated - expect(channel.messageCount, equals(42)); + expect(tester.channel.messageCount, equals(42)); }, ); - test( + channelTest( 'should update channel messageCount from message.new and message.deleted events', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelCountEvents()), + body: (tester) async { // Test with message.new event - count increases - final messageNewEvent = Event( - cid: channel.cid, + final messageNewEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.messageNew, - message: Message( + message: createDefaultMessage( id: 'new-message-1', text: 'Hello world!', user: User(id: 'user-1'), @@ -5402,15 +6625,14 @@ void main() { channelMessageCount: 1, ); - client.addEvent(messageNewEvent); - await Future.delayed(Duration.zero); - expect(channel.messageCount, equals(1)); + await tester.emitEvent(messageNewEvent); + expect(tester.channel.messageCount, equals(1)); // Test with another message.new event - count increases - final messageNewEvent2 = Event( - cid: channel.cid, + final messageNewEvent2 = createDefaultEvent( + cid: tester.channel.cid, type: EventType.messageNew, - message: Message( + message: createDefaultMessage( id: 'new-message-2', text: 'Second message', user: User(id: 'user-2'), @@ -5418,15 +6640,14 @@ void main() { channelMessageCount: 2, ); - client.addEvent(messageNewEvent2); - await Future.delayed(Duration.zero); - expect(channel.messageCount, equals(2)); + await tester.emitEvent(messageNewEvent2); + expect(tester.channel.messageCount, equals(2)); // Test with message.deleted event - count decreases - final messageDeletedEvent = Event( - cid: channel.cid, + final messageDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.messageDeleted, - message: Message( + message: createDefaultMessage( id: 'new-message-1', text: 'Hello world!', user: User(id: 'user-1'), @@ -5434,67 +6655,71 @@ void main() { channelMessageCount: 1, ); - client.addEvent(messageDeletedEvent); - await Future.delayed(Duration.zero); - expect(channel.messageCount, equals(1)); + await tester.emitEvent(messageDeletedEvent); + expect(tester.channel.messageCount, equals(1)); }, ); - test( + channelTest( 'should preserve other channel properties when updating messageCount', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelCountEvents()), + body: (tester) async { // Set initial channel state with some properties - final initialChannel = channel.state?.channelState.channel?.copyWith( + final initialChannel = tester.channelState?.channelState.channel?.copyWith( extraData: {'name': 'Test Channel'}, memberCount: 5, frozen: true, ); if (initialChannel != null) { - channel.state?.updateChannelState( - channel.state!.channelState.copyWith(channel: initialChannel), + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith(channel: initialChannel), ); } // Verify initial state - expect(channel.name, 'Test Channel'); - expect(channel.memberCount, equals(5)); - expect(channel.frozen, equals(true)); - expect(channel.messageCount, isNull); + expect(tester.channel.name, 'Test Channel'); + expect(tester.channel.memberCount, equals(5)); + expect(tester.channel.frozen, equals(true)); + expect(tester.channel.messageCount, isNull); // Update messageCount via event - final messageCountEvent = Event( - cid: channel.cid, + final messageCountEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.messageNew, channelMessageCount: 100, ); - client.addEvent(messageCountEvent); - await Future.delayed(Duration.zero); + await tester.emitEvent(messageCountEvent); // Verify messageCount was updated while preserving other properties - expect(channel.messageCount, equals(100)); - expect(channel.name, 'Test Channel'); - expect(channel.memberCount, equals(5)); - expect(channel.frozen, equals(true)); + expect(tester.channel.messageCount, equals(100)); + expect(tester.channel.name, 'Test Channel'); + expect(tester.channel.memberCount, equals(5)); + expect(tester.channel.frozen, equals(true)); }, ); - test( + channelTest( 'should provide messageCountStream for reactive updates', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelCountEvents()), + body: (tester) async { final emitted = []; - final subscription = channel.messageCountStream.listen(emitted.add); + final subscription = tester.channel.messageCountStream.listen(emitted.add); addTearDown(subscription.cancel); await Future.delayed(Duration.zero); // Update messageCount multiple times, repeating one of the counts. final counts = [1, 5, 5, 10]; for (final (index, count) in counts.indexed) { - final event = Event( - cid: channel.cid, + final event = createDefaultEvent( + cid: tester.channel.cid, type: EventType.messageNew, - message: Message( + message: createDefaultMessage( id: 'msg-$index', text: 'Message $count', user: User(id: 'user-1'), @@ -5502,8 +6727,7 @@ void main() { channelMessageCount: count, ); - client.addEvent(event); - await Future.delayed(Duration.zero); + await tester.emitEvent(event); } // The repeated count should not be emitted twice. @@ -5513,203 +6737,174 @@ void main() { }); group('Channel member count events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test( + channelTest( 'should update channel memberCount when event contains channelMemberCount', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelCountEvents()), + body: (tester) async { // Verify initial state - default memberCount - expect(channel.memberCount, equals(0)); + expect(tester.channel.memberCount, equals(0)); // Create event with channelMemberCount - final memberCountEvent = Event( - cid: channel.cid, + final memberCountEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.memberAdded, - member: Member( - userId: 'user-1', - user: User(id: 'user-1'), - ), + member: createDefaultMember(user: User(id: 'user-1')), channelMemberCount: 42, ); - // Dispatch event - client.addEvent(memberCountEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Dispatch event and wait for it to be processed + await tester.emitEvent(memberCountEvent); // Verify channel memberCount was updated - expect(channel.memberCount, equals(42)); + expect(tester.channel.memberCount, equals(42)); }, ); - test( + channelTest( 'should update channel memberCount from member.added and member.removed events', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelCountEvents()), + body: (tester) async { // Test with member.added event - count increases - final memberAddedEvent = Event( - cid: channel.cid, + final memberAddedEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.memberAdded, - member: Member( - userId: 'user-1', - user: User(id: 'user-1'), - ), + member: createDefaultMember(user: User(id: 'user-1')), channelMemberCount: 1, ); - client.addEvent(memberAddedEvent); - await Future.delayed(Duration.zero); - expect(channel.memberCount, equals(1)); - expect(channel.state?.channelState.members?.map((it) => it.userId), equals(['user-1'])); + await tester.emitEvent(memberAddedEvent); + expect(tester.channel.memberCount, equals(1)); + expect(tester.channelState?.channelState.members?.map((it) => it.userId), equals(['user-1'])); // Test with another member.added event - count increases - final memberAddedEvent2 = Event( - cid: channel.cid, + final memberAddedEvent2 = createDefaultEvent( + cid: tester.channel.cid, type: EventType.memberAdded, - member: Member( - userId: 'user-2', - user: User(id: 'user-2'), - ), + member: createDefaultMember(user: User(id: 'user-2')), channelMemberCount: 2, ); - client.addEvent(memberAddedEvent2); - await Future.delayed(Duration.zero); - expect(channel.memberCount, equals(2)); + await tester.emitEvent(memberAddedEvent2); + expect(tester.channel.memberCount, equals(2)); expect( - channel.state?.channelState.members?.map((it) => it.userId), + tester.channelState?.channelState.members?.map((it) => it.userId), equals(['user-1', 'user-2']), ); // Test with member.removed event - count decreases - final memberRemovedEvent = Event( - cid: channel.cid, + final memberRemovedEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.memberRemoved, user: User(id: 'user-1'), channelMemberCount: 1, ); - client.addEvent(memberRemovedEvent); - await Future.delayed(Duration.zero); - expect(channel.memberCount, equals(1)); - expect(channel.state?.channelState.members?.map((it) => it.userId), equals(['user-2'])); + await tester.emitEvent(memberRemovedEvent); + expect(tester.channel.memberCount, equals(1)); + expect(tester.channelState?.channelState.members?.map((it) => it.userId), equals(['user-2'])); }, ); - test( + channelTest( 'should preserve other channel properties when updating memberCount', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelCountEvents()), + body: (tester) async { // Set initial channel state with some properties - final initialChannel = channel.state?.channelState.channel?.copyWith( + final initialChannel = tester.channelState?.channelState.channel?.copyWith( extraData: {'name': 'Test Channel'}, messageCount: 7, frozen: true, ); if (initialChannel != null) { - channel.state?.updateChannelState( - channel.state!.channelState.copyWith(channel: initialChannel), + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith(channel: initialChannel), ); } // Verify initial state - expect(channel.name, 'Test Channel'); - expect(channel.messageCount, equals(7)); - expect(channel.frozen, equals(true)); - expect(channel.memberCount, equals(0)); + expect(tester.channel.name, 'Test Channel'); + expect(tester.channel.messageCount, equals(7)); + expect(tester.channel.frozen, equals(true)); + expect(tester.channel.memberCount, equals(0)); // Update memberCount via event - final memberCountEvent = Event( - cid: channel.cid, + final memberCountEvent = createDefaultEvent( + cid: tester.channel.cid, type: EventType.memberAdded, - member: Member( - userId: 'user-1', - user: User(id: 'user-1'), - ), + member: createDefaultMember(user: User(id: 'user-1')), channelMemberCount: 100, ); - client.addEvent(memberCountEvent); - await Future.delayed(Duration.zero); + await tester.emitEvent(memberCountEvent); // Verify memberCount was updated while preserving other properties - expect(channel.memberCount, equals(100)); - expect(channel.name, 'Test Channel'); - expect(channel.messageCount, equals(7)); - expect(channel.frozen, equals(true)); + expect(tester.channel.memberCount, equals(100)); + expect(tester.channel.name, 'Test Channel'); + expect(tester.channel.messageCount, equals(7)); + expect(tester.channel.frozen, equals(true)); }, ); - test( + channelTest( 'should not update memberCount when the event omits channelMemberCount', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelCountEvents()), + body: (tester) async { // Seed a known member count. - client.addEvent( - Event( - cid: channel.cid, + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, type: EventType.memberAdded, - member: Member( - userId: 'user-1', - user: User(id: 'user-1'), - ), + member: createDefaultMember(user: User(id: 'user-1')), channelMemberCount: 5, ), ); - await Future.delayed(Duration.zero); - expect(channel.memberCount, equals(5)); + expect(tester.channel.memberCount, equals(5)); // An event without the field should leave the count untouched. - client.addEvent( - Event( - cid: channel.cid, + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, type: EventType.memberAdded, - member: Member( - userId: 'user-2', - user: User(id: 'user-2'), - ), + member: createDefaultMember(user: User(id: 'user-2')), ), ); - await Future.delayed(Duration.zero); - expect(channel.memberCount, equals(5)); + expect(tester.channel.memberCount, equals(5)); }, ); - test( + channelTest( 'should provide memberCountStream for reactive updates', - () async { + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelCountEvents()), + body: (tester) async { final emitted = []; - final subscription = channel.memberCountStream.listen(emitted.add); + final subscription = tester.channel.memberCountStream.listen(emitted.add); addTearDown(subscription.cancel); await Future.delayed(Duration.zero); // Update memberCount multiple times, repeating one of the counts. final counts = [1, 5, 5, 10]; for (final (index, count) in counts.indexed) { - final event = Event( - cid: channel.cid, + final event = createDefaultEvent( + cid: tester.channel.cid, type: EventType.memberAdded, - member: Member( - userId: 'user-$index', - user: User(id: 'user-$index'), - ), + member: createDefaultMember(user: User(id: 'user-$index')), channelMemberCount: count, ); - client.addEvent(event); - await Future.delayed(Duration.zero); + await tester.emitEvent(event); } // The repeated count should not be emitted twice. @@ -5717,989 +6912,985 @@ void main() { }, ); }); - }); - - group('Channel filterTags', () { - late final client = MockStreamChatClient(); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - setUpAll(() { - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - }); - test('should return filterTags from channel state', () { - final channelModel = ChannelModel( - id: channelId, - type: channelType, - filterTags: ['tag1', 'tag2'], - ); - - final channelState = ChannelState(channel: channelModel); - final testChannel = Channel.fromState(client, channelState); - addTearDown(testChannel.dispose); + channelTest( + 'should throw StateError when accessing config on non-initialized channel', + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final channel = tester.channel; + expect(() => channel.config, throwsA(isA())); + }, + ); - expect(testChannel.filterTags, equals(['tag1', 'tag2'])); - }); + channelTest( + 'should return the config of an initialized channel', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ), + ), + ), + body: (tester) async { + expect(tester.channel.config?.readEvents, isTrue); + expect(tester.channel.config?.typingEvents, isTrue); + }, + ); + }); - test('should update filterTags when channel state is updated', () { - final channelModel = ChannelModel( - id: channelId, - type: channelType, - filterTags: ['tag1', 'tag2'], + group('Channel filterTags', () { + ChannelState Function(ChannelState) _seedChannel({ + List? filterTags, + }) { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + filterTags: filterTags, + ), ); + } - final channelState = ChannelState(channel: channelModel); - final testChannel = Channel.fromState(client, channelState); - addTearDown(testChannel.dispose); + channelTest( + 'should return filterTags from channel state', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel(filterTags: ['tag1', 'tag2']), + ), + body: (tester) async { + expect(tester.channel.filterTags, equals(['tag1', 'tag2'])); + }, + ); - expect(testChannel.filterTags, equals(['tag1', 'tag2'])); + channelTest( + 'should update filterTags when channel state is updated', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel(filterTags: ['tag1', 'tag2']), + ), + body: (tester) async { + expect(tester.channel.filterTags, equals(['tag1', 'tag2'])); - final updatedChannel = channelModel.copyWith( - filterTags: ['tag3', 'tag4', 'tag5'], - ); + final channelModel = tester.channelState!.channelState.channel!; + final updatedChannel = channelModel.copyWith( + filterTags: ['tag3', 'tag4', 'tag5'], + ); - testChannel.state?.updateChannelState( - testChannel.state!.channelState.copyWith(channel: updatedChannel), - ); + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith(channel: updatedChannel), + ); - expect(testChannel.filterTags, equals(['tag3', 'tag4', 'tag5'])); - }); + expect(tester.channel.filterTags, equals(['tag3', 'tag4', 'tag5'])); + }, + ); }); group('Typing Indicator', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); - - setUpAll(() { - // Fallback values - registerFallbackValue(FakeMessage()); - registerFallbackValue(FakeAttachmentFile()); - registerFallbackValue(FakeEvent()); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, + ChannelState Function(ChannelState) _seedChannel({ + List ownCapabilities = const [], + }) { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + ownCapabilities: ownCapabilities, + ), ); - when(() => client.retryPolicy).thenReturn(retryPolicy); + } - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - }); + // Reconnects the current user with typing indicators disabled in their + // privacy settings, the way a server-sent `health.check` frame would. + Future _disableTypingIndicators(ChannelTester tester) { + return tester.emitEvent( + createDefaultConnectedEvent( + me: createDefaultOwnUser( + privacySettings: const PrivacySettings( + typingIndicators: TypingIndicators(enabled: false), + ), + ), + ), + ); + } - test( + channelTest( ".keystore should return if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no typingEvents capability - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + // no typingEvents capability + modifyResponse: _seedChannel(ownCapabilities: []), + ), + body: (tester) async { final typingEvent = Event(type: EventType.typingStart); - await expectLater(channel.keyStroke(), completes); + await expectLater(tester.channel.keyStroke(), completes); - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingEvent)), + tester.verifyNeverCalled( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingEvent, matchParentId: true)), ), ); }, ); - test( + channelTest( '.keystore should return when user privacy settings is disabled', - () async { - final currentUser = client.state.currentUser; - final updatedUser = currentUser?.copyWith( - privacySettings: const PrivacySettings( - typingIndicators: TypingIndicators(enabled: false), - ), - ); - - client.state.updateUser(updatedUser); - addTearDown(() => client.state.updateUser(currentUser)); - - final channelState = _generateChannelState( - channelId, - channelType, + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + ), + ), + body: (tester) async { + await _disableTypingIndicators(tester); final typingEvent = Event(type: EventType.typingStart); - await expectLater(channel.keyStroke(), completes); + await expectLater(tester.channel.keyStroke(), completes); - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingEvent)), + tester.verifyNeverCalled( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingEvent, matchParentId: true)), ), ); }, ); - test( + channelTest( ".keystore should send 'typingStart' event if there is not already a typingEvent or the difference between the two is > 3 seconds", - () async { - final channelState = _generateChannelState( - channelId, - channelType, + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - + ), + ), + body: (tester) async { final startTypingEvent = Event(type: EventType.typingStart); final stopTypingEvent = Event(type: EventType.typingStop); - when( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(startTypingEvent)), - ), - ).thenAnswer((_) async => EmptyResponse()); - - when( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(stopTypingEvent)), - ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater(channel.keyStroke(), completes); + tester + ..mockApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(startTypingEvent, matchParentId: true)), + ), + result: createDefaultEmptyResponse(), + ) + ..mockApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(stopTypingEvent, matchParentId: true)), + ), + result: createDefaultEmptyResponse(), + ); - verify( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(startTypingEvent)), - ), - ).called(1); + await expectLater(tester.channel.keyStroke(), completes); - verify( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(stopTypingEvent)), - ), - ).called(1); + tester + ..verifyApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(startTypingEvent, matchParentId: true)), + ), + ) + ..verifyApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(stopTypingEvent, matchParentId: true)), + ), + ); }, ); - test( + channelTest( ".startTyping should return if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no typingEvents capability - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + // no typingEvents capability + modifyResponse: _seedChannel(ownCapabilities: []), + ), + body: (tester) async { final typingStartEvent = Event(type: EventType.typingStart); - await expectLater(channel.startTyping(), completes); + await expectLater(tester.channel.startTyping(), completes); - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStartEvent)), + tester.verifyNeverCalled( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingStartEvent, matchParentId: true)), ), ); }, ); - test( + channelTest( '.startTyping should return when user privacy settings is disabled', - () async { - final currentUser = client.state.currentUser; - final updatedUser = currentUser?.copyWith( - privacySettings: const PrivacySettings( - typingIndicators: TypingIndicators(enabled: false), - ), - ); - - client.state.updateUser(updatedUser); - addTearDown(() => client.state.updateUser(currentUser)); - - final channelState = _generateChannelState( - channelId, - channelType, + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + ), + ), + body: (tester) async { + await _disableTypingIndicators(tester); final typingStartEvent = Event(type: EventType.typingStart); - await expectLater(channel.startTyping(), completes); + await expectLater(tester.channel.startTyping(), completes); - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStartEvent)), + tester.verifyNeverCalled( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingStartEvent, matchParentId: true)), ), ); }, ); - test(".startTyping should send 'typingStart' successfully", () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final typingStartEvent = Event(type: EventType.typingStart); - - when( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStartEvent)), - ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater(channel.startTyping(), completes); - - verify( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStartEvent)), + channelTest( + ".startTyping should send 'typingStart' successfully", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.typingEvents], ), - ).called(1); - }); + ), + body: (tester) async { + final typingStartEvent = Event(type: EventType.typingStart); - test(".stopTyping should return if we don't have the capability", () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no typingEvents capability - ); + tester.mockApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingStartEvent, matchParentId: true)), + ), + result: createDefaultEmptyResponse(), + ); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + await expectLater(tester.channel.startTyping(), completes); - final typingStopEvent = Event(type: EventType.typingStop); + tester.verifyApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingStartEvent, matchParentId: true)), + ), + ); + }, + ); - await expectLater(channel.stopTyping(), completes); + channelTest( + ".stopTyping should return if we don't have the capability", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + // no typingEvents capability + modifyResponse: _seedChannel(ownCapabilities: []), + ), + body: (tester) async { + final typingStopEvent = Event(type: EventType.typingStop); - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStopEvent)), - ), - ); - }); + await expectLater(tester.channel.stopTyping(), completes); - test( - '.stopTyping should return when user privacy settings is disabled', - () async { - final currentUser = client.state.currentUser; - final updatedUser = currentUser?.copyWith( - privacySettings: const PrivacySettings( - typingIndicators: TypingIndicators(enabled: false), + tester.verifyNeverCalled( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingStopEvent, matchParentId: true)), ), ); + }, + ); - client.state.updateUser(updatedUser); - addTearDown(() => client.state.updateUser(currentUser)); - - final channelState = _generateChannelState( - channelId, - channelType, + channelTest( + '.stopTyping should return when user privacy settings is disabled', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + ), + ), + body: (tester) async { + await _disableTypingIndicators(tester); final typingStopEvent = Event(type: EventType.typingStop); - await expectLater(channel.stopTyping(), completes); + await expectLater(tester.channel.stopTyping(), completes); - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStopEvent)), + tester.verifyNeverCalled( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingStopEvent, matchParentId: true)), ), ); }, ); - test(".stopTyping should send 'typingStop' successfully", () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final typingStopEvent = Event(type: EventType.typingStop); - - when( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStopEvent)), + channelTest( + ".stopTyping should send 'typingStop' successfully", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.typingEvents], ), - ).thenAnswer((_) async => EmptyResponse()); + ), + body: (tester) async { + final typingStopEvent = Event(type: EventType.typingStop); + + tester.mockApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingStopEvent, matchParentId: true)), + ), + result: createDefaultEmptyResponse(), + ); - await expectLater(channel.stopTyping(), completes); + await expectLater(tester.channel.stopTyping(), completes); - verify( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStopEvent)), - ), - ).called(1); - }); + tester.verifyApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingStopEvent, matchParentId: true)), + ), + ); + }, + ); }); group('Read Receipts', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); - - setUpAll(() { - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, + ChannelState Function(ChannelState) _seedChannel({ + List ownCapabilities = const [], + }) { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + ownCapabilities: ownCapabilities, + ), ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - }); + } - test( + channelTest( ".markRead should throw if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + // no readEvents capability + modifyResponse: _seedChannel(ownCapabilities: []), + ), + body: (tester) async { await expectLater( - channel.markRead(messageId: 'message-id-123'), + tester.channel.markRead(messageId: 'message-id-123'), throwsA(isA()), ); }, ); - test( + channelTest( '.markRead should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( ownCapabilities: [ChannelCapability.readEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - when( - () => client.markChannelRead( - channelId, - channelType, + ), + ), + body: (tester) async { + tester.mockApi( + (api) => api.channel.markRead( + _channelId, + _channelType, messageId: 'message-id-123', ), - ).thenAnswer((_) async => EmptyResponse()); + result: createDefaultEmptyResponse(), + ); await expectLater( - channel.markRead(messageId: 'message-id-123'), + tester.channel.markRead(messageId: 'message-id-123'), completes, ); - verify( - () => client.markChannelRead( - channelId, - channelType, + tester.verifyApi( + (api) => api.channel.markRead( + _channelId, + _channelType, messageId: 'message-id-123', ), - ).called(1); + ); }, ); - test( + channelTest( ".markUnread should throw if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + // no readEvents capability + modifyResponse: _seedChannel(ownCapabilities: []), + ), + body: (tester) async { await expectLater( - channel.markUnread('message-id-123'), + tester.channel.markUnread('message-id-123'), throwsA(isA()), ); }, ); - test( + channelTest( '.markUnread should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( ownCapabilities: [ChannelCapability.readEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - when( - () => client.markChannelUnread( - channelId, - channelType, + ), + ), + body: (tester) async { + tester.mockApi( + (api) => api.channel.markUnread( + _channelId, + _channelType, 'message-id-123', ), - ).thenAnswer((_) async => EmptyResponse()); + result: createDefaultEmptyResponse(), + ); await expectLater( - channel.markUnread('message-id-123'), + tester.channel.markUnread('message-id-123'), completes, ); - verify( - () => client.markChannelUnread( - channelId, - channelType, + tester.verifyApi( + (api) => api.channel.markUnread( + _channelId, + _channelType, 'message-id-123', ), - ).called(1); + ); }, ); - test( + channelTest( ".markUnreadByTimestamp should throw if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + // no readEvents capability + modifyResponse: _seedChannel(ownCapabilities: []), + ), + body: (tester) async { final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); await expectLater( - channel.markUnreadByTimestamp(timestamp), + tester.channel.markUnreadByTimestamp(timestamp), throwsA(isA()), ); }, ); - test( + channelTest( '.markUnreadByTimestamp should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( ownCapabilities: [ChannelCapability.readEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - + ), + ), + body: (tester) async { final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); - when( - () => client.markChannelUnreadByTimestamp( - channelId, - channelType, + tester.mockApi( + (api) => api.channel.markUnreadByTimestamp( + _channelId, + _channelType, timestamp, ), - ).thenAnswer((_) async => EmptyResponse()); + result: createDefaultEmptyResponse(), + ); await expectLater( - channel.markUnreadByTimestamp(timestamp), + tester.channel.markUnreadByTimestamp(timestamp), completes, ); - verify( - () => client.markChannelUnreadByTimestamp( - channelId, - channelType, + tester.verifyApi( + (api) => api.channel.markUnreadByTimestamp( + _channelId, + _channelType, timestamp, ), - ).called(1); + ); }, ); - test( + channelTest( ".markThreadRead should throw if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + // no readEvents capability + modifyResponse: _seedChannel(ownCapabilities: []), + ), + body: (tester) async { await expectLater( - channel.markThreadRead('thread-id-123'), + tester.channel.markThreadRead('thread-id-123'), throwsA(isA()), ); }, ); - test( + channelTest( '.markThreadRead should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( ownCapabilities: [ChannelCapability.readEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - when( - () => client.markThreadRead( - channelId, - channelType, + ), + ), + body: (tester) async { + tester.mockApi( + (api) => api.channel.markThreadRead( + _channelId, + _channelType, 'thread-id-123', ), - ).thenAnswer((_) async => EmptyResponse()); + result: createDefaultEmptyResponse(), + ); await expectLater( - channel.markThreadRead('thread-id-123'), + tester.channel.markThreadRead('thread-id-123'), completes, ); - verify( - () => client.markThreadRead( - channelId, - channelType, + tester.verifyApi( + (api) => api.channel.markThreadRead( + _channelId, + _channelType, 'thread-id-123', ), - ).called(1); + ); }, ); - test( + channelTest( ".markThreadUnread should throw if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + // no readEvents capability + modifyResponse: _seedChannel(ownCapabilities: []), + ), + body: (tester) async { await expectLater( - channel.markThreadUnread('thread-id-123'), + tester.channel.markThreadUnread('thread-id-123'), throwsA(isA()), ); }, ); - test( + channelTest( '.markThreadUnread should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( ownCapabilities: [ChannelCapability.readEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - when( - () => client.markThreadUnread( - channelId, - channelType, + ), + ), + body: (tester) async { + tester.mockApi( + (api) => api.channel.markThreadUnread( + _channelId, + _channelType, 'thread-id-123', ), - ).thenAnswer((_) async => EmptyResponse()); + result: createDefaultEmptyResponse(), + ); await expectLater( - channel.markThreadUnread('thread-id-123'), + tester.channel.markThreadUnread('thread-id-123'), completes, ); - verify( - () => client.markThreadUnread( - channelId, - channelType, + tester.verifyApi( + (api) => api.channel.markThreadUnread( + _channelId, + _channelType, 'thread-id-123', ), - ).called(1); + ); }, ); }); group('Retry functionality with parameter preservation', () { - late final client = MockStreamChatClient(); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUpAll(() { - registerFallbackValue(FakeMessage()); - registerFallbackValue([]); - registerFallbackValue(FakeAttachmentFile()); - - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, error) => error?.isRetriable ?? false, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - }); - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - group('retryMessage method', () { - test('should call sendMessage with preserved skipPush and skipEnrichUrl parameters', () async { - final message = Message( - id: 'test-message-id', - text: 'Hello, World!', - state: MessageState.sendingFailed( - skipPush: true, - skipEnrichUrl: true, - ), + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), ); + } - final sendMessageResponse = SendMessageResponse()..message = message.copyWith(state: MessageState.sent); - - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - skipPush: true, - skipEnrichUrl: true, - ), - ).thenAnswer((_) async => sendMessageResponse); - - final result = await channel.retryMessage(message); - - expect(result, isNotNull); - expect(result, isA()); - - verify( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - skipPush: true, - skipEnrichUrl: true, - ), - ).called(1); - }); - - test('should call sendMessage with preserved skipPush parameter', () async { - final message = Message( - id: 'test-message-id', - text: 'Hello, World!', - state: MessageState.sendingFailed( - skipPush: true, - skipEnrichUrl: false, - ), - ); + channelTest( + 'should call sendMessage with preserved skipPush and skipEnrichUrl parameters', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id', + text: 'Hello, World!', + state: MessageState.sendingFailed( + skipPush: true, + skipEnrichUrl: true, + ), + ); - final sendMessageResponse = SendMessageResponse()..message = message.copyWith(state: MessageState.sent); + tester.mockApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - skipPush: true, - ), - ).thenAnswer((_) async => sendMessageResponse); + final result = await tester.channel.retryMessage(message); - final result = await channel.retryMessage(message); + expect(result, isNotNull); + expect(result, isA()); - expect(result, isNotNull); - expect(result, isA()); + tester.verifyApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + ); + }, + ); - verify( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - skipPush: true, - ), - ).called(1); - }); + channelTest( + 'should call sendMessage with preserved skipPush parameter', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id', + text: 'Hello, World!', + state: MessageState.sendingFailed( + skipPush: true, + skipEnrichUrl: false, + ), + ); - test('should call sendMessage with preserved skipEnrichUrl parameter', () async { - final message = Message( - id: 'test-message-id', - text: 'Hello, World!', - state: MessageState.sendingFailed( - skipPush: false, - skipEnrichUrl: true, - ), - ); + tester.mockApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + ), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); - final sendMessageResponse = SendMessageResponse()..message = message.copyWith(state: MessageState.sent); + final result = await tester.channel.retryMessage(message); - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - skipEnrichUrl: true, - ), - ).thenAnswer((_) async => sendMessageResponse); + expect(result, isNotNull); + expect(result, isA()); - final result = await channel.retryMessage(message); + tester.verifyApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + ), + ); + }, + ); - expect(result, isNotNull); - expect(result, isA()); + channelTest( + 'should call sendMessage with preserved skipEnrichUrl parameter', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id', + text: 'Hello, World!', + state: MessageState.sendingFailed( + skipPush: false, + skipEnrichUrl: true, + ), + ); - verify( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - skipEnrichUrl: true, - ), - ).called(1); - }); + tester.mockApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipEnrichUrl: true, + ), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); - test('should call sendMessage with preserved false skipPush and skipEnrichUrl parameters', () async { - final message = Message( - id: 'test-message-id', - text: 'Hello, World!', - state: MessageState.sendingFailed( - skipPush: false, - skipEnrichUrl: false, - ), - ); + final result = await tester.channel.retryMessage(message); - final sendMessageResponse = SendMessageResponse()..message = message.copyWith(state: MessageState.sent); + expect(result, isNotNull); + expect(result, isA()); - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).thenAnswer((_) async => sendMessageResponse); + tester.verifyApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipEnrichUrl: true, + ), + ); + }, + ); - final result = await channel.retryMessage(message); + channelTest( + 'should call sendMessage with preserved false skipPush and skipEnrichUrl parameters', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id', + text: 'Hello, World!', + state: MessageState.sendingFailed( + skipPush: false, + skipEnrichUrl: false, + ), + ); - expect(result, isNotNull); - expect(result, isA()); + tester.mockApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + ), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); - verify( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).called(1); - }); + final result = await tester.channel.retryMessage(message); - test('should call updateMessage with preserved skipPush, skipEnrichUrl parameter', () async { - final message = Message( - id: 'test-message-id', - text: 'Hello, World!', - state: MessageState.updatingFailed( - skipPush: true, - skipEnrichUrl: true, - ), - ); + expect(result, isNotNull); + expect(result, isA()); - final updateMessageResponse = UpdateMessageResponse()..message = message.copyWith(state: MessageState.updated); + tester.verifyApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + ), + ); + }, + ); - when( - () => client.updateMessage( - any(that: isSameMessageAs(message)), - skipPush: true, - skipEnrichUrl: true, - ), - ).thenAnswer((_) async => updateMessageResponse); + channelTest( + 'should call updateMessage with preserved skipPush, skipEnrichUrl parameter', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id', + text: 'Hello, World!', + state: MessageState.updatingFailed( + skipPush: true, + skipEnrichUrl: true, + ), + ); - final result = await channel.retryMessage(message); + tester.mockApi( + (api) => api.message.updateMessage( + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + result: createDefaultUpdateMessageResponse(message: message.copyWith(state: MessageState.updated)), + ); - expect(result, isNotNull); - expect(result, isA()); + final result = await tester.channel.retryMessage(message); - verify( - () => client.updateMessage( - any(that: isSameMessageAs(message)), - skipPush: true, - skipEnrichUrl: true, - ), - ).called(1); - }); + expect(result, isNotNull); + expect(result, isA()); - test('should call updateMessage with preserved false skipPush, skipEnrichUrl parameter', () async { - final message = Message( - id: 'test-message-id', - state: MessageState.updatingFailed( - skipPush: false, - skipEnrichUrl: false, - ), - ); + tester.verifyApi( + (api) => api.message.updateMessage( + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + ); + }, + ); - final updateMessageResponse = UpdateMessageResponse()..message = message.copyWith(state: MessageState.updated); + channelTest( + 'should call updateMessage with preserved false skipPush, skipEnrichUrl parameter', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id', + state: MessageState.updatingFailed( + skipPush: false, + skipEnrichUrl: false, + ), + ); - when( - () => client.updateMessage( - any(that: isSameMessageAs(message)), - ), - ).thenAnswer((_) async => updateMessageResponse); + tester.mockApi( + (api) => api.message.updateMessage( + any(that: isSameMessageAs(message)), + ), + result: createDefaultUpdateMessageResponse(message: message.copyWith(state: MessageState.updated)), + ); - final result = await channel.retryMessage(message); + final result = await tester.channel.retryMessage(message); - expect(result, isNotNull); - expect(result, isA()); + expect(result, isNotNull); + expect(result, isA()); - verify( - () => client.updateMessage( - any(that: isSameMessageAs(message)), - ), - ).called(1); - }); + tester.verifyApi( + (api) => api.message.updateMessage( + any(that: isSameMessageAs(message)), + ), + ); + }, + ); - test('should call deleteMessage with preserved hard parameter', () async { - final message = Message( - id: 'test-message-id', - createdAt: DateTime.now(), - state: MessageState.hardDeletingFailed, - ); + channelTest( + 'should call deleteMessage with preserved hard parameter', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id', + createdAt: DateTime.utc(2021, 3), + state: MessageState.hardDeletingFailed, + ); - when( - () => client.deleteMessage( - message.id, - hard: true, - ), - ).thenAnswer((_) async => EmptyResponse()); + tester.mockApi( + (api) => api.message.deleteMessage(message.id, hard: true), + result: createDefaultEmptyResponse(), + ); - final result = await channel.retryMessage(message); + final result = await tester.channel.retryMessage(message); - expect(result, isNotNull); - expect(result, isA()); + expect(result, isNotNull); + expect(result, isA()); - verify( - () => client.deleteMessage( - message.id, - hard: true, - ), - ).called(1); - }); + tester.verifyApi( + (api) => api.message.deleteMessage(message.id, hard: true), + ); + }, + ); - test('should call deleteMessage with preserved false hard parameter', () async { - final message = Message( - id: 'test-message-id', - createdAt: DateTime.now(), - state: MessageState.softDeletingFailed, - ); + channelTest( + 'should call deleteMessage with preserved false hard parameter', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id', + createdAt: DateTime.utc(2021, 3), + state: MessageState.softDeletingFailed, + ); - when( - () => client.deleteMessage( - message.id, - ), - ).thenAnswer((_) async => EmptyResponse()); + tester.mockApi( + (api) => api.message.deleteMessage(message.id, hard: false), + result: createDefaultEmptyResponse(), + ); - final result = await channel.retryMessage(message); + final result = await tester.channel.retryMessage(message); - expect(result, isNotNull); - expect(result, isA()); + expect(result, isNotNull); + expect(result, isA()); - verify( - () => client.deleteMessage( - message.id, - ), - ).called(1); - }); + tester.verifyApi( + (api) => api.message.deleteMessage(message.id, hard: false), + ); + }, + ); - test('should call deleteMessageForMe for deletingForMeFailed state', () async { - final message = Message( - id: 'test-message-id', - createdAt: DateTime.now(), - state: MessageState.deletingForMeFailed, - ); + channelTest( + 'should call deleteMessageForMe for deletingForMeFailed state', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id', + createdAt: DateTime.utc(2021, 3), + state: MessageState.deletingForMeFailed, + ); - when(() => client.deleteMessageForMe(message.id)).thenAnswer((_) async => EmptyResponse()); + tester.mockApi( + (api) => api.message.deleteMessage(message.id, deleteForMe: true), + result: createDefaultEmptyResponse(), + ); - final result = await channel.retryMessage(message); + final result = await tester.channel.retryMessage(message); - expect(result, isNotNull); - expect(result, isA()); + expect(result, isNotNull); + expect(result, isA()); - verify(() => client.deleteMessageForMe(message.id)).called(1); - }); + tester.verifyApi( + (api) => api.message.deleteMessage(message.id, deleteForMe: true), + ); + }, + ); - test('should throw AssertionError when message state is not failed', () async { - final message = Message( - id: 'test-message-id', - state: MessageState.sent, - ); + channelTest( + 'should throw AssertionError when message state is not failed', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id', + state: MessageState.sent, + ); - expect(() => channel.retryMessage(message), throwsA(isA())); - }); + expect(() => tester.channel.retryMessage(message), throwsA(isA())); + }, + ); }); }); } diff --git a/packages/stream_chat/test/src/client/channel_delivery_reporter_test.dart b/packages/stream_chat/test/src/client/channel_delivery_reporter_test.dart index 3b83e62658..f1b97ca4ca 100644 --- a/packages/stream_chat/test/src/client/channel_delivery_reporter_test.dart +++ b/packages/stream_chat/test/src/client/channel_delivery_reporter_test.dart @@ -1,28 +1,14 @@ -import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/stream_chat.dart'; -import 'package:test/test.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; -import '../fakes.dart'; -import '../mocks.dart'; import '../utils.dart'; void main() { group('ChannelDeliveryReporter', () { - late StreamChatClient client; late List capturedDeliveries; late ChannelDeliveryReporter reporter; - setUpAll(() { - registerFallbackValue( - const MessageDelivery( - channelCid: 'test:test', - messageId: 'test-message-id', - ), - ); - }); - setUp(() { - client = _createMockClient(); capturedDeliveries = []; reporter = ChannelDeliveryReporter( throttleDuration: const Duration(milliseconds: 100), @@ -37,383 +23,440 @@ void main() { }); group('submitForDelivery', () { - test('should submit channels with valid messages', () async { - final message1 = _createMessage((m) => m.copyWith(id: 'message-1')); - final message2 = _createMessage((m) => m.copyWith(id: 'message-2')); - - final channel1 = _createDeliverableChannel( - client, - cid: 'test:channel-1', - message: message1, - ); - - final channel2 = _createDeliverableChannel( - client, - cid: 'test:channel-2', - message: message2, - ); - - await reporter.submitForDelivery([channel1, channel2]); - await delay(150); - - expect(capturedDeliveries, hasLength(2)); - expect( - capturedDeliveries.any( - (d) => d.channelCid == 'test:channel-1' && d.messageId == 'message-1', - ), - isTrue, - ); - expect( - capturedDeliveries.any( - (d) => d.channelCid == 'test:channel-2' && d.messageId == 'message-2', - ), - isTrue, - ); - }); - - test('should skip channels without cid', () async { - final channel = Channel(client, 'test-type', null); - - await reporter.submitForDelivery([channel]); - await delay(150); - - expect(capturedDeliveries, isEmpty); - }); - - test('should skip channels without last message', () async { - final channel = _createChannel(client, cid: 'test:channel-1'); - - await reporter.submitForDelivery([channel]); - await delay(150); - - expect(capturedDeliveries, isEmpty); - }); - - test('should skip channels without delivery capability', () async { - final channel = _createChannelWithoutCapability( - client, - cid: 'test:channel-1', - ); - - await reporter.submitForDelivery([channel]); - await delay(150); - - expect(capturedDeliveries, isEmpty); - }); - - test('should skip messages from current user', () async { - final channel = _createChannelWithOwnMessage( - client, - cid: 'test:channel-1', - ); - - await reporter.submitForDelivery([channel]); - await delay(150); - - expect(capturedDeliveries, isEmpty); - }); - - test('should skip messages that are already read', () async { - final message = _createMessage( - (m) => m.copyWith( - id: 'message-1', - createdAt: DateTime(2023), - ), - ); - - final channel = _createChannel( - client, - cid: 'test:channel-1', - lastMessage: message, - currentUserRead: _createCurrentUserRead( - client, - lastRead: DateTime(2023, 1, 2), - lastReadMessageId: message.id, - ), - ); - - await reporter.submitForDelivery([channel]); - await delay(150); - - expect(capturedDeliveries, isEmpty); - }); - - test('should skip messages that are already delivered', () async { - final message = _createMessage( - (m) => m.copyWith( - id: 'message-1', - createdAt: DateTime(2023), - ), - ); - - final channel = _createChannel( - client, - cid: 'test:channel-1', - lastMessage: message, - currentUserRead: _createCurrentUserRead( - client, - lastDeliveredAt: DateTime(2023, 1, 2), - lastDeliveredMessageId: message.id, - ), - ); - - await reporter.submitForDelivery([channel]); - await delay(150); - - expect(capturedDeliveries, isEmpty); - }); - - test('should update existing candidates with newer messages', () async { - final message1 = _createMessage((m) => m.copyWith(id: 'message-1')); - final message2 = _createMessage((m) => m.copyWith(id: 'message-2')); - - final channel1 = _createDeliverableChannel( - client, - cid: 'test:channel-1', - message: message1, - ); - await reporter.submitForDelivery([channel1]); - - final channel1Updated = _createDeliverableChannel( - client, - cid: 'test:channel-1', - message: message2, - ); - await reporter.submitForDelivery([channel1Updated]); - - await delay(150); - - expect(capturedDeliveries, hasLength(1)); - expect(capturedDeliveries.first.channelCid, 'test:channel-1'); - expect(capturedDeliveries.first.messageId, 'message-2'); - }); - - test('should throttle multiple submit calls', () async { - final message1 = _createMessage((m) => m.copyWith(id: 'message-1')); - final message2 = _createMessage((m) => m.copyWith(id: 'message-2')); - final message3 = _createMessage((m) => m.copyWith(id: 'message-3')); - - final channel1 = _createDeliverableChannel( - client, - cid: 'test:channel-1', - message: message1, - ); - final channel2 = _createDeliverableChannel( - client, - cid: 'test:channel-2', - message: message2, - ); - final channel3 = _createDeliverableChannel( - client, - cid: 'test:channel-3', - message: message3, - ); - - // Submit 3 different channels in quick succession - await reporter.submitForDelivery([channel1]); - await reporter.submitForDelivery([channel2]); - await reporter.submitForDelivery([channel3]); - - // All 3 should be batched into a single delivery call due to throttling - await delay(150); - - expect(capturedDeliveries, hasLength(3)); - }); + chatClientTest( + 'should submit channels with valid messages', + body: (tester) async { + final message1 = _createMessage((m) => m.copyWith(id: 'message-1')); + final message2 = _createMessage((m) => m.copyWith(id: 'message-2')); + + final channel1 = _createDeliverableChannel( + tester.client, + cid: 'test:channel-1', + message: message1, + ); + + final channel2 = _createDeliverableChannel( + tester.client, + cid: 'test:channel-2', + message: message2, + ); + + await reporter.submitForDelivery([channel1, channel2]); + await delay(150); + + expect(capturedDeliveries, hasLength(2)); + expect( + capturedDeliveries.any( + (d) => d.channelCid == 'test:channel-1' && d.messageId == 'message-1', + ), + isTrue, + ); + expect( + capturedDeliveries.any( + (d) => d.channelCid == 'test:channel-2' && d.messageId == 'message-2', + ), + isTrue, + ); + }, + ); + + chatClientTest( + 'should skip channels without cid', + body: (tester) async { + final channel = Channel(tester.client, 'test-type', null); + + await reporter.submitForDelivery([channel]); + await delay(150); + + expect(capturedDeliveries, isEmpty); + }, + ); + + chatClientTest( + 'should skip channels without last message', + body: (tester) async { + final channel = _createChannel(tester.client, cid: 'test:channel-1'); + + await reporter.submitForDelivery([channel]); + await delay(150); + + expect(capturedDeliveries, isEmpty); + }, + ); + + chatClientTest( + 'should skip channels without delivery capability', + body: (tester) async { + final channel = _createChannelWithoutCapability( + tester.client, + cid: 'test:channel-1', + ); + + await reporter.submitForDelivery([channel]); + await delay(150); + + expect(capturedDeliveries, isEmpty); + }, + ); + + chatClientTest( + 'should skip messages from current user', + body: (tester) async { + final channel = _createChannelWithOwnMessage( + tester.client, + cid: 'test:channel-1', + ); + + await reporter.submitForDelivery([channel]); + await delay(150); + + expect(capturedDeliveries, isEmpty); + }, + ); + + chatClientTest( + 'should skip messages that are already read', + body: (tester) async { + final message = _createMessage( + (m) => m.copyWith( + id: 'message-1', + createdAt: DateTime(2023), + ), + ); + + final channel = _createChannel( + tester.client, + cid: 'test:channel-1', + lastMessage: message, + currentUserRead: _createCurrentUserRead( + tester.client, + lastRead: DateTime(2023, 1, 2), + lastReadMessageId: message.id, + ), + ); + + await reporter.submitForDelivery([channel]); + await delay(150); + + expect(capturedDeliveries, isEmpty); + }, + ); + + chatClientTest( + 'should skip messages that are already delivered', + body: (tester) async { + final message = _createMessage( + (m) => m.copyWith( + id: 'message-1', + createdAt: DateTime(2023), + ), + ); + + final channel = _createChannel( + tester.client, + cid: 'test:channel-1', + lastMessage: message, + currentUserRead: _createCurrentUserRead( + tester.client, + lastDeliveredAt: DateTime(2023, 1, 2), + lastDeliveredMessageId: message.id, + ), + ); + + await reporter.submitForDelivery([channel]); + await delay(150); + + expect(capturedDeliveries, isEmpty); + }, + ); + + chatClientTest( + 'should update existing candidates with newer messages', + body: (tester) async { + final message1 = _createMessage((m) => m.copyWith(id: 'message-1')); + final message2 = _createMessage((m) => m.copyWith(id: 'message-2')); + + final channel1 = _createDeliverableChannel( + tester.client, + cid: 'test:channel-1', + message: message1, + ); + await reporter.submitForDelivery([channel1]); + + final channel1Updated = _createDeliverableChannel( + tester.client, + cid: 'test:channel-1', + message: message2, + ); + await reporter.submitForDelivery([channel1Updated]); + + await delay(150); + + expect(capturedDeliveries, hasLength(1)); + expect(capturedDeliveries.first.channelCid, 'test:channel-1'); + expect(capturedDeliveries.first.messageId, 'message-2'); + }, + ); + + chatClientTest( + 'should throttle multiple submit calls', + body: (tester) async { + final message1 = _createMessage((m) => m.copyWith(id: 'message-1')); + final message2 = _createMessage((m) => m.copyWith(id: 'message-2')); + final message3 = _createMessage((m) => m.copyWith(id: 'message-3')); + + final channel1 = _createDeliverableChannel( + tester.client, + cid: 'test:channel-1', + message: message1, + ); + final channel2 = _createDeliverableChannel( + tester.client, + cid: 'test:channel-2', + message: message2, + ); + final channel3 = _createDeliverableChannel( + tester.client, + cid: 'test:channel-3', + message: message3, + ); + + // Submit 3 different channels in quick succession + await reporter.submitForDelivery([channel1]); + await reporter.submitForDelivery([channel2]); + await reporter.submitForDelivery([channel3]); + + // All 3 should be batched into a single delivery call due to throttling + await delay(150); + + expect(capturedDeliveries, hasLength(3)); + }, + ); }); group('reconcileDelivery', () { - test('should remove candidates that are now read', () async { - final message = _createMessage( - (m) => m.copyWith( - id: 'message-1', - createdAt: DateTime(2023), - ), - ); - - final channel = _createChannel( - client, - cid: 'test:channel-1', - lastMessage: message, - ); - await reporter.submitForDelivery([channel]); - - final channelRead = _createChannel( - client, - cid: 'test:channel-1', - lastMessage: message, - currentUserRead: _createCurrentUserRead( - client, - lastRead: DateTime(2023, 1, 2), - lastReadMessageId: message.id, - ), - ); - await reporter.reconcileDelivery([channelRead]); - - await delay(150); - - expect(capturedDeliveries, isEmpty); - }); - - test('should remove candidates that are now delivered', () async { - final message = _createMessage( - (m) => m.copyWith( - id: 'message-1', - createdAt: DateTime(2023), - ), - ); - - final channel = _createChannel( - client, - cid: 'test:channel-1', - lastMessage: message, - ); - await reporter.submitForDelivery([channel]); - - final channelDelivered = _createChannel( - client, - cid: 'test:channel-1', - lastMessage: message, - currentUserRead: _createCurrentUserRead( - client, - lastDeliveredAt: DateTime(2023, 1, 2), - lastDeliveredMessageId: message.id, - ), - ); - await reporter.reconcileDelivery([channelDelivered]); - - await delay(150); - - expect(capturedDeliveries, isEmpty); - }); - - test('should keep candidates that still need delivery', () async { - final message = _createMessage((m) => m.copyWith(id: 'message-1')); - final channel = _createDeliverableChannel( - client, - cid: 'test:channel-1', - message: message, - ); - - await reporter.submitForDelivery([channel]); - await reporter.reconcileDelivery([channel]); - - await delay(150); - - expect(capturedDeliveries, hasLength(1)); - expect(capturedDeliveries.first.messageId, 'message-1'); - }); - - test('should handle channels not in candidates', () async { - final message = _createMessage((m) => m.copyWith(id: 'message-1')); - final channel = _createDeliverableChannel( - client, - cid: 'test:channel-1', - message: message, - ); - - await reporter.reconcileDelivery([channel]); - await delay(150); - - expect(capturedDeliveries, isEmpty); - }); - - test('should handle channels without cid', () async { - final channel = Channel(client, 'test-type', null); - - await reporter.reconcileDelivery([channel]); - }); + chatClientTest( + 'should remove candidates that are now read', + body: (tester) async { + final message = _createMessage( + (m) => m.copyWith( + id: 'message-1', + createdAt: DateTime(2023), + ), + ); + + final channel = _createChannel( + tester.client, + cid: 'test:channel-1', + lastMessage: message, + ); + await reporter.submitForDelivery([channel]); + + final channelRead = _createChannel( + tester.client, + cid: 'test:channel-1', + lastMessage: message, + currentUserRead: _createCurrentUserRead( + tester.client, + lastRead: DateTime(2023, 1, 2), + lastReadMessageId: message.id, + ), + ); + await reporter.reconcileDelivery([channelRead]); + + await delay(150); + + expect(capturedDeliveries, isEmpty); + }, + ); + + chatClientTest( + 'should remove candidates that are now delivered', + body: (tester) async { + final message = _createMessage( + (m) => m.copyWith( + id: 'message-1', + createdAt: DateTime(2023), + ), + ); + + final channel = _createChannel( + tester.client, + cid: 'test:channel-1', + lastMessage: message, + ); + await reporter.submitForDelivery([channel]); + + final channelDelivered = _createChannel( + tester.client, + cid: 'test:channel-1', + lastMessage: message, + currentUserRead: _createCurrentUserRead( + tester.client, + lastDeliveredAt: DateTime(2023, 1, 2), + lastDeliveredMessageId: message.id, + ), + ); + await reporter.reconcileDelivery([channelDelivered]); + + await delay(150); + + expect(capturedDeliveries, isEmpty); + }, + ); + + chatClientTest( + 'should keep candidates that still need delivery', + body: (tester) async { + final message = _createMessage((m) => m.copyWith(id: 'message-1')); + final channel = _createDeliverableChannel( + tester.client, + cid: 'test:channel-1', + message: message, + ); + + await reporter.submitForDelivery([channel]); + await reporter.reconcileDelivery([channel]); + + await delay(150); + + expect(capturedDeliveries, hasLength(1)); + expect(capturedDeliveries.first.messageId, 'message-1'); + }, + ); + + chatClientTest( + 'should handle channels not in candidates', + body: (tester) async { + final message = _createMessage((m) => m.copyWith(id: 'message-1')); + final channel = _createDeliverableChannel( + tester.client, + cid: 'test:channel-1', + message: message, + ); + + await reporter.reconcileDelivery([channel]); + await delay(150); + + expect(capturedDeliveries, isEmpty); + }, + ); + + chatClientTest( + 'should handle channels without cid', + body: (tester) async { + final channel = Channel(tester.client, 'test-type', null); + + await reporter.reconcileDelivery([channel]); + await delay(150); + + expect(capturedDeliveries, isEmpty); + }, + ); }); group('cancelDelivery', () { - test('should remove channels from candidates', () async { - final message = _createMessage((m) => m.copyWith(id: 'message-1')); - final channel = _createDeliverableChannel( - client, - cid: 'test:channel-1', - message: message, - ); - - await reporter.submitForDelivery([channel]); - await reporter.cancelDelivery(['test:channel-1']); - - await delay(150); - - expect(capturedDeliveries, isEmpty); - }); - - test('should handle channels not in candidates', () async { - await reporter.cancelDelivery(['test:channel-1']); - await delay(150); - - expect(capturedDeliveries, isEmpty); - }); - - test('should cancel specific channels only', () async { - final message1 = _createMessage((m) => m.copyWith(id: 'message-1')); - final message2 = _createMessage((m) => m.copyWith(id: 'message-2')); - - final channel1 = _createDeliverableChannel( - client, - cid: 'test:channel-1', - message: message1, - ); - final channel2 = _createDeliverableChannel( - client, - cid: 'test:channel-2', - message: message2, - ); - - await reporter.submitForDelivery([channel1, channel2]); - await reporter.cancelDelivery(['test:channel-1']); - - await delay(150); - - expect(capturedDeliveries, hasLength(1)); - expect(capturedDeliveries.first.channelCid, 'test:channel-2'); - }); + chatClientTest( + 'should remove channels from candidates', + body: (tester) async { + final message = _createMessage((m) => m.copyWith(id: 'message-1')); + final channel = _createDeliverableChannel( + tester.client, + cid: 'test:channel-1', + message: message, + ); + + await reporter.submitForDelivery([channel]); + await reporter.cancelDelivery(['test:channel-1']); + + await delay(150); + + expect(capturedDeliveries, isEmpty); + }, + ); + + chatClientTest( + 'should handle channels not in candidates', + body: (tester) async { + await reporter.cancelDelivery(['test:channel-1']); + await delay(150); + + expect(capturedDeliveries, isEmpty); + }, + ); + + chatClientTest( + 'should cancel specific channels only', + body: (tester) async { + final message1 = _createMessage((m) => m.copyWith(id: 'message-1')); + final message2 = _createMessage((m) => m.copyWith(id: 'message-2')); + + final channel1 = _createDeliverableChannel( + tester.client, + cid: 'test:channel-1', + message: message1, + ); + final channel2 = _createDeliverableChannel( + tester.client, + cid: 'test:channel-2', + message: message2, + ); + + await reporter.submitForDelivery([channel1, channel2]); + await reporter.cancelDelivery(['test:channel-1']); + + await delay(150); + + expect(capturedDeliveries, hasLength(1)); + expect(capturedDeliveries.first.channelCid, 'test:channel-2'); + }, + ); }); group('Batching and throttling', () { - test('should batch deliveries with max 100 channels', () async { - final channels = List.generate( - 150, - (index) { - final message = _createMessage( - (m) => m.copyWith(id: 'message-$index'), - ); - return _createDeliverableChannel( - client, - cid: 'test:channel-$index', - message: message, - ); - }, - ); + chatClientTest( + 'should batch deliveries with max 100 channels', + body: (tester) async { + final channels = List.generate( + 150, + (index) { + final message = _createMessage( + (m) => m.copyWith(id: 'message-$index'), + ); + return _createDeliverableChannel( + tester.client, + cid: 'test:channel-$index', + message: message, + ); + }, + ); - await reporter.submitForDelivery(channels); - await delay(150); + await reporter.submitForDelivery(channels); + await delay(150); - expect(capturedDeliveries, hasLength(100)); + expect(capturedDeliveries, hasLength(100)); - await delay(150); + await delay(150); - expect(capturedDeliveries, hasLength(150)); - }); + expect(capturedDeliveries, hasLength(150)); + }, + ); - test( + chatClientTest( 'should only deliver latest message when updated before throttle', - () async { + body: (tester) async { final message1 = _createMessage((m) => m.copyWith(id: 'message-1')); final message2 = _createMessage((m) => m.copyWith(id: 'message-2')); final channel1 = _createDeliverableChannel( - client, + tester.client, cid: 'test:channel-1', message: message1, ); await reporter.submitForDelivery([channel1]); final channel1Updated = _createDeliverableChannel( - client, + tester.client, cid: 'test:channel-1', message: message2, ); @@ -426,103 +469,98 @@ void main() { }, ); - test('should handle delivery errors gracefully', () async { - var shouldFail = true; - final errorReporter = ChannelDeliveryReporter( - throttleDuration: const Duration(milliseconds: 50), - onMarkChannelsDelivered: (deliveries) async { - if (shouldFail) { - shouldFail = false; - throw Exception('Network error'); - } - capturedDeliveries.addAll(deliveries); - }, - ); - - try { - final message = _createMessage((m) => m.copyWith(id: 'message-1')); - final channel = _createDeliverableChannel( - client, - cid: 'test:channel-1', - message: message, + chatClientTest( + 'should handle delivery errors gracefully', + body: (tester) async { + var shouldFail = true; + final errorReporter = ChannelDeliveryReporter( + throttleDuration: const Duration(milliseconds: 50), + onMarkChannelsDelivered: (deliveries) async { + if (shouldFail) { + shouldFail = false; + throw Exception('Network error'); + } + capturedDeliveries.addAll(deliveries); + }, ); - await errorReporter.submitForDelivery([channel]); - await delay(100); + try { + final message = _createMessage((m) => m.copyWith(id: 'message-1')); + final channel = _createDeliverableChannel( + tester.client, + cid: 'test:channel-1', + message: message, + ); - expect(capturedDeliveries, isEmpty); + await errorReporter.submitForDelivery([channel]); + await delay(100); - await errorReporter.submitForDelivery([channel]); - await delay(100); + expect(capturedDeliveries, isEmpty); - expect(capturedDeliveries, hasLength(1)); - } finally { - errorReporter.cancel(); - } - }); + await errorReporter.submitForDelivery([channel]); + await delay(100); + + expect(capturedDeliveries, hasLength(1)); + } finally { + errorReporter.cancel(); + } + }, + ); }); group('cancel', () { - test('should clear all candidates', () async { - final message1 = _createMessage((m) => m.copyWith(id: 'message-1')); - final message2 = _createMessage((m) => m.copyWith(id: 'message-2')); + chatClientTest( + 'should clear all candidates', + body: (tester) async { + final message1 = _createMessage((m) => m.copyWith(id: 'message-1')); + final message2 = _createMessage((m) => m.copyWith(id: 'message-2')); - final channel1 = _createDeliverableChannel( - client, - cid: 'test:channel-1', - message: message1, - ); - final channel2 = _createDeliverableChannel( - client, - cid: 'test:channel-2', - message: message2, - ); + final channel1 = _createDeliverableChannel( + tester.client, + cid: 'test:channel-1', + message: message1, + ); + final channel2 = _createDeliverableChannel( + tester.client, + cid: 'test:channel-2', + message: message2, + ); - await reporter.submitForDelivery([channel1, channel2]); + await reporter.submitForDelivery([channel1, channel2]); - reporter.cancel(); + reporter.cancel(); - await delay(150); + await delay(150); - expect(capturedDeliveries, isEmpty); - }); + expect(capturedDeliveries, isEmpty); + }, + ); - test('should cancel pending throttled calls', () async { - final message = _createMessage((m) => m.copyWith(id: 'message-1')); - final channel = _createDeliverableChannel( - client, - cid: 'test:channel-1', - message: message, - ); + chatClientTest( + 'should cancel pending throttled calls', + body: (tester) async { + final message = _createMessage((m) => m.copyWith(id: 'message-1')); + final channel = _createDeliverableChannel( + tester.client, + cid: 'test:channel-1', + message: message, + ); - await reporter.submitForDelivery([channel]); + await reporter.submitForDelivery([channel]); - reporter.cancel(); + reporter.cancel(); - await delay(150); + await delay(150); - expect(capturedDeliveries, isEmpty); - }); + expect(capturedDeliveries, isEmpty); + }, + ); }); }); } // region Test Helpers -StreamChatClient _createMockClient() { - final client = MockStreamChatClient(); - final clientState = FakeClientState( - currentUser: OwnUser(id: 'current-user-id'), - ); - - when(() => client.state).thenReturn(clientState); - when(() => client.retryPolicy).thenReturn( - RetryPolicy(shouldRetry: (_, __, ___) => false), - ); - - return client; -} - Message _createMessage([Message Function(Message)? builder]) { final baseMessage = Message( id: 'default-id', diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index bf3863e37d..bc9de145bf 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -1,15 +1,27 @@ -// ignore_for_file: avoid_redundant_argument_values, lines_longer_than_80_chars, deprecated_member_use_from_same_package +// LEGACY CARVE-OUT: these tests intentionally stay on a hand-rolled fake +// WebSocket instead of the `stream_chat_test` BDD harness: +// +// - The `.connectAnonymousUser` tests: the anonymous token's user id cannot +// pass the fake server's connect-URI validation without a TokenManager seam. +// - The reconnect-recovery tests: the harness exposes no seam to drop and +// restore the underlying connection mid-test. +// +// Revisit once the TokenManager seam lands in the harness. import 'dart:async'; -import 'package:mocktail/mocktail.dart'; +import 'package:dio/dio.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/src/ws/websocket.dart'; import 'package:stream_chat/stream_chat.dart'; -import 'package:test/test.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; -import '../fakes.dart'; -import '../matchers.dart'; -import '../mocks.dart'; -import '../utils.dart'; +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +// The unread counts are derived from the current user, so assigning a new +// one has to republish them. void main() { group('Fake web-socket connection functions', () { @@ -20,11 +32,11 @@ void main() { setUpAll(() { // fallback values - registerFallbackValue(FakeUser()); + registerFallbackValue(_FakeUser()); }); setUp(() { - final ws = FakeWebSocket(); + final ws = _FakeWebSocket(); client = StreamChatClient(apiKey, ws: ws, chatApi: api); }); @@ -32,100 +44,123 @@ void main() { client.dispose(); }); - test('`.connectUser` should work fine', () async { - final user = User(id: 'test-user-id'); - final token = testUserToken(user.id).rawValue; - - expectLater( - // skipping first seed status -> ConnectionStatus.disconnected - client.wsConnectionStatusStream.skip(1), - emitsInOrder([ - ConnectionStatus.connecting, - ConnectionStatus.connected, - ]), - ); - - final res = await client.connectUser(user, token); - expect(res, isNotNull); - expect(res, isSameUserAs(user)); - }); - - test('`.connectUserWithProvider` should work fine', () async { - final user = User(id: 'test-user-id'); - Future tokenProvider(String userId) async { - expect(userId, user.id); - return testUserToken(userId); - } + chatClientTest( + '`.connectUser` should work fine', + connect: (tester) => tester.mockSuccessfulAuth(), + body: (tester) async { + final user = tester.user; + final token = createTestToken(user.id).rawValue; - expectLater( - // skipping first seed status -> ConnectionStatus.disconnected - client.wsConnectionStatusStream.skip(1), - emitsInOrder([ - ConnectionStatus.connecting, - ConnectionStatus.connected, - ]), - ); + final statusEmitted = expectLater( + // skipping first seed status -> ConnectionStatus.disconnected + tester.client.wsConnectionStatusStream.skip(1), + emitsInOrder([ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); - final res = await client.connectUserWithProvider(user, TokenProvider.dynamic(tokenProvider)); - expect(res, isNotNull); - expect(res, isSameUserAs(user)); - }); + final res = await tester.client.connectUser(user, token); + expect(res, isNotNull); + expect(res, isSameUserAs(user)); - group('`.connectGuestUser`', () { - test('should work fine', () async { - final user = User(id: 'test-user-id'); - final token = testUserToken(user.id).rawValue; + await statusEmitted; + }, + ); - when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))).thenAnswer( - (_) async => ConnectGuestUserResponse() - ..user = user - ..accessToken = token, - ); + chatClientTest( + '`.connectUserWithProvider` should work fine', + connect: (tester) => tester.mockSuccessfulAuth(), + body: (tester) async { + final user = tester.user; + Future tokenProvider(String userId) async { + expect(userId, user.id); + return createTestToken(userId); + } - expectLater( + final statusEmitted = expectLater( // skipping first seed status -> ConnectionStatus.disconnected - client.wsConnectionStatusStream.skip(1), + tester.client.wsConnectionStatusStream.skip(1), emitsInOrder([ ConnectionStatus.connecting, ConnectionStatus.connected, ]), ); - final res = await client.connectGuestUser(user); + final res = await tester.client.connectUserWithProvider(user, TokenProvider.dynamic(tokenProvider)); expect(res, isNotNull); expect(res, isSameUserAs(user)); - verify( - () => api.guest.getGuestUser(any(that: isSameUserAs(user))), - ).called(1); - }); + await statusEmitted; + }, + ); - test('should throw if `.getGuestUser` fails', () async { - final user = User(id: 'test-user-id'); + group('`.connectGuestUser`', () { + chatClientTest( + 'should work fine', + connect: (tester) => tester.mockSuccessfulAuth(), + body: (tester) async { + final user = tester.user; + final token = createTestToken(user.id).rawValue; + + tester.mockApi( + (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), + result: createDefaultConnectGuestUserResponse(user: user, accessToken: token), + ); - when( - () => api.guest.getGuestUser(any(that: isSameUserAs(user))), - ).thenThrow(apiException(code: StreamErrorCode.inputError, statusCode: 400)); + final statusEmitted = expectLater( + // skipping first seed status -> ConnectionStatus.disconnected + tester.client.wsConnectionStatusStream.skip(1), + emitsInOrder([ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); - expectLater( - client.wsConnectionStatusStream, - emitsInOrder([ - // only emits the seed -> disconnected status - // as the call never reaches `ws.connect` - ConnectionStatus.disconnected, - ]), - ); + final res = await tester.client.connectGuestUser(user); + expect(res, isNotNull); + expect(res, isSameUserAs(user)); - try { - await client.connectGuestUser(user); - } catch (e) { - expect(e, isA()); - } + tester.verifyApi( + (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), + ); - verify( - () => api.guest.getGuestUser(any(that: isSameUserAs(user))), - ).called(1); - }); + await statusEmitted; + }, + ); + + chatClientTest( + 'should throw if `.getGuestUser` fails', + connect: (_) {}, + body: (tester) async { + final user = tester.user; + + tester.mockApiFailure( + (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), + error: createDefaultNetworkError(code: StreamErrorCode.inputError, statusCode: 400), + ); + + final statusEmitted = expectLater( + tester.client.wsConnectionStatusStream, + emitsInOrder([ + // only emits the seed -> disconnected status + // as the call never reaches `ws.connect` + ConnectionStatus.disconnected, + ]), + ); + + await expectLater( + tester.client.connectGuestUser(user), + throwsA(isA()), + ); + + tester.verifyApi( + (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), + ); + + await statusEmitted; + }, + ); }); test('`.connectAnonymousUser` should work fine', () async { @@ -143,53 +178,73 @@ void main() { }); group('`.openConnection`', () { - test('should throw if state does not contain user', () async { - expect(client.state.currentUser, isNull); - try { - await client.openConnection(); - } catch (e) { - expect(e, isA()); - } - }); + chatClientTest( + 'should throw if state does not contain user', + connect: (_) {}, + body: (tester) async { + expect(tester.currentUser, isNull); + await expectLater( + tester.client.openConnection(), + throwsA(isA()), + ); + }, + ); - test('should throw if connection is already available', () async { - expect(client.state.currentUser, isNull); - try { - await client.connectAnonymousUser(); - // waiting 300ms for `wsConnectionStatusStream` to emit - await delay(300); + chatClientTest( + 'should throw if connection is already available', + connect: (tester) => tester.mockSuccessfulAuth(), + body: (tester) async { + expect(tester.currentUser, isNull); - await client.openConnection(); - } catch (e) { - // Misuse, so it leaves the StreamException hierarchy entirely. - expect(e, isA()); - expect((e as StateError).message, contains('already available for')); - } - }); + // The anonymous connect path is unavailable here, so the + // connection is opened with the regular user instead. + final token = createTestToken(tester.user.id).rawValue; + await tester.client.connectUser(tester.user, token); - test('should open connection for closed connection', () async { - expectLater( - client.wsConnectionStatusStream.skip(1), - emitsInOrder([ - // initial connectUser - ConnectionStatus.connecting, - ConnectionStatus.connected, - // close connection - ConnectionStatus.disconnected, - // open connection - ConnectionStatus.connecting, - ConnectionStatus.connected, - ]), - ); + await expectLater( + tester.client.openConnection(), + throwsA( + // Misuse, so it leaves the StreamException hierarchy entirely. + isA().having( + (error) => error.message, + 'message', + contains('already available for'), + ), + ), + ); + }, + ); + + chatClientTest( + 'should open connection for closed connection', + connect: (tester) => tester.mockSuccessfulAuth(), + body: (tester) async { + final statusEmitted = expectLater( + tester.client.wsConnectionStatusStream.skip(1), + emitsInOrder([ + // initial connectUser + ConnectionStatus.connecting, + ConnectionStatus.connected, + // close connection + ConnectionStatus.disconnected, + // open connection + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); - await client.connectAnonymousUser(); - // waiting 300ms for `wsConnectionStatusStream` to emit - await delay(300); + // The anonymous connect path is unavailable here, so the + // connection is opened with the regular user instead. + final token = createTestToken(tester.user.id).rawValue; + await tester.client.connectUser(tester.user, token); - client.closeConnection(); + tester.client.closeConnection(); - await client.openConnection(); - }); + await tester.client.openConnection(); + + await statusEmitted; + }, + ); }); }); @@ -201,11 +256,11 @@ void main() { setUpAll(() { // fallback values - registerFallbackValue(FakeUser()); + registerFallbackValue(_FakeUser()); }); setUp(() { - final ws = FakeWebSocketWithConnectionError(); + final ws = _FakeWebSocketWithConnectionError(); client = StreamChatClient(apiKey, chatApi: api, ws: ws); }); @@ -213,53 +268,59 @@ void main() { client.dispose(); }); - test('`.connectUser` should throw if `ws.connect` fails', () async { - final user = User(id: 'test-user-id'); - final token = testUserToken(user.id).rawValue; + chatClientTest( + '`.connectUser` should throw if `ws.connect` fails', + connect: (tester) => tester.mockConnectionError(), + body: (tester) async { + final user = tester.user; + final token = createTestToken(user.id).rawValue; - try { - await client.connectUser(user, token); - } catch (e) { - expect(e, isA()); - } - }); + await expectLater( + tester.client.connectUser(user, token), + throwsA(isA()), + ); + }, + ); - test( + chatClientTest( '`.connectUserWithProvider` should throw if `ws.connect` fails', - () async { - final user = User(id: 'test-user-id'); + connect: (tester) => tester.mockConnectionError(), + body: (tester) async { + final user = tester.user; Future tokenProvider(String userId) async { expect(userId, user.id); - return testUserToken(userId); + return createTestToken(userId); } - try { - await client.connectUserWithProvider(user, TokenProvider.dynamic(tokenProvider)); - } catch (e) { - expect(e, isA()); - } + await expectLater( + tester.client.connectUserWithProvider(user, TokenProvider.dynamic(tokenProvider)), + throwsA(isA()), + ); }, ); - test('`.connectGuestUser` should throw if `ws.connect` fails', () async { - final user = User(id: 'test-user-id'); - final token = testUserToken(user.id).rawValue; + chatClientTest( + '`.connectGuestUser` should throw if `ws.connect` fails', + connect: (tester) => tester.mockConnectionError(), + body: (tester) async { + final user = tester.user; + final token = createTestToken(user.id).rawValue; - when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))).thenAnswer( - (_) async => ConnectGuestUserResponse() - ..user = user - ..accessToken = token, - ); + tester.mockApi( + (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), + result: createDefaultConnectGuestUserResponse(user: user, accessToken: token), + ); - try { - await client.connectGuestUser(user); - } catch (e) { - expect(e, isA()); - } - verify( - () => api.guest.getGuestUser(any(that: isSameUserAs(user))), - ).called(1); - }); + await expectLater( + tester.client.connectGuestUser(user), + throwsA(isA()), + ); + + tester.verifyApi( + (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), + ); + }, + ); test( '`.connectAnonymousUser` should throw if `ws.connect` fails', @@ -281,7 +342,7 @@ void main() { setUpAll(() { // fallback values - registerFallbackValue(FakeUser()); + registerFallbackValue(_FakeUser()); }); setUp(() { @@ -292,59 +353,67 @@ void main() { client.dispose(); }); - test('`.connectUser` should succeed without connecting', () async { - final user = User(id: 'test-user-id'); - final token = testUserToken(user.id).rawValue; + chatClientTest( + '`.connectUser` should succeed without connecting', + connect: (_) {}, + body: (tester) async { + final user = tester.user; + final token = createTestToken(user.id).rawValue; - final res = await client.connectUser( - user, - token, - connectWebSocket: false, - ); - expect(res, isSameUserAs(user)); - expect(client.wsConnectionStatus, ConnectionStatus.disconnected); - }); + final res = await tester.client.connectUser( + user, + token, + connectWebSocket: false, + ); + expect(res, isSameUserAs(user)); + expect(tester.connectionStatus, ConnectionStatus.disconnected); + }, + ); - test( + chatClientTest( '`.connectUserWithProvider` should succeed without connecting', - () async { - final user = User(id: 'test-user-id'); + connect: (_) {}, + body: (tester) async { + final user = tester.user; Future tokenProvider(String userId) async { expect(userId, user.id); - return testUserToken(userId); + return createTestToken(userId); } - final res = await client.connectUserWithProvider( + final res = await tester.client.connectUserWithProvider( user, TokenProvider.dynamic(tokenProvider), connectWebSocket: false, ); expect(res, isSameUserAs(user)); - expect(client.wsConnectionStatus, ConnectionStatus.disconnected); + expect(tester.connectionStatus, ConnectionStatus.disconnected); }, ); - test('`.connectGuestUser` should succeed without connecting', () async { - final user = User(id: 'test-user-id'); - final token = testUserToken(user.id).rawValue; + chatClientTest( + '`.connectGuestUser` should succeed without connecting', + connect: (_) {}, + body: (tester) async { + final user = tester.user; + final token = createTestToken(user.id).rawValue; - when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))).thenAnswer( - (_) async => ConnectGuestUserResponse() - ..user = user - ..accessToken = token, - ); + tester.mockApi( + (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), + result: createDefaultConnectGuestUserResponse(user: user, accessToken: token), + ); - final res = await client.connectGuestUser( - user, - connectWebSocket: false, - ); + final res = await tester.client.connectGuestUser( + user, + connectWebSocket: false, + ); - expect(res, isSameUserAs(user)); - expect(client.wsConnectionStatus, ConnectionStatus.disconnected); - verify( - () => api.guest.getGuestUser(any(that: isSameUserAs(user))), - ).called(1); - }); + expect(res, isSameUserAs(user)); + expect(tester.connectionStatus, ConnectionStatus.disconnected); + tester.verifyApi( + (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), + ); + }, + ); test( '`.connectAnonymousUser` should succeed without connecting', @@ -360,6 +429,24 @@ void main() { }); group('Fake web-socket connection function with failure and persistence', () { + // Runs [body] as a [chatClientTest] whose client is backed by a fresh + // [MockPersistenceClient] and whose WebSocket transport fails to connect: the + // connect attempt fails with a retriable error, so the client falls back to + // the persisted connection info. + void _failedConnectionWithPersistenceTest( + String description, { + required Future Function(ChatClientTester tester, MockPersistenceClient persistence) body, + }) { + final persistence = MockPersistenceClient(); + + chatClientTest( + description, + chatPersistenceClient: persistence, + connect: (tester) => tester.mockConnectionError(), + body: (tester) => body(tester, persistence), + ); + } + const apiKey = 'test-api-key'; late final api = FakeChatApi(); late final persistence = MockPersistenceClient(); @@ -368,11 +455,11 @@ void main() { setUpAll(() { // fallback values - registerFallbackValue(FakeUser()); + registerFallbackValue(_FakeUser()); }); setUp(() { - final ws = FakeWebSocketWithConnectionError(); + final ws = _FakeWebSocketWithConnectionError(); client = StreamChatClient(apiKey, chatApi: api, ws: ws)..chatPersistenceClient = persistence; }); @@ -380,11 +467,10 @@ void main() { client.dispose(); }); - test( - '''`.connectUser` should connect successfully if persistence contains event''', - () async { - final user = User(id: 'test-user-id'); - final token = testUserToken(user.id).rawValue; + _failedConnectionWithPersistenceTest( + '`.connectUser` should connect successfully if persistence contains event', + body: (tester, persistence) async { + final user = tester.user; final event = Event( type: EventType.healthCheck, @@ -393,7 +479,7 @@ void main() { ); when(persistence.getConnectionInfo).thenAnswer((_) async => event); - final res = await client.connectUser(user, token); + final res = await tester.client.connectUser(user, createTestToken(user.id).rawValue); expect(res, isNotNull); expect(res, isSameUserAs(user)); @@ -402,13 +488,13 @@ void main() { }, ); - test( - '''`.connectUserWithProvider` should connect successfully if persistence contains event''', - () async { - final user = User(id: 'test-user-id'); + _failedConnectionWithPersistenceTest( + '`.connectUserWithProvider` should connect successfully if persistence contains event', + body: (tester, persistence) async { + final user = tester.user; Future tokenProvider(String userId) async { expect(userId, user.id); - return testUserToken(userId); + return createTestToken(userId); } final event = Event( @@ -418,7 +504,7 @@ void main() { ); when(persistence.getConnectionInfo).thenAnswer((_) async => event); - final res = await client.connectUserWithProvider(user, TokenProvider.dynamic(tokenProvider)); + final res = await tester.client.connectUserWithProvider(user, TokenProvider.dynamic(tokenProvider)); expect(res, isNotNull); expect(res, isSameUserAs(user)); @@ -427,11 +513,10 @@ void main() { }, ); - test( - '''`.connectGuestUser` should connect successfully if persistence contains event''', - () async { - final user = User(id: 'test-user-id'); - final token = testUserToken(user.id).rawValue; + _failedConnectionWithPersistenceTest( + '`.connectGuestUser` should connect successfully if persistence contains event', + body: (tester, persistence) async { + final user = tester.user; final event = Event( type: EventType.healthCheck, @@ -440,20 +525,20 @@ void main() { ); when(persistence.getConnectionInfo).thenAnswer((_) async => event); - when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))).thenAnswer( - (_) async => ConnectGuestUserResponse() - ..user = user - ..accessToken = token, + tester.mockApi( + (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), + result: createDefaultConnectGuestUserResponse(user: user), ); - final res = await client.connectGuestUser(user); + final res = await tester.client.connectGuestUser(user); expect(res, isNotNull); expect(res, isSameUserAs(user)); verify(persistence.getConnectionInfo).called(1); verifyNoMoreInteractions(persistence); - verify(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))).called(1); - verifyNoMoreInteractions(api.guest); + tester + ..verifyApi((api) => api.guest.getGuestUser(any(that: isSameUserAs(user)))) + ..verifyNoMoreApiInteractions((api) => api.guest); }, ); @@ -480,56 +565,55 @@ void main() { }); group('Client with connected user with persistence', () { - const apiKey = 'test-api-key'; - late final api = FakeChatApi(); - late final persistence = MockPersistenceClient(); - - final user = User(id: 'test-user-id'); - final token = testUserToken(user.id).rawValue; - - late StreamChatClient client; - - setUpAll(() { - // fallback values - registerFallbackValue(FakeEvent()); - registerFallbackValue(const PaginationParams()); - registerFallbackValue(FakeChannelState()); - registerFallbackValue(const ChannelFilter.raw({})); - }); + // Runs [body] as a [chatClientTest] whose client is backed by a fresh + // [MockPersistenceClient]: the persistence stubs the connect path needs are + // installed first, then the client is connected and persistence is asserted + // enabled. + void _clientWithPersistenceTest( + String description, { + required Future Function(ChatClientTester tester, MockPersistenceClient persistence) body, + }) { + final persistence = MockPersistenceClient(); + + chatClientTest( + description, + chatPersistenceClient: persistence, + connect: (tester) async { + // The real engine routes the connect health-check through the client, + // which forwards it to persistence — stub the writes up front. + when(() => persistence.updateConnectionInfo(any())).thenAnswer((_) => Future.value()); + when(() => persistence.updateLastSyncAt(any())).thenAnswer((_) => Future.value()); + when(persistence.getLastSyncAt).thenAnswer((_) async => null); - setUp(() async { - when(() => persistence.updateLastSyncAt(any())).thenAnswer((_) => Future.value()); - when(persistence.getLastSyncAt).thenAnswer((_) async => null); - final ws = FakeWebSocket(); - client = StreamChatClient(apiKey, chatApi: api, ws: ws)..chatPersistenceClient = persistence; - await client.connectUser(user, token); - await delay(300); - expect(client.persistenceEnabled, isTrue); - expect(client.wsConnectionStatus, ConnectionStatus.connected); - }); + tester.mockSuccessfulAuth(); + await tester.client.connectUser(tester.user, createTestToken(tester.user.id).rawValue); - tearDown(() async { - await client.dispose(); - }); + expect(tester.client.persistenceEnabled, isTrue); + expect(tester.connectionStatus, ConnectionStatus.connected); + }, + body: (tester) => body(tester, persistence), + ); + } group('`.sync`', () { - test( + _clientWithPersistenceTest( '''should update persistence connectionInfo and lastSync when sync succeeds''', - () async { + body: (tester, persistence) async { // persistence.updateLastSyncAt might be called // when connecting the user. // Resetting the logs so we start counting invocations correctly. reset(persistence); const cids = ['test-cid-1', 'test-cid-2', 'test-cid-3']; - final lastSyncAt = DateTime.now(); - when(() => api.general.sync(cids, lastSyncAt)).thenAnswer( - (_) async => SyncResponse() - ..events = [ + final lastSyncAt = DateTime.utc(2021, 3); + tester.mockApi( + (api) => api.general.sync(cids, lastSyncAt), + result: createDefaultSyncResponse( + events: [ Event( isLocal: false, type: EventType.healthCheck, connectionId: 'test-connection-id', - me: OwnUser.fromUser(user), + me: OwnUser.fromUser(tester.user), ), Event( isLocal: false, @@ -537,40 +621,42 @@ void main() { message: Message(id: 'test-message-id'), ), ], + ), ); when(() => persistence.updateConnectionInfo(any())).thenAnswer((_) => Future.value()); when(() => persistence.updateLastSyncAt(any())).thenAnswer((_) => Future.value()); - await client.sync(cids: cids, lastSyncAt: lastSyncAt); + await tester.client.sync(cids: cids, lastSyncAt: lastSyncAt); verify(() => persistence.updateConnectionInfo(any())).called(1); verify(() => persistence.updateLastSyncAt(any())).called(1); - verify(() => api.general.sync(cids, lastSyncAt)).called(1); + tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); }, ); - test( + _clientWithPersistenceTest( 'should work fine if persistence contains sync params', - () async { + body: (tester, persistence) async { // persistence.updateLastSyncAt might be called // when connecting the user. // Resetting the logs so we start counting invocations correctly. reset(persistence); const cids = ['test-cid-1', 'test-cid-2', 'test-cid-3']; - final lastSyncAt = DateTime.now(); + final lastSyncAt = DateTime.utc(2021, 3); when(persistence.getChannelCids).thenAnswer((_) async => cids); when(persistence.getLastSyncAt).thenAnswer((_) async => lastSyncAt); - when(() => api.general.sync(cids, lastSyncAt)).thenAnswer( - (_) async => SyncResponse() - ..events = [ + tester.mockApi( + (api) => api.general.sync(cids, lastSyncAt), + result: createDefaultSyncResponse( + events: [ Event( isLocal: false, type: EventType.healthCheck, connectionId: 'test-connection-id', - me: OwnUser.fromUser(user), + me: OwnUser.fromUser(tester.user), ), Event( isLocal: false, @@ -578,16 +664,17 @@ void main() { message: Message(id: 'test-message-id', text: 'Hey!'), ), ], + ), ); when(() => persistence.updateConnectionInfo(any())).thenAnswer((_) => Future.value()); when(() => persistence.updateLastSyncAt(any())).thenAnswer((_) => Future.value()); - await client.sync(); + await tester.client.sync(); verify(() => persistence.updateConnectionInfo(any())).called(1); verify(() => persistence.updateLastSyncAt(any())).called(1); - verify(() => api.general.sync(cids, lastSyncAt)).called(1); + tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); verify(persistence.getChannelCids).called(1); verify(persistence.getLastSyncAt).called(1); }, @@ -595,13 +682,13 @@ void main() { }); group('`.queryChannels`', () { - test( + _clientWithPersistenceTest( 'should emit channels twice if persistence contains some channels', - () async { + body: (tester, persistence) async { final persistentChannelStates = List.generate( 3, - (index) => ChannelState( - channel: ChannelModel(cid: 'test-type-$index:test-id-$index'), + (index) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$index:test-id-$index'), ), ); @@ -615,17 +702,17 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).thenAnswer((_) async => QueryChannelsResponse()..channels = persistentChannelStates); + ).thenAnswer((_) async => createDefaultQueryChannelsResponse(channels: persistentChannelStates)); final channelStates = List.generate( 3, - (index) => ChannelState( - channel: ChannelModel(cid: 'test-type-$index:test-id-$index'), + (index) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$index:test-id-$index'), ), ); - when( - () => api.channel.queryChannels( + tester.mockApi( + (api) => api.channel.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), state: any(named: 'state'), @@ -635,14 +722,13 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).thenAnswer( - (_) async => QueryChannelsResponse()..channels = channelStates, + result: createDefaultQueryChannelsResponse(channels: channelStates), ); when(() => persistence.getChannelThreads(any())).thenAnswer( (_) async => >{ for (final channelState in channelStates) - channelState.channel!.cid: [Message(id: 'test-message-id', text: 'Test message')], + channelState.channel!.cid: [createDefaultMessage(id: 'test-message-id', text: 'Test message')], }, ); @@ -662,14 +748,15 @@ void main() { ), ).thenAnswer((_) => Future.value()); - // setUp's `connectUser` schedules debounced persistence writes - // (1s window) that would otherwise fire during this test's wait - // and pollute the call counts. Wait past the debounce, then clear. - await delay(1100); + // The connect phase's `connectUser` schedules debounced persistence + // writes (1s window) that would otherwise fire during this test's + // wait and pollute the call counts. Wait past the debounce, then + // clear. + await Future.delayed(const Duration(milliseconds: 1100)); clearInteractions(persistence); await expectLater( - client.queryChannels(), + tester.client.queryChannels(), emitsInOrder([ // emits persistent channels first persistentChannelStates.map(isCorrectChannelFor), @@ -681,7 +768,7 @@ void main() { // Wait safely past the 1s debounce on persistence writes // (updateChannelState, updateChannelThreads) so all trailing // invocations have fired before we verify counts. - await delay(1500); + await Future.delayed(const Duration(milliseconds: 1500)); verify( () => persistence.queryChannelStates( @@ -695,8 +782,8 @@ void main() { ), ).called(1); - verify( - () => api.channel.queryChannels( + tester.verifyApi( + (api) => api.channel.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), state: any(named: 'state'), @@ -706,7 +793,7 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).called(1); + ); verify(() => persistence.getChannelThreads(any())).called(channelStates.length); verify(() => persistence.updateChannelState(any())).called(channelStates.length); @@ -727,13 +814,13 @@ void main() { }, ); - test( + _clientWithPersistenceTest( '''should never rethrow network call if persistence already emitted some channels''', - () async { + body: (tester, persistence) async { final persistentChannelStates = List.generate( 3, - (index) => ChannelState( - channel: ChannelModel(cid: 'test-type-$index:test-id-$index'), + (index) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$index:test-id-$index'), ), ); @@ -747,10 +834,10 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).thenAnswer((_) async => QueryChannelsResponse()..channels = persistentChannelStates); + ).thenAnswer((_) async => createDefaultQueryChannelsResponse(channels: persistentChannelStates)); - when( - () => api.channel.queryChannels( + tester.mockApiFailure( + (api) => api.channel.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), state: any(named: 'state'), @@ -760,26 +847,28 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).thenThrow(apiException(code: StreamErrorCode.inputError, statusCode: 400)); + error: createDefaultNetworkError(code: StreamErrorCode.inputError, statusCode: 400), + ); when(() => persistence.getChannelThreads(any())).thenAnswer( (_) async => >{ for (final channelState in persistentChannelStates) - channelState.channel!.cid: [Message(id: 'test-message-id', text: 'Test message')], + channelState.channel!.cid: [createDefaultMessage(id: 'test-message-id', text: 'Test message')], }, ); when(() => persistence.updateChannelState(any())).thenAnswer((_) async => {}); when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async => {}); - // setUp's `connectUser` schedules debounced persistence writes - // (1s window) that would otherwise fire during this test's wait - // and pollute the call counts. Wait past the debounce, then clear. - await delay(1100); + // The connect phase's `connectUser` schedules debounced persistence + // writes (1s window) that would otherwise fire during this test's + // wait and pollute the call counts. Wait past the debounce, then + // clear. + await Future.delayed(const Duration(milliseconds: 1100)); clearInteractions(persistence); await expectLater( - client.queryChannels(), + tester.client.queryChannels(), emitsInOrder([ // emits persistent channels persistentChannelStates.map(isCorrectChannelFor), @@ -789,7 +878,7 @@ void main() { // Wait safely past the 1s debounce on persistence writes // (updateChannelState, updateChannelThreads) so all trailing // invocations have fired before we verify counts. - await delay(1500); + await Future.delayed(const Duration(milliseconds: 1500)); verify( () => persistence.queryChannelStates( @@ -803,8 +892,8 @@ void main() { ), ).called(1); - verify( - () => api.channel.queryChannels( + tester.verifyApi( + (api) => api.channel.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), state: any(named: 'state'), @@ -814,7 +903,7 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).called(1); + ); verify(() => persistence.getChannelThreads(any())).called(persistentChannelStates.length); verify(() => persistence.updateChannelState(any())).called(persistentChannelStates.length); @@ -822,18 +911,20 @@ void main() { }, ); - test( + _clientWithPersistenceTest( 'queryChannelsOnline with inline filter persists via saveChannelQueries', - () async { + body: (tester, persistence) async { final filter = ChannelFilter.in_(ChannelFilterField.members, const ['test-user-id']); final channelStates = List.generate( 3, - (i) => ChannelState(channel: ChannelModel(cid: 'test-type-$i:test-id-$i')), + (i) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$i:test-id-$i'), + ), ); - when( - () => api.channel.queryChannels( + tester.mockApi( + (api) => api.channel.queryChannels( filter: filter, state: any(named: 'state'), watch: any(named: 'watch'), @@ -842,8 +933,7 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).thenAnswer( - (_) async => QueryChannelsResponse()..channels = channelStates, + result: createDefaultQueryChannelsResponse(channels: channelStates), ); when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); @@ -863,10 +953,10 @@ void main() { ), ).thenAnswer((_) => Future.value()); - await delay(1100); + await Future.delayed(const Duration(milliseconds: 1100)); clearInteractions(persistence); - await client.queryChannelsOnline(filter: filter); + await tester.client.queryChannelsOnline(filter: filter); // The standard path passes filter (the inline filter) and a null // predefinedFilter. resolvedFilter / resolvedSort stay null — @@ -887,24 +977,26 @@ void main() { }, ); - test( + _clientWithPersistenceTest( 'queryChannelsOnline with predefined filter persists via saveChannelQueries with resolved sort', - () async { + body: (tester, persistence) async { const filterName = 'sample-app-list'; const filterValues = {'user_id': 'test-user-id'}; const sortValues = {'pinned_at': true}; final channelStates = List.generate( 3, - (i) => ChannelState(channel: ChannelModel(cid: 'test-type-$i:test-id-$i')), + (i) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$i:test-id-$i'), + ), ); // `Sort` has no value equality, so the verify below has to match on // the instance the response carried. final resolvedSort = [ChannelSort.desc(ChannelSortField.lastMessageAt)]; - when( - () => api.channel.queryChannels( + tester.mockApi( + (api) => api.channel.queryChannels( predefinedFilter: filterName, filterValues: filterValues, sortValues: sortValues, @@ -915,14 +1007,14 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).thenAnswer( - (_) async => QueryChannelsResponse() - ..channels = channelStates - ..predefinedFilter = PredefinedFilter( + result: createDefaultQueryChannelsResponse( + channels: channelStates, + predefinedFilter: PredefinedFilter( name: filterName, filter: const ChannelFilter.raw({}), sort: resolvedSort, ), + ), ); when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); @@ -942,10 +1034,10 @@ void main() { ), ).thenAnswer((_) => Future.value()); - await delay(1100); + await Future.delayed(const Duration(milliseconds: 1100)); clearInteractions(persistence); - await client.queryChannelsOnline( + await tester.client.queryChannelsOnline( predefinedFilter: filterName, filterValues: filterValues, sortValues: sortValues, @@ -967,16 +1059,18 @@ void main() { }, ); - test( + _clientWithPersistenceTest( 'queryChannelsOffline with predefined filter reads via queryChannelStates', - () async { + body: (tester, persistence) async { const filterName = 'sample-app-list'; const filterValues = {'user_id': 'test-user-id'}; const sortValues = {'pinned_at': true}; final channelStates = List.generate( 3, - (i) => ChannelState(channel: ChannelModel(cid: 'test-type-$i:test-id-$i')), + (i) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$i:test-id-$i'), + ), ); when( @@ -989,16 +1083,16 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).thenAnswer((_) async => QueryChannelsResponse()..channels = channelStates); + ).thenAnswer((_) async => createDefaultQueryChannelsResponse(channels: channelStates)); when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); - await delay(1100); + await Future.delayed(const Duration(milliseconds: 1100)); clearInteractions(persistence); - final channels = await client.queryChannelsOffline( + final channels = await tester.client.queryChannelsOffline( predefinedFilter: filterName, filterValues: filterValues, sortValues: sortValues, @@ -1020,12 +1114,14 @@ void main() { }, ); - test( + _clientWithPersistenceTest( 'queryChannelsWithResult yields QueryChannelsResult with predefinedFilter=null for inline filter', - () async { + body: (tester, persistence) async { final channelStates = List.generate( 2, - (i) => ChannelState(channel: ChannelModel(cid: 'test-type-$i:test-id-$i')), + (i) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$i:test-id-$i'), + ), ); when( @@ -1038,10 +1134,10 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).thenAnswer((_) async => QueryChannelsResponse()..channels = const []); + ).thenAnswer((_) async => createDefaultQueryChannelsResponse()); - when( - () => api.channel.queryChannels( + tester.mockApi( + (api) => api.channel.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), state: any(named: 'state'), @@ -1051,8 +1147,7 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).thenAnswer( - (_) async => QueryChannelsResponse()..channels = channelStates, + result: createDefaultQueryChannelsResponse(channels: channelStates), ); when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); @@ -1072,10 +1167,10 @@ void main() { ), ).thenAnswer((_) => Future.value()); - await delay(1100); + await Future.delayed(const Duration(milliseconds: 1100)); clearInteractions(persistence); - final results = await client.queryChannelsWithResult().toList(); + final results = await tester.client.queryChannelsWithResult().toList(); // Persistence returned empty, so only the online emission is yielded. expect(results, hasLength(1)); @@ -1084,16 +1179,18 @@ void main() { }, ); - test( + _clientWithPersistenceTest( 'queryChannelsWithResult yields QueryChannelsResult with predefinedFilter populated for predefined query', - () async { + body: (tester, persistence) async { const filterName = 'sample-app-list'; const filterValues = {'user_id': 'test-user-id'}; const sortValues = {'pinned_at': true}; final channelStates = List.generate( 2, - (i) => ChannelState(channel: ChannelModel(cid: 'test-type-$i:test-id-$i')), + (i) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$i:test-id-$i'), + ), ); final resolvedSort = [ChannelSort.desc(ChannelSortField.lastMessageAt)]; @@ -1114,10 +1211,10 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).thenAnswer((_) async => QueryChannelsResponse()..channels = const []); + ).thenAnswer((_) async => createDefaultQueryChannelsResponse()); - when( - () => api.channel.queryChannels( + tester.mockApi( + (api) => api.channel.queryChannels( predefinedFilter: filterName, filterValues: filterValues, sortValues: sortValues, @@ -1128,10 +1225,10 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).thenAnswer( - (_) async => QueryChannelsResponse() - ..channels = channelStates - ..predefinedFilter = expectedPredefinedFilter, + result: createDefaultQueryChannelsResponse( + channels: channelStates, + predefinedFilter: expectedPredefinedFilter, + ), ); when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); @@ -1151,10 +1248,10 @@ void main() { ), ).thenAnswer((_) => Future.value()); - await delay(1100); + await Future.delayed(const Duration(milliseconds: 1100)); clearInteractions(persistence); - final results = await client + final results = await tester.client .queryChannelsWithResult( predefinedFilter: filterName, filterValues: filterValues, @@ -1172,153 +1269,128 @@ void main() { ); }); - test('`.disconnectUser` should reset state and user', () async { - expect(client.state.currentUser, isNotNull); - expect(client.wsConnectionStatus, ConnectionStatus.connected); + _clientWithPersistenceTest( + '`.disconnectUser` should reset state and user', + body: (tester, persistence) async { + expect(tester.clientState.currentUser, isNotNull); + expect(tester.connectionStatus, ConnectionStatus.connected); - expectLater( - // skipping initial connected value - client.wsConnectionStatusStream.skip(1), - emits(ConnectionStatus.disconnected), - ); + expectLater( + // skipping initial connected value + tester.client.wsConnectionStatusStream.skip(1), + emits(ConnectionStatus.disconnected), + ); - await client.disconnectUser(flushChatPersistence: true); + await tester.client.disconnectUser(flushChatPersistence: true); - expect(client.state.currentUser, isNull); - expect(client.wsConnectionStatus, ConnectionStatus.disconnected); - }); + expect(tester.clientState.currentUser, isNull); + expect(tester.connectionStatus, ConnectionStatus.disconnected); + }, + ); }); group('Client with connected user without persistence', () { - const apiKey = 'test-api-key'; - const userId = 'test-user-id'; - late final api = FakeChatApi(); - - final user = User(id: userId); - final token = testUserToken(user.id).rawValue; - - late StreamChatClient client; - - setUpAll(() { - // fallback values - registerFallbackValue(FakeEvent()); - registerFallbackValue(FakeMessage()); - registerFallbackValue(FakeDraftMessage()); - registerFallbackValue(FakePollVote()); - registerFallbackValue(const PaginationParams()); - }); + const _channelData = {'name': 'test-channel-name'}; - setUp(() async { - // Clear any accumulated interactions from a previous test so that - // verifyNoMoreInteractions on api.general stays accurate. - clearInteractions(api.general); + group('`.sync`', () { + chatClientTest( + 'should work fine', + body: (tester) async { + const cids = ['test-cid-1', 'test-cid-2', 'test-cid-3']; + final lastSyncAt = DateTime.utc(2021, 3); - final ws = FakeWebSocket(); - client = StreamChatClient(apiKey, chatApi: api, ws: ws); - // Stub getAppSettings so the background fetch after connectUser succeeds. - when(() => api.general.getAppSettings()).thenAnswer( - (_) async => GetAppSettingsResponse()..app = const AppSettings(name: 'test'), - ); - await client.connectUser(user, token); - await delay(300); - expect(client.persistenceEnabled, isFalse); - expect(client.wsConnectionStatus, ConnectionStatus.connected); - }); - - tearDown(() async { - await client.dispose(); - }); - - group('`.sync`', () { - test('should work fine', () async { - const cids = ['test-cid-1', 'test-cid-2', 'test-cid-3']; - final lastSyncAt = DateTime.now(); - - when(() => api.general.sync(cids, lastSyncAt)).thenAnswer( - (_) async => SyncResponse() - ..events = [ - Event( - isLocal: false, - type: EventType.healthCheck, - connectionId: 'test-connection-id', - me: OwnUser.fromUser(user), - ), - Event( - isLocal: false, - type: EventType.messageDeleted, - message: Message(id: 'test-message-id'), - ), - ], - ); + tester.mockApi( + (api) => api.general.sync(cids, lastSyncAt), + result: createDefaultSyncResponse( + events: [ + Event( + isLocal: false, + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(tester.user), + ), + Event( + isLocal: false, + type: EventType.messageDeleted, + message: Message(id: 'test-message-id'), + ), + ], + ), + ); - await client.sync(cids: cids, lastSyncAt: lastSyncAt); + await tester.client.sync(cids: cids, lastSyncAt: lastSyncAt); - verify(() => api.general.sync(cids, lastSyncAt)).called(1); - }); + tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); + }, + ); - test('should return if `cids` is not available', () async { - expect(client.sync, returnsNormally); - verifyNever(() => api.general.sync(any(), any())); - }); + chatClientTest( + 'should return if `cids` is not available', + body: (tester) async { + await expectLater(tester.client.sync(), completes); + tester.verifyNeverCalled((api) => api.general.sync(any(), any())); + }, + ); - test('should return if `lastSyncAt` is not available', () async { - expect(() => client.sync(cids: ['test-cid-1']), returnsNormally); - verifyNever(() => api.general.sync(any(), any())); - }); + chatClientTest( + 'should return if `lastSyncAt` is not available', + body: (tester) async { + await expectLater(tester.client.sync(cids: ['test-cid-1']), completes); + tester.verifyNeverCalled((api) => api.general.sync(any(), any())); + }, + ); }); group('`.queryChannels`', () { - test('should work fine without persistent channels', () async { - final channelStates = List.generate( - 3, - (index) => ChannelState( - channel: ChannelModel(cid: 'test-type-$index:test-id-$index'), - ), - ); + chatClientTest( + 'should work fine without persistent channels', + body: (tester) async { + final channelStates = List.generate( + 3, + (index) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$index:test-id-$index'), + ), + ); - when( - () => api.channel.queryChannels( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - memberLimit: any(named: 'memberLimit'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - ), - ).thenAnswer( - (_) async => QueryChannelsResponse()..channels = channelStates, - ); + tester.mockApi( + (api) => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + result: createDefaultQueryChannelsResponse(channels: channelStates), + ); - expectLater( - client.queryChannels(), - emitsInOrder([channelStates.map(isCorrectChannelFor)]), - ); - - // Hack as `teardown` gets called even - // before our stream starts emitting data - await delay(300); - - verify( - () => api.channel.queryChannels( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - memberLimit: any(named: 'memberLimit'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - ), - ).called(1); - }); + await expectLater( + tester.client.queryChannels(), + emitsInOrder([channelStates.map(isCorrectChannelFor)]), + ); + + tester.verifyApi( + (api) => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ); + }, + ); - test( + chatClientTest( '''should rethrow if `.queryChannelsOnline` throws and persistence channels are empty''', - () async { - when( - () => api.channel.queryChannels( + body: (tester) async { + tester.mockApiFailure( + (api) => api.channel.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), state: any(named: 'state'), @@ -1328,19 +1400,16 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).thenThrow(apiException(code: StreamErrorCode.inputError, statusCode: 400)); + error: createDefaultNetworkError(code: StreamErrorCode.inputError, statusCode: 400), + ); - expectLater( - client.queryChannels(), + await expectLater( + tester.client.queryChannels(), emitsError(isA()), ); - // Hack as `teardown` gets called even - // before our stream starts emitting data - await delay(300); - - verify( - () => api.channel.queryChannels( + tester.verifyApi( + (api) => api.channel.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), state: any(named: 'state'), @@ -1350,13 +1419,13 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).called(1); + ); }, ); - test( + chatClientTest( 'should coalesce concurrent identical calls into a single HTTP request', - () async { + body: (tester) async { // Regression test for a TOCTOU race in the _queryChannelsStreams // cache: the cache write previously happened after an offline-await, // so N sibling calls in the same event-loop tick all missed the @@ -1367,15 +1436,15 @@ void main() { // share its result. final channelStates = List.generate( 3, - (index) => ChannelState( - channel: ChannelModel(cid: 'test-type-$index:test-id-$index'), + (index) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$index:test-id-$index'), ), ); // Slow down the API so all concurrent callers are guaranteed to be // in flight at the same time when the cache write happens. - when( - () => api.channel.queryChannels( + tester.mockApi( + (api) => api.channel.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), state: any(named: 'state'), @@ -1385,14 +1454,13 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).thenAnswer((_) async { - await delay(100); - return QueryChannelsResponse()..channels = channelStates; - }); + result: createDefaultQueryChannelsResponse(channels: channelStates), + delay: const Duration(milliseconds: 100), + ); // Fire 5 identical calls back-to-back in the same tick. final results = await Future.wait( - List.generate(5, (_) => client.queryChannels().toList()), + List.generate(5, (_) => tester.client.queryChannels().toList()), ); // All callers should receive the same channels. @@ -1402,8 +1470,8 @@ void main() { } // But only ONE HTTP request should have been issued. - verify( - () => api.channel.queryChannels( + tester.verifyApi( + (api) => api.channel.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), state: any(named: 'state'), @@ -1413,25 +1481,25 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).called(1); + ); }, ); - test( + chatClientTest( 'should fire a fresh request once the cached future has settled', - () async { + body: (tester) async { // After the in-flight future completes, the cache slot is freed and // the next call must hit the API again — only concurrent callers // share the future, not sequential ones. final channelStates = List.generate( 3, - (index) => ChannelState( - channel: ChannelModel(cid: 'test-type-$index:test-id-$index'), + (index) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$index:test-id-$index'), ), ); - when( - () => api.channel.queryChannels( + tester.mockApi( + (api) => api.channel.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), state: any(named: 'state'), @@ -1441,15 +1509,14 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).thenAnswer( - (_) async => QueryChannelsResponse()..channels = channelStates, + result: createDefaultQueryChannelsResponse(channels: channelStates), ); - await client.queryChannels().toList(); - await client.queryChannels().toList(); + await tester.client.queryChannels().toList(); + await tester.client.queryChannels().toList(); - verify( - () => api.channel.queryChannels( + tester.verifyApiCalled( + (api) => api.channel.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), state: any(named: 'state'), @@ -1459,17 +1526,18 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).called(2); + times: 2, + ); }, ); - test( + chatClientTest( 'concurrent calls with different filters do not share the cache', - () async { + body: (tester) async { // The cache is keyed on a hash of the query parameters. Callers // with different filters/limits must each fire their own request. - when( - () => api.channel.queryChannels( + tester.mockApi( + (api) => api.channel.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), state: any(named: 'state'), @@ -1479,18 +1547,17 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).thenAnswer((_) async { - await delay(100); - return QueryChannelsResponse()..channels = []; - }); + result: createDefaultQueryChannelsResponse(), + delay: const Duration(milliseconds: 100), + ); await Future.wait([ - client.queryChannels(filter: ChannelFilter.in_(ChannelFilterField.cid, const ['a'])).toList(), - client.queryChannels(filter: ChannelFilter.in_(ChannelFilterField.cid, const ['b'])).toList(), + tester.client.queryChannels(filter: ChannelFilter.in_(ChannelFilterField.cid, const ['a'])).toList(), + tester.client.queryChannels(filter: ChannelFilter.in_(ChannelFilterField.cid, const ['b'])).toList(), ]); - verify( - () => api.channel.queryChannels( + tester.verifyApiCalled( + (api) => api.channel.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), state: any(named: 'state'), @@ -1500,18 +1567,19 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).called(2); + times: 2, + ); }, ); - test( + chatClientTest( 'concurrent calls share the same error when the request fails', - () async { + body: (tester) async { // If the in-flight HTTP request fails, every concurrent caller // awaiting the shared future should see the same error rather than // each firing its own retry request. - when( - () => api.channel.queryChannels( + tester.mockApiFailure( + (api) => api.channel.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), state: any(named: 'state'), @@ -1521,15 +1589,14 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).thenAnswer((_) async { - await delay(100); - throw apiException(code: StreamErrorCode.inputError, statusCode: 400); - }); + error: createDefaultNetworkError(code: StreamErrorCode.inputError, statusCode: 400), + delay: const Duration(milliseconds: 100), + ); final errors = await Future.wait( List.generate(5, (_) async { try { - await client.queryChannels().toList(); + await tester.client.queryChannels().toList(); return null; } catch (e) { return e; @@ -1544,8 +1611,8 @@ void main() { } // But only ONE HTTP request was made — the rest piggybacked. - verify( - () => api.channel.queryChannels( + tester.verifyApi( + (api) => api.channel.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), state: any(named: 'state'), @@ -1555,2225 +1622,2433 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), ), - ).called(1); + ); }, ); }); - test('`.queryUsers`', () async { - final users = List.generate( - 3, - (index) => User(id: 'test-user-id-$index'), - ); + chatClientTest( + '`.queryUsers`', + body: (tester) async { + final users = List.generate( + 3, + (index) => User(id: 'test-user-id-$index'), + ); - when( - () => api.user.queryUsers( - presence: any(named: 'presence'), - filter: any(named: 'filter'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - ), - ).thenAnswer((_) async => QueryUsersResponse()..users = users); + tester.mockApi( + (api) => api.user.queryUsers(presence: true), + result: QueryUsersResponse()..users = users, + ); - expectLater( - // skipping initial seed event -> {} users - client.state.usersStream.skip(1), - emitsInOrder([ - {for (final user in users) user.id: user}, - ]), - ); + final usersEmitted = expectLater( + // skipping initial seed event -> {} users + tester.clientState.usersStream.skip(1), + emitsInOrder([ + {for (final user in users) user.id: user}, + ]), + ); - final res = await client.queryUsers(); - expect(res, isNotNull); - expect(res.users.length, users.length); + final res = await tester.client.queryUsers(); + expect(res, isNotNull); + expect(res.users.length, users.length); - verify( - () => api.user.queryUsers( - presence: any(named: 'presence'), - filter: any(named: 'filter'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - ), - ).called(1); - verifyNoMoreInteractions(api.user); - }); + await usersEmitted; - test('`.queryBannedUsers`', () async { - final bans = List.generate( - 3, - (index) => BannedUser( - user: User(id: 'test-user-id-$index'), - bannedBy: User(id: 'test-user-id-${index + 1}'), - ), - ); + tester + ..verifyApi((api) => api.user.queryUsers(presence: true)) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + chatClientTest( + '`.queryBannedUsers`', + body: (tester) async { + final bans = List.generate( + 3, + (index) => BannedUser( + user: User(id: 'test-user-id-$index'), + bannedBy: User(id: 'test-user-id-${index + 1}'), + ), + ); - const cid = 'message:nice-channel'; - final filter = BannedUserFilter.equal(BannedUserFilterField.channelCid, cid); + const cid = 'message:nice-channel'; + final filter = BannedUserFilter.equal(BannedUserFilterField.channelCid, cid); - when( - () => api.moderation.queryBannedUsers( - filter: filter, - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - ), - ).thenAnswer((_) async => QueryBannedUsersResponse()..bans = bans); + tester.mockApi( + (api) => api.moderation.queryBannedUsers(filter: filter), + result: QueryBannedUsersResponse()..bans = bans, + ); - final res = await client.queryBannedUsers(filter: filter); - expect(res, isNotNull); - expect(res.bans.length, bans.length); + final res = await tester.client.queryBannedUsers(filter: filter); + expect(res, isNotNull); + expect(res.bans.length, bans.length); - verify( - () => api.moderation.queryBannedUsers( - filter: filter, - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - ), - ).called(1); - verifyNoMoreInteractions(api.moderation); - }); + tester + ..verifyApi((api) => api.moderation.queryBannedUsers(filter: filter)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); - test('`.search`', () async { - const cid = 'test-type:test-id'; - final filter = ChannelFilter.in_(ChannelFilterField.cid, const [cid]); + chatClientTest( + '`.search`', + body: (tester) async { + const cid = 'test-type:test-id'; + final filter = ChannelFilter.in_(ChannelFilterField.cid, const [cid]); - final messages = List.generate( - 3, - (index) => GetMessageResponse() - ..channel = ChannelModel(cid: cid) - ..message = Message(id: 'test-message-id-$index'), - ); + final messages = List.generate( + 3, + (index) => createDefaultGetMessageResponse( + channel: ChannelModel(cid: cid), + message: Message(id: 'test-message-id-$index'), + ), + ); - when( - () => api.general.searchMessages( - filter, - query: any(named: 'query'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - messageFilters: any(named: 'messageFilters'), - ), - ).thenAnswer((_) async => SearchMessagesResponse()..results = messages); + tester.mockApi( + (api) => api.general.searchMessages(filter), + result: createDefaultSearchMessagesResponse(results: messages), + ); - final res = await client.search(filter); - expect(res, isNotNull); - expect(res.results.length, messages.length); + final res = await tester.client.search(filter); + expect(res, isNotNull); + expect(res.results.length, messages.length); - verify( - () => api.general.searchMessages( - filter, - query: any(named: 'query'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - messageFilters: any(named: 'messageFilters'), - ), - ).called(1); - verify(() => api.general.getAppSettings()).called(1); - verifyNoMoreInteractions(api.general); - }); + tester + ..verifyApi((api) => api.general.searchMessages(filter)) + ..verifyApi((api) => api.general.getAppSettings()) + ..verifyNoMoreApiInteractions((api) => api.general); + }, + ); - test('`.sendFile`', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - final file = AttachmentFile(size: 33, path: 'test-file-path'); + chatClientTest( + '`.sendFile`', + body: (tester) async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + final file = AttachmentFile(size: 33, path: 'test-file-path'); - const fileUrl = 'test-file-url'; + const fileUrl = 'test-file-url'; - when( - () => api.fileUploader.sendFile(file, channelId, channelType), - ).thenAnswer((_) async => SendFileResponse()..file = fileUrl); + tester.mockApi( + (api) => api.fileUploader.sendFile(file, channelId, channelType), + result: SendFileResponse()..file = fileUrl, + ); - final res = await client.sendFile(file, channelId, channelType); - expect(res, isNotNull); - expect(res.file, fileUrl); + final res = await tester.client.sendFile(file, channelId, channelType); + expect(res, isNotNull); + expect(res.file, fileUrl); - verify(() => api.fileUploader.sendFile(file, channelId, channelType)).called(1); - verifyNoMoreInteractions(api.fileUploader); - }); + tester + ..verifyApi((api) => api.fileUploader.sendFile(file, channelId, channelType)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); - test('`.sendImage`', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - final image = AttachmentFile(size: 33, path: 'test-image-path'); + chatClientTest( + '`.sendImage`', + body: (tester) async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + final image = AttachmentFile(size: 33, path: 'test-image-path'); - const fileUrl = 'test-image-url'; + const fileUrl = 'test-image-url'; - when( - () => api.fileUploader.sendImage(image, channelId, channelType), - ).thenAnswer((_) async => SendImageResponse()..file = fileUrl); + tester.mockApi( + (api) => api.fileUploader.sendImage(image, channelId, channelType), + result: SendImageResponse()..file = fileUrl, + ); - final res = await client.sendImage(image, channelId, channelType); - expect(res, isNotNull); - expect(res.file, fileUrl); + final res = await tester.client.sendImage(image, channelId, channelType); + expect(res, isNotNull); + expect(res.file, fileUrl); - verify(() => api.fileUploader.sendImage(image, channelId, channelType)).called(1); - verifyNoMoreInteractions(api.fileUploader); - }); + tester + ..verifyApi((api) => api.fileUploader.sendImage(image, channelId, channelType)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); - test('`.deleteFile`', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - const fileUrl = 'test-file-url'; + chatClientTest( + '`.deleteFile`', + body: (tester) async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const fileUrl = 'test-file-url'; - when(() => api.fileUploader.deleteFile(fileUrl, channelId, channelType)).thenAnswer((_) async => EmptyResponse()); + tester.mockApi( + (api) => api.fileUploader.deleteFile(fileUrl, channelId, channelType), + result: createDefaultEmptyResponse(), + ); - final res = await client.deleteFile(fileUrl, channelId, channelType); - expect(res, isNotNull); + final res = await tester.client.deleteFile(fileUrl, channelId, channelType); + expect(res, isNotNull); - verify(() => api.fileUploader.deleteFile(fileUrl, channelId, channelType)).called(1); - verifyNoMoreInteractions(api.fileUploader); - }); + tester + ..verifyApi((api) => api.fileUploader.deleteFile(fileUrl, channelId, channelType)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); - test('`.deleteImage`', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - const imageUrl = 'test-image-url'; + chatClientTest( + '`.deleteImage`', + body: (tester) async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const imageUrl = 'test-image-url'; - when( - () => api.fileUploader.deleteImage(imageUrl, channelId, channelType), - ).thenAnswer((_) async => EmptyResponse()); + tester.mockApi( + (api) => api.fileUploader.deleteImage(imageUrl, channelId, channelType), + result: createDefaultEmptyResponse(), + ); - final res = await client.deleteImage(imageUrl, channelId, channelType); - expect(res, isNotNull); + final res = await tester.client.deleteImage(imageUrl, channelId, channelType); + expect(res, isNotNull); - verify( - () => api.fileUploader.deleteImage(imageUrl, channelId, channelType), - ).called(1); - verifyNoMoreInteractions(api.fileUploader); - }); + tester + ..verifyApi((api) => api.fileUploader.deleteImage(imageUrl, channelId, channelType)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); - test('`.uploadImage`', () async { - final image = AttachmentFile(size: 33, path: 'test-image-path'); - const fileUrl = 'test-image-url'; + chatClientTest( + '`.uploadImage`', + body: (tester) async { + final image = AttachmentFile(size: 33, path: 'test-image-path'); + const fileUrl = 'test-image-url'; - when(() => api.fileUploader.uploadImage(image)).thenAnswer((_) async => UploadImageResponse()..file = fileUrl); + tester.mockApi( + (api) => api.fileUploader.uploadImage(image), + result: UploadImageResponse()..file = fileUrl, + ); - final res = await client.uploadImage(image); - expect(res, isNotNull); - expect(res.file, fileUrl); + final res = await tester.client.uploadImage(image); + expect(res, isNotNull); + expect(res.file, fileUrl); - verify(() => api.fileUploader.uploadImage(image)).called(1); - verifyNoMoreInteractions(api.fileUploader); - }); + tester + ..verifyApi((api) => api.fileUploader.uploadImage(image)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); - test('`.uploadFile`', () async { - final file = AttachmentFile(size: 33, path: 'test-file-path'); - const fileUrl = 'test-file-url'; + chatClientTest( + '`.uploadFile`', + body: (tester) async { + final file = AttachmentFile(size: 33, path: 'test-file-path'); + const fileUrl = 'test-file-url'; - when(() => api.fileUploader.uploadFile(file)).thenAnswer((_) async => UploadFileResponse()..file = fileUrl); + tester.mockApi( + (api) => api.fileUploader.uploadFile(file), + result: UploadFileResponse()..file = fileUrl, + ); - final res = await client.uploadFile(file); - expect(res, isNotNull); - expect(res.file, fileUrl); + final res = await tester.client.uploadFile(file); + expect(res, isNotNull); + expect(res.file, fileUrl); - verify(() => api.fileUploader.uploadFile(file)).called(1); - verifyNoMoreInteractions(api.fileUploader); - }); + tester + ..verifyApi((api) => api.fileUploader.uploadFile(file)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); - test('`.removeImage`', () async { - const imageUrl = 'test-image-url'; + chatClientTest( + '`.removeImage`', + body: (tester) async { + const imageUrl = 'test-image-url'; - when(() => api.fileUploader.removeImage(imageUrl)).thenAnswer((_) async => EmptyResponse()); + tester.mockApi( + (api) => api.fileUploader.removeImage(imageUrl), + result: createDefaultEmptyResponse(), + ); - final res = await client.removeImage(imageUrl); - expect(res, isNotNull); + final res = await tester.client.removeImage(imageUrl); + expect(res, isNotNull); - verify(() => api.fileUploader.removeImage(imageUrl)).called(1); - verifyNoMoreInteractions(api.fileUploader); - }); + tester + ..verifyApi((api) => api.fileUploader.removeImage(imageUrl)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); - test('`.removeFile`', () async { - const fileUrl = 'test-file-url'; + chatClientTest( + '`.removeFile`', + body: (tester) async { + const fileUrl = 'test-file-url'; - when(() => api.fileUploader.removeFile(fileUrl)).thenAnswer((_) async => EmptyResponse()); + tester.mockApi( + (api) => api.fileUploader.removeFile(fileUrl), + result: createDefaultEmptyResponse(), + ); - final res = await client.removeFile(fileUrl); - expect(res, isNotNull); + final res = await tester.client.removeFile(fileUrl); + expect(res, isNotNull); - verify(() => api.fileUploader.removeFile(fileUrl)).called(1); - verifyNoMoreInteractions(api.fileUploader); - }); + tester + ..verifyApi((api) => api.fileUploader.removeFile(fileUrl)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); - test('`.updateChannel`', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - const data = {'name': 'test-channel'}; - - when(() => api.channel.updateChannel(channelId, channelType, data)).thenAnswer( - (invocation) async => UpdateChannelResponse() - ..channel = ChannelModel( - id: channelId, - type: channelType, - extraData: {...data}, - ), - ); + chatClientTest( + '`.updateChannel`', + body: (tester) async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const data = {'name': 'test-channel'}; + + tester.mockApi( + (api) => api.channel.updateChannel(channelId, channelType, data), + result: UpdateChannelResponse() + ..channel = ChannelModel( + id: channelId, + type: channelType, + extraData: {...data}, + ), + ); - final res = await client.updateChannel(channelId, channelType, data); - expect(res, isNotNull); - expect(res.channel.cid, '$channelType:$channelId'); - expect(res.channel.extraData['name'], 'test-channel'); + final res = await tester.client.updateChannel(channelId, channelType, data); + expect(res, isNotNull); + expect(res.channel.cid, '$channelType:$channelId'); + expect(res.channel.extraData['name'], 'test-channel'); - verify(() => api.channel.updateChannel(channelId, channelType, data)).called(1); - verifyNoMoreInteractions(api.channel); - }); + tester + ..verifyApi((api) => api.channel.updateChannel(channelId, channelType, data)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - test('`.updateChannelPartial`', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - const set = { - 'name': 'Stream Team', - 'profile_image': 'test-profile-image', - }; - const unset = ['tag', 'last_name']; - - when(() => api.channel.updateChannelPartial(channelId, channelType, set: set, unset: unset)).thenAnswer( - (invocation) async => PartialUpdateChannelResponse() - ..channel = ChannelModel( - id: channelId, - type: channelType, - extraData: {...set}, + chatClientTest( + '`.updateChannelPartial`', + body: (tester) async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const set = { + 'name': 'Stream Team', + 'profile_image': 'test-profile-image', + }; + const unset = ['tag', 'last_name']; + + tester.mockApi( + (api) => api.channel.updateChannelPartial(channelId, channelType, set: set, unset: unset), + result: createDefaultPartialUpdateChannelResponse( + channel: ChannelModel( + id: channelId, + type: channelType, + extraData: {...set}, + ), ), - ); + ); - final res = await client.updateChannelPartial( - channelId, - channelType, - set: set, - unset: unset, - ); - expect(res, isNotNull); - expect(res.channel.cid, '$channelType:$channelId'); - expect(res.channel.extraData, set); + final res = await tester.client.updateChannelPartial( + channelId, + channelType, + set: set, + unset: unset, + ); + expect(res, isNotNull); + expect(res.channel.cid, '$channelType:$channelId'); + expect(res.channel.extraData, set); - verify(() => api.channel.updateChannelPartial(channelId, channelType, set: set, unset: unset)).called(1); - verifyNoMoreInteractions(api.channel); - }); + tester + ..verifyApi((api) => api.channel.updateChannelPartial(channelId, channelType, set: set, unset: unset)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - test('`.addDevice should work`', () async { - const id = 'test-device-id'; - const provider = PushProvider.firebase; + chatClientTest( + '`.addDevice should work`', + body: (tester) async { + const id = 'test-device-id'; + const provider = PushProvider.firebase; - when(() => api.device.addDevice(id, provider)).thenAnswer((_) async => EmptyResponse()); + tester.mockApi( + (api) => api.device.addDevice(id, provider), + result: createDefaultEmptyResponse(), + ); - final res = await client.addDevice(id, provider); - expect(res, isNotNull); + final res = await tester.client.addDevice(id, provider); + expect(res, isNotNull); - verify(() => api.device.addDevice(id, provider)).called(1); - verifyNoMoreInteractions(api.device); - }); + tester + ..verifyApi((api) => api.device.addDevice(id, provider)) + ..verifyNoMoreApiInteractions((api) => api.device); + }, + ); - test('`.addDevice should work with pushProviderName`', () async { - const id = 'test-device-id'; - const provider = PushProvider.firebase; - const pushProviderName = 'my-custom-config'; + chatClientTest( + '`.addDevice should work with pushProviderName`', + body: (tester) async { + const id = 'test-device-id'; + const provider = PushProvider.firebase; + const pushProviderName = 'my-custom-config'; + + tester.mockApi( + (api) => api.device.addDevice( + id, + provider, + pushProviderName: pushProviderName, + ), + result: createDefaultEmptyResponse(), + ); - when( - () => api.device.addDevice( + final res = await tester.client.addDevice( id, provider, pushProviderName: pushProviderName, - ), - ).thenAnswer((_) async => EmptyResponse()); - - final res = await client.addDevice( - id, - provider, - pushProviderName: pushProviderName, - ); - expect(res, isNotNull); + ); + expect(res, isNotNull); - verify( - () => api.device.addDevice( - id, - provider, - pushProviderName: pushProviderName, - ), - ).called(1); - verifyNoMoreInteractions(api.device); - }); + tester + ..verifyApi( + (api) => api.device.addDevice( + id, + provider, + pushProviderName: pushProviderName, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.device); + }, + ); - test('`.getDevices`', () async { - final devices = List.generate( - 3, - (index) => Device( - id: 'test-device-id-$index', - pushProvider: PushProvider.firebase.name, - ), - ); + chatClientTest( + '`.getDevices`', + body: (tester) async { + final devices = List.generate( + 3, + (index) => Device( + id: 'test-device-id-$index', + pushProvider: PushProvider.firebase.name, + ), + ); - when(() => api.device.getDevices()).thenAnswer((_) async => ListDevicesResponse()..devices = devices); + tester.mockApi( + (api) => api.device.getDevices(), + result: ListDevicesResponse()..devices = devices, + ); - final res = await client.getDevices(); - expect(res, isNotNull); - expect(res.devices.length, devices.length); + final res = await tester.client.getDevices(); + expect(res, isNotNull); + expect(res.devices.length, devices.length); - verify(() => api.device.getDevices()).called(1); - verifyNoMoreInteractions(api.device); - }); + tester + ..verifyApi((api) => api.device.getDevices()) + ..verifyNoMoreApiInteractions((api) => api.device); + }, + ); - test('`.removeDevice`', () async { - const deviceId = 'test-device-id'; + chatClientTest( + '`.removeDevice`', + body: (tester) async { + const deviceId = 'test-device-id'; - when(() => api.device.removeDevice(deviceId)).thenAnswer((_) async => EmptyResponse()); + tester.mockApi( + (api) => api.device.removeDevice(deviceId), + result: createDefaultEmptyResponse(), + ); - final res = await client.removeDevice(deviceId); - expect(res, isNotNull); + final res = await tester.client.removeDevice(deviceId); + expect(res, isNotNull); - verify(() => api.device.removeDevice(deviceId)).called(1); - verifyNoMoreInteractions(api.device); - }); + tester + ..verifyApi((api) => api.device.removeDevice(deviceId)) + ..verifyNoMoreApiInteractions((api) => api.device); + }, + ); - test('`.setPushPreferences`', () async { - const pushPreferenceInput = PushPreferenceInput( - chatLevel: ChatLevel.mentions, - ); + chatClientTest( + '`.setPushPreferences`', + body: (tester) async { + const pushPreferenceInput = PushPreferenceInput( + chatLevel: ChatLevel.mentions, + ); - const channelCid = 'messaging:123'; - const channelPreferenceInput = PushPreferenceInput.channel( - channelCid: channelCid, - chatLevel: ChatLevel.mentions, - ); + const channelCid = 'messaging:123'; + const channelPreferenceInput = PushPreferenceInput.channel( + channelCid: channelCid, + chatLevel: ChatLevel.mentions, + ); - const preferences = [pushPreferenceInput, channelPreferenceInput]; + const preferences = [pushPreferenceInput, channelPreferenceInput]; - final currentUser = client.state.currentUser; - when(() => api.device.setPushPreferences(preferences)).thenAnswer( - (_) async => UpsertPushPreferencesResponse() - ..userPreferences = { - '${currentUser?.id}': PushPreference( - chatLevel: pushPreferenceInput.chatLevel, - ), - } - ..userChannelPreferences = { - '${currentUser?.id}': { - channelCid: ChannelPushPreference( - chatLevel: channelPreferenceInput.chatLevel, + final currentUser = tester.currentUser; + tester.mockApi( + (api) => api.device.setPushPreferences(preferences), + result: UpsertPushPreferencesResponse() + ..userPreferences = { + '${currentUser?.id}': PushPreference( + chatLevel: pushPreferenceInput.chatLevel, ), + } + ..userChannelPreferences = { + '${currentUser?.id}': { + channelCid: ChannelPushPreference( + chatLevel: channelPreferenceInput.chatLevel, + ), + }, }, - }, - ); - - expect( - client.eventStream, - emitsInOrder([ - isA().having( - (e) => e.type, - 'push_preference.updated event', - EventType.pushPreferenceUpdated, - ), - isA().having( - (e) => e.type, - 'channel.push_preference.updated event', - EventType.channelPushPreferenceUpdated, - ), - ]), - ); - - final res = await client.setPushPreferences(preferences); - expect(res, isNotNull); - - verify(() => api.device.setPushPreferences(preferences)).called(1); - verifyNoMoreInteractions(api.device); - }); - - test('should handle push_preference.updated event', () async { - final pushPreference = PushPreference( - chatLevel: ChatLevel.mentions, - callLevel: CallLevel.all, - disabledUntil: DateTime.now().add(const Duration(hours: 1)), - ); + ); - final event = Event( - type: EventType.pushPreferenceUpdated, - pushPreference: pushPreference, - ); + expect( + tester.events, + emitsInOrder([ + isA().having( + (e) => e.type, + 'push_preference.updated event', + EventType.pushPreferenceUpdated, + ), + isA().having( + (e) => e.type, + 'channel.push_preference.updated event', + EventType.channelPushPreferenceUpdated, + ), + ]), + ); - // Initially null - expect(client.state.currentUser?.pushPreferences, isNull); + final res = await tester.client.setPushPreferences(preferences); + expect(res, isNotNull); - // Trigger the event - client.handleEvent(event); + tester + ..verifyApi((api) => api.device.setPushPreferences(preferences)) + ..verifyNoMoreApiInteractions((api) => api.device); + }, + ); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + chatClientTest( + 'should handle push_preference.updated event', + body: (tester) async { + final pushPreference = PushPreference( + chatLevel: ChatLevel.mentions, + callLevel: CallLevel.all, + disabledUntil: DateTime.utc(2021, 3), + ); - // Should update currentUser.pushPreferences - final pushPreferences = client.state.currentUser?.pushPreferences; - expect(pushPreferences, isNotNull); - expect(pushPreferences?.chatLevel, ChatLevel.mentions); - expect(pushPreferences?.callLevel, CallLevel.all); - expect(pushPreferences?.disabledUntil, pushPreference.disabledUntil); - }); + final event = createDefaultEvent( + type: EventType.pushPreferenceUpdated, + pushPreference: pushPreference, + ); - test('`.listUserGroups`', () async { - const limit = 10; - const idGt = 'cursor-group-id'; - final createdAtGt = DateTime.utc(2024, 6, 15, 12); - const teamId = 'test-team-id'; + // Initially null + expect(tester.currentUser?.pushPreferences, isNull); - when( - () => api.userGroups.listUserGroups( - limit: limit, - idGt: idGt, - createdAtGt: createdAtGt, - teamId: teamId, - ), - ).thenAnswer((_) async => ListUserGroupsResponse()..userGroups = const []); + // Trigger the event + await tester.emitEvent(event); + + // Should update currentUser.pushPreferences + final pushPreferences = tester.currentUser?.pushPreferences; + expect(pushPreferences, isNotNull); + expect(pushPreferences?.chatLevel, ChatLevel.mentions); + expect(pushPreferences?.callLevel, CallLevel.all); + expect(pushPreferences?.disabledUntil, pushPreference.disabledUntil); + }, + ); - final res = await client.listUserGroups( - limit: limit, - idGt: idGt, - createdAtGt: createdAtGt, - teamId: teamId, - ); - expect(res, isNotNull); + chatClientTest( + '`.listUserGroups`', + body: (tester) async { + const limit = 10; + const idGt = 'cursor-group-id'; + final createdAtGt = DateTime.utc(2024, 6, 15, 12); + const teamId = 'test-team-id'; + + tester.mockApi( + (api) => api.userGroups.listUserGroups( + limit: limit, + idGt: idGt, + createdAtGt: createdAtGt, + teamId: teamId, + ), + result: ListUserGroupsResponse()..userGroups = const [], + ); - verify( - () => api.userGroups.listUserGroups( + final res = await tester.client.listUserGroups( limit: limit, idGt: idGt, createdAtGt: createdAtGt, teamId: teamId, - ), - ).called(1); - verifyNoMoreInteractions(api.userGroups); - }); + ); + expect(res, isNotNull); - test('`.searchUserGroups`', () async { - const query = 'eng'; - const limit = 10; - const nameGt = 'engineering'; - const idGt = 'cursor-group-id'; - const teamId = 'test-team-id'; + tester + ..verifyApi( + (api) => api.userGroups.listUserGroups( + limit: limit, + idGt: idGt, + createdAtGt: createdAtGt, + teamId: teamId, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.userGroups); + }, + ); - when( - () => api.userGroups.searchUserGroups( - query, - limit: limit, - nameGt: nameGt, - idGt: idGt, - teamId: teamId, - ), - ).thenAnswer((_) async => SearchUserGroupsResponse()..userGroups = const []); - - final res = await client.searchUserGroups( - query, - limit: limit, - nameGt: nameGt, - idGt: idGt, - teamId: teamId, - ); - expect(res, isNotNull); + chatClientTest( + '`.searchUserGroups`', + body: (tester) async { + const query = 'eng'; + const limit = 10; + const nameGt = 'engineering'; + const idGt = 'cursor-group-id'; + const teamId = 'test-team-id'; + + tester.mockApi( + (api) => api.userGroups.searchUserGroups( + query, + limit: limit, + nameGt: nameGt, + idGt: idGt, + teamId: teamId, + ), + result: SearchUserGroupsResponse()..userGroups = const [], + ); - verify( - () => api.userGroups.searchUserGroups( + final res = await tester.client.searchUserGroups( query, limit: limit, nameGt: nameGt, idGt: idGt, teamId: teamId, - ), - ).called(1); - verifyNoMoreInteractions(api.userGroups); - }); - - test('`.getUserGroup`', () async { - const id = 'test-group-id'; - const teamId = 'test-team-id'; + ); + expect(res, isNotNull); - when(() => api.userGroups.getUserGroup(id, teamId: teamId)).thenAnswer( - (_) async => GetUserGroupResponse() - ..userGroup = UserGroup( - id: id, - name: 'test-group-name', - createdAt: DateTime.utc(2024, 1, 1), - updatedAt: DateTime.utc(2024, 1, 2), - ), - ); + tester + ..verifyApi( + (api) => api.userGroups.searchUserGroups( + query, + limit: limit, + nameGt: nameGt, + idGt: idGt, + teamId: teamId, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.userGroups); + }, + ); - final res = await client.getUserGroup(id, teamId: teamId); - expect(res, isNotNull); - expect(res.userGroup.id, id); + chatClientTest( + '`.getUserGroup`', + body: (tester) async { + const id = 'test-group-id'; + const teamId = 'test-team-id'; + + tester.mockApi( + (api) => api.userGroups.getUserGroup(id, teamId: teamId), + result: GetUserGroupResponse() + ..userGroup = UserGroup( + id: id, + name: 'test-group-name', + createdAt: DateTime.utc(2024, 1, 1), + updatedAt: DateTime.utc(2024, 1, 2), + ), + ); - verify(() => api.userGroups.getUserGroup(id, teamId: teamId)).called(1); - verifyNoMoreInteractions(api.userGroups); - }); + final res = await tester.client.getUserGroup(id, teamId: teamId); + expect(res, isNotNull); + expect(res.userGroup.id, id); - test('`.createUserGroup`', () async { - const name = 'Engineering'; - const id = 'eng'; - const description = 'Engineering team'; - const teamId = 'test-team-id'; - const memberIds = ['user-1', 'user-2']; + tester + ..verifyApi((api) => api.userGroups.getUserGroup(id, teamId: teamId)) + ..verifyNoMoreApiInteractions((api) => api.userGroups); + }, + ); - when( - () => api.userGroups.createUserGroup( - name, - id: id, - description: description, - teamId: teamId, - memberIds: memberIds, - ), - ).thenAnswer( - (_) async => CreateUserGroupResponse() - ..userGroup = UserGroup( + chatClientTest( + '`.createUserGroup`', + body: (tester) async { + const name = 'Engineering'; + const id = 'eng'; + const description = 'Engineering team'; + const teamId = 'test-team-id'; + const memberIds = ['user-1', 'user-2']; + + tester.mockApi( + (api) => api.userGroups.createUserGroup( + name, id: id, - name: name, description: description, teamId: teamId, - createdAt: DateTime.utc(2024, 1, 1), - updatedAt: DateTime.utc(2024, 1, 2), + memberIds: memberIds, ), - ); - - final res = await client.createUserGroup( - name, - id: id, - description: description, - teamId: teamId, - memberIds: memberIds, - ); - expect(res, isNotNull); - expect(res.userGroup.id, id); + result: CreateUserGroupResponse() + ..userGroup = UserGroup( + id: id, + name: name, + description: description, + teamId: teamId, + createdAt: DateTime.utc(2024, 1, 1), + updatedAt: DateTime.utc(2024, 1, 2), + ), + ); - verify( - () => api.userGroups.createUserGroup( + final res = await tester.client.createUserGroup( name, id: id, description: description, teamId: teamId, memberIds: memberIds, - ), - ).called(1); - verifyNoMoreInteractions(api.userGroups); - }); - - test('`.updateUserGroup`', () async { - const id = 'test-group-id'; - const name = 'New Name'; - const description = 'New description'; - const teamId = 'test-team-id'; + ); + expect(res, isNotNull); + expect(res.userGroup.id, id); + + tester + ..verifyApi( + (api) => api.userGroups.createUserGroup( + name, + id: id, + description: description, + teamId: teamId, + memberIds: memberIds, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.userGroups); + }, + ); - when( - () => api.userGroups.updateUserGroup( - id, - name: name, - description: description, - teamId: teamId, - ), - ).thenAnswer( - (_) async => UpdateUserGroupResponse() - ..userGroup = UserGroup( - id: id, + chatClientTest( + '`.updateUserGroup`', + body: (tester) async { + const id = 'test-group-id'; + const name = 'New Name'; + const description = 'New description'; + const teamId = 'test-team-id'; + + tester.mockApi( + (api) => api.userGroups.updateUserGroup( + id, name: name, description: description, teamId: teamId, - createdAt: DateTime.utc(2024, 1, 1), - updatedAt: DateTime.utc(2024, 1, 2), ), - ); - - final res = await client.updateUserGroup( - id, - name: name, - description: description, - teamId: teamId, - ); - expect(res, isNotNull); - expect(res.userGroup.name, name); + result: UpdateUserGroupResponse() + ..userGroup = UserGroup( + id: id, + name: name, + description: description, + teamId: teamId, + createdAt: DateTime.utc(2024, 1, 1), + updatedAt: DateTime.utc(2024, 1, 2), + ), + ); - verify( - () => api.userGroups.updateUserGroup( + final res = await tester.client.updateUserGroup( id, name: name, description: description, teamId: teamId, - ), - ).called(1); - verifyNoMoreInteractions(api.userGroups); - }); - - test('`.deleteUserGroup`', () async { - const id = 'test-group-id'; - const teamId = 'test-team-id'; + ); + expect(res, isNotNull); + expect(res.userGroup.name, name); + + tester + ..verifyApi( + (api) => api.userGroups.updateUserGroup( + id, + name: name, + description: description, + teamId: teamId, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.userGroups); + }, + ); - when(() => api.userGroups.deleteUserGroup(id, teamId: teamId)).thenAnswer( - (_) async => EmptyResponse(), - ); + chatClientTest( + '`.deleteUserGroup`', + body: (tester) async { + const id = 'test-group-id'; + const teamId = 'test-team-id'; - final res = await client.deleteUserGroup(id, teamId: teamId); - expect(res, isNotNull); + tester.mockApi( + (api) => api.userGroups.deleteUserGroup(id, teamId: teamId), + result: createDefaultEmptyResponse(), + ); - verify(() => api.userGroups.deleteUserGroup(id, teamId: teamId)).called(1); - verifyNoMoreInteractions(api.userGroups); - }); + final res = await tester.client.deleteUserGroup(id, teamId: teamId); + expect(res, isNotNull); - test('`.addUserGroupMembers`', () async { - const id = 'test-group-id'; - const memberIds = ['user-1', 'user-2']; - const asAdmin = true; - const teamId = 'test-team-id'; + tester + ..verifyApi((api) => api.userGroups.deleteUserGroup(id, teamId: teamId)) + ..verifyNoMoreApiInteractions((api) => api.userGroups); + }, + ); - when( - () => api.userGroups.addUserGroupMembers( - id, - memberIds, - asAdmin: asAdmin, - teamId: teamId, - ), - ).thenAnswer( - (_) async => AddUserGroupMembersResponse() - ..userGroup = UserGroup( - id: id, - name: 'test-group-name', - createdAt: DateTime.utc(2024, 1, 1), - updatedAt: DateTime.utc(2024, 1, 2), + chatClientTest( + '`.addUserGroupMembers`', + body: (tester) async { + const id = 'test-group-id'; + const memberIds = ['user-1', 'user-2']; + const asAdmin = true; + const teamId = 'test-team-id'; + + tester.mockApi( + (api) => api.userGroups.addUserGroupMembers( + id, + memberIds, + asAdmin: asAdmin, + teamId: teamId, ), - ); - - final res = await client.addUserGroupMembers( - id, - memberIds, - asAdmin: asAdmin, - teamId: teamId, - ); - expect(res, isNotNull); + result: AddUserGroupMembersResponse() + ..userGroup = UserGroup( + id: id, + name: 'test-group-name', + createdAt: DateTime.utc(2024, 1, 1), + updatedAt: DateTime.utc(2024, 1, 2), + ), + ); - verify( - () => api.userGroups.addUserGroupMembers( + final res = await tester.client.addUserGroupMembers( id, memberIds, asAdmin: asAdmin, teamId: teamId, - ), - ).called(1); - verifyNoMoreInteractions(api.userGroups); - }); + ); + expect(res, isNotNull); - test('`.removeUserGroupMembers`', () async { - const id = 'test-group-id'; - const memberIds = ['user-1', 'user-2']; - const teamId = 'test-team-id'; + tester + ..verifyApi( + (api) => api.userGroups.addUserGroupMembers( + id, + memberIds, + asAdmin: asAdmin, + teamId: teamId, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.userGroups); + }, + ); - when( - () => api.userGroups.removeUserGroupMembers( - id, - memberIds, - teamId: teamId, - ), - ).thenAnswer( - (_) async => RemoveUserGroupMembersResponse() - ..userGroup = UserGroup( - id: id, - name: 'test-group-name', - createdAt: DateTime.utc(2024, 1, 1), - updatedAt: DateTime.utc(2024, 1, 2), + chatClientTest( + '`.removeUserGroupMembers`', + body: (tester) async { + const id = 'test-group-id'; + const memberIds = ['user-1', 'user-2']; + const teamId = 'test-team-id'; + + tester.mockApi( + (api) => api.userGroups.removeUserGroupMembers( + id, + memberIds, + teamId: teamId, ), - ); - - final res = await client.removeUserGroupMembers( - id, - memberIds, - teamId: teamId, - ); - expect(res, isNotNull); + result: RemoveUserGroupMembersResponse() + ..userGroup = UserGroup( + id: id, + name: 'test-group-name', + createdAt: DateTime.utc(2024, 1, 1), + updatedAt: DateTime.utc(2024, 1, 2), + ), + ); - verify( - () => api.userGroups.removeUserGroupMembers( + final res = await tester.client.removeUserGroupMembers( id, memberIds, teamId: teamId, - ), - ).called(1); - verifyNoMoreInteractions(api.userGroups); - }); + ); + expect(res, isNotNull); - test('`.searchRoles`', () async { - const query = 'adm'; - const limit = 10; - const nameGt = 'admin'; - const roleType = RoleType.user; - const includeGlobalRoles = true; + tester + ..verifyApi( + (api) => api.userGroups.removeUserGroupMembers( + id, + memberIds, + teamId: teamId, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.userGroups); + }, + ); - when( - () => api.roles.searchRoles( - query, - limit: limit, - nameGt: nameGt, - roleType: roleType, - includeGlobalRoles: includeGlobalRoles, - ), - ).thenAnswer((_) async => SearchRolesResponse()..roles = const []); - - final res = await client.searchRoles( - query, - limit: limit, - nameGt: nameGt, - roleType: roleType, - includeGlobalRoles: includeGlobalRoles, - ); - expect(res, isNotNull); + chatClientTest( + '`.searchRoles`', + body: (tester) async { + const query = 'adm'; + const limit = 10; + const nameGt = 'admin'; + const roleType = RoleType.user; + const includeGlobalRoles = true; + + tester.mockApi( + (api) => api.roles.searchRoles( + query, + limit: limit, + nameGt: nameGt, + roleType: roleType, + includeGlobalRoles: includeGlobalRoles, + ), + result: SearchRolesResponse()..roles = const [], + ); - verify( - () => api.roles.searchRoles( + final res = await tester.client.searchRoles( query, limit: limit, nameGt: nameGt, roleType: roleType, includeGlobalRoles: includeGlobalRoles, - ), - ).called(1); - verifyNoMoreInteractions(api.roles); - }); - - group('`.channel`', () { - test('should return back a new channel instance', () { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const channelData = {'name': 'test-channel-name'}; - - final channel = client.channel( - channelType, - id: channelId, - extraData: channelData, - ); - - expect(channel, isNotNull); - expect(channel.type, channelType); - expect(channel.id, channelId); - expect(channel.cid, '$channelType:$channelId'); - expect(channel.extraData, channelData); - }); - - test('should return back in memory channel instance if available', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const channelData = {'name': 'test-channel-name'}; - const channelCid = '$channelType:$channelId'; - - final channel = client.channel( - channelType, - id: channelId, - extraData: channelData, - ); - - final channelState = ChannelState( - channel: ChannelModel(cid: channelCid), ); + expect(res, isNotNull); - when( - () => api.channel.queryChannel( - channelType, - channelId: channelId, - channelData: channelData, - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).thenAnswer((_) async => channelState); - - expectLater( - client.state.channelsStream.skip(1), - emitsInOrder([ - {channelCid: isCorrectChannelFor(channelState)}, - ]), - ); - - await channel.watch(); - - final newChannel = client.channel(channelType, id: channelId); - expect(newChannel, channel); - - verify( - () => api.channel.queryChannel( - channelType, - channelId: channelId, - channelData: channelData, - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).called(1); - }); - }); - - test('`.createChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const channelData = {'name': 'test-channel-name'}; - const channelCid = '$channelType:$channelId'; - - final channelState = ChannelState( - channel: ChannelModel(cid: channelCid, extraData: channelData), - ); - - when( - () => api.channel.queryChannel( - channelType, - channelId: channelId, - channelData: channelData, - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).thenAnswer((_) async => channelState); - - final res = await client.createChannel( - channelType, - channelId: channelId, - channelData: channelData, - ); - - expect(res, isNotNull); - expect(res.channel, isNotNull); - final channel = res.channel!; - expect(channel.type, channelType); - expect(channel.id, channelId); - expect(channel.cid, '$channelType:$channelId'); - expect(channel.extraData, channelData); - - verify( - () => api.channel.queryChannel( - channelType, - channelId: channelId, - channelData: channelData, - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); - - test('`.watchChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const channelData = {'name': 'test-channel-name'}; - const channelCid = '$channelType:$channelId'; - - final channelState = ChannelState( - channel: ChannelModel(cid: channelCid, extraData: channelData), - ); - - when( - () => api.channel.queryChannel( - channelType, - channelId: channelId, - channelData: channelData, - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).thenAnswer((_) async => channelState); - - final res = await client.watchChannel( - channelType, - channelId: channelId, - channelData: channelData, - ); - - expect(res, isNotNull); - expect(res.channel, isNotNull); - final channel = res.channel!; - expect(channel.type, channelType); - expect(channel.id, channelId); - expect(channel.cid, '$channelType:$channelId'); - expect(channel.extraData, channelData); - - verify( - () => api.channel.queryChannel( - channelType, - channelId: channelId, - channelData: channelData, - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); + tester + ..verifyApi( + (api) => api.roles.searchRoles( + query, + limit: limit, + nameGt: nameGt, + roleType: roleType, + includeGlobalRoles: includeGlobalRoles, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.roles); + }, + ); - test('`.queryChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const channelData = {'name': 'test-channel-name'}; - const channelCid = '$channelType:$channelId'; + group('`.channel`', () { + chatClientTest( + 'should return back a new channel instance', + body: (tester) { + final channel = tester.client.channel( + _channelType, + id: _channelId, + extraData: _channelData, + ); - final channelState = ChannelState( - channel: ChannelModel(cid: channelCid, extraData: channelData), + expect(channel, isNotNull); + expect(channel.type, _channelType); + expect(channel.id, _channelId); + expect(channel.cid, _channelCid); + expect(channel.extraData, _channelData); + }, ); - when( - () => api.channel.queryChannel( - channelType, - channelId: channelId, - channelData: channelData, - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).thenAnswer((_) async => channelState); + chatClientTest( + 'should return back in memory channel instance if available', + body: (tester) async { + final channel = tester.client.channel( + _channelType, + id: _channelId, + extraData: _channelData, + ); - final res = await client.queryChannel( - channelType, - channelId: channelId, - channelData: channelData, - ); + final channelState = createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); - expect(res, isNotNull); - expect(res.channel, isNotNull); - final channel = res.channel!; - expect(channel.type, channelType); - expect(channel.id, channelId); - expect(channel.cid, '$channelType:$channelId'); - expect(channel.extraData, channelData); + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + state: true, + watch: true, + presence: false, + ), + result: channelState, + ); - verify( - () => api.channel.queryChannel( - channelType, - channelId: channelId, - channelData: channelData, - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); + final channelsEmission = expectLater( + tester.clientState.channelsStream.skip(1), + emitsInOrder([ + {_channelCid: isCorrectChannelFor(channelState)}, + ]), + ); - test('`.queryMembers`', () async { - const channelType = 'test-channel-type'; + await channel.watch(); + await channelsEmission; - final members = List.generate( - 3, - (index) => Member(userId: 'test-user-id-$index'), - ); + final newChannel = tester.client.channel(_channelType, id: _channelId); + expect(newChannel, channel); - when(() => api.general.queryMembers(channelType)).thenAnswer( - (_) async => QueryMembersResponse()..members = members, + tester.verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + state: true, + watch: true, + presence: false, + ), + ); + }, ); - - final res = await client.queryMembers(channelType); - expect(res, isNotNull); - expect(res.members.length, members.length); - - verify(() => api.general.queryMembers(channelType)).called(1); - verify(() => api.general.getAppSettings()).called(1); - verifyNoMoreInteractions(api.general); }); - test('`.hideChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - - when(() => api.channel.hideChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.hideChannel(channelId, channelType); - - expect(res, isNotNull); - - verify(() => api.channel.hideChannel(channelId, channelType)).called(1); - verifyNoMoreInteractions(api.channel); - }); - - test('`.showChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - - when(() => api.channel.showChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.showChannel(channelId, channelType); - - expect(res, isNotNull); - - verify(() => api.channel.showChannel(channelId, channelType)).called(1); - verifyNoMoreInteractions(api.channel); - }); + chatClientTest( + '`.createChannel`', + body: (tester) async { + final channelState = createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid, extraData: _channelData), + ); - test('`.deleteChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + state: false, + ), + result: channelState, + ); - when(() => api.channel.deleteChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); + final res = await tester.client.createChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + ); - final res = await client.deleteChannel(channelId, channelType); + expect(res, isNotNull); + expect(res.channel, isNotNull); + final channel = res.channel!; + expect(channel.type, _channelType); + expect(channel.id, _channelId); + expect(channel.cid, _channelCid); + expect(channel.extraData, _channelData); + + tester + ..verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + state: false, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - expect(res, isNotNull); + chatClientTest( + '`.watchChannel`', + body: (tester) async { + final channelState = createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid, extraData: _channelData), + ); - verify(() => api.channel.deleteChannel(channelId, channelType)).called(1); - verifyNoMoreInteractions(api.channel); - }); + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + watch: true, + ), + result: channelState, + ); - test('`.truncateChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; + final res = await tester.client.watchChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + ); - when(() => api.channel.truncateChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); + expect(res, isNotNull); + expect(res.channel, isNotNull); + final channel = res.channel!; + expect(channel.type, _channelType); + expect(channel.id, _channelId); + expect(channel.cid, _channelCid); + expect(channel.extraData, _channelData); + + tester + ..verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + watch: true, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - final res = await client.truncateChannel(channelId, channelType); + chatClientTest( + '`.queryChannel`', + body: (tester) async { + final channelState = createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid, extraData: _channelData), + ); - expect(res, isNotNull); + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + ), + result: channelState, + ); - verify( - () => api.channel.truncateChannel(channelId, channelType), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); + final res = await tester.client.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + ); - test('`.muteChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const channelCid = '$channelType:$channelId'; + expect(res, isNotNull); + expect(res.channel, isNotNull); + final channel = res.channel!; + expect(channel.type, _channelType); + expect(channel.id, _channelId); + expect(channel.cid, _channelCid); + expect(channel.extraData, _channelData); + + tester + ..verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - when(() => api.moderation.muteChannel(channelCid)).thenAnswer((_) async => EmptyResponse()); + chatClientTest( + '`.queryMembers`', + body: (tester) async { + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); - final res = await client.muteChannel(channelCid); + tester.mockApi( + (api) => api.general.queryMembers(_channelType), + result: QueryMembersResponse()..members = members, + ); - expect(res, isNotNull); + final res = await tester.client.queryMembers(_channelType); + expect(res, isNotNull); + expect(res.members.length, members.length); - verify(() => api.moderation.muteChannel(channelCid)).called(1); - verifyNoMoreInteractions(api.moderation); - }); + tester + ..verifyApi((api) => api.general.queryMembers(_channelType)) + ..verifyApi((api) => api.general.getAppSettings()) + ..verifyNoMoreApiInteractions((api) => api.general); + }, + ); - test('`.unmuteChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const channelCid = '$channelType:$channelId'; + chatClientTest( + '`.hideChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.hideChannel(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); - when(() => api.moderation.unmuteChannel(channelCid)).thenAnswer((_) async => EmptyResponse()); + final res = await tester.client.hideChannel(_channelId, _channelType); - final res = await client.unmuteChannel(channelCid); + expect(res, isNotNull); - expect(res, isNotNull); + tester + ..verifyApi((api) => api.channel.hideChannel(_channelId, _channelType)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - verify(() => api.moderation.unmuteChannel(channelCid)).called(1); - verifyNoMoreInteractions(api.moderation); - }); + chatClientTest( + '`.showChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.showChannel(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); - test('`.partialMemberUpdate with userId`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const otherUserId = 'test-other-user-id'; - const set = {'pinned': true}; - const unset = ['pinned']; + final res = await tester.client.showChannel(_channelId, _channelType); - when( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: set, - unset: unset, - ), - ).thenAnswer( - (_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: otherUserId), - ), - ); + expect(res, isNotNull); - final res = await client.partialMemberUpdate( - channelId: channelId, - channelType: channelType, - set: set, - unset: unset, - ); + tester + ..verifyApi((api) => api.channel.showChannel(_channelId, _channelType)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - expect(res, isNotNull); - expect(res.channelMember.userId, otherUserId); + chatClientTest( + '`.deleteChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.deleteChannel(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); - verify( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: set, - unset: unset, - ), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); + final res = await tester.client.deleteChannel(_channelId, _channelType); - test('`.partialMemberUpdate with current user`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const set = {'pinned': true}; - const unset = ['pinned']; + expect(res, isNotNull); - when( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: set, - unset: unset, - ), - ).thenAnswer( - (_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: userId), - ), - ); + tester + ..verifyApi((api) => api.channel.deleteChannel(_channelId, _channelType)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - final res = await client.partialMemberUpdate( - channelId: channelId, - channelType: channelType, - set: set, - unset: unset, - ); + chatClientTest( + '`.truncateChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.truncateChannel(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); - expect(res, isNotNull); - expect(res.channelMember.userId, userId); - verify( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: set, - unset: unset, - ), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); + final res = await tester.client.truncateChannel(_channelId, _channelType); - test('`.pinChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; + expect(res, isNotNull); - when( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: const MemberUpdatePayload(pinned: true).toJson(), - ), - ).thenAnswer( - (_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: userId, pinnedAt: DateTime.now()), - ), - ); + tester + ..verifyApi( + (api) => api.channel.truncateChannel(_channelId, _channelType), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - final res = await client.pinChannel( - channelId: channelId, - channelType: channelType, - ); + chatClientTest( + '`.muteChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.moderation.muteChannel(_channelCid), + result: createDefaultEmptyResponse(), + ); - expect(res, isNotNull); + final res = await tester.client.muteChannel(_channelCid); - verify( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: const MemberUpdatePayload(pinned: true).toJson(), - ), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); + expect(res, isNotNull); - test('`.unpinChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; + tester + ..verifyApi((api) => api.moderation.muteChannel(_channelCid)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); - when( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - unset: [MemberUpdateType.pinned.name], - ), - ).thenAnswer( - (_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: userId, pinnedAt: DateTime.now()), - ), - ); + chatClientTest( + '`.unmuteChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.moderation.unmuteChannel(_channelCid), + result: createDefaultEmptyResponse(), + ); - final res = await client.unpinChannel( - channelId: channelId, - channelType: channelType, - ); + final res = await tester.client.unmuteChannel(_channelCid); - expect(res, isNotNull); + expect(res, isNotNull); - verify( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - unset: [MemberUpdateType.pinned.name], - ), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); + tester + ..verifyApi((api) => api.moderation.unmuteChannel(_channelCid)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); - test('`.archiveChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; + chatClientTest( + '`.partialMemberUpdate with userId`', + body: (tester) async { + const otherUserId = 'test-other-user-id'; + const set = {'pinned': true}; + const unset = ['pinned']; + + tester.mockApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: set, + unset: unset, + ), + result: createDefaultPartialUpdateMemberResponse( + channelMember: Member(userId: otherUserId), + ), + ); - when( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: const MemberUpdatePayload(archived: true).toJson(), - ), - ).thenAnswer( - (_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: userId, archivedAt: DateTime.now()), - ), - ); + final res = await tester.client.partialMemberUpdate( + channelId: _channelId, + channelType: _channelType, + set: set, + unset: unset, + ); - final res = await client.archiveChannel( - channelId: channelId, - channelType: channelType, - ); + expect(res, isNotNull); + expect(res.channelMember.userId, otherUserId); + + tester + ..verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: set, + unset: unset, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - expect(res, isNotNull); + chatClientTest( + '`.partialMemberUpdate with current user`', + body: (tester) async { + const set = {'pinned': true}; + const unset = ['pinned']; + + tester.mockApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: set, + unset: unset, + ), + result: createDefaultPartialUpdateMemberResponse( + channelMember: Member(userId: tester.user.id), + ), + ); - verify( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: const MemberUpdatePayload(archived: true).toJson(), - ), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); + final res = await tester.client.partialMemberUpdate( + channelId: _channelId, + channelType: _channelType, + set: set, + unset: unset, + ); - test('`.unarchiveChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; + expect(res, isNotNull); + expect(res.channelMember.userId, tester.user.id); + tester + ..verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: set, + unset: unset, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - when( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - unset: [MemberUpdateType.archived.name], - ), - ).thenAnswer( - (_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: userId, pinnedAt: DateTime.now()), - ), - ); + chatClientTest( + '`.pinChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: const MemberUpdatePayload(pinned: true).toJson(), + ), + result: createDefaultPartialUpdateMemberResponse( + channelMember: Member(userId: tester.user.id, pinnedAt: DateTime.utc(2021, 3)), + ), + ); - final res = await client.unarchiveChannel( - channelId: channelId, - channelType: channelType, - ); + final res = await tester.client.pinChannel( + channelId: _channelId, + channelType: _channelType, + ); - expect(res, isNotNull); + expect(res, isNotNull); - verify( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - unset: [MemberUpdateType.archived.name], - ), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); + tester + ..verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: const MemberUpdatePayload(pinned: true).toJson(), + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - test('`.acceptChannelInvite`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const channelCid = '$channelType:$channelId'; + chatClientTest( + '`.unpinChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + unset: [MemberUpdateType.pinned.name], + ), + result: createDefaultPartialUpdateMemberResponse( + channelMember: Member(userId: tester.user.id, pinnedAt: DateTime.utc(2021, 3)), + ), + ); - when( - () => api.channel.acceptChannelInvite(channelId, channelType), - ).thenAnswer((_) async => AcceptInviteResponse()..channel = ChannelModel(cid: channelCid)); + final res = await tester.client.unpinChannel( + channelId: _channelId, + channelType: _channelType, + ); - final res = await client.acceptChannelInvite(channelId, channelType); - expect(res, isNotNull); - expect(res.channel.cid, channelCid); + expect(res, isNotNull); - verify(() => api.channel.acceptChannelInvite(channelId, channelType)).called(1); - verifyNoMoreInteractions(api.channel); - }); + tester + ..verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + unset: [MemberUpdateType.pinned.name], + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - test('`.rejectChannelInvite`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const channelCid = '$channelType:$channelId'; + chatClientTest( + '`.archiveChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: const MemberUpdatePayload(archived: true).toJson(), + ), + result: createDefaultPartialUpdateMemberResponse( + channelMember: Member(userId: tester.user.id, archivedAt: DateTime.utc(2021, 3)), + ), + ); - when( - () => api.channel.rejectChannelInvite(channelId, channelType), - ).thenAnswer((_) async => RejectInviteResponse()..channel = ChannelModel(cid: channelCid)); + final res = await tester.client.archiveChannel( + channelId: _channelId, + channelType: _channelType, + ); - final res = await client.rejectChannelInvite(channelId, channelType); - expect(res, isNotNull); - expect(res.channel.cid, channelCid); + expect(res, isNotNull); - verify(() => api.channel.rejectChannelInvite(channelId, channelType)).called(1); - verifyNoMoreInteractions(api.channel); - }); + tester + ..verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: const MemberUpdatePayload(archived: true).toJson(), + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - test('`.addChannelMembers`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const channelCid = '$channelType:$channelId'; + chatClientTest( + '`.unarchiveChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + unset: [MemberUpdateType.archived.name], + ), + result: createDefaultPartialUpdateMemberResponse( + channelMember: Member(userId: tester.user.id, pinnedAt: DateTime.utc(2021, 3)), + ), + ); - final members = List.generate( - 3, - (index) => Member(userId: 'test-user-id-$index'), - ); + final res = await tester.client.unarchiveChannel( + channelId: _channelId, + channelType: _channelType, + ); - final memberIds = members.map((e) => e.userId!).toList(growable: false); + expect(res, isNotNull); - when(() => api.channel.addMembers(channelId, channelType, memberIds)).thenAnswer( - (_) async => AddMembersResponse() - ..channel = ChannelModel(cid: channelCid) - ..members = members, - ); + tester + ..verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + unset: [MemberUpdateType.archived.name], + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - final res = await client.addChannelMembers( - channelId, - channelType, - memberIds, - ); + chatClientTest( + '`.acceptChannelInvite`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.acceptChannelInvite(_channelId, _channelType), + result: AcceptInviteResponse()..channel = createDefaultChannelModel(cid: _channelCid), + ); - expect(res, isNotNull); - expect(res.channel.cid, channelCid); - expect(res.members.length, memberIds.length); + final res = await tester.client.acceptChannelInvite(_channelId, _channelType); + expect(res, isNotNull); + expect(res.channel.cid, _channelCid); - verify( - () => api.channel.addMembers(channelId, channelType, memberIds), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); + tester + ..verifyApi((api) => api.channel.acceptChannelInvite(_channelId, _channelType)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - test('`.addChannelMembers` with hideHistoryBefore', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const channelCid = '$channelType:$channelId'; + chatClientTest( + '`.rejectChannelInvite`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.rejectChannelInvite(_channelId, _channelType), + result: RejectInviteResponse()..channel = createDefaultChannelModel(cid: _channelCid), + ); - final members = List.generate( - 3, - (index) => Member(userId: 'test-user-id-$index'), - ); + final res = await tester.client.rejectChannelInvite(_channelId, _channelType); + expect(res, isNotNull); + expect(res.channel.cid, _channelCid); - final memberIds = members.map((e) => e.userId!).toList(growable: false); - final hideHistoryBefore = DateTime.parse('2024-01-01T00:00:00Z'); + tester + ..verifyApi((api) => api.channel.rejectChannelInvite(_channelId, _channelType)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - when( - () => api.channel.addMembers( - channelId, - channelType, - memberIds, - hideHistoryBefore: hideHistoryBefore, - ), - ).thenAnswer( - (_) async => AddMembersResponse() - ..channel = ChannelModel(cid: channelCid) - ..members = members, - ); + chatClientTest( + '`.addChannelMembers`', + body: (tester) async { + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); - final res = await client.addChannelMembers( - channelId, - channelType, - memberIds, - hideHistoryBefore: hideHistoryBefore, - ); + final memberIds = members.map((e) => e.userId!).toList(growable: false); - expect(res, isNotNull); - expect(res.channel.cid, channelCid); - expect(res.members.length, memberIds.length); + tester.mockApi( + (api) => api.channel.addMembers(_channelId, _channelType, memberIds), + result: createDefaultAddMembersResponse( + channel: createDefaultChannelModel(cid: _channelCid), + members: members, + ), + ); - verify( - () => api.channel.addMembers( - channelId, - channelType, + final res = await tester.client.addChannelMembers( + _channelId, + _channelType, memberIds, - hideHistoryBefore: hideHistoryBefore, - ), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); + ); - test('`.removeChannelMembers`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const channelCid = '$channelType:$channelId'; + expect(res, isNotNull); + expect(res.channel.cid, _channelCid); + expect(res.members.length, memberIds.length); + + tester + ..verifyApi( + (api) => api.channel.addMembers(_channelId, _channelType, memberIds), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - final members = List.generate( - 3, - (index) => Member(userId: 'test-user-id-$index'), - ); + chatClientTest( + '`.addChannelMembers` with hideHistoryBefore', + body: (tester) async { + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); - final memberIds = members.map((e) => e.userId!).toList(growable: false); + final memberIds = members.map((e) => e.userId!).toList(growable: false); + final hideHistoryBefore = DateTime.parse('2024-01-01T00:00:00Z'); - when(() => api.channel.removeMembers(channelId, channelType, memberIds)).thenAnswer( - (_) async => RemoveMembersResponse() - ..channel = ChannelModel(cid: channelCid) - ..members = members, - ); + tester.mockApi( + (api) => api.channel.addMembers( + _channelId, + _channelType, + memberIds, + hideHistoryBefore: hideHistoryBefore, + ), + result: createDefaultAddMembersResponse( + channel: createDefaultChannelModel(cid: _channelCid), + members: members, + ), + ); - final res = await client.removeChannelMembers( - channelId, - channelType, - memberIds, - ); + final res = await tester.client.addChannelMembers( + _channelId, + _channelType, + memberIds, + hideHistoryBefore: hideHistoryBefore, + ); - expect(res, isNotNull); - expect(res.channel.cid, channelCid); - expect(res.members.length, memberIds.length); + expect(res, isNotNull); + expect(res.channel.cid, _channelCid); + expect(res.members.length, memberIds.length); + + tester + ..verifyApi( + (api) => api.channel.addMembers( + _channelId, + _channelType, + memberIds, + hideHistoryBefore: hideHistoryBefore, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - verify( - () => api.channel.removeMembers(channelId, channelType, memberIds), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); + chatClientTest( + '`.removeChannelMembers`', + body: (tester) async { + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); - test('`.inviteChannelMembers`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const channelCid = '$channelType:$channelId'; + final memberIds = members.map((e) => e.userId!).toList(growable: false); - final members = List.generate( - 3, - (index) => Member(userId: 'test-user-id-$index'), - ); + tester.mockApi( + (api) => api.channel.removeMembers(_channelId, _channelType, memberIds), + result: RemoveMembersResponse() + ..channel = createDefaultChannelModel(cid: _channelCid) + ..members = members, + ); - final memberIds = members.map((e) => e.userId!).toList(growable: false); + final res = await tester.client.removeChannelMembers( + _channelId, + _channelType, + memberIds, + ); - when(() => api.channel.inviteChannelMembers(channelId, channelType, memberIds)).thenAnswer( - (_) async => InviteMembersResponse() - ..channel = ChannelModel(cid: channelCid) - ..members = members, - ); + expect(res, isNotNull); + expect(res.channel.cid, _channelCid); + expect(res.members.length, memberIds.length); + + tester + ..verifyApi( + (api) => api.channel.removeMembers(_channelId, _channelType, memberIds), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - final res = await client.inviteChannelMembers( - channelId, - channelType, - memberIds, - ); + chatClientTest( + '`.inviteChannelMembers`', + body: (tester) async { + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); - expect(res, isNotNull); - expect(res.channel.cid, channelCid); - expect(res.members.length, memberIds.length); + final memberIds = members.map((e) => e.userId!).toList(growable: false); - verify(() => api.channel.inviteChannelMembers(channelId, channelType, memberIds)).called(1); - verifyNoMoreInteractions(api.channel); - }); + tester.mockApi( + (api) => api.channel.inviteChannelMembers(_channelId, _channelType, memberIds), + result: InviteMembersResponse() + ..channel = createDefaultChannelModel(cid: _channelCid) + ..members = members, + ); - test('`.stopChannelWatching`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; + final res = await tester.client.inviteChannelMembers( + _channelId, + _channelType, + memberIds, + ); - when(() => api.channel.stopWatching(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); + expect(res, isNotNull); + expect(res.channel.cid, _channelCid); + expect(res.members.length, memberIds.length); - final res = await client.stopChannelWatching(channelId, channelType); - expect(res, isNotNull); + tester + ..verifyApi((api) => api.channel.inviteChannelMembers(_channelId, _channelType, memberIds)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - verify(() => api.channel.stopWatching(channelId, channelType)).called(1); - verifyNoMoreInteractions(api.channel); - }); + chatClientTest( + '`.stopChannelWatching`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.stopWatching(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); - test('`.sendAction`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const messageId = 'test-message-id'; - const formData = {'key': 'value'}; + final res = await tester.client.stopChannelWatching(_channelId, _channelType); + expect(res, isNotNull); - when( - () => api.message.sendAction(channelId, channelType, messageId, formData), - ).thenAnswer((_) async => SendActionResponse()); - - final res = await client.sendAction( - channelId, - channelType, - messageId, - formData, - ); + tester + ..verifyApi((api) => api.channel.stopWatching(_channelId, _channelType)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - expect(res, isNotNull); + chatClientTest( + '`.sendAction`', + body: (tester) async { + const messageId = 'test-message-id'; + const formData = {'key': 'value'}; - verify(() => api.message.sendAction(channelId, channelType, messageId, formData)).called(1); - verifyNoMoreInteractions(api.message); - }); + tester.mockApi( + (api) => api.message.sendAction(_channelId, _channelType, messageId, formData), + result: createDefaultSendActionResponse(), + ); - test('`.markChannelRead`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; + final res = await tester.client.sendAction( + _channelId, + _channelType, + messageId, + formData, + ); - when(() => api.channel.markRead(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); + expect(res, isNotNull); - final res = await client.markChannelRead(channelId, channelType); + tester + ..verifyApi((api) => api.message.sendAction(_channelId, _channelType, messageId, formData)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); - expect(res, isNotNull); + chatClientTest( + '`.markChannelRead`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.markRead(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); - verify(() => api.channel.markRead(channelId, channelType)).called(1); - verifyNoMoreInteractions(api.channel); - }); + final res = await tester.client.markChannelRead(_channelId, _channelType); - test('`.markChannelUnread`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const messageId = 'test-message-id'; + expect(res, isNotNull); - when(() => api.channel.markUnread(channelId, channelType, messageId)).thenAnswer((_) async => EmptyResponse()); + tester + ..verifyApi((api) => api.channel.markRead(_channelId, _channelType)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - final res = await client.markChannelUnread( - channelId, - channelType, - messageId, - ); + chatClientTest( + '`.markChannelUnread`', + body: (tester) async { + const messageId = 'test-message-id'; - expect(res, isNotNull); + tester.mockApi( + (api) => api.channel.markUnread(_channelId, _channelType, messageId), + result: createDefaultEmptyResponse(), + ); - verify(() => api.channel.markUnread(channelId, channelType, messageId)).called(1); - verifyNoMoreInteractions(api.channel); - }); + final res = await tester.client.markChannelUnread( + _channelId, + _channelType, + messageId, + ); - test('`.markChannelUnreadByTimestamp`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); + expect(res, isNotNull); - when( - () => api.channel.markUnreadByTimestamp( - channelId, - channelType, - timestamp, - ), - ).thenAnswer((_) async => EmptyResponse()); + tester + ..verifyApi((api) => api.channel.markUnread(_channelId, _channelType, messageId)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - final res = await client.markChannelUnreadByTimestamp( - channelId, - channelType, - timestamp, - ); + chatClientTest( + '`.markChannelUnreadByTimestamp`', + body: (tester) async { + final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); - expect(res, isNotNull); + tester.mockApi( + (api) => api.channel.markUnreadByTimestamp( + _channelId, + _channelType, + timestamp, + ), + result: createDefaultEmptyResponse(), + ); - verify( - () => api.channel.markUnreadByTimestamp( - channelId, - channelType, + final res = await tester.client.markChannelUnreadByTimestamp( + _channelId, + _channelType, timestamp, - ), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); + ); - test('`.createPoll`', () async { - final poll = Poll( - name: 'What is your favorite color?', - options: const [ - PollOption(text: 'Red'), - PollOption(text: 'Blue'), - ], - ); + expect(res, isNotNull); - when(() => api.polls.createPoll(poll)).thenAnswer( - (_) async => CreatePollResponse()..poll = poll, - ); + tester + ..verifyApi( + (api) => api.channel.markUnreadByTimestamp( + _channelId, + _channelType, + timestamp, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - final res = await client.createPoll(poll); - expect(res, isNotNull); - expect(res.poll, poll); + chatClientTest( + '`.createPoll`', + body: (tester) async { + final poll = createDefaultPoll(); - verify(() => api.polls.createPoll(poll)).called(1); - verifyNoMoreInteractions(api.polls); - }); + tester.mockApi( + (api) => api.polls.createPoll(poll), + result: CreatePollResponse()..poll = poll, + ); - test('`.getPoll`', () async { - const pollId = 'test-poll-id'; - final poll = Poll( - id: pollId, - name: 'What is your favorite color?', - options: const [ - PollOption(text: 'Red'), - PollOption(text: 'Blue'), - ], - ); + final res = await tester.client.createPoll(poll); + expect(res, isNotNull); + expect(res.poll, poll); - when(() => api.polls.getPoll(pollId)).thenAnswer( - (_) async => GetPollResponse()..poll = poll, - ); + tester + ..verifyApi((api) => api.polls.createPoll(poll)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); - final res = await client.getPoll(pollId); - expect(res, isNotNull); - expect(res.poll, poll); + chatClientTest( + '`.getPoll`', + body: (tester) async { + const pollId = 'test-poll-id'; + final poll = createDefaultPoll(id: pollId); - verify(() => api.polls.getPoll(pollId)).called(1); - verifyNoMoreInteractions(api.polls); - }); + tester.mockApi( + (api) => api.polls.getPoll(pollId), + result: GetPollResponse()..poll = poll, + ); - test('`.updatePoll`', () async { - final poll = Poll( - id: 'test-poll-id', - name: 'What is your favorite color?', - options: const [ - PollOption(text: 'Red'), - PollOption(text: 'Blue'), - ], - ); + final res = await tester.client.getPoll(pollId); + expect(res, isNotNull); + expect(res.poll, poll); - when(() => api.polls.updatePoll(poll)).thenAnswer( - (_) async => UpdatePollResponse()..poll = poll, - ); + tester + ..verifyApi((api) => api.polls.getPoll(pollId)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); - final res = await client.updatePoll(poll); - expect(res, isNotNull); - expect(res.poll, poll); + chatClientTest( + '`.updatePoll`', + body: (tester) async { + final poll = createDefaultPoll(id: 'test-poll-id'); - verify(() => api.polls.updatePoll(poll)).called(1); - verifyNoMoreInteractions(api.polls); - }); + tester.mockApi( + (api) => api.polls.updatePoll(poll), + result: createDefaultUpdatePollResponse(poll: poll), + ); - test('`.partialUpdatePoll`', () async { - const pollId = 'test-poll-id'; - final set = {'name': 'What is your favorite color?'}; - final unset = []; - - final poll = Poll( - id: pollId, - name: set['name']!, - options: const [ - PollOption(text: 'Red'), - PollOption(text: 'Blue'), - ], - ); + final res = await tester.client.updatePoll(poll); + expect(res, isNotNull); + expect(res.poll, poll); - when( - () => api.polls.partialUpdatePoll(pollId, set: set, unset: unset), - ).thenAnswer((_) async => UpdatePollResponse()..poll = poll); + tester + ..verifyApi((api) => api.polls.updatePoll(poll)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); - final res = await client.partialUpdatePoll(pollId, set: set, unset: unset); - expect(res, isNotNull); - expect(res.poll.id, pollId); - expect(res.poll.name, set['name']); + chatClientTest( + '`.partialUpdatePoll`', + body: (tester) async { + const pollId = 'test-poll-id'; + final set = {'name': 'What is your favorite color?'}; + final unset = []; - verify(() => api.polls.partialUpdatePoll(pollId, set: set, unset: unset)).called(1); - verifyNoMoreInteractions(api.polls); - }); + final poll = createDefaultPoll(id: pollId, name: set['name']!); - test('`.deletePoll`', () async { - const pollId = 'test-poll-id'; + tester.mockApi( + (api) => api.polls.partialUpdatePoll(pollId, set: set, unset: unset), + result: createDefaultUpdatePollResponse(poll: poll), + ); - when(() => api.polls.deletePoll(pollId)).thenAnswer((_) async => EmptyResponse()); + final res = await tester.client.partialUpdatePoll(pollId, set: set, unset: unset); + expect(res, isNotNull); + expect(res.poll.id, pollId); + expect(res.poll.name, set['name']); - final res = await client.deletePoll(pollId); - expect(res, isNotNull); + tester + ..verifyApi((api) => api.polls.partialUpdatePoll(pollId, set: set, unset: unset)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); - verify(() => api.polls.deletePoll(pollId)).called(1); - verifyNoMoreInteractions(api.polls); - }); + chatClientTest( + '`.deletePoll`', + body: (tester) async { + const pollId = 'test-poll-id'; - test('`.closePoll`', () async { - const pollId = 'test-poll-id'; + tester.mockApi( + (api) => api.polls.deletePoll(pollId), + result: createDefaultEmptyResponse(), + ); - when( - () => api.polls.partialUpdatePoll(pollId, set: {'is_closed': true}), - ).thenAnswer((_) async => UpdatePollResponse()); + final res = await tester.client.deletePoll(pollId); + expect(res, isNotNull); - final res = await client.closePoll(pollId); - expect(res, isNotNull); + tester + ..verifyApi((api) => api.polls.deletePoll(pollId)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); - verify(() => api.polls.partialUpdatePoll(pollId, set: {'is_closed': true})).called(1); - verifyNoMoreInteractions(api.polls); - }); + chatClientTest( + '`.closePoll`', + body: (tester) async { + const pollId = 'test-poll-id'; - test('`.createPollOption`', () async { - const pollId = 'test-poll-id'; - const option = PollOption(text: 'Red'); + tester.mockApi( + (api) => api.polls.partialUpdatePoll(pollId, set: {'is_closed': true}), + result: createDefaultUpdatePollResponse(), + ); - when( - () => api.polls.createPollOption(pollId, option), - ).thenAnswer((_) async => CreatePollOptionResponse()..pollOption = option); + final res = await tester.client.closePoll(pollId); + expect(res, isNotNull); - final res = await client.createPollOption(pollId, option); - expect(res, isNotNull); - expect(res.pollOption, option); + tester + ..verifyApi((api) => api.polls.partialUpdatePoll(pollId, set: {'is_closed': true})) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); - verify(() => api.polls.createPollOption(pollId, option)).called(1); - verifyNoMoreInteractions(api.polls); - }); + chatClientTest( + '`.createPollOption`', + body: (tester) async { + const pollId = 'test-poll-id'; + final option = createDefaultPollOption(); - test('`.getPollOption`', () async { - const pollId = 'test-poll-id'; - const optionId = 'test-option-id'; - const option = PollOption(id: optionId, text: 'Red'); + tester.mockApi( + (api) => api.polls.createPollOption(pollId, option), + result: CreatePollOptionResponse()..pollOption = option, + ); - when( - () => api.polls.getPollOption(pollId, optionId), - ).thenAnswer((_) async => GetPollOptionResponse()..pollOption = option); + final res = await tester.client.createPollOption(pollId, option); + expect(res, isNotNull); + expect(res.pollOption, option); + + tester + ..verifyApi((api) => api.polls.createPollOption(pollId, option)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); - final res = await client.getPollOption(pollId, optionId); - expect(res, isNotNull); - expect(res.pollOption, option); + chatClientTest( + '`.getPollOption`', + body: (tester) async { + const pollId = 'test-poll-id'; + const optionId = 'test-option-id'; + final option = createDefaultPollOption(id: optionId); - verify(() => api.polls.getPollOption(pollId, optionId)).called(1); - verifyNoMoreInteractions(api.polls); - }); + tester.mockApi( + (api) => api.polls.getPollOption(pollId, optionId), + result: GetPollOptionResponse()..pollOption = option, + ); - test('`.updatePollOption`', () async { - const pollId = 'test-poll-id'; - const option = PollOption(id: 'test-option-id', text: 'Red'); + final res = await tester.client.getPollOption(pollId, optionId); + expect(res, isNotNull); + expect(res.pollOption, option); - when( - () => api.polls.updatePollOption(pollId, option), - ).thenAnswer((_) async => UpdatePollOptionResponse()..pollOption = option); + tester + ..verifyApi((api) => api.polls.getPollOption(pollId, optionId)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); - final res = await client.updatePollOption(pollId, option); - expect(res, isNotNull); - expect(res.pollOption, option); + chatClientTest( + '`.updatePollOption`', + body: (tester) async { + const pollId = 'test-poll-id'; + final option = createDefaultPollOption(id: 'test-option-id'); - verify(() => api.polls.updatePollOption(pollId, option)).called(1); - verifyNoMoreInteractions(api.polls); - }); + tester.mockApi( + (api) => api.polls.updatePollOption(pollId, option), + result: UpdatePollOptionResponse()..pollOption = option, + ); - test('`.deletePollOption`', () async { - const pollId = 'test-poll-id'; - const optionId = 'test-option-id'; + final res = await tester.client.updatePollOption(pollId, option); + expect(res, isNotNull); + expect(res.pollOption, option); - when(() => api.polls.deletePollOption(pollId, optionId)).thenAnswer((_) async => EmptyResponse()); + tester + ..verifyApi((api) => api.polls.updatePollOption(pollId, option)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); - final res = await client.deletePollOption(pollId, optionId); - expect(res, isNotNull); + chatClientTest( + '`.deletePollOption`', + body: (tester) async { + const pollId = 'test-poll-id'; + const optionId = 'test-option-id'; - verify(() => api.polls.deletePollOption(pollId, optionId)).called(1); - verifyNoMoreInteractions(api.polls); - }); + tester.mockApi( + (api) => api.polls.deletePollOption(pollId, optionId), + result: createDefaultEmptyResponse(), + ); - test('`.castPollVote`', () async { - const messageId = 'test-message-id'; - const pollId = 'test-poll-id'; - const optionId = 'test-option-id'; - final vote = PollVote(optionId: optionId); + final res = await tester.client.deletePollOption(pollId, optionId); + expect(res, isNotNull); - // Custom matcher to check if the Vote object has the specified id - Matcher matchesVoteOption(String expected) => predicate( - (vote) => vote.optionId == expected, - 'Vote with option $expected', - ); + tester + ..verifyApi((api) => api.polls.deletePollOption(pollId, optionId)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); - when( - () => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteOption(optionId))), - ).thenAnswer((_) async => CastPollVoteResponse()..vote = vote); + chatClientTest( + '`.castPollVote`', + body: (tester) async { + const messageId = 'test-message-id'; + const pollId = 'test-poll-id'; + const optionId = 'test-option-id'; + final vote = createDefaultPollVote(optionId: optionId); + + // Custom matcher to check if the Vote object has the specified id + Matcher matchesVoteOption(String expected) => predicate( + (vote) => vote.optionId == expected, + 'Vote with option $expected', + ); - final res = await client.castPollVote(messageId, pollId, optionId: optionId); - expect(res, isNotNull); - expect(res.vote, vote); + tester.mockApi( + (api) => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteOption(optionId))), + result: createDefaultCastPollVoteResponse(vote: vote), + ); - verify(() => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteOption(optionId)))).called(1); - verifyNoMoreInteractions(api.polls); - }); + final res = await tester.client.castPollVote(messageId, pollId, optionId: optionId); + expect(res, isNotNull); + expect(res.vote, vote); - test('`.addPollAnswer`', () async { - const messageId = 'test-message-id'; - const pollId = 'test-poll-id'; - const answerText = 'Red'; - final vote = PollVote(answerText: answerText); + tester + ..verifyApi((api) => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteOption(optionId)))) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); - // Custom matcher to check if the Vote object has the specified id - Matcher matchesVoteAnswer(String expected) => predicate( - (vote) => vote.answerText == expected, - 'Vote with answer $expected', - ); + chatClientTest( + '`.addPollAnswer`', + body: (tester) async { + const messageId = 'test-message-id'; + const pollId = 'test-poll-id'; + const answerText = 'Red'; + final vote = createDefaultPollVote(answerText: answerText); + + // Custom matcher to check if the Vote object has the specified id + Matcher matchesVoteAnswer(String expected) => predicate( + (vote) => vote.answerText == expected, + 'Vote with answer $expected', + ); - when( - () => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteAnswer(answerText))), - ).thenAnswer((_) async => CastPollVoteResponse()..vote = vote); + tester.mockApi( + (api) => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteAnswer(answerText))), + result: createDefaultCastPollVoteResponse(vote: vote), + ); - final res = await client.addPollAnswer(messageId, pollId, answerText: answerText); - expect(res, isNotNull); - expect(res.vote, vote); + final res = await tester.client.addPollAnswer(messageId, pollId, answerText: answerText); + expect(res, isNotNull); + expect(res.vote, vote); - verify(() => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteAnswer(answerText)))).called(1); - verifyNoMoreInteractions(api.polls); - }); + tester + ..verifyApi((api) => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteAnswer(answerText)))) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); - test('`.removePollVote`', () async { - const messageId = 'test-message-id'; - const pollId = 'test-poll-id'; - const voteId = 'test-vote-id'; + chatClientTest( + '`.removePollVote`', + body: (tester) async { + const messageId = 'test-message-id'; + const pollId = 'test-poll-id'; + const voteId = 'test-vote-id'; - when(() => api.polls.removePollVote(messageId, pollId, voteId)).thenAnswer((_) async => RemovePollVoteResponse()); + tester.mockApi( + (api) => api.polls.removePollVote(messageId, pollId, voteId), + result: RemovePollVoteResponse(), + ); - final res = await client.removePollVote(messageId, pollId, voteId); - expect(res, isNotNull); + final res = await tester.client.removePollVote(messageId, pollId, voteId); + expect(res, isNotNull); - verify(() => api.polls.removePollVote(messageId, pollId, voteId)).called(1); - verifyNoMoreInteractions(api.polls); - }); + tester + ..verifyApi((api) => api.polls.removePollVote(messageId, pollId, voteId)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); - test('`.queryPolls`', () async { - final filter = PollFilter.in_(PollFilterField.id, const ['test-poll-id']); - final sort = [PollSort.desc(PollSortField.createdAt)]; - const pagination = PaginationParams(limit: 20); - - final polls = List.generate( - pagination.limit, - (index) => Poll( - id: 'test-poll-id-$index', - name: 'What is your favorite color?', - options: const [ - PollOption(text: 'Red'), - PollOption(text: 'Blue'), - ], - ), - ); + chatClientTest( + '`.queryPolls`', + body: (tester) async { + final filter = PollFilter.in_(PollFilterField.id, const ['test-poll-id']); + final sort = [PollSort.desc(PollSortField.createdAt)]; + const pagination = PaginationParams(limit: 20); - when( - () => api.polls.queryPolls( - filter: filter, - sort: sort, - pagination: pagination, - ), - ).thenAnswer( - (_) async => QueryPollsResponse()..polls = polls, - ); + final polls = List.generate( + pagination.limit, + (index) => createDefaultPoll(id: 'test-poll-id-$index'), + ); - final res = await client.queryPolls( - filter: filter, - sort: sort, - pagination: pagination, - ); - expect(res, isNotNull); - expect(res.polls.length, polls.length); + tester.mockApi( + (api) => api.polls.queryPolls( + filter: filter, + sort: sort, + pagination: pagination, + ), + result: QueryPollsResponse()..polls = polls, + ); - verify( - () => api.polls.queryPolls( + final res = await tester.client.queryPolls( filter: filter, sort: sort, pagination: pagination, - ), - ).called(1); - verifyNoMoreInteractions(api.polls); - }); - - test('`.queryPollVotes`', () async { - const pollId = 'test-poll-id'; - final filter = PollVoteFilter.in_(PollVoteFilterField.id, const ['test-vote-id']); - final sort = [PollVoteSort.desc(PollVoteSortField.createdAt)]; - const pagination = PaginationParams(limit: 20); + ); + expect(res, isNotNull); + expect(res.polls.length, polls.length); - final votes = List.generate( - pagination.limit, - (index) => PollVote(id: 'test-vote-id-$index', answerText: 'Red'), - ); + tester + ..verifyApi( + (api) => api.polls.queryPolls( + filter: filter, + sort: sort, + pagination: pagination, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); - when( - () => api.polls.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - ), - ).thenAnswer( - (_) async => QueryPollVotesResponse()..votes = votes, - ); + chatClientTest( + '`.queryPollVotes`', + body: (tester) async { + const pollId = 'test-poll-id'; + final filter = PollVoteFilter.in_(PollVoteFilterField.id, const ['test-vote-id']); + final sort = [PollVoteSort.desc(PollVoteSortField.createdAt)]; + const pagination = PaginationParams(limit: 20); + + final votes = List.generate( + pagination.limit, + (index) => createDefaultPollVote(id: 'test-vote-id-$index', answerText: 'Red'), + ); - final res = await client.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - ); - expect(res, isNotNull); - expect(res.votes.length, votes.length); + tester.mockApi( + (api) => api.polls.queryPollVotes( + pollId, + filter: filter, + sort: sort, + pagination: pagination, + ), + result: QueryPollVotesResponse()..votes = votes, + ); - verify( - () => api.polls.queryPollVotes( + final res = await tester.client.queryPollVotes( pollId, filter: filter, sort: sort, pagination: pagination, - ), - ).called(1); - verifyNoMoreInteractions(api.polls); - }); + ); + expect(res, isNotNull); + expect(res.votes.length, votes.length); - test('`.updateUser`', () async { - final user = User( - id: 'test-user-id', - extraData: const {'name': 'test-user'}, - ); + tester + ..verifyApi( + (api) => api.polls.queryPollVotes( + pollId, + filter: filter, + sort: sort, + pagination: pagination, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); - when(() => api.user.updateUsers([user])).thenAnswer((_) async => UpdateUsersResponse()..users = {user.id: user}); + chatClientTest( + '`.updateUser`', + body: (tester) async { + final user = User( + id: 'test-user-id', + extraData: const {'name': 'test-user'}, + ); - final res = await client.updateUser(user); + tester.mockApi( + (api) => api.user.updateUsers([user]), + result: createDefaultUpdateUsersResponse(users: {user.id: user}), + ); - expect(res, isNotNull); - expect(res.users, {user.id: user}); + final res = await tester.client.updateUser(user); - verify(() => api.user.updateUsers([user])).called(1); - verifyNoMoreInteractions(api.user); - }); + expect(res, isNotNull); + expect(res.users, {user.id: user}); - test('`.partialUpdateUser`', () async { - const userId = 'test-user-id'; + tester + ..verifyApi((api) => api.user.updateUsers([user])) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); - final set = {'color': 'yellow'}; - final unset = []; + chatClientTest( + '`.partialUpdateUser`', + body: (tester) async { + const userId = 'test-user-id'; - final partialUpdateRequest = PartialUpdateUserRequest( - id: userId, - set: set, - unset: unset, - ); + final set = {'color': 'yellow'}; + final unset = []; - final updatedUser = User( - id: userId, - extraData: {'color': set['color']}, - ); + final partialUpdateRequest = PartialUpdateUserRequest( + id: userId, + set: set, + unset: unset, + ); - when(() => api.user.partialUpdateUsers([partialUpdateRequest])).thenAnswer( - (_) async => UpdateUsersResponse() - ..users = { - updatedUser.id: updatedUser, - }, - ); + final updatedUser = User( + id: userId, + extraData: {'color': set['color']}, + ); - final res = await client.partialUpdateUser( - userId, - set: set, - unset: unset, - ); + tester.mockApi( + (api) => api.user.partialUpdateUsers([partialUpdateRequest]), + result: createDefaultUpdateUsersResponse(users: {updatedUser.id: updatedUser}), + ); - expect(res, isNotNull); - expect(res.users, {updatedUser.id: updatedUser}); + final res = await tester.client.partialUpdateUser( + userId, + set: set, + unset: unset, + ); - verify( - () => api.user.partialUpdateUsers([partialUpdateRequest]), - ).called(1); - verifyNoMoreInteractions(api.user); - }); + expect(res, isNotNull); + expect(res.users, {updatedUser.id: updatedUser}); - test('`.banUser`', () async { - const userId = 'test-user-id'; + tester + ..verifyApi((api) => api.user.partialUpdateUsers([partialUpdateRequest])) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); - when( - () => api.moderation.banUser(userId, options: any(named: 'options')), - ).thenAnswer((_) async => EmptyResponse()); + chatClientTest( + '`.banUser`', + body: (tester) async { + const userId = 'test-user-id'; - final res = await client.banUser(userId); + tester.mockApi( + (api) => api.moderation.banUser(userId, options: any(named: 'options')), + result: createDefaultEmptyResponse(), + ); - expect(res, isNotNull); + final res = await tester.client.banUser(userId); - verify( - () => api.moderation.banUser(userId, options: any(named: 'options')), - ).called(1); - verifyNoMoreInteractions(api.moderation); - }); + expect(res, isNotNull); - test('`.unbanUser`', () async { - const userId = 'test-user-id'; + tester + ..verifyApi((api) => api.moderation.banUser(userId, options: any(named: 'options'))) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); - when( - () => api.moderation.unbanUser(userId, options: any(named: 'options')), - ).thenAnswer((_) async => EmptyResponse()); + chatClientTest( + '`.unbanUser`', + body: (tester) async { + const userId = 'test-user-id'; - final res = await client.unbanUser(userId); + tester.mockApi( + (api) => api.moderation.unbanUser(userId, options: any(named: 'options')), + result: createDefaultEmptyResponse(), + ); - expect(res, isNotNull); + final res = await tester.client.unbanUser(userId); - verify( - () => api.moderation.unbanUser(userId, options: any(named: 'options')), - ).called(1); - verifyNoMoreInteractions(api.moderation); - }); + expect(res, isNotNull); - test('`.blockUser`', () async { - const userId = 'test-user-id'; + tester + ..verifyApi((api) => api.moderation.unbanUser(userId, options: any(named: 'options'))) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); - when(() => api.user.blockUser(userId)).thenAnswer( - (_) async => UserBlockResponse.fromJson({ - 'blocked_by_user_id': 'deven', - 'blocked_user_id': 'jaap', - 'created_at': '2024-10-01 12:45:23.456', - }), - ); + chatClientTest( + '`.blockUser`', + body: (tester) async { + const userId = 'test-user-id'; + + tester.mockApi( + (api) => api.user.blockUser(userId), + result: UserBlockResponse.fromJson({ + 'blocked_by_user_id': 'deven', + 'blocked_user_id': 'jaap', + 'created_at': '2024-10-01 12:45:23.456', + }), + ); - final res = await client.blockUser(userId); + final res = await tester.client.blockUser(userId); - expect(res, isNotNull); + expect(res, isNotNull); - verify( - () => api.user.blockUser(userId), - ).called(1); - verifyNoMoreInteractions(api.user); - }); + tester + ..verifyApi((api) => api.user.blockUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); - test('`.unblockUser`', () async { - const userId = 'test-user-id'; + chatClientTest( + '`.unblockUser`', + body: (tester) async { + const userId = 'test-user-id'; - when(() => api.user.unblockUser(userId)).thenAnswer((_) async => EmptyResponse()); + tester.mockApi( + (api) => api.user.unblockUser(userId), + result: createDefaultEmptyResponse(), + ); - final res = await client.unblockUser(userId); + final res = await tester.client.unblockUser(userId); - expect(res, isNotNull); + expect(res, isNotNull); - verify( - () => api.user.unblockUser(userId), - ).called(1); - verifyNoMoreInteractions(api.user); - }); + tester + ..verifyApi((api) => api.user.unblockUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); - test('`.queryBlockedUsers`', () async { - final users = List.generate( - 3, - (index) => User(id: 'test-user-id-$index'), - ); + chatClientTest( + '`.queryBlockedUsers`', + body: (tester) async { + final users = List.generate( + 3, + (index) => User(id: 'test-user-id-$index'), + ); - when(() => api.user.queryBlockedUsers()).thenAnswer( - (_) async => BlockedUsersResponse() - ..blocks = [ - UserBlock(user: users[0], blockedUser: users[1]), - UserBlock(user: users[0], blockedUser: users[2]), - ], - ); + tester.mockApi( + (api) => api.user.queryBlockedUsers(), + result: createDefaultBlockedUsersResponse( + blocks: [ + UserBlock(user: users[0], blockedUser: users[1]), + UserBlock(user: users[0], blockedUser: users[2]), + ], + ), + ); - final res = await client.queryBlockedUsers(); - expect(res, isNotNull); - expect(res.blocks.length, 2); + final res = await tester.client.queryBlockedUsers(); + expect(res, isNotNull); + expect(res.blocks.length, 2); - verify(() => api.user.queryBlockedUsers()).called(1); - verifyNoMoreInteractions(api.user); - }); + tester + ..verifyApi((api) => api.user.queryBlockedUsers()) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); group('Block user state management', () { - test('blockUser should update blockedUserIds on client state', () async { - final testUser = OwnUser(id: 'test-user'); - const userId = 'blocked-user-id'; + chatClientTest( + 'blockUser should update blockedUserIds on client state', + body: (tester) async { + final testUser = OwnUser(id: 'test-user'); + const userId = 'blocked-user-id'; - // Verify initial state - expect(client.state.currentUser?.blockedUserIds, isEmpty); + // Verify initial state + expect(tester.currentUser?.blockedUserIds, isEmpty); - when(() => api.user.blockUser(userId)).thenAnswer( - (_) async => UserBlockResponse() - ..blockedUserId = userId - ..blockedByUserId = testUser.id - ..createdAt = DateTime.now(), - ); + tester.mockApi( + (api) => api.user.blockUser(userId), + result: createDefaultUserBlockResponse( + blockedUserId: userId, + blockedByUserId: testUser.id, + ), + ); - await client.blockUser(userId); + await tester.client.blockUser(userId); - // Verify - should now include the blocked user ID - expect(client.state.currentUser?.blockedUserIds, contains(userId)); - verify(() => api.user.blockUser(userId)).called(1); - verifyNoMoreInteractions(api.user); - }); + // Verify - should now include the blocked user ID + expect(tester.currentUser?.blockedUserIds, contains(userId)); + tester + ..verifyApi((api) => api.user.blockUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); - test( + chatClientTest( 'blockUser should not duplicate existing blocked user IDs', - () async { + body: (tester) async { const userId = 'blocked-user-id'; - client.state.blockedUserIds = const [userId]; + tester.clientState.blockedUserIds = const [userId]; // Verify the user is already in the blocked list - expect(client.state.currentUser?.blockedUserIds, contains(userId)); + expect(tester.currentUser?.blockedUserIds, contains(userId)); - when(() => api.user.blockUser(userId)).thenAnswer( - (_) async => UserBlockResponse() - ..blockedUserId = userId - ..blockedByUserId = client.state.currentUser!.id - ..createdAt = DateTime.now(), + tester.mockApi( + (api) => api.user.blockUser(userId), + result: createDefaultUserBlockResponse( + blockedUserId: userId, + blockedByUserId: tester.currentUser!.id, + ), ); - await client.blockUser(userId); + await tester.client.blockUser(userId); // Verify - should still have only one entry - expect(client.state.currentUser?.blockedUserIds, contains(userId)); - expect(client.state.currentUser?.blockedUserIds.length, 1); - verify(() => api.user.blockUser(userId)).called(1); - verifyNoMoreInteractions(api.user); + expect(tester.currentUser?.blockedUserIds, contains(userId)); + expect(tester.currentUser?.blockedUserIds.length, 1); + tester + ..verifyApi((api) => api.user.blockUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.user); }, ); - test('unblockUser should remove user from blockedUserIds', () async { - const blockedUserId = 'blocked-user-id'; - const otherBlockedId = 'other-blocked-id'; - client.state.blockedUserIds = const [blockedUserId, otherBlockedId]; + chatClientTest( + 'unblockUser should remove user from blockedUserIds', + body: (tester) async { + const blockedUserId = 'blocked-user-id'; + const otherBlockedId = 'other-blocked-id'; + tester.clientState.blockedUserIds = const [blockedUserId, otherBlockedId]; - // Verify initial state includes both blocked IDs - expect( - client.state.currentUser?.blockedUserIds, - containsAll([blockedUserId, otherBlockedId]), - ); + // Verify initial state includes both blocked IDs + expect( + tester.currentUser?.blockedUserIds, + containsAll([blockedUserId, otherBlockedId]), + ); - when(() => api.user.unblockUser(blockedUserId)).thenAnswer( - (_) async => EmptyResponse(), - ); + tester.mockApi( + (api) => api.user.unblockUser(blockedUserId), + result: createDefaultEmptyResponse(), + ); - await client.unblockUser(blockedUserId); + await tester.client.unblockUser(blockedUserId); - // Verify - blockedUserId should be removed - expect( - client.state.currentUser?.blockedUserIds, - contains(otherBlockedId), - ); + // Verify - blockedUserId should be removed + expect( + tester.currentUser?.blockedUserIds, + contains(otherBlockedId), + ); - expect( - client.state.currentUser?.blockedUserIds, - isNot(contains(blockedUserId)), - ); + expect( + tester.currentUser?.blockedUserIds, + isNot(contains(blockedUserId)), + ); - verify(() => api.user.unblockUser(blockedUserId)).called(1); - verifyNoMoreInteractions(api.user); - }); + tester + ..verifyApi((api) => api.user.unblockUser(blockedUserId)) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); - test( + chatClientTest( 'unblockUser should be resilient if user ID not in blocked list', - () async { + body: (tester) async { const nonBlockedUserId = 'not-in-list'; const otherBlockedId = 'other-blocked-id'; - client.state.blockedUserIds = const [otherBlockedId]; + tester.clientState.blockedUserIds = const [otherBlockedId]; // Verify initial state expect( - client.state.currentUser?.blockedUserIds, + tester.currentUser?.blockedUserIds, contains(otherBlockedId), ); expect( - client.state.currentUser?.blockedUserIds, + tester.currentUser?.blockedUserIds, isNot(contains(nonBlockedUserId)), ); - when(() => api.user.unblockUser(nonBlockedUserId)).thenAnswer( - (_) async => EmptyResponse(), + tester.mockApi( + (api) => api.user.unblockUser(nonBlockedUserId), + result: createDefaultEmptyResponse(), ); - await client.unblockUser(nonBlockedUserId); + await tester.client.unblockUser(nonBlockedUserId); // Verify - should remain unchanged - expect(client.state.currentUser?.blockedUserIds, contains(otherBlockedId)); - expect(client.state.currentUser?.blockedUserIds, isNot(contains(nonBlockedUserId))); - verify(() => api.user.unblockUser(nonBlockedUserId)).called(1); - verifyNoMoreInteractions(api.user); + expect(tester.currentUser?.blockedUserIds, contains(otherBlockedId)); + expect(tester.currentUser?.blockedUserIds, isNot(contains(nonBlockedUserId))); + tester + ..verifyApi((api) => api.user.unblockUser(nonBlockedUserId)) + ..verifyNoMoreApiInteractions((api) => api.user); }, ); - test( + chatClientTest( 'queryBlockedUsers should update client state with blockedUserIds', - () async { + body: (tester) async { const blockedId1 = 'blocked-1'; const blockedId2 = 'blocked-2'; // Verify initial state - expect(client.state.currentUser?.blockedUserIds, isEmpty); + expect(tester.currentUser?.blockedUserIds, isEmpty); // Create mock users + final user = tester.user; final blockedUser1 = User(id: 'blocked-user-1'); final blockedUser2 = User(id: 'blocked-user-2'); // Mock the queryBlockedUsers API call - when(() => api.user.queryBlockedUsers()).thenAnswer( - (_) async => BlockedUsersResponse() - ..blocks = [ + tester.mockApi( + (api) => api.user.queryBlockedUsers(), + result: createDefaultBlockedUsersResponse( + blocks: [ UserBlock( user: user, userId: user.id, @@ -3787,1134 +4062,1325 @@ void main() { blockedUserId: blockedId2, ), ], + ), ); - await client.queryBlockedUsers(); + await tester.client.queryBlockedUsers(); // Verify - should now include both blocked IDs expect( - client.state.currentUser?.blockedUserIds, + tester.currentUser?.blockedUserIds, containsAll([blockedId1, blockedId2]), ); - verify(() => api.user.queryBlockedUsers()).called(1); - verifyNoMoreInteractions(api.user); + tester + ..verifyApi((api) => api.user.queryBlockedUsers()) + ..verifyNoMoreApiInteractions((api) => api.user); }, ); }); - test('`.getUnreadCount`', () async { - when(() => api.user.getUnreadCount()).thenAnswer( - (_) async => GetUnreadCountResponse() - ..totalUnreadCount = 42 - ..totalUnreadThreadsCount = 8 - ..channelType = [] - ..channels = [ - UnreadCountsChannel( - channelId: 'messaging:test-channel-1', - unreadCount: 10, - lastRead: DateTime.now(), - ), - UnreadCountsChannel( - channelId: 'messaging:test-channel-2', - unreadCount: 15, - lastRead: DateTime.now(), - ), - ] - ..threads = [ - UnreadCountsThread( - unreadCount: 3, - lastRead: DateTime.now(), - lastReadMessageId: 'message-1', - parentMessageId: 'parent-message-1', - ), - UnreadCountsThread( - unreadCount: 5, - lastRead: DateTime.now(), - lastReadMessageId: 'message-2', - parentMessageId: 'parent-message-2', - ), - ], - ); + chatClientTest( + '`.getUnreadCount`', + body: (tester) async { + tester.mockApi( + (api) => api.user.getUnreadCount(), + result: createDefaultGetUnreadCountResponse( + totalUnreadCount: 42, + totalUnreadThreadsCount: 8, + channels: [ + UnreadCountsChannel( + channelId: 'messaging:test-channel-1', + unreadCount: 10, + lastRead: DateTime.utc(2021, 3), + ), + UnreadCountsChannel( + channelId: 'messaging:test-channel-2', + unreadCount: 15, + lastRead: DateTime.utc(2021, 3), + ), + ], + threads: [ + UnreadCountsThread( + unreadCount: 3, + lastRead: DateTime.utc(2021, 3), + lastReadMessageId: 'message-1', + parentMessageId: 'parent-message-1', + ), + UnreadCountsThread( + unreadCount: 5, + lastRead: DateTime.utc(2021, 3), + lastReadMessageId: 'message-2', + parentMessageId: 'parent-message-2', + ), + ], + ), + ); - final res = await client.getUnreadCount(); + final res = await tester.client.getUnreadCount(); - expect(res, isNotNull); - expect(res.totalUnreadCount, 42); - expect(res.totalUnreadThreadsCount, 8); + expect(res, isNotNull); + expect(res.totalUnreadCount, 42); + expect(res.totalUnreadThreadsCount, 8); - verify(() => api.user.getUnreadCount()).called(1); - verifyNoMoreInteractions(api.user); - }); + tester + ..verifyApi((api) => api.user.getUnreadCount()) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); - test( + chatClientTest( '`.getUnreadCount` should also update user unread count as a side effect', - () async { - when(() => api.user.getUnreadCount()).thenAnswer( - (_) async => GetUnreadCountResponse() - ..totalUnreadCount = 25 - ..totalUnreadThreadsCount = 2 - ..channelType = [] - ..channels = [ + body: (tester) async { + tester.mockApi( + (api) => api.user.getUnreadCount(), + result: createDefaultGetUnreadCountResponse( + totalUnreadCount: 25, + totalUnreadThreadsCount: 2, + channels: [ UnreadCountsChannel( channelId: 'messaging:test-channel-1', unreadCount: 10, - lastRead: DateTime.now(), + lastRead: DateTime.utc(2021, 3), ), UnreadCountsChannel( channelId: 'messaging:test-channel-2', unreadCount: 15, - lastRead: DateTime.now(), + lastRead: DateTime.utc(2021, 3), ), - ] - ..threads = [ + ], + threads: [ UnreadCountsThread( unreadCount: 3, - lastRead: DateTime.now(), + lastRead: DateTime.utc(2021, 3), lastReadMessageId: 'message-1', parentMessageId: 'parent-message-1', ), UnreadCountsThread( unreadCount: 5, - lastRead: DateTime.now(), + lastRead: DateTime.utc(2021, 3), lastReadMessageId: 'message-2', parentMessageId: 'parent-message-2', ), ], + ), ); - client.getUnreadCount().ignore(); + tester.client.getUnreadCount().ignore(); // Wait for the local side effect event to be processed await Future.delayed(Duration.zero); - expect(client.state.currentUser?.totalUnreadCount, 25); - expect(client.state.currentUser?.unreadChannels, 2); // channels.length - expect(client.state.currentUser?.unreadThreads, 2); // threads.length + expect(tester.currentUser?.totalUnreadCount, 25); + expect(tester.currentUser?.unreadChannels, 2); // channels.length + expect(tester.currentUser?.unreadThreads, 2); // threads.length - verify(() => api.user.getUnreadCount()).called(1); - verifyNoMoreInteractions(api.user); + tester + ..verifyApi((api) => api.user.getUnreadCount()) + ..verifyNoMoreApiInteractions((api) => api.user); }, ); - test('`.shadowBan`', () async { - const userId = 'test-user-id'; - - when(() => api.moderation.banUser(userId, options: {'shadow': true})).thenAnswer((_) async => EmptyResponse()); - - final res = await client.shadowBan(userId); - - expect(res, isNotNull); - - verify( - () => api.moderation.banUser(userId, options: {'shadow': true}), - ).called(1); - verifyNoMoreInteractions(api.moderation); - }); - - test('`.removeShadowBan`', () async { - const userId = 'test-user-id'; - - when(() => api.moderation.unbanUser(userId, options: {'shadow': true})).thenAnswer((_) async => EmptyResponse()); - - final res = await client.removeShadowBan(userId); - - expect(res, isNotNull); + chatClientTest( + '`.shadowBan`', + body: (tester) async { + const userId = 'test-user-id'; - verify( - () => api.moderation.unbanUser(userId, options: {'shadow': true}), - ).called(1); - verifyNoMoreInteractions(api.moderation); - }); - - test('`.muteUser`', () async { - const userId = 'test-user-id'; - - when(() => api.moderation.muteUser(userId)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.muteUser(userId); - - expect(res, isNotNull); + tester.mockApi( + (api) => api.moderation.banUser(userId, options: {'shadow': true}), + result: createDefaultEmptyResponse(), + ); - verify(() => api.moderation.muteUser(userId)).called(1); - verifyNoMoreInteractions(api.moderation); - }); + final res = await tester.client.shadowBan(userId); - test('`.unmuteUser`', () async { - const userId = 'test-user-id'; + expect(res, isNotNull); - when(() => api.moderation.unmuteUser(userId)).thenAnswer((_) async => EmptyResponse()); + tester + ..verifyApi((api) => api.moderation.banUser(userId, options: {'shadow': true})) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); - final res = await client.unmuteUser(userId); + chatClientTest( + '`.removeShadowBan`', + body: (tester) async { + const userId = 'test-user-id'; - expect(res, isNotNull); + tester.mockApi( + (api) => api.moderation.unbanUser(userId, options: {'shadow': true}), + result: createDefaultEmptyResponse(), + ); - verify(() => api.moderation.unmuteUser(userId)).called(1); - verifyNoMoreInteractions(api.moderation); - }); + final res = await tester.client.removeShadowBan(userId); - test('`.flagMessage`', () async { - const messageId = 'test-message-id'; + expect(res, isNotNull); - when(() => api.moderation.flagMessage(messageId)).thenAnswer((_) async => EmptyResponse()); + tester + ..verifyApi((api) => api.moderation.unbanUser(userId, options: {'shadow': true})) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); - final res = await client.flagMessage(messageId); + chatClientTest( + '`.muteUser`', + body: (tester) async { + const userId = 'test-user-id'; - expect(res, isNotNull); + tester.mockApi( + (api) => api.moderation.muteUser(userId), + result: createDefaultEmptyResponse(), + ); - verify(() => api.moderation.flagMessage(messageId)).called(1); - verifyNoMoreInteractions(api.moderation); - }); + final res = await tester.client.muteUser(userId); - test('`.unflagMessage`', () async { - const messageId = 'test-message-id'; + expect(res, isNotNull); - when(() => api.moderation.unflagMessage(messageId)).thenAnswer((_) async => EmptyResponse()); + tester + ..verifyApi((api) => api.moderation.muteUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); - final res = await client.unflagMessage(messageId); + chatClientTest( + '`.unmuteUser`', + body: (tester) async { + const userId = 'test-user-id'; - expect(res, isNotNull); + tester.mockApi( + (api) => api.moderation.unmuteUser(userId), + result: createDefaultEmptyResponse(), + ); - verify(() => api.moderation.unflagMessage(messageId)).called(1); - verifyNoMoreInteractions(api.moderation); - }); + final res = await tester.client.unmuteUser(userId); - test('`.flagUser`', () async { - const userId = 'test-message-id'; + expect(res, isNotNull); - when(() => api.moderation.flagUser(userId)).thenAnswer((_) async => EmptyResponse()); + tester + ..verifyApi((api) => api.moderation.unmuteUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); - final res = await client.flagUser(userId); + chatClientTest( + '`.flagMessage`', + body: (tester) async { + const messageId = 'test-message-id'; - expect(res, isNotNull); + tester.mockApi( + (api) => api.moderation.flagMessage(messageId), + result: createDefaultEmptyResponse(), + ); - verify(() => api.moderation.flagUser(userId)).called(1); - verifyNoMoreInteractions(api.moderation); - }); + final res = await tester.client.flagMessage(messageId); - test('`.unflagUser`', () async { - const userId = 'test-message-id'; + expect(res, isNotNull); - when(() => api.moderation.unflagUser(userId)).thenAnswer((_) async => EmptyResponse()); + tester + ..verifyApi((api) => api.moderation.flagMessage(messageId)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); - final res = await client.unflagUser(userId); + chatClientTest( + '`.unflagMessage`', + body: (tester) async { + const messageId = 'test-message-id'; - expect(res, isNotNull); + tester.mockApi( + (api) => api.moderation.unflagMessage(messageId), + result: createDefaultEmptyResponse(), + ); - verify(() => api.moderation.unflagUser(userId)).called(1); - verifyNoMoreInteractions(api.moderation); - }); + final res = await tester.client.unflagMessage(messageId); - test('`.getActiveLiveLocations`', () async { - final locations = [ - Location( - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device-1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ), - Location( - latitude: 34.0522, - longitude: -118.2437, - createdByDeviceId: 'device-2', - endAt: DateTime.now().add(const Duration(hours: 2)), - ), - ]; + expect(res, isNotNull); - when(() => api.user.getActiveLiveLocations()).thenAnswer( - (_) async => - GetActiveLiveLocationsResponse() // - ..activeLiveLocations = locations, - ); + tester + ..verifyApi((api) => api.moderation.unflagMessage(messageId)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); - // Initial state should be empty - expect(client.state.activeLiveLocations, isEmpty); + chatClientTest( + '`.flagUser`', + body: (tester) async { + const userId = 'test-message-id'; - final res = await client.getActiveLiveLocations(); + tester.mockApi( + (api) => api.moderation.flagUser(userId), + result: createDefaultEmptyResponse(), + ); - expect(res, isNotNull); - expect(res.activeLiveLocations, hasLength(2)); - expect(res.activeLiveLocations, equals(locations)); - expect(client.state.activeLiveLocations, equals(locations)); + final res = await tester.client.flagUser(userId); - verify(() => api.user.getActiveLiveLocations()).called(1); - verifyNoMoreInteractions(api.user); - }); + expect(res, isNotNull); - test('`.updateLiveLocation`', () async { - const messageId = 'test-message-id'; - const createdByDeviceId = 'test-device-id'; - final endAt = DateTime.timestamp().add(const Duration(hours: 1)); - const location = LocationCoordinate( - latitude: 40.7128, - longitude: -74.0060, - ); + tester + ..verifyApi((api) => api.moderation.flagUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); - final expectedLocation = Location( - latitude: location.latitude, - longitude: location.longitude, - createdByDeviceId: createdByDeviceId, - endAt: endAt, - ); + chatClientTest( + '`.unflagUser`', + body: (tester) async { + const userId = 'test-message-id'; - when( - () => api.user.updateLiveLocation( - messageId: messageId, - createdByDeviceId: createdByDeviceId, - location: location, - endAt: endAt, - ), - ).thenAnswer((_) async => expectedLocation); + tester.mockApi( + (api) => api.moderation.unflagUser(userId), + result: createDefaultEmptyResponse(), + ); - final res = await client.updateLiveLocation( - messageId: messageId, - createdByDeviceId: createdByDeviceId, - location: location, - endAt: endAt, - ); + final res = await tester.client.unflagUser(userId); - expect(res, isNotNull); - expect(res, equals(expectedLocation)); + expect(res, isNotNull); - verify( - () => api.user.updateLiveLocation( - messageId: messageId, - createdByDeviceId: createdByDeviceId, - location: location, - endAt: endAt, - ), - ).called(1); - verifyNoMoreInteractions(api.user); - }); + tester + ..verifyApi((api) => api.moderation.unflagUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); - test('`.stopLiveLocation`', () async { - const messageId = 'test-message-id'; - const createdByDeviceId = 'test-device-id'; + chatClientTest( + '`.getActiveLiveLocations`', + body: (tester) async { + final locations = [ + Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + endAt: DateTime.timestamp().add(const Duration(hours: 1)), + ), + Location( + latitude: 34.0522, + longitude: -118.2437, + createdByDeviceId: 'device-2', + endAt: DateTime.timestamp().add(const Duration(hours: 2)), + ), + ]; - final expectedLocation = Location( - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: createdByDeviceId, - endAt: DateTime.now(), // Should be expired - ); + tester.mockApi( + (api) => api.user.getActiveLiveLocations(), + result: GetActiveLiveLocationsResponse()..activeLiveLocations = locations, + ); - when( - () => api.user.updateLiveLocation( - messageId: messageId, - createdByDeviceId: createdByDeviceId, - endAt: any(named: 'endAt'), - ), - ).thenAnswer((_) async => expectedLocation); + // Initial state should be empty + expect(tester.clientState.activeLiveLocations, isEmpty); - final res = await client.stopLiveLocation( - messageId: messageId, - createdByDeviceId: createdByDeviceId, - ); + final res = await tester.client.getActiveLiveLocations(); - expect(res, isNotNull); - expect(res, equals(expectedLocation)); + expect(res, isNotNull); + expect(res.activeLiveLocations, hasLength(2)); + expect(res.activeLiveLocations, equals(locations)); + expect(tester.clientState.activeLiveLocations, equals(locations)); - verify( - () => api.user.updateLiveLocation( - messageId: messageId, - createdByDeviceId: createdByDeviceId, - endAt: any(named: 'endAt'), - ), - ).called(1); - verifyNoMoreInteractions(api.user); - }); + tester + ..verifyApi((api) => api.user.getActiveLiveLocations()) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); - group('Live Location Event Handling', () { - test('should handle location.shared event', () async { - final location = Location( - channelCid: 'test-channel:123', - messageId: 'message-123', - userId: userId, + chatClientTest( + '`.updateLiveLocation`', + body: (tester) async { + const messageId = 'test-message-id'; + const createdByDeviceId = 'test-device-id'; + final endAt = DateTime.timestamp().add(const Duration(hours: 1)); + const location = LocationCoordinate( latitude: 40.7128, longitude: -74.0060, - createdByDeviceId: 'device-1', - endAt: DateTime.now().add(const Duration(hours: 1)), ); - final event = Event( - type: EventType.locationShared, - cid: 'test-channel:123', - message: Message( - id: 'message-123', - sharedLocation: location, - ), + final expectedLocation = Location( + latitude: location.latitude, + longitude: location.longitude, + createdByDeviceId: createdByDeviceId, + endAt: endAt, ); - // Initially empty - expect(client.state.activeLiveLocations, isEmpty); + tester.mockApi( + (api) => api.user.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + location: location, + endAt: endAt, + ), + result: expectedLocation, + ); - // Trigger the event - client.handleEvent(event); + final res = await tester.client.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + location: location, + endAt: endAt, + ); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + expect(res, isNotNull); + expect(res, equals(expectedLocation)); + + tester + ..verifyApi( + (api) => api.user.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + location: location, + endAt: endAt, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); - // Should add location to active live locations - final activeLiveLocations = client.state.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations.first.messageId, equals('message-123')); - }); + chatClientTest( + '`.stopLiveLocation`', + body: (tester) async { + const messageId = 'test-message-id'; + const createdByDeviceId = 'test-device-id'; - test('should handle location.updated event', () async { - final initialLocation = Location( - channelCid: 'test-channel:123', - messageId: 'message-123', - userId: userId, + final expectedLocation = Location( latitude: 40.7128, longitude: -74.0060, - createdByDeviceId: 'device-1', - endAt: DateTime.now().add(const Duration(hours: 1)), + createdByDeviceId: createdByDeviceId, + endAt: DateTime.timestamp(), // Should be expired ); - // Set initial location - client.state.activeLiveLocations = [initialLocation]; - - final updatedLocation = Location( - channelCid: 'test-channel:123', - messageId: 'message-123', - userId: userId, - latitude: 40.7500, // Updated latitude - longitude: -74.1000, // Updated longitude - createdByDeviceId: 'device-1', - endAt: DateTime.now().add(const Duration(hours: 1)), + tester.mockApi( + (api) => api.user.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + endAt: any(named: 'endAt'), + ), + result: expectedLocation, ); - final event = Event( - type: EventType.locationUpdated, - cid: 'test-channel:123', - message: Message( - id: 'message-123', - sharedLocation: updatedLocation, - ), + final res = await tester.client.stopLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, ); - // Trigger the event - client.handleEvent(event); + expect(res, isNotNull); + expect(res, equals(expectedLocation)); + + tester + ..verifyApi( + (api) => api.user.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + endAt: any(named: 'endAt'), + ), + ) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + group('Live Location Event Handling', () { + chatClientTest( + 'should handle location.shared event', + body: (tester) async { + final location = Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: tester.currentUser!.id, + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + endAt: DateTime.timestamp().add(const Duration(hours: 1)), + ); - // Should update the location - final activeLiveLocations = client.state.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations.first.latitude, equals(40.7500)); - expect(activeLiveLocations.first.longitude, equals(-74.1000)); - }); + final event = createDefaultEvent( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: location, + ), + ); - test('should handle location.expired event', () async { - final location = Location( - channelCid: 'test-channel:123', - messageId: 'message-123', - userId: userId, - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device-1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); + // Initially empty + expect(tester.clientState.activeLiveLocations, isEmpty); - // Set initial location - client.state.activeLiveLocations = [location]; - expect(client.state.activeLiveLocations, hasLength(1)); + // Trigger the event + await tester.emitEvent(event); - final expiredLocation = location.copyWith( - endAt: DateTime.now().subtract(const Duration(hours: 1)), - ); + // Should add location to active live locations + final activeLiveLocations = tester.clientState.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations.first.messageId, equals('message-123')); + }, + ); - final event = Event( - type: EventType.locationExpired, - cid: 'test-channel:123', - message: Message( - id: 'message-123', - sharedLocation: expiredLocation, - ), - ); + chatClientTest( + 'should handle location.updated event', + body: (tester) async { + final initialLocation = Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: tester.currentUser!.id, + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + endAt: DateTime.timestamp().add(const Duration(hours: 1)), + ); - // Trigger the event - client.handleEvent(event); + // Set initial location + tester.clientState.activeLiveLocations = [initialLocation]; - // Wait for the event to get processed - await Future.delayed(Duration.zero); + final updatedLocation = Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: tester.currentUser!.id, + latitude: 40.7500, // Updated latitude + longitude: -74.1000, // Updated longitude + createdByDeviceId: 'device-1', + endAt: DateTime.timestamp().add(const Duration(hours: 1)), + ); - // Should remove the location - expect(client.state.activeLiveLocations, isEmpty); - }); + final event = createDefaultEvent( + type: EventType.locationUpdated, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: updatedLocation, + ), + ); - test('should auto-expire an active live location once at endAt', () async { - final expiredEvents = []; - final sub = client.on(EventType.locationExpired).listen(expiredEvents.add); - addTearDown(sub.cancel); + // Trigger the event + await tester.emitEvent(event); - // Setting an active location schedules a one-shot expiry timer. - client.state.activeLiveLocations = [ - Location( + // Should update the location + final activeLiveLocations = tester.clientState.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations.first.latitude, equals(40.7500)); + expect(activeLiveLocations.first.longitude, equals(-74.1000)); + }, + ); + + chatClientTest( + 'should handle location.expired event', + body: (tester) async { + final location = Location( channelCid: 'test-channel:123', messageId: 'message-123', - userId: userId, + userId: tester.currentUser!.id, latitude: 40.7128, longitude: -74.0060, createdByDeviceId: 'device-1', - endAt: DateTime.now().add(const Duration(milliseconds: 800)), - ), - ]; - expect(client.state.activeLiveLocations, hasLength(1)); + endAt: DateTime.timestamp().add(const Duration(hours: 1)), + ); - // Before endAt nothing is emitted and the location stays active. - await delay(200); - expect(expiredEvents, isEmpty); - expect(client.state.activeLiveLocations, hasLength(1)); + // Set initial location + tester.clientState.activeLiveLocations = [location]; + expect(tester.clientState.activeLiveLocations, hasLength(1)); - // After endAt the timer fires once and the location is removed. - await delay(900); - expect(expiredEvents, hasLength(1)); - expect(client.state.activeLiveLocations, isEmpty); + final expiredLocation = location.copyWith( + endAt: DateTime.timestamp().subtract(const Duration(hours: 1)), + ); - // The timer is one-shot: no further events are emitted. - await delay(300); - expect(expiredEvents, hasLength(1)); - }); + final event = createDefaultEvent( + type: EventType.locationExpired, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: expiredLocation, + ), + ); - test('should ignore location events for other users', () async { - final location = Location( - channelCid: 'test-channel:123', - messageId: 'message-123', - userId: 'other-user', // Different user - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device-1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); + // Trigger the event + await tester.emitEvent(event); - final event = Event( - type: EventType.locationShared, - cid: 'test-channel:123', - message: Message( - id: 'message-123', - sharedLocation: location, - ), - ); + // Should remove the location + expect(tester.clientState.activeLiveLocations, isEmpty); + }, + ); - // Trigger the event - client.handleEvent(event); + chatClientTest( + 'should auto-expire an active live location once at endAt', + body: (tester) async { + final expiredEvents = []; + final sub = tester.client.on(EventType.locationExpired).listen(expiredEvents.add); + addTearDown(sub.cancel); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Setting an active location schedules a one-shot expiry timer. + tester.clientState.activeLiveLocations = [ + Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: tester.currentUser!.id, + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + endAt: DateTime.timestamp().add(const Duration(milliseconds: 800)), + ), + ]; + expect(tester.clientState.activeLiveLocations, hasLength(1)); + + // Before endAt nothing is emitted and the location stays active. + await Future.delayed(const Duration(milliseconds: 200)); + expect(expiredEvents, isEmpty); + expect(tester.clientState.activeLiveLocations, hasLength(1)); + + // After endAt the timer fires once and the location is removed. + await Future.delayed(const Duration(milliseconds: 900)); + expect(expiredEvents, hasLength(1)); + expect(tester.clientState.activeLiveLocations, isEmpty); + + // The timer is one-shot: no further events are emitted. + await Future.delayed(const Duration(milliseconds: 300)); + expect(expiredEvents, hasLength(1)); + }, + ); - // Should not add location from other user - expect(client.state.activeLiveLocations, isEmpty); - }); + chatClientTest( + 'should ignore location events for other users', + body: (tester) async { + final location = Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: 'other-user', // Different user + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + endAt: DateTime.timestamp().add(const Duration(hours: 1)), + ); - test('should ignore static location events', () async { - final staticLocation = Location( - channelCid: 'test-channel:123', - messageId: 'message-123', - userId: userId, - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device-1', - // No endAt means it's static - ); + final event = createDefaultEvent( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: location, + ), + ); - final event = Event( - type: EventType.locationShared, - cid: 'test-channel:123', - message: Message( - id: 'message-123', - sharedLocation: staticLocation, - ), - ); + // Trigger the event + await tester.emitEvent(event); - // Trigger the event - client.handleEvent(event); + // Should not add location from other user + expect(tester.clientState.activeLiveLocations, isEmpty); + }, + ); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + chatClientTest( + 'should ignore static location events', + body: (tester) async { + final staticLocation = Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: tester.currentUser!.id, + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + // No endAt means it's static + ); - // Should not add static location - expect(client.state.activeLiveLocations, isEmpty); - }); + final event = createDefaultEvent( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: staticLocation, + ), + ); - test('should merge locations with same key', () async { - final location1 = Location( - channelCid: 'test-channel:123', - messageId: 'message-123', - userId: userId, - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device-1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final location2 = Location( - channelCid: 'test-channel:123', - messageId: 'message-456', - userId: userId, - latitude: 40.7500, - longitude: -74.1000, - createdByDeviceId: 'device-1', // Same device, should merge - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final event1 = Event( - type: EventType.locationShared, - cid: 'test-channel:123', - message: Message( - id: 'message-123', - sharedLocation: location1, - ), - ); + // Trigger the event + await tester.emitEvent(event); - final event2 = Event( - type: EventType.locationShared, - cid: 'test-channel:123', - message: Message( - id: 'message-456', - sharedLocation: location2, - ), - ); + // Should not add static location + expect(tester.clientState.activeLiveLocations, isEmpty); + }, + ); - // Trigger first event - client.handleEvent(event1); - await Future.delayed(Duration.zero); + chatClientTest( + 'should merge locations with same key', + body: (tester) async { + final location1 = Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: tester.currentUser!.id, + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + endAt: DateTime.timestamp().add(const Duration(hours: 1)), + ); - final activeLiveLocations = client.state.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations.first.messageId, equals('message-123')); + final location2 = Location( + channelCid: 'test-channel:123', + messageId: 'message-456', + userId: tester.currentUser!.id, + latitude: 40.7500, + longitude: -74.1000, + createdByDeviceId: 'device-1', // Same device, should merge + endAt: DateTime.timestamp().add(const Duration(hours: 1)), + ); - // Trigger second event - should merge/update - client.handleEvent(event2); - await Future.delayed(Duration.zero); + final event1 = createDefaultEvent( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: location1, + ), + ); - final activeLiveLocations2 = client.state.activeLiveLocations; - expect(activeLiveLocations2, hasLength(1)); - expect(activeLiveLocations2.first.messageId, equals('message-456')); - }); - }); + final event2 = createDefaultEvent( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-456', + sharedLocation: location2, + ), + ); - test('`.markAllRead`', () async { - when(() => api.channel.markAllRead()).thenAnswer((_) async => EmptyResponse()); + // Trigger first event + await tester.emitEvent(event1); - final res = await client.markAllRead(); - expect(res, isNotNull); + final activeLiveLocations = tester.clientState.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations.first.messageId, equals('message-123')); + + // Trigger second event - should merge/update + await tester.emitEvent(event2); - verify(() => api.channel.markAllRead()).called(1); - verifyNoMoreInteractions(api.channel); + final activeLiveLocations2 = tester.clientState.activeLiveLocations; + expect(activeLiveLocations2, hasLength(1)); + expect(activeLiveLocations2.first.messageId, equals('message-456')); + }, + ); }); - test('`.markChannelsDelivered`', () async { - final deliveries = [ - const MessageDelivery( - channelCid: 'messaging:test-channel-1', - messageId: 'test-message-id-1', - ), - const MessageDelivery( - channelCid: 'messaging:test-channel-2', - messageId: 'test-message-id-2', - ), - ]; + chatClientTest( + '`.markAllRead`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.markAllRead(), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.markAllRead(); + expect(res, isNotNull); - when(() => api.channel.markChannelsDelivered(deliveries)).thenAnswer((_) async => EmptyResponse()); + tester + ..verifyApi((api) => api.channel.markAllRead()) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - final res = await client.markChannelsDelivered(deliveries); - expect(res, isNotNull); + chatClientTest( + '`.markChannelsDelivered`', + body: (tester) async { + final deliveries = [ + const MessageDelivery( + channelCid: 'messaging:test-channel-1', + messageId: 'test-message-id-1', + ), + const MessageDelivery( + channelCid: 'messaging:test-channel-2', + messageId: 'test-message-id-2', + ), + ]; - verify(() => api.channel.markChannelsDelivered(deliveries)).called(1); - verifyNoMoreInteractions(api.channel); - }); + tester.mockApi( + (api) => api.channel.markChannelsDelivered(deliveries), + result: createDefaultEmptyResponse(), + ); - test('`.sendEvent`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - final event = Event(type: EventType.any); + final res = await tester.client.markChannelsDelivered(deliveries); + expect(res, isNotNull); - when( - () => api.channel.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(event)), - ), - ).thenAnswer((_) async => EmptyResponse()); + tester + ..verifyApi((api) => api.channel.markChannelsDelivered(deliveries)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - final res = await client.sendEvent(channelId, channelType, event); - expect(res, isNotNull); + chatClientTest( + '`.sendEvent`', + body: (tester) async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + final event = Event(type: EventType.any); - verify( - () => api.channel.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(event)), - ), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); + tester.mockApi( + (api) => api.channel.sendEvent(channelId, channelType, event), + result: createDefaultEmptyResponse(), + ); - test('`.sendReaction`', () async { - const messageId = 'test-message-id'; - const reactionType = 'like'; - const emojiCode = '👍'; - const score = 4; - - final reaction = Reaction( - type: reactionType, - messageId: messageId, - emojiCode: emojiCode, - score: score, - ); + final res = await tester.client.sendEvent(channelId, channelType, event); + expect(res, isNotNull); - when(() => api.message.sendReaction(messageId, reaction)).thenAnswer( - (_) async => SendReactionResponse() - ..message = Message(id: messageId) - ..reaction = reaction, - ); + tester + ..verifyApi( + (api) => api.channel.sendEvent(channelId, channelType, event), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); - final res = await client.sendReaction(messageId, reaction); - expect(res, isNotNull); - expect(res.message.id, messageId); - expect(res.reaction.type, reactionType); - expect(res.reaction.emojiCode, emojiCode); - expect(res.reaction.score, score); - expect(res.reaction.messageId, messageId); - - verify(() => api.message.sendReaction(messageId, reaction)).called(1); - verifyNoMoreInteractions(api.message); - }); + chatClientTest( + '`.sendReaction`', + body: (tester) async { + const messageId = 'test-message-id'; + const reactionType = 'like'; + const emojiCode = '👍'; + const score = 4; - test('`.deleteReaction`', () async { - const messageId = 'test-message-id'; - const reactionType = 'like'; + final reaction = Reaction( + type: reactionType, + messageId: messageId, + emojiCode: emojiCode, + score: score, + ); - when(() => api.message.deleteReaction(messageId, reactionType)).thenAnswer((_) async => EmptyResponse()); + tester.mockApi( + (api) => api.message.sendReaction(messageId, reaction), + result: createDefaultSendReactionResponse( + message: Message(id: messageId), + reaction: reaction, + ), + ); - final res = await client.deleteReaction(messageId, reactionType); - expect(res, isNotNull); + final res = await tester.client.sendReaction(messageId, reaction); + expect(res, isNotNull); + expect(res.message.id, messageId); + expect(res.reaction.type, reactionType); + expect(res.reaction.emojiCode, emojiCode); + expect(res.reaction.score, score); + expect(res.reaction.messageId, messageId); + + tester + ..verifyApi((api) => api.message.sendReaction(messageId, reaction)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); - verify( - () => api.message.deleteReaction(messageId, reactionType), - ).called(1); - verifyNoMoreInteractions(api.message); - }); + chatClientTest( + '`.deleteReaction`', + body: (tester) async { + const messageId = 'test-message-id'; + const reactionType = 'like'; - test('`.sendMessage`', () async { - final message = Message(id: 'test-message-id'); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; + tester.mockApi( + (api) => api.message.deleteReaction(messageId, reactionType), + result: createDefaultEmptyResponse(), + ); - when( - () => api.message.sendMessage(channelId, channelType, any(that: isSameMessageAs(message))), - ).thenAnswer((_) async => SendMessageResponse()..message = message); + final res = await tester.client.deleteReaction(messageId, reactionType); + expect(res, isNotNull); - final res = await client.sendMessage(message, channelId, channelType); - expect(res, isNotNull); - expect(res.message, isSameMessageAs(message)); + tester + ..verifyApi((api) => api.message.deleteReaction(messageId, reactionType)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); - verify( - () => api.message.sendMessage( - channelId, - channelType, - any(that: isSameMessageAs(message)), - ), - ).called(1); - verifyNoMoreInteractions(api.message); - }); + chatClientTest( + '`.sendMessage`', + body: (tester) async { + final message = Message(id: 'test-message-id'); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; - test('`.createDraft`', () async { - final message = DraftMessage(id: 'test-message-id', text: 'Hello!'); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; + tester.mockApi( + (api) => api.message.sendMessage(channelId, channelType, any(that: isSameMessageAs(message))), + result: createDefaultSendMessageResponse(message: message), + ); - when( - () => api.message.createDraft( - channelId, - channelType, - any(that: isSameDraftMessageAs(message)), - ), - ).thenAnswer( - (_) async => CreateDraftResponse() - ..draft = Draft( - channelCid: '$channelType:$channelId', - createdAt: DateTime.now(), - message: message, - ), - ); + final res = await tester.client.sendMessage(message, channelId, channelType); + expect(res, isNotNull); + expect(res.message, isSameMessageAs(message)); + + tester + ..verifyApi( + (api) => api.message.sendMessage( + channelId, + channelType, + any(that: isSameMessageAs(message)), + ), + ) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); - final res = await client.createDraft( - message, - channelId, - channelType, - ); + chatClientTest( + '`.createDraft`', + body: (tester) async { + final message = DraftMessage(id: 'test-message-id', text: 'Hello!'); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; - expect(res, isNotNull); - expect(res.draft.message, isSameDraftMessageAs(message)); + tester.mockApi( + (api) => api.message.createDraft( + channelId, + channelType, + any(that: isSameDraftMessageAs(message)), + ), + result: createDefaultCreateDraftResponse( + draft: createDefaultDraft( + channelCid: '$channelType:$channelId', + message: message, + ), + ), + ); - verify( - () => api.message.createDraft( + final res = await tester.client.createDraft( + message, channelId, channelType, - any(that: isSameDraftMessageAs(message)), - ), - ).called(1); + ); - verifyNoMoreInteractions(api.message); - }); + expect(res, isNotNull); + expect(res.draft.message, isSameDraftMessageAs(message)); + + tester + ..verifyApi( + (api) => api.message.createDraft( + channelId, + channelType, + any(that: isSameDraftMessageAs(message)), + ), + ) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); - test('`.deleteDraft`', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; + chatClientTest( + '`.deleteDraft`', + body: (tester) async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; - when(() => api.message.deleteDraft(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); + tester.mockApi( + (api) => api.message.deleteDraft(channelId, channelType), + result: createDefaultEmptyResponse(), + ); - final res = await client.deleteDraft(channelId, channelType); - expect(res, isNotNull); + final res = await tester.client.deleteDraft(channelId, channelType); + expect(res, isNotNull); - verify(() => api.message.deleteDraft(channelId, channelType)); - verifyNoMoreInteractions(api.message); - }); + tester + ..verifyApi((api) => api.message.deleteDraft(channelId, channelType)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); - test('`.getDraft`', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; + chatClientTest( + '`.getDraft`', + body: (tester) async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; - final message = DraftMessage(id: 'test-message-id', text: 'Hello!'); + final message = DraftMessage(id: 'test-message-id', text: 'Hello!'); - when(() => api.message.getDraft(channelId, channelType)).thenAnswer( - (_) async => GetDraftResponse() - ..draft = Draft( - channelCid: '$channelType:$channelId', - createdAt: DateTime.now(), - message: message, + tester.mockApi( + (api) => api.message.getDraft(channelId, channelType), + result: createDefaultGetDraftResponse( + draft: createDefaultDraft( + channelCid: '$channelType:$channelId', + message: message, + ), ), - ); + ); - final res = await client.getDraft(channelId, channelType); + final res = await tester.client.getDraft(channelId, channelType); - expect(res, isNotNull); - expect(res.draft.message, isSameDraftMessageAs(message)); + expect(res, isNotNull); + expect(res.draft.message, isSameDraftMessageAs(message)); - verify(() => api.message.getDraft(channelId, channelType)); - verifyNoMoreInteractions(api.message); - }); + tester + ..verifyApi((api) => api.message.getDraft(channelId, channelType)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); - test('`.queryDrafts`', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; + chatClientTest( + '`.queryDrafts`', + body: (tester) async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; - final filter = DraftFilter.equal(DraftFilterField.channelCid, '$channelType:$channelId'); - final sort = [DraftSort.desc(DraftSortField.createdAt)]; - const pagination = PaginationParams(limit: 20); + final filter = DraftFilter.equal(DraftFilterField.channelCid, '$channelType:$channelId'); + final sort = [DraftSort.desc(DraftSortField.createdAt)]; + const pagination = PaginationParams(limit: 20); - final drafts = [ - Draft( - channelCid: '$channelType:$channelId', - createdAt: DateTime.now(), - message: DraftMessage(id: 'test-message-id', text: 'Hello!'), - ), - ]; + final drafts = [ + createDefaultDraft( + channelCid: '$channelType:$channelId', + message: DraftMessage(id: 'test-message-id', text: 'Hello!'), + ), + ]; - when( - () => api.message.queryDrafts( + tester.mockApi( + (api) => api.message.queryDrafts( + filter: filter, + sort: sort, + pagination: pagination, + ), + result: QueryDraftsResponse()..drafts = drafts, + ); + + final res = await tester.client.queryDrafts( filter: filter, sort: sort, pagination: pagination, - ), - ).thenAnswer((_) async => QueryDraftsResponse()..drafts = drafts); + ); - final res = await client.queryDrafts( - filter: filter, - sort: sort, - pagination: pagination, - ); + expect(res, isNotNull); + expect(res.drafts.length, drafts.length); - expect(res, isNotNull); - expect(res.drafts.length, drafts.length); + tester + ..verifyApi( + (api) => api.message.queryDrafts( + filter: filter, + sort: sort, + pagination: pagination, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); - verify( - () => api.message.queryDrafts( - filter: filter, - sort: sort, - pagination: pagination, - ), - ).called(1); - verifyNoMoreInteractions(api.message); - }); + chatClientTest( + '`.getReplies`', + body: (tester) async { + const parentId = 'test-parent-id'; - test('`.getReplies`', () async { - const parentId = 'test-parent-id'; + final messages = List.generate( + 3, + (index) => Message(id: 'test-message-id-$index'), + ); - final messages = List.generate( - 3, - (index) => Message(id: 'test-message-id-$index'), - ); + tester.mockApi( + (api) => api.message.getReplies(parentId), + result: createDefaultQueryRepliesResponse(messages: messages), + ); - when(() => api.message.getReplies(parentId)).thenAnswer((_) async => QueryRepliesResponse()..messages = messages); + final res = await tester.client.getReplies(parentId); + expect(res, isNotNull); + expect(res.messages.length, messages.length); - final res = await client.getReplies(parentId); - expect(res, isNotNull); - expect(res.messages.length, messages.length); + tester + ..verifyApi((api) => api.message.getReplies(parentId)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); - verify(() => api.message.getReplies(parentId)).called(1); - verifyNoMoreInteractions(api.message); - }); + chatClientTest( + '`.getReactions`', + body: (tester) async { + const messageId = 'test-parent-id'; - test('`.getReactions`', () async { - const messageId = 'test-parent-id'; + final reactions = List.generate( + 3, + (index) => Reaction( + type: 'test-reactions-type-$index', + messageId: messageId, + ), + ); - final reactions = List.generate( - 3, - (index) => Reaction( - type: 'test-reactions-type-$index', - messageId: messageId, - ), - ); + tester.mockApi( + (api) => api.message.getReactions(messageId), + result: createDefaultQueryReactionsResponse(reactions: reactions), + ); - when( - () => api.message.getReactions(messageId), - ).thenAnswer((_) async => QueryReactionsResponse()..reactions = reactions); + final res = await tester.client.getReactions(messageId); + expect(res, isNotNull); + expect(res.reactions.length, reactions.length); + expect(res.reactions.every((it) => it.messageId == messageId), isTrue); - final res = await client.getReactions(messageId); - expect(res, isNotNull); - expect(res.reactions.length, reactions.length); - expect(res.reactions.every((it) => it.messageId == messageId), isTrue); + tester + ..verifyApi((api) => api.message.getReactions(messageId)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); - verify(() => api.message.getReactions(messageId)).called(1); - verifyNoMoreInteractions(api.message); - }); + chatClientTest( + '`.queryReactions`', + body: (tester) async { + const messageId = 'test-message-id'; - test('`.queryReactions`', () async { - const messageId = 'test-message-id'; + final reactions = List.generate( + 3, + (index) => Reaction( + type: 'test-reactions-type-$index', + messageId: messageId, + ), + ); - final reactions = List.generate( - 3, - (index) => Reaction( - type: 'test-reactions-type-$index', - messageId: messageId, - ), - ); + tester.mockApi( + (api) => api.message.queryReactions(messageId), + result: createDefaultQueryReactionsResponse(reactions: reactions), + ); - when( - () => api.message.queryReactions(messageId), - ).thenAnswer( - (_) async => QueryReactionsResponse() - ..reactions = reactions - ..next = null, - ); + final res = await tester.client.queryReactions(messageId); + expect(res, isNotNull); + expect(res.reactions.length, reactions.length); + expect(res.reactions.every((it) => it.messageId == messageId), isTrue); - final res = await client.queryReactions(messageId); - expect(res, isNotNull); - expect(res.reactions.length, reactions.length); - expect(res.reactions.every((it) => it.messageId == messageId), isTrue); + tester + ..verifyApi((api) => api.message.queryReactions(messageId)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); - verify(() => api.message.queryReactions(messageId)).called(1); - verifyNoMoreInteractions(api.message); - }); + chatClientTest( + '`.updateMessage`', + body: (tester) async { + final message = Message(id: 'test-message-id', text: 'Hello!'); - test('`.updateMessage`', () async { - final message = Message(id: 'test-message-id', text: 'Hello!'); + tester.mockApi( + (api) => api.message.updateMessage(any(that: isSameMessageAs(message))), + result: createDefaultUpdateMessageResponse(message: message), + ); - when( - () => api.message.updateMessage(any(that: isSameMessageAs(message))), - ).thenAnswer((_) async => UpdateMessageResponse()..message = message); + final res = await tester.client.updateMessage(message); + expect(res, isNotNull); + expect(res.message, isSameMessageAs(message)); - final res = await client.updateMessage(message); - expect(res, isNotNull); - expect(res.message, isSameMessageAs(message)); + tester + ..verifyApi( + (api) => api.message.updateMessage(any(that: isSameMessageAs(message))), + ) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); - verify( - () => api.message.updateMessage(any(that: isSameMessageAs(message))), - ).called(1); - verifyNoMoreInteractions(api.message); - }); + chatClientTest( + '`.deleteMessage`', + body: (tester) async { + const messageId = 'test-message-id'; - test('`.deleteMessage`', () async { - const messageId = 'test-message-id'; + tester.mockApi( + (api) => api.message.deleteMessage(messageId, hard: false), + result: createDefaultEmptyResponse(), + ); - when(() => api.message.deleteMessage(messageId, hard: false)).thenAnswer((_) async => EmptyResponse()); + final res = await tester.client.deleteMessage(messageId); + expect(res, isNotNull); - final res = await client.deleteMessage(messageId); - expect(res, isNotNull); + tester + ..verifyApi((api) => api.message.deleteMessage(messageId, hard: false)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); - verify(() => api.message.deleteMessage(messageId, hard: false)).called(1); - verifyNoMoreInteractions(api.message); - }); + chatClientTest( + '`.deleteMessageForMe`', + body: (tester) async { + const messageId = 'test-message-id'; - test('`.deleteMessageForMe`', () async { - const messageId = 'test-message-id'; + tester.mockApi( + (api) => api.message.deleteMessage(messageId, deleteForMe: true), + result: createDefaultEmptyResponse(), + ); - when(() => api.message.deleteMessage(messageId, deleteForMe: true)).thenAnswer((_) async => EmptyResponse()); + final res = await tester.client.deleteMessageForMe(messageId); + expect(res, isNotNull); - final res = await client.deleteMessageForMe(messageId); - expect(res, isNotNull); + tester + ..verifyApi((api) => api.message.deleteMessage(messageId, deleteForMe: true)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); - verify(() => api.message.deleteMessage(messageId, deleteForMe: true)).called(1); - verifyNoMoreInteractions(api.message); - }); + chatClientTest( + '`.getMessage`', + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); - test('`.getMessage`', () async { - const messageId = 'test-message-id'; - final message = Message(id: messageId); + tester.mockApi( + (api) => api.message.getMessage(messageId), + result: createDefaultGetMessageResponse(message: message), + ); - when(() => api.message.getMessage(messageId)).thenAnswer((_) async => GetMessageResponse()..message = message); + final res = await tester.client.getMessage(messageId); + expect(res, isNotNull); + expect(res.message.id, messageId); - final res = await client.getMessage(messageId); - expect(res, isNotNull); - expect(res.message.id, messageId); + tester + ..verifyApi((api) => api.message.getMessage(messageId)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); - verify(() => api.message.getMessage(messageId)).called(1); - verifyNoMoreInteractions(api.message); - }); + chatClientTest( + '`.getMessagesById`', + body: (tester) async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const messageIds = ['test-message-id']; - test('`.getMessagesById`', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - const messageIds = ['test-message-id']; + final messages = messageIds.map((id) => Message(id: id)).toList(); - final messages = messageIds.map((id) => Message(id: id)).toList(); + tester.mockApi( + (api) => api.message.getMessagesById(channelId, channelType, messageIds), + result: createDefaultGetMessagesByIdResponse(messages: messages), + ); - when( - () => api.message.getMessagesById(channelId, channelType, messageIds), - ).thenAnswer((_) async => GetMessagesByIdResponse()..messages = messages); + final res = await tester.client.getMessagesById( + channelId, + channelType, + messageIds, + ); + expect(res, isNotNull); + expect(res.messages.length, messageIds.length); - final res = await client.getMessagesById( - channelId, - channelType, - messageIds, - ); - expect(res, isNotNull); - expect(res.messages.length, messageIds.length); + tester + ..verifyApi( + (api) => api.message.getMessagesById(channelId, channelType, messageIds), + ) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); - verify( - () => api.message.getMessagesById(channelId, channelType, messageIds), - ).called(1); - verifyNoMoreInteractions(api.message); - }); + chatClientTest( + '`.translateMessage`', + body: (tester) async { + const messageId = 'test-message-id'; + const language = 'hi'; // Hindi + const translatedMessageText = 'नमस्ते'; + final translatedMessage = Message( + i18n: const { + language: translatedMessageText, + }, + ); - test('`.translateMessage`', () async { - const messageId = 'test-message-id'; - const language = 'hi'; // Hindi - const translatedMessageText = 'नमस्ते'; - final translatedMessage = Message( - i18n: const { - language: translatedMessageText, - }, - ); + tester.mockApi( + (api) => api.message.translateMessage(messageId, language), + result: createDefaultTranslateMessageResponse(message: translatedMessage), + ); - when(() => api.message.translateMessage(messageId, language)).thenAnswer( - (_) async => TranslateMessageResponse()..message = translatedMessage, - ); + final res = await tester.client.translateMessage(messageId, language); - final res = await client.translateMessage(messageId, language); + expect(res, isNotNull); + expect(res.message.i18n, translatedMessage.i18n); - expect(res, isNotNull); - expect(res.message.i18n, translatedMessage.i18n); + tester + ..verifyApi((api) => api.message.translateMessage(messageId, language)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); - verify(() => api.message.translateMessage(messageId, language)).called(1); - verifyNoMoreInteractions(api.message); - }); + chatClientTest( + '`.partialUpdateMessage`', + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); - test('`.partialUpdateMessage`', () async { - const messageId = 'test-message-id'; - final message = Message(id: messageId); + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; - const set = {'text': 'Update Message text'}; - const unset = ['pinExpires']; + final updateMessageResponse = createDefaultUpdateMessageResponse( + message: message.copyWith(text: set['text'], pinExpires: null), + ); - final updateMessageResponse = UpdateMessageResponse() - ..message = message.copyWith(text: set['text'], pinExpires: null); + tester.mockApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + ), + result: updateMessageResponse, + ); - when( - () => api.message.partialUpdateMessage( - message.id, + final res = await tester.client.partialUpdateMessage( + messageId, set: set, unset: unset, - ), - ).thenAnswer((_) async => updateMessageResponse); - - final res = await client.partialUpdateMessage( - messageId, - set: set, - unset: unset, - ); + ); - expect(res, isNotNull); - expect(res.message.id, message.id); - expect(res.message.id, message.id); - expect(res.message.text, set['text']); - expect(res.message.pinExpires, isNull); + expect(res, isNotNull); + expect(res.message.id, message.id); + expect(res.message.id, message.id); + expect(res.message.text, set['text']); + expect(res.message.pinExpires, isNull); - verify( - () => api.message.partialUpdateMessage( - message.id, - set: set, - unset: unset, - ), - ).called(1); - verifyNoMoreInteractions(api.message); - }); + tester + ..verifyApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); group('`.pinMessage`', () { - test('should work fine without passing timeoutOrExpirationDate', () async { - const messageId = 'test-message-id'; - final message = Message(id: messageId); - - when( - () => api.message.partialUpdateMessage( - messageId, - set: any(named: 'set'), - unset: any(named: 'unset'), + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], ), - ).thenAnswer( - (_) async => UpdateMessageResponse() - ..message = message.copyWith( - pinned: true, - pinExpires: null, - state: MessageState.sent, + ); + } + + chatClientTest( + 'should work fine without passing timeoutOrExpirationDate', + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + + tester.mockApi( + (api) => api.message.partialUpdateMessage( + messageId, + set: {'pinned': true, 'pin_expires': null}, ), - ); + result: createDefaultUpdateMessageResponse( + message: message.copyWith( + pinned: true, + pinExpires: null, + state: MessageState.sent, + ), + ), + ); - final res = await client.pinMessage(messageId); + final res = await tester.client.pinMessage(messageId); - expect(res, isNotNull); - expect(res.message.pinned, isTrue); - expect(res.message.pinExpires, isNull); + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNull); - verify( - () => api.message.partialUpdateMessage( - messageId, - set: any(named: 'set'), - unset: any(named: 'unset'), - ), - ).called(1); - verifyNoMoreInteractions(api.message); - }); + tester + ..verifyApi( + (api) => api.message.partialUpdateMessage( + messageId, + set: {'pinned': true, 'pin_expires': null}, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); - test( + chatClientTest( 'should work fine if passed timeoutOrExpirationDate as num(seconds)', - () async { + body: (tester) async { const messageId = 'test-message-id'; final message = Message(id: messageId); const timeoutOrExpirationDate = 300; // 300 seconds - when( - () => api.message.partialUpdateMessage( + // The client computes `pin_expires` from the current time, so the + // stub cannot match the exact `set` map. + tester.mockApi( + (api) => api.message.partialUpdateMessage( message.id, set: any(named: 'set'), - unset: any(named: 'unset'), ), - ).thenAnswer( - (_) async => UpdateMessageResponse() - ..message = message.copyWith( + result: createDefaultUpdateMessageResponse( + message: message.copyWith( pinned: true, - pinExpires: DateTime.now().add( + pinExpires: DateTime.utc(2021, 3).add( const Duration(seconds: timeoutOrExpirationDate), ), state: MessageState.sent, ), + ), ); - final res = await client.pinMessage( + final res = await tester.client.pinMessage( messageId, timeoutOrExpirationDate: timeoutOrExpirationDate, ); @@ -4923,40 +5389,42 @@ void main() { expect(res.message.pinned, isTrue); expect(res.message.pinExpires, isNotNull); - verify( - () => api.message.partialUpdateMessage( - messageId, - set: any(named: 'set'), - unset: any(named: 'unset'), - ), - ).called(1); - verifyNoMoreInteractions(api.message); + tester + ..verifyApi( + (api) => api.message.partialUpdateMessage( + messageId, + set: any(named: 'set'), + ), + ) + ..verifyNoMoreApiInteractions((api) => api.message); }, ); - test( + chatClientTest( 'should work fine if passed timeoutOrExpirationDate as DateTime', - () async { + body: (tester) async { const messageId = 'test-message-id'; final message = Message(id: messageId); - final timeoutOrExpirationDate = DateTime.now().add(const Duration(days: 3)); // 3 days + final timeoutOrExpirationDate = DateTime.utc(2021, 3).add(const Duration(days: 3)); // 3 days - when( - () => api.message.partialUpdateMessage( + tester.mockApi( + (api) => api.message.partialUpdateMessage( messageId, - set: any(named: 'set'), - unset: any(named: 'unset'), + set: { + 'pinned': true, + 'pin_expires': timeoutOrExpirationDate.toUtc().toIso8601String(), + }, ), - ).thenAnswer( - (_) async => UpdateMessageResponse() - ..message = message.copyWith( + result: createDefaultUpdateMessageResponse( + message: message.copyWith( pinned: true, pinExpires: timeoutOrExpirationDate, state: MessageState.sent, ), + ), ); - final res = await client.pinMessage( + final res = await tester.client.pinMessage( messageId, timeoutOrExpirationDate: timeoutOrExpirationDate, ); @@ -4966,26 +5434,32 @@ void main() { expect(res.message.pinExpires, isNotNull); expect(res.message.pinExpires, timeoutOrExpirationDate.toUtc()); - verify( - () => api.message.partialUpdateMessage( - messageId, - set: any(named: 'set'), - unset: any(named: 'unset'), - ), - ).called(1); - verifyNoMoreInteractions(api.message); + tester + ..verifyApi( + (api) => api.message.partialUpdateMessage( + messageId, + set: { + 'pinned': true, + 'pin_expires': timeoutOrExpirationDate.toUtc().toIso8601String(), + }, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.message); }, ); - test( + channelTest( 'should throw if invalid timeoutOrExpirationDate is passed', - () async { - const messageId = 'test-message-id'; + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message(id: 'test-message-id'); const timeoutOrExpirationDate = 'invalid-value'; try { - await client.pinMessage( - messageId, + await tester.channel.pinMessage( + message, timeoutOrExpirationDate: timeoutOrExpirationDate, ); } catch (e) { @@ -4995,69 +5469,82 @@ void main() { ); }); - test('`.unpinMessage`', () async { - const messageId = 'test-message-id'; - final message = Message(id: messageId, pinned: true); + chatClientTest( + '`.unpinMessage`', + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message(id: messageId, pinned: true); - when( - () => api.message.partialUpdateMessage( - messageId, - set: {'pinned': false}, - ), - ).thenAnswer( - (_) async => UpdateMessageResponse() - ..message = message.copyWith( - pinned: false, - state: MessageState.sent, + tester.mockApi( + (api) => api.message.partialUpdateMessage( + messageId, + set: {'pinned': false}, ), - ); - - final res = await client.unpinMessage(messageId); + result: createDefaultUpdateMessageResponse( + message: message.copyWith( + pinned: false, + state: MessageState.sent, + ), + ), + ); - expect(res, isNotNull); - expect(res.message.pinned, isFalse); + final res = await tester.client.unpinMessage(messageId); - verify( - () => api.message.partialUpdateMessage( - messageId, - set: {'pinned': false}, - ), - ).called(1); - verifyNoMoreInteractions(api.message); - }); + expect(res, isNotNull); + expect(res.message.pinned, isFalse); - test('`.enrichUrl`', () async { - const url = 'https://www.techyourchance.com/finite-state-machine-with-unit-tests-real-world-example'; + tester + ..verifyApi( + (api) => api.message.partialUpdateMessage( + messageId, + set: {'pinned': false}, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); - when(() => api.general.enrichUrl(url)).thenAnswer( - (_) async => OGAttachmentResponse() - ..type = 'image' - ..ogScrapeUrl = url - ..authorName = 'TechYourChance' - ..title = 'Finite State Machine with Unit Tests: Real World Example', - ); + chatClientTest( + '`.enrichUrl`', + body: (tester) async { + const url = 'https://www.techyourchance.com/finite-state-machine-with-unit-tests-real-world-example'; + + tester.mockApi( + (api) => api.general.enrichUrl(url), + result: OGAttachmentResponse() + ..type = 'image' + ..ogScrapeUrl = url + ..authorName = 'TechYourChance' + ..title = 'Finite State Machine with Unit Tests: Real World Example', + ); - final res = await client.enrichUrl(url); + final res = await tester.client.enrichUrl(url); - expect(res, isNotNull); - expect(res.type, 'image'); - expect(res.ogScrapeUrl, url); - expect(res.authorName, 'TechYourChance'); - expect( - res.title, - 'Finite State Machine with Unit Tests: Real World Example', - ); + expect(res, isNotNull); + expect(res.type, 'image'); + expect(res.ogScrapeUrl, url); + expect(res.authorName, 'TechYourChance'); + expect( + res.title, + 'Finite State Machine with Unit Tests: Real World Example', + ); - verify(() => api.general.enrichUrl(url)).called(1); - verify(() => api.general.getAppSettings()).called(1); - verifyNoMoreInteractions(api.general); - }); + tester + ..verifyApi((api) => api.general.enrichUrl(url)) + ..verifyApi((api) => api.general.getAppSettings()) + ..verifyNoMoreApiInteractions((api) => api.general); + }, + ); - test( + // The unread counts are derived from the current user, so assigning a new + // one has to republish them. + chatClientTest( '''setting the `currentUser` should also compute and update the unreadCounts''', - () { - final state = client.state; - final initialUser = OwnUser.fromUser(user); + body: (tester) async { + final state = tester.clientState; + // Derived from the harness user rather than read back out of the state, + // so this still asserts that connecting produced the expected user. + final initialUser = OwnUser.fromUser(tester.user); expect(state.currentUser, initialUser); expect(state.totalUnreadCount, 0); @@ -5077,119 +5564,132 @@ void main() { }); group('PersistenceConnectionTests', () { - const apiKey = 'test-api-key'; - late final api = FakeChatApi(); - - final user = User(id: 'test-user-id'); - final token = testUserToken(user.id).rawValue; - - late StreamChatClient client; - - setUp(() async { - final ws = FakeWebSocket(); - client = StreamChatClient(apiKey, chatApi: api, ws: ws); - expect(client.persistenceEnabled, isFalse); - }); - - tearDown(() async { - client.chatPersistenceClient = null; - expect(client.persistenceEnabled, isFalse); - await client.dispose(); - }); + // Runs [body] as a [chatClientTest] that never opens the socket: persistence + // starts disabled, and the persistence client is detached and asserted + // disabled again after the body. + void _persistenceConnectionTest( + String description, { + required Future Function(ChatClientTester tester) body, + }) { + chatClientTest( + description, + connect: (_) {}, + setUp: (tester) => expect(tester.client.persistenceEnabled, isFalse), + body: body, + tearDown: (tester) { + tester.client.chatPersistenceClient = null; + expect(tester.client.persistenceEnabled, isFalse); + }, + ); + } - test('openPersistenceConnection connects the client to the user', () async { - client.chatPersistenceClient = MockPersistenceClient(); - await client.openPersistenceConnection(user); - expect(client.persistenceEnabled, isTrue); - }); + _persistenceConnectionTest( + 'openPersistenceConnection connects the client to the user', + body: (tester) async { + tester.client.chatPersistenceClient = MockPersistenceClient(); + await tester.client.openPersistenceConnection(tester.user); + expect(tester.client.persistenceEnabled, isTrue); + }, + ); - test( + _persistenceConnectionTest( '''multiple call to openPersistenceConnection does not throws an error if already connected to the same user''', - () async { - client.chatPersistenceClient = MockPersistenceClient(); - await client.openPersistenceConnection(user); - expect(client.persistenceEnabled, isTrue); - - await expectLater(client.openPersistenceConnection(user), completes); - await expectLater(client.openPersistenceConnection(user), completes); - await expectLater(client.openPersistenceConnection(user), completes); + body: (tester) async { + tester.client.chatPersistenceClient = MockPersistenceClient(); + await tester.client.openPersistenceConnection(tester.user); + expect(tester.client.persistenceEnabled, isTrue); + + await expectLater(tester.client.openPersistenceConnection(tester.user), completes); + await expectLater(tester.client.openPersistenceConnection(tester.user), completes); + await expectLater(tester.client.openPersistenceConnection(tester.user), completes); }, ); - test( + _persistenceConnectionTest( '''openPersistenceConnection throws an error if client is already connected to a different user''', - () async { - client.chatPersistenceClient = MockPersistenceClient(); - await client.openPersistenceConnection(user); - expect(client.persistenceEnabled, isTrue); + body: (tester) async { + tester.client.chatPersistenceClient = MockPersistenceClient(); + await tester.client.openPersistenceConnection(tester.user); + expect(tester.client.persistenceEnabled, isTrue); await expectLater( - client.openPersistenceConnection(user.copyWith(id: 'new-id')), + tester.client.openPersistenceConnection(tester.user.copyWith(id: 'new-id')), throwsA(isA()), ); }, ); - test( + _persistenceConnectionTest( '''openPersistenceConnection throws an error if chatPersistenceClient is not set''', - () async { + body: (tester) async { await expectLater( - client.openPersistenceConnection(user), + tester.client.openPersistenceConnection(tester.user), throwsA(isA()), ); }, ); - test('closePersistenceConnection disconnects the client', () async { - client.chatPersistenceClient = MockPersistenceClient(); - await client.openPersistenceConnection(user); - expect(client.persistenceEnabled, isTrue); + _persistenceConnectionTest( + 'closePersistenceConnection disconnects the client', + body: (tester) async { + tester.client.chatPersistenceClient = MockPersistenceClient(); + await tester.client.openPersistenceConnection(tester.user); + expect(tester.client.persistenceEnabled, isTrue); - await client.closePersistenceConnection(); - expect(client.persistenceEnabled, isFalse); - }); + await tester.client.closePersistenceConnection(); + expect(tester.client.persistenceEnabled, isFalse); + }, + ); - test( + _persistenceConnectionTest( '''closePersistenceConnection compeletes normally if chatPersistenceClient is not connected''', - () async { - client.chatPersistenceClient = MockPersistenceClient(); - expect(client.chatPersistenceClient!.isConnected, isFalse); + body: (tester) async { + tester.client.chatPersistenceClient = MockPersistenceClient(); + expect(tester.client.chatPersistenceClient!.isConnected, isFalse); - await expectLater(client.closePersistenceConnection(), completes); + await expectLater(tester.client.closePersistenceConnection(), completes); }, ); - test( + _persistenceConnectionTest( '''closePersistenceConnection completes normally if chatPersistenceClient is not set''', - () async { - expect(client.persistenceEnabled, isFalse); - await expectLater(client.closePersistenceConnection(), completes); + body: (tester) async { + expect(tester.client.persistenceEnabled, isFalse); + await expectLater(tester.client.closePersistenceConnection(), completes); }, ); - test( + _persistenceConnectionTest( '''connectUser completes normally if the persistence connection is already connected to the same user''', - () async { - client.chatPersistenceClient = MockPersistenceClient(); - await client.openPersistenceConnection(user); - expect(client.persistenceEnabled, isTrue); + body: (tester) async { + tester.client.chatPersistenceClient = MockPersistenceClient(); + await tester.client.openPersistenceConnection(tester.user); + expect(tester.client.persistenceEnabled, isTrue); await expectLater( - client.connectUser(user, token, connectWebSocket: false), + tester.client.connectUser( + tester.user, + createTestToken(tester.user.id).rawValue, + connectWebSocket: false, + ), completes, ); }, ); - test( + _persistenceConnectionTest( '''connectUser should throw if the persistence connection if already connected to a different user''', - () async { - client.chatPersistenceClient = MockPersistenceClient(); - await client.openPersistenceConnection(user.copyWith(id: 'new-id')); - expect(client.persistenceEnabled, isTrue); + body: (tester) async { + tester.client.chatPersistenceClient = MockPersistenceClient(); + await tester.client.openPersistenceConnection(tester.user.copyWith(id: 'new-id')); + expect(tester.client.persistenceEnabled, isTrue); await expectLater( - client.connectUser(user, token, connectWebSocket: false), + tester.client.connectUser( + tester.user, + createTestToken(tester.user.id).rawValue, + connectWebSocket: false, + ), throwsA(isA()), ); }, @@ -5197,85 +5697,96 @@ void main() { group('Sync Method Tests', () { setUpAll(() { + // fallback values + registerFallbackValue([]); + registerFallbackValue(DateTime(0)); registerFallbackValue(const PaginationParams()); registerFallbackValue(ChannelFilter.equal(ChannelFilterField.cid, '')); }); - test( + _persistenceConnectionTest( 'should retrieve data from persistence client and sync successfully', - () async { + body: (tester) async { final cids = ['channel1', 'channel2']; - final lastSyncAt = DateTime.now().subtract(const Duration(hours: 1)); + final lastSyncAt = DateTime.utc(2021, 3); final fakeClient = FakePersistenceClient( channelCids: cids, lastSyncAt: lastSyncAt, ); - client.chatPersistenceClient = fakeClient; - when(() => api.general.sync(cids, lastSyncAt)).thenAnswer( - (_) async => SyncResponse()..events = [], + tester.client.chatPersistenceClient = fakeClient; + tester.mockApi( + (api) => api.general.sync(cids, lastSyncAt), + result: createDefaultSyncResponse(), ); - await client.sync(); + await tester.client.sync(); - verify(() => api.general.sync(cids, lastSyncAt)).called(1); + tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); final newLastSyncAt = await fakeClient.getLastSyncAt(); expect(newLastSyncAt?.isAfter(lastSyncAt), isTrue); }, ); - test('should set lastSyncAt on first sync when null', () async { - final fakeClient = FakePersistenceClient( - channelCids: ['channel1'], - lastSyncAt: null, - ); + _persistenceConnectionTest( + 'should set lastSyncAt on first sync when null', + body: (tester) async { + final fakeClient = FakePersistenceClient( + channelCids: ['channel1'], + lastSyncAt: null, + ); - client.chatPersistenceClient = fakeClient; + tester.client.chatPersistenceClient = fakeClient; - await client.sync(); + await tester.client.sync(); - expectLater(fakeClient.getLastSyncAt(), completion(isNotNull)); - verifyNever(() => api.general.sync(any(), any())); - }); + expectLater(fakeClient.getLastSyncAt(), completion(isNotNull)); + tester.verifyNeverCalled((api) => api.general.sync(any(), any())); + }, + ); - test('should flush persistence client on 400 error', () async { - final cids = ['channel1']; - final lastSyncAt = DateTime.now().subtract(const Duration(hours: 1)); - final fakeClient = FakePersistenceClient( - channelCids: cids, - lastSyncAt: lastSyncAt, - ); - - client.chatPersistenceClient = fakeClient; - // What `/sync` answers when `lastSyncAt` is too old, or the channel - // list or event count is oversized. - when(() => api.general.sync(cids, lastSyncAt)).thenThrow( - apiException( - code: StreamErrorCode.inputError, - statusCode: 400, - message: 'Too many events', - ), - ); + _persistenceConnectionTest( + 'should flush persistence client on 400 error', + body: (tester) async { + final cids = ['channel1']; + final lastSyncAt = DateTime.utc(2021, 3); + final fakeClient = FakePersistenceClient( + channelCids: cids, + lastSyncAt: lastSyncAt, + ); - await client.sync(); + tester.client.chatPersistenceClient = fakeClient; + tester.mockApiFailure( + (api) => api.general.sync(cids, lastSyncAt), + // What `/sync` answers when the window it was asked for is wider + // than the server will serve. + error: createDefaultNetworkError( + code: StreamErrorCode.inputError, + statusCode: 400, + message: 'Too many events', + ), + ); - expect(await fakeClient.getChannelCids(), isEmpty); // Should be flushed + await tester.client.sync(); - verify(() => api.general.sync(cids, lastSyncAt)).called(1); - }); + expect(await fakeClient.getChannelCids(), isEmpty); // Should be flushed + + tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); + }, + ); - test( + _persistenceConnectionTest( '''should replay events and advance lastSyncAt when the payload is within the replay limit''', - () async { + body: (tester) async { final cids = ['channel1']; - final lastSyncAt = DateTime.now().subtract(const Duration(hours: 1)); + final lastSyncAt = DateTime.timestamp().subtract(const Duration(hours: 1)); final fakeClient = FakePersistenceClient( channelCids: cids, lastSyncAt: lastSyncAt, ); - client.chatPersistenceClient = fakeClient; + tester.client.chatPersistenceClient = fakeClient; final events = List.generate( 10, (index) => Event( @@ -5285,18 +5796,19 @@ void main() { createdAt: lastSyncAt.add(Duration(seconds: index + 1)), ), ); - when(() => api.general.sync(cids, lastSyncAt)).thenAnswer( - (_) async => SyncResponse()..events = events, + tester.mockApi( + (api) => api.general.sync(cids, lastSyncAt), + result: SyncResponse()..events = events, ); final replayed = []; - final sub = client.on(EventType.messageNew).listen(replayed.add); + final sub = tester.client.on(EventType.messageNew).listen(replayed.add); addTearDown(sub.cancel); - await client.sync(); + await tester.client.sync(); await pumpEventQueue(); - verify(() => api.general.sync(cids, lastSyncAt)).called(1); + tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); // Within the limit, every event is replayed through the event handler. expect(replayed, hasLength(events.length)); // lastSyncAt advances to the newest replayed event date. @@ -5304,17 +5816,17 @@ void main() { }, ); - test( + _persistenceConnectionTest( '''should refresh the synced channels in place of a payload that exceeds the replay limit''', - () async { + body: (tester) async { final cids = ['channel1']; - final lastSyncAt = DateTime.now().subtract(const Duration(hours: 1)); + final lastSyncAt = DateTime.timestamp().subtract(const Duration(hours: 1)); final fakeClient = FakePersistenceClient( channelCids: cids, lastSyncAt: lastSyncAt, ); - client.chatPersistenceClient = fakeClient; + tester.client.chatPersistenceClient = fakeClient; // 251 events exceeds the internal replay limit of 250. final events = List.generate( 251, @@ -5325,40 +5837,43 @@ void main() { createdAt: lastSyncAt.add(Duration(seconds: index + 1)), ), ); - when(() => api.general.sync(cids, lastSyncAt)).thenAnswer( - (_) async => SyncResponse()..events = events, - ); - - when( - () => api.channel.queryChannels( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - memberLimit: any(named: 'memberLimit'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - ), - ).thenAnswer((_) async => QueryChannelsResponse()..channels = []); + tester + ..mockApi( + (api) => api.general.sync(cids, lastSyncAt), + result: SyncResponse()..events = events, + ) + ..mockApi( + (api) => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + result: QueryChannelsResponse()..channels = [], + ); final replayed = []; - final sub = client.on(EventType.messageNew).listen(replayed.add); + final sub = tester.client.on(EventType.messageNew).listen(replayed.add); addTearDown(sub.cancel); - // The group shares one api mock, so only count this test's calls. - clearInteractions(api.channel); - - await client.sync(); + await tester.client.sync(); await pumpEventQueue(); - verify(() => api.general.sync(cids, lastSyncAt)).called(1); + tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); // Replay is skipped; no events are dispatched through the handler. expect(replayed, isEmpty); // The channels the payload covered are refreshed in its place. - verify( - () => api.channel.queryChannels( - filter: any(named: 'filter', that: isSameFilterAs(ChannelFilter.in_(ChannelFilterField.cid, cids))), + tester.verifyApi( + (api) => api.channel.queryChannels( + // A filter compares by identity, so match on what it sends. + filter: any( + named: 'filter', + that: isSameFilterAs(ChannelFilter.in_(ChannelFilterField.cid, cids)), + ), sort: any(named: 'sort'), state: any(named: 'state'), watch: any(named: 'watch'), @@ -5367,7 +5882,7 @@ void main() { messageLimit: any(named: 'messageLimit'), paginationParams: const PaginationParams(limit: 1), ), - ).called(1); + ); // lastSyncAt moves to the newest event in the skipped payload, so // that payload is not re-fetched while anything after it still is. expect(await fakeClient.getLastSyncAt(), events.last.createdAt); @@ -5379,10 +5894,10 @@ void main() { group('recoverStateOnReconnect', () { const apiKey = 'test-api-key'; final user = User(id: 'test-user-id'); - final token = testUserToken(user.id).rawValue; + final token = createTestToken(user.id).rawValue; late FakeChatApi api; - late FakeWebSocket ws; + late _FakeWebSocket ws; late StreamChatClient client; setUpAll(() { @@ -5392,7 +5907,7 @@ void main() { setUp(() { api = FakeChatApi(); - ws = FakeWebSocket(); + ws = _FakeWebSocket(); // Stub queryChannels for every test — it's the API the recovery path // calls when enabled, and a missing stub would surface as an unhandled @@ -5415,7 +5930,7 @@ void main() { await client.dispose(); }); - // Drives the FakeWebSocket through a connected → disconnected → connected + // Drives the _FakeWebSocket through a connected → disconnected → connected // transition so the client's pairwise listener fires the recovery path. Future simulateReconnect() async { ws.connectionStatus = ConnectionStatus.disconnected; @@ -5424,6 +5939,10 @@ void main() { await delay(300); } + // Recovery runs inside the client's own connection-status listener, so a + // failure there has no future for the app to catch. It must be swallowed, + // and must not stop `connectionRecovered` from firing. + // Recovery asks about channels most recently active first, so every fixture // pins its own recency rather than inheriting the moment it was built. // Both dates are set so `lastUpdatedAt` lands on [lastActiveAt] either way. @@ -5956,10 +6475,10 @@ void main() { group('dispose during reconnect recovery', () { const apiKey = 'test-api-key'; final user = User(id: 'test-user-id'); - final token = testUserToken(user.id).rawValue; + final token = createTestToken(user.id).rawValue; late FakeChatApi api; - late FakeWebSocket ws; + late _FakeWebSocket ws; late StreamChatClient client; var disposed = false; @@ -5970,7 +6489,7 @@ void main() { setUp(() { api = FakeChatApi(); - ws = FakeWebSocket(); + ws = _FakeWebSocket(); disposed = false; }); @@ -5979,6 +6498,10 @@ void main() { if (!disposed) await client.dispose(); }); + // Disposing the client while a reconnect is still recovering must complete + // cleanly: recovery work that finishes after disposal is discarded, never + // surfacing as an error. + // Disposing the client while a reconnect is still recovering must complete // cleanly: recovery work that finishes after disposal is discarded, never // surfacing as an error. @@ -6031,28 +6554,10 @@ void main() { }); group('WS events', () { - late StreamChatClient client; - - setUp(() async { - final ws = FakeWebSocket(); - client = StreamChatClient('test-api-key', ws: ws); - - final user = User(id: 'test-user-id'); - final token = testUserToken(user.id).rawValue; - - await client.connectUser(user, token); - await delay(300); - expect(client.wsConnectionStatus, ConnectionStatus.connected); - }); - - tearDown(() async { - await client.dispose(); - }); - group('User messages deleted event', () { - test( + chatClientTest( 'should broadcast global user.messages.deleted event to all channels', - () async { + body: (tester) async { // Add messages from the user to be deleted final bannedUser = User(id: 'banned-user', name: 'Banned User'); final message1 = Message( @@ -6067,20 +6572,20 @@ void main() { ); // Setup: Create multiple channels with state - final channelState1 = ChannelState( - channel: ChannelModel(id: 'channel-1', type: 'messaging'), + final channelState1 = createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'messaging:channel-1'), messages: [message1], ); - final channelState2 = ChannelState( - channel: ChannelModel(id: 'channel-2', type: 'messaging'), + final channelState2 = createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'messaging:channel-2'), messages: [message2], ); - final channel1 = Channel.fromState(client, channelState1); - final channel2 = Channel.fromState(client, channelState2); + final channel1 = Channel.fromState(tester.client, channelState1); + final channel2 = Channel.fromState(tester.client, channelState2); // Register channels in client state - client.state.addChannels({ + tester.clientState.addChannels({ 'messaging:channel-1': channel1, 'messaging:channel-2': channel2, }); @@ -6089,18 +6594,15 @@ void main() { expect(channel1.state?.messages.length, equals(1)); expect(channel2.state?.messages.length, equals(1)); - // Simulate global user.messages.deleted event being broadcast to channels - // (In production, ClientState._listenUserMessagesDeleted does this) - final event = Event( + // Emit a global (cid-less) user.messages.deleted event; + // ClientState rebroadcasts it to every registered channel. + final event = createDefaultEvent( type: EventType.userMessagesDeleted, user: bannedUser, hardDelete: false, ); - client.handleEvent(event); - - // Wait for the events to be processed - await Future.delayed(Duration.zero); + await tester.emitEvent(event); // Verify messages are soft deleted in all channels final channel1Message = channel1.state?.messages.first; @@ -6113,9 +6615,9 @@ void main() { }, ); - test( + chatClientTest( 'should broadcast global hard delete to all channels', - () async { + body: (tester) async { // Add messages from the user to be deleted final bannedUser = User(id: 'banned-user', name: 'Banned User'); final otherUser = User(id: 'other-user', name: 'Other User'); @@ -6137,20 +6639,20 @@ void main() { ); // Setup: Create multiple channels with state - final channelState1 = ChannelState( - channel: ChannelModel(id: 'channel-1', type: 'messaging'), + final channelState1 = createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'messaging:channel-1'), messages: [message1, message3], ); - final channelState2 = ChannelState( - channel: ChannelModel(id: 'channel-2', type: 'messaging'), + final channelState2 = createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'messaging:channel-2'), messages: [message2], ); - final channel1 = Channel.fromState(client, channelState1); - final channel2 = Channel.fromState(client, channelState2); + final channel1 = Channel.fromState(tester.client, channelState1); + final channel2 = Channel.fromState(tester.client, channelState2); // Register channels in client state - client.state.addChannels({ + tester.clientState.addChannels({ 'messaging:channel-1': channel1, 'messaging:channel-2': channel2, }); @@ -6159,18 +6661,15 @@ void main() { expect(channel1.state?.messages.length, equals(2)); expect(channel2.state?.messages.length, equals(1)); - // Simulate global user.messages.deleted event being broadcast to channels - // (In production, ClientState._listenUserMessagesDeleted does this) - final event = Event( + // Emit a global (cid-less) user.messages.deleted event; + // ClientState rebroadcasts it to every registered channel. + final event = createDefaultEvent( type: EventType.userMessagesDeleted, user: bannedUser, hardDelete: true, ); - client.handleEvent(event); - - // Wait for the events to be processed - await Future.delayed(Duration.zero); + await tester.emitEvent(event); // Verify banned user's messages are removed from all channels expect(channel1.state?.messages.length, equals(1)); @@ -6189,120 +6688,276 @@ void main() { }); group('ClientState mutation guards', () { - const apiKey = 'test-api-key'; - late final api = FakeChatApi(); - late StreamChatClient client; + // The guards hold on a client that was never connected, so these skip + // the connect phase. + + // The guards hold on a client that was never connected, so these skip + // the connect phase. + chatClientTest( + '`state.channels` returns an unmodifiable view', + connect: (_) {}, + body: (tester) async { + final channel = Channel.fromState( + tester.client, + createDefaultChannelState(channel: createDefaultChannelModel(cid: 'messaging:c1')), + ); + tester.clientState.addChannels({'messaging:c1': channel}); - setUp(() { - final ws = FakeWebSocket(); - client = StreamChatClient(apiKey, ws: ws, chatApi: api); - }); + expect(tester.clientState.channels, hasLength(1)); + expect(() => tester.clientState.channels.remove('messaging:c1'), throwsUnsupportedError); + expect(() => tester.clientState.channels.clear(), throwsUnsupportedError); + expect(() => tester.clientState.channels['messaging:c2'] = channel, throwsUnsupportedError); + }, + ); - tearDown(() { - client.dispose(); - }); + chatClientTest( + '`state.users` returns an unmodifiable view', + connect: (_) {}, + body: (tester) async { + tester.clientState.updateUser(User(id: 'u1')); - test('`state.channels` returns an unmodifiable view', () { - final channel = Channel.fromState( - client, - ChannelState(channel: ChannelModel(cid: 'messaging:c1')), - ); - client.state.addChannels({'messaging:c1': channel}); + expect(tester.clientState.users.containsKey('u1'), isTrue); + expect(() => tester.clientState.users.remove('u1'), throwsUnsupportedError); + expect(() => tester.clientState.users.clear(), throwsUnsupportedError); + }, + ); - expect(client.state.channels, hasLength(1)); - expect(() => client.state.channels.remove('messaging:c1'), throwsUnsupportedError); - expect(() => client.state.channels.clear(), throwsUnsupportedError); - expect(() => client.state.channels['messaging:c2'] = channel, throwsUnsupportedError); - }); + chatClientTest( + '`state.activeLiveLocations` returns an unmodifiable view', + connect: (_) {}, + body: (tester) async { + expect(() => tester.clientState.activeLiveLocations.clear(), throwsUnsupportedError); + }, + ); - test('`state.users` returns an unmodifiable view', () { - client.state.updateUser(User(id: 'u1')); + chatClientTest( + '`removeChannel` emits a fresh map so distinct subscribers see the change', + connect: (_) {}, + body: (tester) async { + final channel = Channel.fromState( + tester.client, + createDefaultChannelState(channel: createDefaultChannelModel(cid: 'messaging:c1')), + ); + tester.clientState.addChannels({'messaging:c1': channel}); - expect(client.state.users.containsKey('u1'), isTrue); - expect(() => client.state.users.remove('u1'), throwsUnsupportedError); - expect(() => client.state.users.clear(), throwsUnsupportedError); - }); + final received = >[]; + // Skip the BehaviorSubject's replay of the current value to new subscribers. + final sub = tester.clientState.channelsStream.distinct().skip(1).listen(received.add); - test('`state.activeLiveLocations` returns an unmodifiable view', () { - expect(() => client.state.activeLiveLocations.clear(), throwsUnsupportedError); - }); + tester.clientState.removeChannel('messaging:c1'); + await Future.delayed(Duration.zero); - test('`removeChannel` emits a fresh map so distinct subscribers see the change', () async { - final channel = Channel.fromState( - client, - ChannelState(channel: ChannelModel(cid: 'messaging:c1')), - ); - client.state.addChannels({'messaging:c1': channel}); + expect(received, hasLength(1)); + expect(received.single, isEmpty); - final received = >[]; - // Skip the BehaviorSubject's replay of the current value to new subscribers. - final sub = client.state.channelsStream.distinct().skip(1).listen(received.add); + await sub.cancel(); + }, + ); - client.state.removeChannel('messaging:c1'); - await Future.delayed(Duration.zero); + chatClientTest( + 'initial seeded values are unmodifiable (before any write)', + connect: (_) {}, + body: (tester) async { + // Fresh client, no mutations yet — subscribers connecting at this point + // still see unmodifiable seeds. + expect(() => tester.clientState.channels.clear(), throwsUnsupportedError); + expect(() => tester.clientState.users.clear(), throwsUnsupportedError); + expect(() => tester.clientState.activeLiveLocations.clear(), throwsUnsupportedError); + }, + ); - expect(received, hasLength(1)); - expect(received.single, isEmpty); + chatClientTest( + '`channelsStream` emits unmodifiable maps', + connect: (_) {}, + body: (tester) async { + final received = >[]; + final sub = tester.clientState.channelsStream.listen(received.add); - await sub.cancel(); - }); + final channel = Channel.fromState( + tester.client, + createDefaultChannelState(channel: createDefaultChannelModel(cid: 'messaging:c1')), + ); + tester.clientState.addChannels({'messaging:c1': channel}); + await Future.delayed(Duration.zero); - test('initial seeded values are unmodifiable (before any write)', () { - // Fresh client, no mutations yet — subscribers connecting at this point - // still see unmodifiable seeds. - expect(() => client.state.channels.clear(), throwsUnsupportedError); - expect(() => client.state.users.clear(), throwsUnsupportedError); - expect(() => client.state.activeLiveLocations.clear(), throwsUnsupportedError); - }); + // Both the initial seed and the post-write emission must be unmodifiable. + expect(received, hasLength(greaterThanOrEqualTo(2))); + for (final emitted in received) { + expect(emitted.clear, throwsUnsupportedError); + } - test('`channelsStream` emits unmodifiable maps', () async { - final received = >[]; - final sub = client.state.channelsStream.listen(received.add); + await sub.cancel(); + }, + ); - final channel = Channel.fromState( - client, - ChannelState(channel: ChannelModel(cid: 'messaging:c1')), - ); - client.state.addChannels({'messaging:c1': channel}); - await Future.delayed(Duration.zero); + chatClientTest( + '`usersStream` emits unmodifiable maps', + connect: (_) {}, + body: (tester) async { + final received = >[]; + final sub = tester.clientState.usersStream.listen(received.add); - // Both the initial seed and the post-write emission must be unmodifiable. - expect(received, hasLength(greaterThanOrEqualTo(2))); - for (final emitted in received) { - expect(emitted.clear, throwsUnsupportedError); - } + tester.clientState.updateUser(User(id: 'u1')); + await Future.delayed(Duration.zero); - await sub.cancel(); - }); + expect(received, hasLength(greaterThanOrEqualTo(2))); + for (final emitted in received) { + expect(emitted.clear, throwsUnsupportedError); + } + + await sub.cancel(); + }, + ); - test('`usersStream` emits unmodifiable maps', () async { - final received = >[]; - final sub = client.state.usersStream.listen(received.add); + chatClientTest( + '`activeLiveLocationsStream` emits unmodifiable lists', + connect: (_) {}, + body: (tester) async { + final received = >[]; + final sub = tester.clientState.activeLiveLocationsStream.listen(received.add); - client.state.updateUser(User(id: 'u1')); - await Future.delayed(Duration.zero); + tester.clientState.activeLiveLocations = const []; + await Future.delayed(Duration.zero); - expect(received, hasLength(greaterThanOrEqualTo(2))); - for (final emitted in received) { - expect(emitted.clear, throwsUnsupportedError); - } + expect(received, isNotEmpty); + for (final emitted in received) { + expect(emitted.clear, throwsUnsupportedError); + } - await sub.cancel(); - }); + await sub.cancel(); + }, + ); + }); - test('`activeLiveLocationsStream` emits unmodifiable lists', () async { - final received = >[]; - final sub = client.state.activeLiveLocationsStream.listen(received.add); + group('default REST wiring', () { + setUpAll(() => registerFallbackValue(RequestOptions())); - client.state.activeLiveLocations = const []; - await Future.delayed(Duration.zero); + test('builds a REST stack carrying the api key and user credentials', () async { + const apiKey = 'test-api-key'; + final adapter = _MockHttpClientAdapter(); - expect(received, isNotEmpty); - for (final emitted in received) { - expect(emitted.clear, throwsUnsupportedError); - } + when(() => adapter.fetch(any(), any(), any())).thenAnswer( + (_) async => ResponseBody.fromString( + '{"app":{}}', + 200, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ), + ); - await sub.cancel(); + final client = StreamChatClient(apiKey, httpClientAdapter: adapter); + addTearDown(client.dispose); + + final user = User(id: 'test-user-id'); + final token = createTestToken(user.id); + + // Connecting without a socket still loads the app settings, which is the + // request captured below. + await client.connectUser(user, token.rawValue, connectWebSocket: false); + await pumpEventQueue(); + + final captured = verify(() => adapter.fetch(captureAny(), any(), any())).captured; + final options = captured.first as RequestOptions; + + expect(options.queryParameters['api_key'], apiKey); + expect(options.queryParameters['user_id'], user.id); + expect(options.headers['Authorization'], token.rawValue); + expect(options.headers['stream-auth-type'], token.authType.name); }); }); } + +class _MockHttpClientAdapter extends Mock implements HttpClientAdapter {} + +class _FakeUser extends Fake implements User {} + +class _FakeWebSocket extends Fake implements WebSocket { + late final _connectionStatusController = BehaviorSubject.seeded( + ConnectionStatus.disconnected, + ); + + set connectionStatus(ConnectionStatus value) { + _connectionStatusController.add(value); + } + + @override + ConnectionStatus get connectionStatus => _connectionStatusController.value; + + @override + Stream get connectionStatusStream => _connectionStatusController.stream; + + @override + Completer? connectionCompleter; + + @override + Future connect( + User user, { + bool? includeUserDetails = true, + }) async { + connectionStatus = ConnectionStatus.connecting; + final event = Event( + type: EventType.healthCheck, + connectionId: 'fake-connection-id', + me: OwnUser.fromUser(user), + ); + connectionCompleter = Completer()..complete(event); + connectionStatus = ConnectionStatus.connected; + return connectionCompleter!.future; + } + + @override + void disconnect() { + connectionStatus = ConnectionStatus.disconnected; + connectionCompleter = null; + } + + @override + Future dispose() async { + await _connectionStatusController.close(); + } +} + +class _FakeWebSocketWithConnectionError extends Fake implements WebSocket { + late final _connectionStatusController = BehaviorSubject.seeded( + ConnectionStatus.disconnected, + ); + + set connectionStatus(ConnectionStatus value) { + _connectionStatusController.add(value); + } + + @override + ConnectionStatus get connectionStatus => _connectionStatusController.value; + + @override + Stream get connectionStatusStream => _connectionStatusController.stream; + + @override + Completer? connectionCompleter; + + @override + Future connect( + User user, { + bool? includeUserDetails = true, + }) async { + connectionStatus = ConnectionStatus.connecting; + const error = StreamNetworkException(message: 'Error Connecting'); + connectionCompleter = Completer()..completeError(error); + return connectionCompleter!.future; + } + + @override + void disconnect() { + connectionStatus = ConnectionStatus.disconnected; + connectionCompleter = null; + } + + @override + Future dispose() async { + await _connectionStatusController.close(); + } +} + +// Top level util function to delay the code execution +Future delay(num milliseconds) => Future.delayed(Duration(milliseconds: milliseconds.toInt())); diff --git a/packages/stream_chat/test/src/client/retry_queue_test.dart b/packages/stream_chat/test/src/client/retry_queue_test.dart index 993848ca19..8299bbb860 100644 --- a/packages/stream_chat/test/src/client/retry_queue_test.dart +++ b/packages/stream_chat/test/src/client/retry_queue_test.dart @@ -3,26 +3,46 @@ import 'package:stream_chat/src/client/retry_queue.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; -import '../mocks.dart'; +import '../utils.dart'; + +class _MockClient extends Mock implements StreamChatClient { + @override + Stream on([ + String? eventType, + String? eventType2, + String? eventType3, + String? eventType4, + ]) => const Stream.empty(); +} + +class _MockChannel extends Mock implements Channel { + final _client = _MockClient(); + + @override + StreamChatClient get client => _client; +} void main() { - late final channel = MockRetryQueueChannel(); + // Built per test: the logger assertions below count what was reported, so + // sharing the doubles would make them depend on the order the tests run in. + late _MockChannel channel; late RetryQueue retryQueue; late List records; - setUpAll(() { - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, error) => error?.isRetriable ?? false, - ); - when(() => channel.client.retryPolicy).thenReturn(retryPolicy); - }); - setUp(() { + channel = _MockChannel(); + // The queue owns its logger, so what it reports is observed through the // handler rather than through an injected mock. records = []; StreamLogger.handler = _CapturingHandler(records.add); StreamLogger.priority = StreamLogPriority.verbose; + + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, error) => error?.isRetriable ?? false, + ); + when(() => channel.client.retryPolicy).thenReturn(retryPolicy); + retryQueue = RetryQueue(channel: channel); }); @@ -61,6 +81,40 @@ void main() { expect(records.where((it) => it.message == 'Adding 1 messages to the queue'), hasLength(1)); }); + // The queue consults the retry policy through `retryIf` whenever an + // attempt fails with a `StreamChatException`; a policy that declines has to + // end the retrying rather than back off and try again. + // + // `createdAt` is explicit on purpose. `Message.createdAt` falls back to + // `DateTime.now()` on every read when neither timestamp is set, which + // makes the queue's date comparator non-reflexive — the message then + // cannot be located for removal and gets retried an arbitrary number of + // times. + test('should stop retrying when the policy declines the error', () async { + final message = Message( + id: 'test-message-id', + text: 'Sample message test', + createdAt: DateTime.utc(2021), + state: MessageState.sendingFailed( + skipPush: false, + skipEnrichUrl: false, + ), + ); + + // Non-retriable: the server rejected the request as malformed, which + // is a verdict the same request cannot talk its way out of. + final error = apiException(); + expect(error.isRetriable, isFalse); + + when(() => channel.retryMessage(message)).thenAnswer((_) => Future.error(error)); + + retryQueue.add([message]); + await pumpEventQueue(); + + verify(() => channel.retryMessage(message)).called(1); + expect(retryQueue.hasMessages, isFalse); + }); + test('`.add` should add failed request to the queue', () async { final message = Message( id: 'test-message-id', diff --git a/packages/stream_chat/test/src/client/sync_manager_test.dart b/packages/stream_chat/test/src/client/sync_manager_test.dart index 6ca14e0026..73a8b97b75 100644 --- a/packages/stream_chat/test/src/client/sync_manager_test.dart +++ b/packages/stream_chat/test/src/client/sync_manager_test.dart @@ -1,11 +1,9 @@ import 'package:clock/clock.dart'; -import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/src/client/sync_manager.dart'; import 'package:stream_chat/stream_chat.dart'; -import 'package:test/test.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; -import '../fakes.dart'; -import '../mocks.dart'; +import '../mocks.dart' show MockClientState, MockStreamChatClient; // Records what `/sync` was asked for, and answers with events or an error. // Callable, so it can be passed straight in as a [FetchMissedEvents]. @@ -78,9 +76,7 @@ typedef _Harness = ({ }); void main() { - registerFallbackValue(FakeEvent()); - registerFallbackValue(const PaginationParams()); - registerFallbackValue(const ChannelFilter.raw({})); + registerChatFallbackValues(); final t0 = DateTime.utc(2026, 3, 1, 12); final anHourAgo = t0.subtract(const Duration(hours: 1)); diff --git a/packages/stream_chat/test/src/core/api/attachment_file_uploader_test.dart b/packages/stream_chat/test/src/core/api/attachment_file_uploader_test.dart index 1b319718e1..2c57899e68 100644 --- a/packages/stream_chat/test/src/core/api/attachment_file_uploader_test.dart +++ b/packages/stream_chat/test/src/core/api/attachment_file_uploader_test.dart @@ -1,12 +1,12 @@ import 'package:dio/dio.dart'; -import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; import 'package:stream_chat/src/core/models/attachment_file.dart'; -import 'package:test/test.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; -import '../../fakes.dart'; -import '../../matchers.dart'; -import '../../mocks.dart'; +// Shown explicitly: `mocks.dart` also declares doubles that `stream_chat_test` +// exports under the same names, and importing both unqualified would make +// those names ambiguous. +import '../../mocks.dart' show MockHttpClient; import '../../utils.dart'; void main() { @@ -15,7 +15,7 @@ void main() { setUp(() { fileUploader = StreamAttachmentFileUploader(client); - registerFallbackValue(FakeMultiPartFile()); + registerFallbackValue(_FakeMultipartFile()); }); Response successResponse(String path, {Object? data}) => Response( @@ -264,3 +264,5 @@ void main() { verifyNoMoreInteractions(client); }); } + +class _FakeMultipartFile extends Fake implements MultipartFile {} diff --git a/packages/stream_chat/test/src/core/util/message_rules_test.dart b/packages/stream_chat/test/src/core/util/message_rules_test.dart index 848adf0ea1..923891097d 100644 --- a/packages/stream_chat/test/src/core/util/message_rules_test.dart +++ b/packages/stream_chat/test/src/core/util/message_rules_test.dart @@ -1,12 +1,11 @@ // ignore_for_file: avoid_redundant_argument_values +import 'dart:async'; + import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; -import '../../fakes.dart'; -import '../../mocks.dart'; - void main() { group('MessageRules', () { late StreamChatClient client; @@ -692,8 +691,8 @@ StreamChatClient _createMockClient({ OwnUser? currentUser, bool isLocalUnreadCountEnabled = false, }) { - final client = MockStreamChatClient(); - final clientState = FakeClientState(currentUser: currentUser); + final client = _MockStreamChatClient(); + final clientState = _FakeClientState(currentUser: currentUser); when(() => client.state).thenReturn(clientState); when(() => client.retryPolicy).thenReturn( @@ -747,3 +746,67 @@ Channel _createChannel( } // endregion + +// region Test Doubles + +class _MockStreamChatClient extends Mock implements StreamChatClient { + // A plain settable field (not a `when(...)` stub) so tests can flip it + // with a direct assignment, e.g. `client.isLocalUnreadCountEnabled = true`. + // Stubbing it via `when()` in this constructor would be re-entrant: this + // mock is often stored in a `late final` and lazily constructed as a side + // effect of evaluating another `when(() => client....)` call already in + // progress, which corrupts mocktail's global stubbing state. + @override + bool isLocalUnreadCountEnabled = false; + + @override + Stream get eventStream => _eventController.stream; + final _eventController = StreamController.broadcast(); + + @override + Stream on([ + String? eventType, + String? eventType2, + String? eventType3, + String? eventType4, + ]) { + if (eventType == null || eventType == EventType.any) return eventStream; + return eventStream.where( + (event) => + event.type == eventType || event.type == eventType2 || event.type == eventType3 || event.type == eventType4, + ); + } + + @override + Future dispose() => _eventController.close(); +} + +class _FakeClientState extends Fake implements ClientState { + _FakeClientState({ + this._currentUser, + }); + + OwnUser? _currentUser; + + @override + OwnUser? get currentUser => _currentUser; + + @override + void updateUser(User? user) { + if (user == null) return; + if (_currentUser case final current? when user.id != current.id) return; + + _currentUser = OwnUser.fromUser(user); + } + + @override + Map get channels => _channels; + final _channels = {}; + + @override + void addChannels(Map channelMap) { + _channels.addAll(channelMap); + } +} + +// endregion diff --git a/packages/stream_chat/test/src/fakes.dart b/packages/stream_chat/test/src/fakes.dart deleted file mode 100644 index 50d0cef6cf..0000000000 --- a/packages/stream_chat/test/src/fakes.dart +++ /dev/null @@ -1,336 +0,0 @@ -import 'dart:async'; - -import 'package:mocktail/mocktail.dart'; -import 'package:rxdart/rxdart.dart'; -import 'package:stream_chat/src/core/api/channel_api.dart'; -import 'package:stream_chat/src/core/api/device_api.dart'; -import 'package:stream_chat/src/core/api/general_api.dart'; -import 'package:stream_chat/src/core/api/guest_api.dart'; -import 'package:stream_chat/src/core/api/message_api.dart'; -import 'package:stream_chat/src/core/api/moderation_api.dart'; -import 'package:stream_chat/src/core/api/polls_api.dart'; -import 'package:stream_chat/src/core/api/roles_api.dart'; -import 'package:stream_chat/src/core/api/user_api.dart'; -import 'package:stream_chat/src/core/api/user_groups_api.dart'; -import 'package:stream_chat/src/ws/websocket.dart'; -import 'package:stream_chat/stream_chat.dart'; - -import 'mocks.dart'; -import 'utils.dart'; - -class FakeTokenManager extends Fake implements TokenManager { - final token = testUserToken('test-user-id'); - - @override - bool get usesStaticProvider => true; - - @override - String? get userId => token.userId; - - @override - UserToken? peekToken() => token; - - @override - Future getToken() async => token; - - @override - void setTokenProvider(String userId, {required TokenProvider tokenProvider}) {} - - @override - void expireToken() {} - - @override - void reset() {} -} - -class FakeMultiPartFile extends Fake implements MultipartFile {} - -/// Fake persistence client for testing persistence client reliability features -class FakePersistenceClient extends Fake implements ChatPersistenceClient { - FakePersistenceClient({ - this._lastSyncAt, - List? channelCids, - }) : _channelCids = channelCids ?? []; - - String? _userId; - bool _isConnected = false; - DateTime? _lastSyncAt; - List _channelCids; - - // Track method calls for testing - int connectCallCount = 0; - int disconnectCallCount = 0; - int flushCallCount = 0; - - @override - bool get isConnected => _isConnected; - - @override - String? get userId => _userId; - - @override - Future connect(String userId) async { - _userId = userId; - _isConnected = true; - connectCallCount++; - } - - @override - Future disconnect({bool flush = false}) async { - if (flush) await this.flush(); - - _userId = null; - _isConnected = false; - disconnectCallCount++; - } - - @override - Future flush() async { - flushCallCount++; - _lastSyncAt = null; - _channelCids = []; - } - - @override - Future getLastSyncAt() async => _lastSyncAt; - - @override - Future updateLastSyncAt(DateTime lastSyncAt) async { - _lastSyncAt = lastSyncAt; - } - - @override - Future> getChannelCids() async => _channelCids; - - @override - Future saveChannelQueries({ - required List cids, - ChannelFilter? filter, - List? sort, - String? predefinedFilter, - ChannelFilter? resolvedFilter, - List? resolvedSort, - Map? filterValues, - Map? sortValues, - bool clearQueryCache = false, - }) async {} - - @override - Future updateChannelStates(List channelStates) async {} -} - -class FakeChatApi extends Fake implements StreamChatApi { - UserApi? _user; - - @override - UserApi get user => _user ??= MockUserApi(); - - GuestApi? _guest; - - @override - GuestApi get guest => _guest ??= MockGuestApi(); - - MessageApi? _message; - - @override - MessageApi get message => _message ??= MockMessageApi(); - - @override - PollsApi get polls => _polls ??= MockPollsApi(); - - PollsApi? _polls; - - ChannelApi? _channel; - - @override - ChannelApi get channel => _channel ??= MockChannelApi(); - - DeviceApi? _device; - - @override - DeviceApi get device => _device ??= MockDeviceApi(); - - ModerationApi? _moderation; - - @override - ModerationApi get moderation => _moderation ??= MockModerationApi(); - - UserGroupsApi? _userGroups; - - @override - UserGroupsApi get userGroups => _userGroups ??= MockUserGroupsApi(); - - RolesApi? _roles; - - @override - RolesApi get roles => _roles ??= MockRolesApi(); - - GeneralApi? _general; - - @override - GeneralApi get general => _general ??= MockGeneralApi(); - - AttachmentFileUploader? _fileUploader; - - @override - AttachmentFileUploader get fileUploader => _fileUploader ??= MockAttachmentFileUploader(); -} - -class FakeClientState extends Fake implements ClientState { - FakeClientState({ - this._currentUser, - }); - - OwnUser? _currentUser; - - @override - OwnUser? get currentUser { - return _currentUser ??= OwnUser( - id: 'test-user-id', - name: 'Test User', - privacySettings: const PrivacySettings( - typingIndicators: TypingIndicators(), - readReceipts: ReadReceipts(), - ), - ); - } - - @override - Map get users => const {}; - - @override - void updateUser(User? user) { - if (user == null) return; - if (_currentUser case final current? when user.id != current.id) return; - - _currentUser = OwnUser.fromUser(user); - } - - @override - int totalUnreadCount = 0; - - @override - Map get channels => _channels; - final _channels = {}; - - @override - void addChannels(Map channelMap) { - _channels.addAll(channelMap); - } - - @override - void removeChannel(String channelCid) { - _channels.remove(channelCid); - } -} - -class FakeMessage extends Fake implements Message {} - -class FakeDraftMessage extends Fake implements DraftMessage {} - -class FakeAttachmentFile extends Fake implements AttachmentFile {} - -class FakeEvent extends Fake implements Event {} - -class FakeUser extends Fake implements User {} - -class FakePollVote extends Fake implements PollVote {} - -class FakeWebSocket extends Fake implements WebSocket { - late final _connectionStatusController = BehaviorSubject.seeded( - ConnectionStatus.disconnected, - ); - - set connectionStatus(ConnectionStatus value) { - _connectionStatusController.add(value); - } - - @override - ConnectionStatus get connectionStatus => _connectionStatusController.value; - - @override - Stream get connectionStatusStream => _connectionStatusController.stream; - - @override - Completer? connectionCompleter; - - @override - Future connect( - User user, { - bool? includeUserDetails = true, - }) async { - connectionStatus = ConnectionStatus.connecting; - final event = Event( - type: EventType.healthCheck, - connectionId: 'fake-connection-id', - me: OwnUser.fromUser(user), - ); - connectionCompleter = Completer()..complete(event); - connectionStatus = ConnectionStatus.connected; - return connectionCompleter!.future; - } - - @override - void disconnect() { - connectionStatus = ConnectionStatus.disconnected; - connectionCompleter = null; - } - - @override - Future dispose() async { - await _connectionStatusController.close(); - } -} - -class FakeWebSocketWithConnectionError extends Fake implements WebSocket { - late final _connectionStatusController = BehaviorSubject.seeded( - ConnectionStatus.disconnected, - ); - - set connectionStatus(ConnectionStatus value) { - _connectionStatusController.add(value); - } - - @override - ConnectionStatus get connectionStatus => _connectionStatusController.value; - - @override - Stream get connectionStatusStream => _connectionStatusController.stream; - - @override - Completer? connectionCompleter; - - @override - Future connect( - User user, { - bool? includeUserDetails = true, - }) async { - connectionStatus = ConnectionStatus.connecting; - const error = StreamNetworkException(message: 'Error Connecting'); - connectionCompleter = Completer()..completeError(error); - return connectionCompleter!.future; - } - - @override - void disconnect() { - connectionStatus = ConnectionStatus.disconnected; - connectionCompleter = null; - } - - @override - Future dispose() async { - await _connectionStatusController.close(); - } -} - -class FakeChannelState extends Fake implements ChannelState {} - -class FakePartialUpdateMemberResponse extends Fake implements PartialUpdateMemberResponse { - FakePartialUpdateMemberResponse({ - Member? channelMember, - }) : _channelMember = channelMember ?? Member(); - - final Member _channelMember; - @override - Member get channelMember => _channelMember; -} diff --git a/packages/stream_chat/test/src/mocks.dart b/packages/stream_chat/test/src/mocks.dart index 932d330ead..6a82c7dc74 100644 --- a/packages/stream_chat/test/src/mocks.dart +++ b/packages/stream_chat/test/src/mocks.dart @@ -1,28 +1,9 @@ import 'package:dio/dio.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat/src/client/channel/channel.dart'; -import 'package:stream_chat/src/client/channel_delivery_reporter.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'; import 'package:stream_chat/src/core/api/general_api.dart'; -import 'package:stream_chat/src/core/api/guest_api.dart'; -import 'package:stream_chat/src/core/api/message_api.dart'; -import 'package:stream_chat/src/core/api/moderation_api.dart'; -import 'package:stream_chat/src/core/api/polls_api.dart'; -import 'package:stream_chat/src/core/api/roles_api.dart'; -import 'package:stream_chat/src/core/api/user_api.dart'; -import 'package:stream_chat/src/core/api/user_groups_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/models/channel_config.dart'; -import 'package:stream_chat/src/core/models/event.dart'; -import 'package:stream_chat/src/core/util/event_controller.dart'; -import 'package:stream_chat/src/db/chat_persistence_client.dart'; -import 'package:stream_chat/src/event_type.dart'; -import 'package:stream_chat/src/ws/websocket.dart'; -import 'package:stream_core/stream_core.dart' show StreamLogger, TokenManager; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart' show MockPersistenceClient; import 'package:web_socket_channel/web_socket_channel.dart'; class MockWebSocketChannel extends Mock implements WebSocketChannel {} @@ -47,50 +28,9 @@ class MockTokenManager extends Mock implements TokenManager {} class MockConnectionIdManager extends Mock implements ConnectionIdManager {} -class MockUserApi extends Mock implements UserApi {} - -class MockGuestApi extends Mock implements GuestApi {} - -class MockMessageApi extends Mock implements MessageApi {} - -class MockPollsApi extends Mock implements PollsApi {} - -class MockChannelApi extends Mock implements ChannelApi {} - -class MockDeviceApi extends Mock implements DeviceApi {} - -class MockModerationApi extends Mock implements ModerationApi {} - -class MockUserGroupsApi extends Mock implements UserGroupsApi {} - -class MockRolesApi extends Mock implements RolesApi {} - class MockGeneralApi extends Mock implements GeneralApi {} -class MockAttachmentFileUploader extends Mock implements AttachmentFileUploader {} - -class MockPersistenceClient extends Mock implements ChatPersistenceClient { - String? _userId; - bool _isConnected = false; - - @override - bool get isConnected => _isConnected; - - @override - String? get userId => _userId; - - @override - Future connect(String userId) async { - _userId = userId; - _isConnected = true; - } - - @override - Future disconnect({bool flush = false}) async { - _userId = null; - _isConnected = false; - } -} +class MockChannelDeliveryReporter extends Mock implements ChannelDeliveryReporter {} class MockStreamChatClient extends Mock implements StreamChatClient { // A plain settable field for the same reason as [isLocalUnreadCountEnabled] @@ -120,28 +60,6 @@ class MockStreamChatClient extends Mock implements StreamChatClient { ChannelDeliveryReporter get channelDeliveryReporter { return _deliveryReporter ??= MockChannelDeliveryReporter(); } - - @override - Stream get eventStream => _eventController.stream; - final _eventController = EventController(); - void addEvent(Event event) => _eventController.add(event); - - @override - Stream on([ - String? eventType, - String? eventType2, - String? eventType3, - String? eventType4, - ]) { - if (eventType == null || eventType == EventType.any) return eventStream; - return eventStream.where( - (event) => - event.type == eventType || event.type == eventType2 || event.type == eventType3 || event.type == eventType4, - ); - } - - @override - Future dispose() => _eventController.close(); } class MockStreamChatClientWithPersistence extends MockStreamChatClient { @@ -161,35 +79,50 @@ class MockClientState extends Mock implements ClientState {} class MockChannelConfig extends Mock implements ChannelConfig {} -class MockRetryQueueChannel extends Mock implements Channel { - final channelId = 'test-channel-id'; - final channelType = 'test-channel-type'; +class FakeClientState extends Fake implements ClientState { + FakeClientState({ + this._currentUser, + }); + + OwnUser? _currentUser; @override - String? get id => channelId; + OwnUser? get currentUser { + return _currentUser ??= OwnUser( + id: 'test-user-id', + name: 'Test User', + privacySettings: const PrivacySettings( + typingIndicators: TypingIndicators(), + readReceipts: ReadReceipts(), + ), + ); + } @override - String get type => channelType; + Map get users => const {}; @override - String? get cid => '$channelType:$channelId'; + void updateUser(User? user) { + if (user == null) return; + if (_currentUser case final current? when user.id != current.id) return; - StreamChatClient? _client; + _currentUser = OwnUser.fromUser(user); + } @override - StreamChatClient get client => _client ??= MockStreamChatClient(); + int totalUnreadCount = 0; @override - Stream on([ - String? eventType, - String? eventType2, - String? eventType3, - String? eventType4, - ]) { - return client.on(eventType, eventType2, eventType3, eventType4).where((e) => e.cid == cid); - } -} + Map get channels => _channels; + final _channels = {}; -class MockWebSocket extends Mock implements WebSocket {} + @override + void addChannels(Map channelMap) { + _channels.addAll(channelMap); + } -class MockChannelDeliveryReporter extends Mock implements ChannelDeliveryReporter {} + @override + void removeChannel(String channelCid) { + _channels.remove(channelCid); + } +} diff --git a/packages/stream_chat/test/src/ws/websocket_test.dart b/packages/stream_chat/test/src/ws/websocket_test.dart index 866c151dae..46f7403ee1 100644 --- a/packages/stream_chat/test/src/ws/websocket_test.dart +++ b/packages/stream_chat/test/src/ws/websocket_test.dart @@ -7,8 +7,8 @@ import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; -import '../fakes.dart'; import '../mocks.dart'; +import '../utils.dart'; void main() { late TokenManager tokenManager; @@ -17,7 +17,7 @@ void main() { late WebSocket webSocket; setUp(() { - tokenManager = FakeTokenManager(); + tokenManager = _FakeTokenManager(); webSocketChannel = MockWebSocketChannel(); WebSocketChannel channelProvider( @@ -590,3 +590,28 @@ class _CapturingHandler extends StreamLogHandler { @override void handle(StreamLogRecord record) => _onRecord(record); } + +class _FakeTokenManager extends Fake implements TokenManager { + final token = testUserToken('test-user-id'); + + @override + bool get usesStaticProvider => true; + + @override + String? get userId => token.userId; + + @override + UserToken? peekToken() => token; + + @override + Future getToken() async => token; + + @override + void setTokenProvider(String userId, {required TokenProvider tokenProvider}) {} + + @override + void expireToken() {} + + @override + void reset() {} +} diff --git a/packages/stream_chat_test/CHANGELOG.md b/packages/stream_chat_test/CHANGELOG.md new file mode 100644 index 0000000000..6c44d0fabe --- /dev/null +++ b/packages/stream_chat_test/CHANGELOG.md @@ -0,0 +1,10 @@ +## 0.1.0 + +✅ Added + +- Initial release of internal BDD-style test helpers for the `stream_chat` package: the `chatClientTest` and `channelTest` entry points, which build a real `StreamChatClient` with the REST API, WebSocket transport and persistence seams replaced. +- Added `isSameXxxAs` matchers for messages, drafts, attachments, users, events and channels. +- Added `createDefaultXxx` fixtures and response factories for users, channels, polls, drafts, reminders, members, reactions, replies, sync and unread counts, all with deterministic timestamps. +- Added `MockPersistenceClient` and `FakePersistenceClient` doubles for the persistence seam. +- Added `mockApi`, `mockApiFailure`, `mockApiFailureOnce`, `verifyApi`, `verifyApiCalled`, `verifyNeverCalled`, `verifyNoMoreApiInteractions` and `captureApi` for stubbing and verifying calls against the chat API's sub-APIs. +- Emitted events are serialized with server fidelity, so message and reaction payloads keep the server-assigned fields their own `toJson` drops (sender, timestamps, reactions, moderation). diff --git a/packages/stream_chat_test/README.md b/packages/stream_chat_test/README.md new file mode 100644 index 0000000000..1d2279ea9f --- /dev/null +++ b/packages/stream_chat_test/README.md @@ -0,0 +1,181 @@ +# stream_chat_test + +Internal BDD-style test helpers and utilities for testing the `stream_chat` package. + +This package is **not published** — it is a dev dependency for the packages in this +monorepo. It ports the test algebra of `stream_feeds_test` (from the +stream-feeds-flutter repository) to chat, keeping strict shape parity: file names, +public API, and lifecycle semantics match, so knowledge transfers between the two +packages file-for-file. + +## What it provides + +A test is one function call with named phases — two imports, no boilerplate: + +```dart +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +void main() { + chatClientTest( + 'stubs and verifies API calls with exact arguments', + body: (tester) async { + final message = createDefaultMessage(id: 'message-id'); + tester.mockApi( + (api) => api.message.getMessage('message-id'), + result: createDefaultGetMessageResponse(message: message), + ); + + final response = await tester.client.getMessage('message-id'); + + expect(response.message.id, 'message-id'); + tester.verifyApi((api) => api.message.getMessage('message-id')); + }, + ); +} +``` + +Under the hood every test gets a **real** `StreamChatClient` with exactly three seams +replaced: + +| Seam | Replaced with | +|---|---| +| REST API | `FakeChatApi` — a `StreamChatApi` whose sub-APIs are mocktail mocks | +| WebSocket transport | A mocked `WebSocketChannel` driven by `WebSocketTester`; the real `WebSocket` engine (URI building, frame decoding, health checks, reconnection) stays in play | +| Persistence | Optional `chatPersistenceClient:` parameter (none by default) | + +Everything above those seams — client, channels, state, event routing, token +handling — is production code. + +### Lifecycle + +Each test runs the phases `connect → setUp → body → verify → tearDown` inside a +guarded zone (errors from event handlers, timers and unawaited futures raised +while the body runs fail the test — see the limitation below for errors that +arrive after it). The default connect phase mocks successful authentication, +connects the client as `luke_skywalker` with a development token, and asserts +the connection. +Pass `connect:` to replace it — e.g. to test failed connections, or to skip the +socket entirely with `connect: (_) {}`. + +`verify:` and `tearDown:` run straight after `body:`, in the same zone and under +the same error handling — the split reads as arrange/act/assert, it is not an +isolation boundary, and a `body:` that throws skips both. + +```dart +chatClientTest( + 'fails to connect when authentication is rejected', + connect: (tester) => tester.mockFailedAuth(errorCode: 43), + body: (tester) async { + await expectLater( + tester.client.connectUser(tester.user, createTestToken(tester.user.id).rawValue), + throwsA(isA()), + ); + }, +); +``` + +### Testers + +- `chatClientTest` / `ChatClientTester` — subject is the `StreamChatClient`; + aliases: `clientState`, `connectionStatus`, `events`. Tag: `chat-client`. +- `channelTest` / `ChannelTester` — subject is a `Channel`; aliases: `channel`, + `channelState`; `tester.watch()` seeds the channel (stubs the query with the + exact request shape the SDK sends **and** performs it), with a + `modifyResponse:` hook to adjust the canonical fixture. Tag: `channel`. + +```dart +channelTest( + 'adds a new message on message.new event', + setUp: (tester) => tester.watch(), + body: (tester) async { + await tester.emitEvent( + createDefaultEvent( + type: EventType.messageNew, + cid: tester.channel.cid, + message: createDefaultMessage(id: 'new-message'), + ), + ); + + expect(tester.channelState?.messages.map((m) => m.id), contains('new-message')); + }, +); +``` + +More testers (for controllers and other state objects) follow the same three-part +template: an `@isTest` entry function, a `final class XxxTester extends BaseTester` +with alias getters and a seeding method, and a private factory matching +`TesterFactory`. + +### Helpers + +- `ApiMockerMixin` — `mockApi` / `mockApiFailure` / `verifyApi` / `verifyApiCalled` / + `captureApi` / `verifyNeverCalled`. Pass **exact** argument values, not `any()`: + a mocktail stub only answers on a match, so stubbing doubles as request + verification. +- `WebSocketTester` — `mockSuccessfulAuth` / `mockFailedAuth` / `mockConnectionError` / + `emitEvent` / `emitRawFrame`. Emitted events are serialized with server fidelity + (`serverEventJson`): nested message/reaction payloads keep the server-assigned fields + (`user`, timestamps, reactions, ...) that their request-shaped `toJson` omits. + Chat authenticates through the connect URI (token and + user payload in query parameters), so the fake server validates credentials from + the URI it receives and answers with the `health.check` + `me` handshake. Outgoing + health-check pings are acknowledged automatically. +- `test_data.dart` — `createDefaultXxx` fixture factories with fully-defaulted + parameters and deterministic timestamps (`DateTime(2021, 1, 1)` / + `DateTime(2021, 2, 1)`). +- `mocks.dart` — mocks for all 12 sub-APIs, `FakeChatApi`, + `registerChatFallbackValues()` (called automatically by the lifecycle). + +## Known parity-inherited limitations + +Kept 1:1 with `stream_feeds_test` on purpose; fix upstream and here together: + +- The guarded zone drops async errors that arrive **after** the test body has + completed (a timer armed during the test that fires in the teardown window fails + silently instead of failing the test, which plain `package:test` would report). + +## Token handling — known limitation + +The harness gives the injected `WebSocket` its **own** `TokenManager`, pre-loaded +with the harness `token:`/`tokenProvider:`, because the client's manager is private +and only wired into the WebSocket the client builds itself. Consequence: the token +argument passed to `client.connectUser(user, token)` never reaches the connect URI — +the harness credentials are what authenticate, so "rejects a bad token" cannot be +tested through `connectUser` (a self-test pins this behavior). The fake server still +validates everything it can see: the connect payload's user id and the `user_id` +claim of the token in the URI. + +The proper fix is a `@visibleForTesting TokenManager?` seam on the +`StreamChatClient` constructor, letting the client and the injected WebSocket share +one manager. That approach has been validated (the full `stream_chat` test suite +passes with it) but is deliberately not applied yet — adopt it together with the +internals-access re-evaluation below. Adopting it later is contained: tests using +the default credentials behave identically with either wiring, so the only test +whose outcome changes is the pinning one above (flip it to expect rejection). + +## Tags + +Tests declared through `chatClientTest` / `channelTest` carry no tags. `tags:` is +forwarded to `package:test` if you want to tag a test yourself, in which case the +consuming package has to declare those tags in its own `dart_test.yaml` to avoid +`package:test` warnings. + +## Internals access — re-evaluate + +> **NOTE(re-evaluate):** this package imports `package:stream_chat/src/...` for seams +> that are not exported from the public barrel (`WebSocket`, `TokenManager`, `Token`, +> and the sub-API classes), with the `implementation_imports` lint disabled in this +> package's `analysis_options.yaml`. Re-evaluate once `stream_chat` exposes a +> dedicated testing entrypoint (e.g. `lib/testing.dart`) or exports these types from +> the main barrel. + +## Long-term: convergence with `stream_feeds_test` + +The domain-free parts of this package (the `testWithTester` lifecycle, the +mocker-mixin pattern, the websocket-channel harness) are candidates for extraction +into a shared `stream_core` test package. The chat-specific parts (`WebSocketTester` +internals, the throwing-API mocker) map one-to-one to where chat's low-level client +diverges from `stream_core`; they become replaceable by shared core equivalents if +chat's client moves onto `stream_core`. Until then, keep the file names and public +API in sync with `stream_feeds_test` when extending this package. diff --git a/packages/stream_chat_test/analysis_options.yaml b/packages/stream_chat_test/analysis_options.yaml new file mode 100644 index 0000000000..55f2c0ff87 --- /dev/null +++ b/packages/stream_chat_test/analysis_options.yaml @@ -0,0 +1,16 @@ +include: ../../analysis_options.yaml + +analyzer: + errors: + # NOTE(re-evaluate): This package reaches into `package:stream_chat/src/...` + # for seams that are not exported from the public barrel (WebSocket, + # TokenManager, Token, and the sub-API classes). Re-evaluate this once + # stream_chat exposes a dedicated testing entrypoint (e.g. `lib/testing.dart`) + # or exports these types from the main barrel. + implementation_imports: ignore + # Allow accessing @internal and @visibleForTesting members in test utilities. + invalid_use_of_internal_member: ignore + invalid_use_of_visible_for_testing_member: ignore + # Currently unused (no @protected member is accessed across a subclass + # boundary yet); kept for symmetry with stream_feeds_test's setup. + invalid_use_of_protected_member: ignore diff --git a/packages/stream_chat_test/example/stream_chat_test_example.dart b/packages/stream_chat_test/example/stream_chat_test_example.dart new file mode 100644 index 0000000000..2f1ce5426c --- /dev/null +++ b/packages/stream_chat_test/example/stream_chat_test_example.dart @@ -0,0 +1,41 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +void main() { + // A client test: connects a real StreamChatClient through the mocked + // WebSocket transport, stubs a REST call, and verifies the request. + chatClientTest( + 'stubs and verifies API calls with exact arguments', + body: (tester) async { + final message = createDefaultMessage(id: 'message-id'); + tester.mockApi( + (api) => api.message.getMessage('message-id'), + result: createDefaultGetMessageResponse(message: message), + ); + + final response = await tester.client.getMessage('message-id'); + + expect(response.message.id, 'message-id'); + tester.verifyApi((api) => api.message.getMessage('message-id')); + }, + ); + + // A channel test: seeds the channel with an initial state, then drives it + // through the real event pipeline by emitting a server-side event. + channelTest( + 'adds a new message on message.new event', + setUp: (tester) => tester.watch(), + body: (tester) async { + await tester.emitEvent( + createDefaultEvent( + type: EventType.messageNew, + cid: tester.channel.cid, + message: createDefaultMessage(id: 'new-message'), + ), + ); + + final messageIds = tester.channelState?.messages.map((message) => message.id); + expect(messageIds, contains('new-message')); + }, + ); +} diff --git a/packages/stream_chat_test/lib/src/helpers/api_mocker_mixin.dart b/packages/stream_chat_test/lib/src/helpers/api_mocker_mixin.dart new file mode 100644 index 0000000000..d1ba520502 --- /dev/null +++ b/packages/stream_chat_test/lib/src/helpers/api_mocker_mixin.dart @@ -0,0 +1,139 @@ +import 'package:meta/meta.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/stream_chat.dart'; + +import 'mocks.dart'; +import 'test_data.dart'; + +/// Mixin providing API mocking and verification methods. +/// +/// The callback in every method invokes the call being stubbed or verified on +/// one of the chat API's sub-APIs, passing the **exact** arguments the +/// production code is expected to send. A mocktail stub only answers on an +/// argument match, so stubbing this way doubles as request verification — +/// prefer exact values over `any()` matchers. +/// +/// An optional named argument omitted in the callback is filled with the +/// sub-API's declared default, so the stub only matches a production call +/// passing that same default; when the SDK passes a non-default value (e.g. +/// `channelData: {}`), the callback must pass it too. +/// +/// Example: +/// ```dart +/// tester.mockApi( +/// (api) => api.message.getMessage('message-id'), +/// result: createDefaultGetMessageResponse(), +/// ); +/// +/// tester.verifyApi((api) => api.message.getMessage('message-id')); +/// ``` +mixin ApiMockerMixin { + /// The fake chat API whose sub-APIs are mocks. + @protected + FakeChatApi get chatApi; + + /// Mocks an API call to return the given [result]. + /// + /// When [delay] is provided, the returned future completes only after it + /// elapses, keeping the request in flight for that duration. + void mockApi( + Future Function(StreamChatApi api) apiCall, { + required T result, + Duration? delay, + }) { + return when(() => apiCall(chatApi)).thenAnswer((_) async { + if (delay != null) await Future.delayed(delay); + return result; + }); + } + + /// Mocks an API call to fail with the given [error]. + /// + /// The returned future completes with the error — matching the real API + /// layer, which always fails asynchronously. Defaults to + /// [createDefaultNetworkError]'s retriable server failure. + /// + /// When [delay] is provided, the returned future fails only after it + /// elapses, keeping the request in flight for that duration. + void mockApiFailure( + Future Function(StreamChatApi api) apiCall, { + Object? error, + Duration? delay, + }) { + final failure = error ?? createDefaultNetworkError(); + return when(() => apiCall(chatApi)).thenAnswer((_) { + if (delay case final delay?) { + return Future.delayed(delay).then((_) => Future.error(failure)); + } + return Future.error(failure); + }); + } + + /// Mocks an API call to fail with the given [error] on the first invocation + /// and return [result] on every subsequent one. + /// + /// Both outcomes complete asynchronously — matching the real API layer. + /// [error] defaults to [createDefaultNetworkError]'s server failure, which + /// is retriable: the failed first call arms the SDK's retry queue and the + /// queue's immediate retry attempt succeeds without waiting on backoff + /// timers. + void mockApiFailureOnce( + Future Function(StreamChatApi api) apiCall, { + Object? error, + required T result, + }) { + final failure = error ?? createDefaultNetworkError(); + var firstCall = true; + return when(() => apiCall(chatApi)).thenAnswer((_) { + if (firstCall) { + firstCall = false; + return Future.error(failure); + } + return Future.value(result); + }); + } + + /// Verifies that an API call was made exactly once. + void verifyApi(Future Function(StreamChatApi api) apiCall) { + return verifyApiCalled(apiCall, times: 1); + } + + /// Verifies that an API call was made exactly [times] times. + void verifyApiCalled( + Future Function(StreamChatApi api) apiCall, { + required int times, + }) { + verify(() => apiCall(chatApi)).called(times); + } + + /// Captures the arguments of an API call for detailed assertions. + /// + /// The arguments to capture are marked with `captureAny()` / + /// `captureAny(named: ...)` matchers in the callback: + /// + /// ```dart + /// final captured = tester.captureApi( + /// (api) => api.message.getMessage(captureAny()), + /// ); + /// expect(captured.single, 'message-id'); + /// ``` + List captureApi(Future Function(StreamChatApi api) apiCall) { + return verify(() => apiCall(chatApi)).captured; + } + + /// Verifies that an API call was never made. + VerificationResult verifyNeverCalled(Future Function(StreamChatApi api) apiCall) { + return verifyNever(() => apiCall(chatApi)); + } + + /// Verifies that the sub-API selected by [subApi] received no interactions + /// beyond the ones already verified. + /// + /// ```dart + /// tester.verifyApi((api) => api.channel.markRead('channel-id', 'type')); + /// tester.verifyNoMoreApiInteractions((api) => api.channel); + /// ``` + void verifyNoMoreApiInteractions(Object Function(StreamChatApi api) subApi) { + return verifyNoMoreInteractions(subApi(chatApi)); + } +} diff --git a/packages/stream_chat/test/src/matchers.dart b/packages/stream_chat_test/lib/src/helpers/matchers.dart similarity index 69% rename from packages/stream_chat/test/src/matchers.dart rename to packages/stream_chat_test/lib/src/helpers/matchers.dart index c7dc60b210..afffb93973 100644 --- a/packages/stream_chat/test/src/matchers.dart +++ b/packages/stream_chat_test/lib/src/helpers/matchers.dart @@ -1,19 +1,14 @@ import 'package:collection/collection.dart'; -import 'package:dio/dio.dart' show MultipartFile; -import 'package:stream_chat/src/client/channel/channel.dart'; -import 'package:stream_chat/src/core/models/attachment.dart'; -import 'package:stream_chat/src/core/models/channel_state.dart'; -import 'package:stream_chat/src/core/models/draft_message.dart'; -import 'package:stream_chat/src/core/models/event.dart'; -import 'package:stream_chat/src/core/models/message.dart'; -import 'package:stream_chat/src/core/models/user.dart'; -import 'package:stream_core/stream_core.dart' show Filter; +import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; /// Matches a filter that serializes the same way as [target]. /// /// A filter compares by identity, so two filters built from the same field /// and value are not equal. Comparing what they send is what callers mean. +/// +/// Usable both as a test matcher and as a mocktail argument matcher via +/// `any(named: 'filter', that: isSameFilterAs(filter))`. Matcher isSameFilterAs(Filter target) { return predicate>( (it) => const DeepCollectionEquality().equals(it.toJson(), target.toJson()), @@ -21,6 +16,28 @@ Matcher isSameFilterAs(Filter target) { ); } +/// Matches a list of sorts that serializes the same way as [target]. +/// +/// A sort compares by identity for the same reason a [Filter] does, so a +/// `verify` naming one has to match on what it sends. +/// +/// Usable both as a test matcher and as a mocktail argument matcher via +/// `any(named: 'sort', that: isSameSortAs(sort))`. +Matcher isSameSortAs(List> target) { + final expected = target.map((it) => it.toJson()).toList(); + return predicate>>( + (it) => const DeepCollectionEquality().equals( + it.map((it) => it.toJson()).toList(), + expected, + ), + 'a sort serializing to $expected', + ); +} + +/// Matches a [MultipartFile] with the same length as [targetFile]. +/// +/// Usable both as a test matcher and as a mocktail argument matcher via +/// `any(that: isSameMultipartFileAs(file))`. Matcher isSameMultipartFileAs(MultipartFile targetFile) => _IsSameMultipartFileAs(targetFile: targetFile); class _IsSameMultipartFileAs extends Matcher { @@ -35,20 +52,50 @@ class _IsSameMultipartFileAs extends Matcher { bool matches(covariant MultipartFile file, Map matchState) => file.length == targetFile.length; } -Matcher isSameEventAs(Event targetEvent) => _IsSameEventAs(targetEvent: targetEvent); +/// Matches an [Event] with the same type as [targetEvent]. +/// +/// The `match*` flags opt additional fields into the comparison. Usable both +/// as a test matcher and as a mocktail argument matcher via +/// `any(that: isSameEventAs(event))` — the form is needed when the SDK builds +/// the event itself, so the test has no instance to compare against. +Matcher isSameEventAs( + Event targetEvent, { + bool matchParentId = false, +}) => _IsSameEventAs( + targetEvent: targetEvent, + matchParentId: matchParentId, +); class _IsSameEventAs extends Matcher { - const _IsSameEventAs({required this.targetEvent}); + const _IsSameEventAs({ + required this.targetEvent, + this.matchParentId = false, + }); final Event targetEvent; + final bool matchParentId; @override Description describe(Description description) => description.add('is same event as $targetEvent'); @override - bool matches(covariant Event event, Map matchState) => event.type == targetEvent.type; + bool matches(covariant Event event, Map matchState) { + var matches = event.type == targetEvent.type; + if (matchParentId) { + matches &= event.parentId == targetEvent.parentId; + } + + return matches; + } } +/// Matches a [Message] with the same id as [targetMessage]. +/// +/// The `match*` flags opt additional fields into the comparison. Usable both +/// as a test matcher and as a mocktail argument matcher via +/// `any(that: isSameMessageAs(message))` — the form message-carrying API stubs +/// must take, because the SDK stamps fields like `localCreatedAt` and `user` +/// on outgoing messages, so exact equality never holds. Matcher isSameMessageAs( Message targetMessage, { bool matchText = false, @@ -135,6 +182,11 @@ class _IsSameMessageAs extends Matcher { } } +/// Matches a [DraftMessage] with the same id as [targetMessage]. +/// +/// The `match*` flags opt additional fields into the comparison. Usable both +/// as a test matcher and as a mocktail argument matcher via +/// `any(that: isSameDraftMessageAs(message))`. Matcher isSameDraftMessageAs( DraftMessage targetMessage, { bool matchText = false, @@ -192,6 +244,11 @@ class _IsSameDraftMessageAs extends Matcher { } } +/// Matches an [Attachment] with the same id as [targetAttachment]. +/// +/// [matchUploadState] opts the upload state into the comparison. Usable both +/// as a test matcher and as a mocktail argument matcher via +/// `any(that: isSameAttachmentAs(attachment))`. Matcher isSameAttachmentAs( Attachment targetAttachment, { bool matchUploadState = false, @@ -222,6 +279,10 @@ class _IsSameAttachmentAs extends Matcher { } } +/// Matches a [User] with the same id as [targetUser]. +/// +/// Usable both as a test matcher and as a mocktail argument matcher via +/// `any(that: isSameUserAs(user))`. Matcher isSameUserAs(User targetUser) => _IsSameUserAs(targetUser: targetUser); class _IsSameUserAs extends Matcher { @@ -236,6 +297,10 @@ class _IsSameUserAs extends Matcher { bool matches(covariant User user, Map matchState) => user.id == targetUser.id; } +/// Matches a [Channel] whose cid equals the cid of [channelState]. +/// +/// Usable both as a test matcher and as a mocktail argument matcher via +/// `any(that: isCorrectChannelFor(channelState))`. Matcher isCorrectChannelFor(ChannelState channelState) => _IsCorrectChannelFor(channelState: channelState); class _IsCorrectChannelFor extends Matcher { diff --git a/packages/stream_chat_test/lib/src/helpers/mocks.dart b/packages/stream_chat_test/lib/src/helpers/mocks.dart new file mode 100644 index 0000000000..75f1f91f08 --- /dev/null +++ b/packages/stream_chat_test/lib/src/helpers/mocks.dart @@ -0,0 +1,310 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/api/channel_api.dart'; +import 'package:stream_chat/src/core/api/device_api.dart'; +import 'package:stream_chat/src/core/api/general_api.dart'; +import 'package:stream_chat/src/core/api/guest_api.dart'; +import 'package:stream_chat/src/core/api/message_api.dart'; +import 'package:stream_chat/src/core/api/moderation_api.dart'; +import 'package:stream_chat/src/core/api/polls_api.dart'; +import 'package:stream_chat/src/core/api/reminders_api.dart'; +import 'package:stream_chat/src/core/api/roles_api.dart'; +import 'package:stream_chat/src/core/api/threads_api.dart'; +import 'package:stream_chat/src/core/api/user_api.dart'; +import 'package:stream_chat/src/core/api/user_groups_api.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +/// Mock implementation of [UserApi]. +class MockUserApi extends Mock implements UserApi {} + +/// Mock implementation of [GuestApi]. +class MockGuestApi extends Mock implements GuestApi {} + +/// Mock implementation of [MessageApi]. +class MockMessageApi extends Mock implements MessageApi {} + +/// Mock implementation of [PollsApi]. +class MockPollsApi extends Mock implements PollsApi {} + +/// Mock implementation of [ThreadsApi]. +class MockThreadsApi extends Mock implements ThreadsApi {} + +/// Mock implementation of [ChannelApi]. +class MockChannelApi extends Mock implements ChannelApi {} + +/// Mock implementation of [DeviceApi]. +class MockDeviceApi extends Mock implements DeviceApi {} + +/// Mock implementation of [ModerationApi]. +class MockModerationApi extends Mock implements ModerationApi {} + +/// Mock implementation of [RemindersApi]. +class MockRemindersApi extends Mock implements RemindersApi {} + +/// Mock implementation of [UserGroupsApi]. +class MockUserGroupsApi extends Mock implements UserGroupsApi {} + +/// Mock implementation of [RolesApi]. +class MockRolesApi extends Mock implements RolesApi {} + +/// Mock implementation of [GeneralApi]. +class MockGeneralApi extends Mock implements GeneralApi {} + +/// Mock implementation of [AttachmentFileUploader]. +class MockAttachmentFileUploader extends Mock implements AttachmentFileUploader {} + +/// Mock implementation of [WebSocketChannel]. +class MockWebSocketChannel extends Mock implements WebSocketChannel {} + +/// Mock implementation of [WebSocketSink]. +class MockWebSocketSink extends Mock implements WebSocketSink {} + +/// Mock implementation of [ChatPersistenceClient]. +/// +/// Tracks real connection state (`connect` / `disconnect` / [isConnected] / +/// [userId]) so the client's persistence lifecycle works without stubbing; +/// every other member is a regular mocktail mock. Pass it to the harness via +/// the `chatPersistenceClient:` parameter. +class MockPersistenceClient extends Mock implements ChatPersistenceClient { + String? _userId; + bool _isConnected = false; + + @override + bool get isConnected => _isConnected; + + @override + String? get userId => _userId; + + @override + Future connect(String userId) async { + _userId = userId; + _isConnected = true; + } + + @override + Future disconnect({bool flush = false}) async { + _userId = null; + _isConnected = false; + } +} + +/// Fake implementation of [ChatPersistenceClient]. +/// +/// Implements the connection lifecycle and sync bookkeeping with real state +/// and records how often `connect` / `disconnect` were called, for tests that +/// assert on the persistence lifecycle itself. Members beyond these throw, +/// as on any [Fake]. +class FakePersistenceClient extends Fake implements ChatPersistenceClient { + /// Creates a [FakePersistenceClient], optionally seeded with a last-sync + /// timestamp and a list of locally cached channel cids. + FakePersistenceClient({ + this._lastSyncAt, + List? channelCids, + }) : _channelCids = channelCids ?? []; + + String? _userId; + bool _isConnected = false; + DateTime? _lastSyncAt; + List _channelCids; + + /// Number of times [connect] was called. + int connectCallCount = 0; + + /// Number of times [disconnect] was called. + int disconnectCallCount = 0; + + /// Number of times [flush] was called. + int flushCallCount = 0; + + @override + bool get isConnected => _isConnected; + + @override + String? get userId => _userId; + + @override + Future connect(String userId) async { + _userId = userId; + _isConnected = true; + connectCallCount++; + } + + @override + Future disconnect({bool flush = false}) async { + if (flush) await this.flush(); + + _userId = null; + _isConnected = false; + disconnectCallCount++; + } + + @override + Future flush() async { + _lastSyncAt = null; + _channelCids = []; + flushCallCount++; + } + + @override + Future getLastSyncAt() async => _lastSyncAt; + + @override + Future updateLastSyncAt(DateTime lastSyncAt) async { + _lastSyncAt = lastSyncAt; + } + + @override + Future> getChannelCids() async => _channelCids; + + // Written by the sync catch-up when it refreshes channels in place of + // replaying an oversized event payload. Unimplemented on a [Fake] these + // throw, and the connection-status listener swallows it, so the sync + // silently stops short of advancing `lastSyncAt`. + @override + Future saveChannelQueries({ + required List cids, + ChannelFilter? filter, + List? sort, + String? predefinedFilter, + ChannelFilter? resolvedFilter, + List? resolvedSort, + Map? filterValues, + Map? sortValues, + bool clearQueryCache = false, + }) async {} + + @override + Future updateChannelStates(List channelStates) async {} +} + +/// A [StreamChatApi] whose sub-APIs are lazily-created mocks. +/// +/// Injected into [StreamChatClient] as the `chatApi:` seam so that every REST +/// call made by the SDK can be stubbed and verified through the mocked +/// sub-APIs, while everything above the API layer stays real. +class FakeChatApi extends Fake implements StreamChatApi { + UserApi? _user; + + @override + UserApi get user => _user ??= MockUserApi(); + + GuestApi? _guest; + + @override + GuestApi get guest => _guest ??= MockGuestApi(); + + MessageApi? _message; + + @override + MessageApi get message => _message ??= MockMessageApi(); + + PollsApi? _polls; + + @override + PollsApi get polls => _polls ??= MockPollsApi(); + + ThreadsApi? _threads; + + @override + ThreadsApi get threads => _threads ??= MockThreadsApi(); + + ChannelApi? _channel; + + @override + ChannelApi get channel => _channel ??= MockChannelApi(); + + DeviceApi? _device; + + @override + DeviceApi get device => _device ??= MockDeviceApi(); + + ModerationApi? _moderation; + + @override + ModerationApi get moderation => _moderation ??= MockModerationApi(); + + RemindersApi? _reminders; + + @override + RemindersApi get reminders => _reminders ??= MockRemindersApi(); + + UserGroupsApi? _userGroups; + + @override + UserGroupsApi get userGroups => _userGroups ??= MockUserGroupsApi(); + + RolesApi? _roles; + + @override + RolesApi get roles => _roles ??= MockRolesApi(); + + GeneralApi? _general; + + @override + GeneralApi get general => _general ??= MockGeneralApi(); + + AttachmentFileUploader? _fileUploader; + + @override + AttachmentFileUploader get fileUploader => _fileUploader ??= MockAttachmentFileUploader(); +} + +class _FakeMessage extends Fake implements Message {} + +class _FakeDraftMessage extends Fake implements DraftMessage {} + +class _FakeAttachmentFile extends Fake implements AttachmentFile {} + +class _FakeEvent extends Fake implements Event {} + +class _FakeUser extends Fake implements User {} + +class _FakePollVote extends Fake implements PollVote {} + +class _FakeChannelState extends Fake implements ChannelState {} + +class _FakeMultipartFile extends Fake implements MultipartFile {} + +/// Registers mocktail fallback values for common chat argument types, so that +/// `any()` / `captureAny()` matchers can be used with them. +/// +/// Called automatically by the tester lifecycle, so tests should not need to +/// call it. Calling it again is harmless but appends another set of values — +/// mocktail keeps every value it is given. +void registerChatFallbackValues() { + registerFallbackValue(_FakeMessage()); + registerFallbackValue(_FakeDraftMessage()); + registerFallbackValue(_FakeAttachmentFile()); + registerFallbackValue(_FakeEvent()); + registerFallbackValue(_FakeUser()); + registerFallbackValue(_FakePollVote()); + registerFallbackValue(_FakeChannelState()); + registerFallbackValue(_FakeMultipartFile()); + registerFallbackValue(const PaginationParams()); + // A filter carries the model it queries in its type, so mocktail needs one + // fallback per query surface rather than a single `Filter`. + registerFallbackValue(const ChannelFilter.raw({})); + registerFallbackValue(const MemberFilter.raw({})); + registerFallbackValue(const UserFilter.raw({})); + registerFallbackValue(const MessageSearchFilter.raw({})); + registerFallbackValue(const ThreadFilter.raw({})); + registerFallbackValue(const PollFilter.raw({})); + registerFallbackValue(const PollVoteFilter.raw({})); + registerFallbackValue(const DraftFilter.raw({})); + registerFallbackValue(const ReactionFilter.raw({})); + registerFallbackValue(const BannedUserFilter.raw({})); + registerFallbackValue(const MessageReminderFilter.raw({})); + + // Same for a sort, which is always passed as a list. + registerFallbackValue(const []); + registerFallbackValue(const []); + registerFallbackValue(const []); + registerFallbackValue(const []); + registerFallbackValue(const []); + registerFallbackValue(const []); + registerFallbackValue(const []); + registerFallbackValue(const []); + registerFallbackValue(const []); + registerFallbackValue(const []); + registerFallbackValue(const []); +} diff --git a/packages/stream_chat_test/lib/src/helpers/server_json.dart b/packages/stream_chat_test/lib/src/helpers/server_json.dart new file mode 100644 index 0000000000..d1a83f706d --- /dev/null +++ b/packages/stream_chat_test/lib/src/helpers/server_json.dart @@ -0,0 +1,181 @@ +import 'package:stream_chat/stream_chat.dart'; + +/// Serializes [event] the way a server sends it over the WebSocket. +/// +/// `Event.toJson` produces the client's outgoing shape: nested payloads like +/// [Message] and [Reaction] omit their server-assigned fields (sender, +/// timestamps, reactions, ...), so an event round-tripped through it loses +/// data a real server push always carries. This restores those payloads to +/// their full wire shape; `WebSocketTester.emitEvent` emits every event +/// through it. +Map serverEventJson(Event event) { + return { + ...event.toJson(), + if (event.channel case final channel?) 'channel': serverChannelJson(channel), + if (event.message case final message?) 'message': serverMessageJson(message), + if (event.reaction case final reaction?) 'reaction': serverReactionJson(reaction), + if (event.poll case final poll?) 'poll': serverPollJson(poll), + if (event.pollVote case final pollVote?) 'poll_vote': serverPollVoteJson(pollVote), + if (event.draft case final draft?) 'draft': serverDraftJson(draft), + if (event.reminder case final reminder?) 'reminder': serverReminderJson(reminder), + }; +} + +/// Serializes [channel] the way a server sends it, restoring the +/// server-assigned fields that `ChannelModel.toJson` omits. +Map serverChannelJson(ChannelModel channel) { + return { + ...channel.toJson(), + 'cid': channel.cid, + if (channel.ownCapabilities case final ownCapabilities?) 'own_capabilities': ownCapabilities, + 'config': channel.config.toJson(), + if (channel.createdBy case final createdBy?) 'created_by': createdBy.toJson(), + if (channel.lastMessageAt case final lastMessageAt?) 'last_message_at': lastMessageAt.toIso8601String(), + 'created_at': channel.createdAt.toIso8601String(), + 'updated_at': channel.updatedAt.toIso8601String(), + if (channel.deletedAt case final deletedAt?) 'deleted_at': deletedAt.toIso8601String(), + 'member_count': channel.memberCount, + if (channel.members case final members?) 'members': [for (final member in members) member.toJson()], + if (channel.team case final team?) 'team': team, + if (channel.messageCount case final messageCount?) 'message_count': messageCount, + if (channel.filterTags case final filterTags?) 'filter_tags': filterTags, + }; +} + +/// Serializes [message] the way a server sends it, restoring the +/// server-assigned fields that `Message.toJson` omits. +Map serverMessageJson(Message message) { + return { + ...message.toJson(), + // The request shape only carries regular/system types; a server push + // carries every type. + 'type': message.type, + if (message.user case final user?) 'user': user.toJson(), + 'created_at': message.createdAt.toIso8601String(), + 'updated_at': message.updatedAt.toIso8601String(), + if (message.deletedAt case final deletedAt?) 'deleted_at': deletedAt.toIso8601String(), + 'shadowed': message.shadowed, + if (message.latestReactions case final reactions?) + 'latest_reactions': [for (final reaction in reactions) serverReactionJson(reaction)], + if (message.ownReactions case final reactions?) + 'own_reactions': [for (final reaction in reactions) serverReactionJson(reaction)], + if (message.reactionGroups case final groups?) + 'reaction_groups': groups.map((type, group) => MapEntry(type, group.toJson())), + if (message.replyCount case final replyCount?) 'reply_count': replyCount, + if (message.threadParticipants case final participants?) + 'thread_participants': [for (final user in participants) user.toJson()], + if (message.pinnedAt case final pinnedAt?) 'pinned_at': pinnedAt.toIso8601String(), + if (message.pinnedBy case final pinnedBy?) 'pinned_by': pinnedBy.toJson(), + if (message.quotedMessage case final quotedMessage?) 'quoted_message': serverMessageJson(quotedMessage), + if (message.poll case final poll?) 'poll': serverPollJson(poll), + if (message.draft case final draft?) 'draft': serverDraftJson(draft), + if (message.reminder case final reminder?) 'reminder': serverReminderJson(reminder), + if (message.i18n case final i18n?) 'i18n': i18n, + if (message.moderation case final moderation?) 'moderation': moderation.toJson(), + if (message.mentionedGroups case final groups?) 'mentioned_groups': [for (final group in groups) group.toJson()], + // The sender's channel role rides inside the message's `member` object, + // which is where `Message.fromJson` reads it back from. + if (message.channelRole case final channelRole?) 'member': {'channel_role': channelRole}, + if (message.messageTextUpdatedAt case final textUpdatedAt?) + 'message_text_updated_at': textUpdatedAt.toIso8601String(), + if (message.deletedForMe case final deletedForMe?) 'deleted_for_me': deletedForMe, + if (message.command case final command?) 'command': command, + if (message.sharedLocation case final location?) 'shared_location': serverLocationJson(location), + }; +} + +/// Serializes [draft] the way a server sends it, restoring the nested +/// payloads that `Draft.toJson` serializes through lossy request shapes. +Map serverDraftJson(Draft draft) { + return { + ...draft.toJson(), + 'message': serverDraftMessageJson(draft.message), + if (draft.channel case final channel?) 'channel': serverChannelJson(channel), + if (draft.parentMessage case final parentMessage?) 'parent_message': serverMessageJson(parentMessage), + if (draft.quotedMessage case final quotedMessage?) 'quoted_message': serverMessageJson(quotedMessage), + }; +} + +/// Serializes [message] the way a server sends it, restoring the fields +/// that `DraftMessage.toJson` omits or rewrites (command, poll, quoted +/// message, full mentioned users, command-prefixed text). +Map serverDraftMessageJson(DraftMessage message) { + return { + ...message.toJson(), + if (message.text case final text?) 'text': text, + 'mentioned_users': [for (final user in message.mentionedUsers) user.toJson()], + if (message.quotedMessage case final quotedMessage?) 'quoted_message': serverMessageJson(quotedMessage), + if (message.command case final command?) 'command': command, + if (message.poll case final poll?) 'poll': serverPollJson(poll), + }; +} + +/// Serializes [reminder] the way a server sends it, restoring the +/// server-assigned fields that `MessageReminder.toJson` omits. +Map serverReminderJson(MessageReminder reminder) { + return { + ...reminder.toJson(), + if (reminder.channel case final channel?) 'channel': serverChannelJson(channel), + if (reminder.message case final message?) 'message': serverMessageJson(message), + if (reminder.user case final user?) 'user': user.toJson(), + }; +} + +/// Serializes [location] the way a server sends it, restoring the +/// server-assigned fields that `Location.toJson` omits. +Map serverLocationJson(Location location) { + return { + ...location.toJson(), + if (location.channelCid case final channelCid?) 'channel_cid': channelCid, + if (location.messageId case final messageId?) 'message_id': messageId, + if (location.userId case final userId?) 'user_id': userId, + 'created_at': location.createdAt.toIso8601String(), + 'updated_at': location.updatedAt.toIso8601String(), + }; +} + +/// Serializes [poll] the way a server sends it, restoring the +/// server-assigned fields that `Poll.toJson` omits. +Map serverPollJson(Poll poll) { + return { + ...poll.toJson(), + 'answers_count': poll.answersCount, + 'vote_counts_by_option': poll.voteCountsByOption, + 'latest_votes_by_option': poll.latestVotesByOption.map( + (optionId, votes) => MapEntry(optionId, [for (final vote in votes) serverPollVoteJson(vote)]), + ), + 'latest_answers': [for (final answer in poll.latestAnswers) serverPollVoteJson(answer)], + 'own_votes': [for (final vote in poll.ownVotesAndAnswers) serverPollVoteJson(vote)], + 'vote_count': poll.voteCount, + if (poll.createdById case final createdById?) 'created_by_id': createdById, + if (poll.createdBy case final createdBy?) 'created_by': createdBy.toJson(), + 'created_at': poll.createdAt.toIso8601String(), + 'updated_at': poll.updatedAt.toIso8601String(), + }; +} + +/// Serializes [pollVote] the way a server sends it, restoring the +/// server-assigned fields that `PollVote.toJson` omits. +Map serverPollVoteJson(PollVote pollVote) { + return { + ...pollVote.toJson(), + if (pollVote.pollId case final pollId?) 'poll_id': pollId, + if (pollVote.userId case final userId?) 'user_id': userId, + if (pollVote.user case final user?) 'user': user.toJson(), + 'created_at': pollVote.createdAt.toIso8601String(), + 'updated_at': pollVote.updatedAt.toIso8601String(), + }; +} + +/// Serializes [reaction] the way a server sends it, restoring the +/// server-assigned fields that `Reaction.toJson` omits. +Map serverReactionJson(Reaction reaction) { + return { + ...reaction.toJson(), + if (reaction.messageId case final messageId?) 'message_id': messageId, + if (reaction.user case final user?) 'user': user.toJson(), + if (reaction.userId case final userId?) 'user_id': userId, + 'created_at': reaction.createdAt.toIso8601String(), + 'updated_at': reaction.updatedAt.toIso8601String(), + }; +} diff --git a/packages/stream_chat_test/lib/src/helpers/test_data.dart b/packages/stream_chat_test/lib/src/helpers/test_data.dart new file mode 100644 index 0000000000..5a70555638 --- /dev/null +++ b/packages/stream_chat_test/lib/src/helpers/test_data.dart @@ -0,0 +1,778 @@ +// Canonical fixture factories for chat models and API responses. +// +// Conventions: +// - One `createDefaultXxx` factory per model/response, with every parameter +// defaulted so call sites only pass what the test cares about. +// - Deterministic timestamps: `DateTime(2021, 1, 1)` for creation and +// `DateTime(2021, 2, 1)` for updates, so equality-based mock matching is +// stable across runs. +// - Derived fields are computed from the provided values instead of being +// accepted separately, so fixtures cannot be internally inconsistent. +// - Response classes are mutable `json_serializable` models with `late` +// fields, so response fixtures are built with cascades +// (`SendMessageResponse()..message = message`) rather than constructors. + +import 'dart:convert'; + +import 'package:stream_chat/stream_chat.dart'; + +/// The creation timestamp used by all fixtures. +final testCreatedAt = DateTime(2021); + +/// The update timestamp used by all fixtures. +final testUpdatedAt = DateTime(2021, 2); + +/// Creates a [UserToken] for [userId], decodable by the SDK's token parsing. +/// +/// Carries the `devtoken` signature rather than one produced with an app +/// secret, so it parses locally but only a development-tokens app would +/// accept it. +UserToken createTestToken(String userId) { + const header = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'; + const signature = 'devtoken'; + final payload = base64.encode(utf8.encode(json.encode({'user_id': userId}))); + return UserToken('$header.$payload.$signature'); +} + +/// Creates a [User] with sensible defaults. +User createDefaultUser({ + String id = 'luke_skywalker', + String name = 'Luke Skywalker', + String role = 'user', + DateTime? createdAt, + DateTime? updatedAt, + bool online = false, + Map extraData = const {}, +}) { + return User( + id: id, + name: name, + role: role, + createdAt: createdAt ?? testCreatedAt, + updatedAt: updatedAt ?? testUpdatedAt, + online: online, + extraData: extraData, + ); +} + +/// Creates an [OwnUser] with sensible defaults. +OwnUser createDefaultOwnUser({ + String id = 'luke_skywalker', + String name = 'Luke Skywalker', + String role = 'user', + int totalUnreadCount = 0, + int unreadChannels = 0, + List devices = const [], + List mutes = const [], + PrivacySettings? privacySettings, + DateTime? createdAt, + DateTime? updatedAt, +}) { + return OwnUser( + id: id, + name: name, + role: role, + totalUnreadCount: totalUnreadCount, + unreadChannels: unreadChannels, + devices: devices, + mutes: mutes, + privacySettings: privacySettings, + createdAt: createdAt ?? testCreatedAt, + updatedAt: updatedAt ?? testUpdatedAt, + ); +} + +/// Creates the `health.check` [Event] a server sends to acknowledge a +/// successful connection. +/// +/// The `me` payload identifies the connected user and is what the client uses +/// to populate its current user. +Event createDefaultConnectedEvent({ + String userId = 'luke_skywalker', + String connectionId = 'test-connection-id', + OwnUser? me, +}) { + return Event( + type: EventType.healthCheck, + connectionId: connectionId, + createdAt: testCreatedAt, + me: me ?? createDefaultOwnUser(id: userId), + ); +} + +/// Creates the raw error frame a server sends when a connection attempt is +/// rejected. +/// +/// [code] is the backend error code (see [StreamErrorCode]); the default `40` +/// is `tokenExpired`. +Map createDefaultConnectionErrorFrame({ + int code = 40, + int statusCode = 401, + String message = 'connection rejected', +}) { + return { + 'error': { + 'code': code, + 'message': message, + 'StatusCode': statusCode, + }, + }; +} + +/// Creates an [Event] with sensible defaults. +Event createDefaultEvent({ + String type = 'message.new', + String? cid, + String? channelId, + String? channelType, + User? user, + Message? message, + Poll? poll, + PollVote? pollVote, + Reaction? reaction, + Member? member, + ChannelModel? channel, + Draft? draft, + MessageReminder? reminder, + PushPreference? pushPreference, + ChannelPushPreference? channelPushPreference, + OwnUser? me, + int? totalUnreadCount, + int? unreadChannels, + int? channelMemberCount, + int? channelMessageCount, + int? watcherCount, + bool? hardDelete, + bool? deletedForMe, + Thread? thread, + DateTime? lastReadAt, + int? unreadMessages, + String? lastReadMessageId, + DateTime? lastDeliveredAt, + String? lastDeliveredMessageId, + DateTime? createdAt, + Map extraData = const {}, +}) { + return Event( + type: type, + cid: cid, + channelId: channelId, + channelType: channelType, + user: user, + message: message, + poll: poll, + pollVote: pollVote, + reaction: reaction, + member: member, + channel: channel, + draft: draft, + reminder: reminder, + pushPreference: pushPreference, + channelPushPreference: channelPushPreference, + me: me, + totalUnreadCount: totalUnreadCount, + unreadChannels: unreadChannels, + channelMemberCount: channelMemberCount, + channelMessageCount: channelMessageCount, + watcherCount: watcherCount, + hardDelete: hardDelete, + deletedForMe: deletedForMe, + thread: thread, + lastReadAt: lastReadAt, + unreadMessages: unreadMessages, + lastReadMessageId: lastReadMessageId, + lastDeliveredAt: lastDeliveredAt, + lastDeliveredMessageId: lastDeliveredMessageId, + createdAt: createdAt ?? testCreatedAt, + extraData: extraData, + ); +} + +/// Creates a [Message] with sensible defaults. +Message createDefaultMessage({ + String id = 'message-id', + String text = 'hello world', + User? user, + String? parentId, + List attachments = const [], + List ownReactions = const [], + List latestReactions = const [], + DateTime? createdAt, + DateTime? updatedAt, + Map extraData = const {}, +}) { + return Message( + id: id, + text: text, + user: user ?? createDefaultUser(), + parentId: parentId, + attachments: attachments, + ownReactions: ownReactions, + latestReactions: latestReactions, + createdAt: createdAt ?? testCreatedAt, + updatedAt: updatedAt ?? testUpdatedAt, + extraData: extraData, + ); +} + +/// Creates a [ChannelModel] with sensible defaults. +/// +/// Timestamps are always passed explicitly because the constructor defaults +/// them to `DateTime.now()`, which would break deterministic matching. The +/// same applies to [config]: leaving it unset would build a [ChannelConfig] +/// carrying `DateTime.now()` timestamps, so it defaults to +/// [createDefaultChannelConfig] instead. +ChannelModel createDefaultChannelModel({ + String cid = 'messaging:test-channel', + User? createdBy, + int memberCount = 0, + int cooldown = 0, + List? members, + List? ownCapabilities, + ChannelConfig? config, + List? filterTags, + DateTime? createdAt, + DateTime? updatedAt, + DateTime? lastMessageAt, + Map extraData = const {}, +}) { + return ChannelModel( + cid: cid, + createdBy: createdBy, + memberCount: memberCount, + cooldown: cooldown, + members: members, + ownCapabilities: ownCapabilities, + config: config ?? createDefaultChannelConfig(), + filterTags: filterTags, + createdAt: createdAt ?? testCreatedAt, + updatedAt: updatedAt ?? testUpdatedAt, + lastMessageAt: lastMessageAt, + extraData: extraData, + ); +} + +/// Creates a [ChannelConfig] with sensible defaults. +/// +/// Timestamps are always passed explicitly because the constructor defaults +/// them to `DateTime.now()`, which would break deterministic matching. +ChannelConfig createDefaultChannelConfig({ + bool readEvents = false, + bool typingEvents = false, + bool reactions = false, + bool replies = false, + bool mutes = false, + bool skipLastMsgUpdateForSystemMsgs = false, + List commands = const [], + DateTime? createdAt, + DateTime? updatedAt, +}) { + return ChannelConfig( + readEvents: readEvents, + typingEvents: typingEvents, + reactions: reactions, + replies: replies, + mutes: mutes, + skipLastMsgUpdateForSystemMsgs: skipLastMsgUpdateForSystemMsgs, + commands: commands, + createdAt: createdAt ?? testCreatedAt, + updatedAt: updatedAt ?? testUpdatedAt, + ); +} + +/// Creates a [ChannelState] with sensible defaults. +ChannelState createDefaultChannelState({ + ChannelModel? channel, + List messages = const [], + List members = const [], + List pinnedMessages = const [], + List read = const [], + Member? membership, + int? watcherCount, +}) { + return ChannelState( + channel: channel ?? createDefaultChannelModel(), + messages: messages, + members: members, + pinnedMessages: pinnedMessages, + read: read, + membership: membership, + watcherCount: watcherCount, + ); +} + +/// Creates a [Member] with sensible defaults. +Member createDefaultMember({ + User? user, + String channelRole = 'channel_member', + DateTime? createdAt, + DateTime? updatedAt, +}) { + return Member( + user: user ?? createDefaultUser(), + channelRole: channelRole, + createdAt: createdAt ?? testCreatedAt, + updatedAt: updatedAt ?? testUpdatedAt, + ); +} + +/// Creates a [Read] with sensible defaults. +Read createDefaultRead({ + User? user, + DateTime? lastRead, + DateTime? lastDeliveredAt, + int unreadMessages = 0, +}) { + return Read( + user: user ?? createDefaultUser(), + lastRead: lastRead ?? testCreatedAt, + lastDeliveredAt: lastDeliveredAt, + unreadMessages: unreadMessages, + ); +} + +/// Creates a [Reaction] with sensible defaults. +Reaction createDefaultReaction({ + String type = 'like', + String? messageId, + User? user, + int score = 1, + DateTime? createdAt, +}) { + return Reaction( + type: type, + messageId: messageId, + user: user ?? createDefaultUser(), + score: score, + createdAt: createdAt ?? testCreatedAt, + ); +} + +/// Creates a [Draft] with sensible defaults. +/// +/// The default [DraftMessage] is given an explicit id because the constructor +/// defaults it to a random UUID, which would break deterministic matching. +Draft createDefaultDraft({ + String channelCid = 'messaging:test-channel', + DraftMessage? message, + String? parentId, + DateTime? createdAt, +}) { + return Draft( + channelCid: channelCid, + message: message ?? DraftMessage(id: 'draft-message-id', text: 'draft message text'), + parentId: parentId, + createdAt: createdAt ?? testCreatedAt, + ); +} + +/// Creates a [MessageReminder] with sensible defaults. +/// +/// Timestamps are always passed explicitly because the constructor defaults +/// them to `DateTime.now()`, which would break deterministic matching. +MessageReminder createDefaultMessageReminder({ + String channelCid = 'messaging:test-channel', + String messageId = 'message-id', + String userId = 'luke_skywalker', + ChannelModel? channel, + Message? message, + User? user, + DateTime? remindAt, + DateTime? createdAt, + DateTime? updatedAt, +}) { + return MessageReminder( + channelCid: channelCid, + channel: channel, + messageId: messageId, + message: message, + userId: userId, + user: user, + remindAt: remindAt, + createdAt: createdAt ?? testCreatedAt, + updatedAt: updatedAt ?? testUpdatedAt, + ); +} + +/// Creates a [Poll] with sensible defaults. +/// +/// The id and timestamps are always passed explicitly because the constructor +/// defaults them to a random UUID and `DateTime.now()` respectively, which +/// would break deterministic matching. +Poll createDefaultPoll({ + String id = 'test-poll-id', + String name = 'What is your favorite color?', + List? options, + DateTime? createdAt, + DateTime? updatedAt, + Map extraData = const {}, +}) { + return Poll( + id: id, + name: name, + options: + options ?? + [ + createDefaultPollOption(text: 'Red'), + createDefaultPollOption(text: 'Blue'), + ], + createdAt: createdAt ?? testCreatedAt, + updatedAt: updatedAt ?? testUpdatedAt, + extraData: extraData, + ); +} + +/// Creates a [PollOption] with sensible defaults. +PollOption createDefaultPollOption({ + String? id, + String text = 'Red', + Map extraData = const {}, +}) { + return PollOption( + id: id, + text: text, + extraData: extraData, + ); +} + +/// Creates a [PollVote] with sensible defaults. +/// +/// A vote must carry either an [optionId] or an [answerText]; when neither is +/// provided, the vote defaults to an option vote for `test-option-id`. +/// Timestamps are always passed explicitly because the constructor defaults +/// them to `DateTime.now()`, which would break deterministic matching. +PollVote createDefaultPollVote({ + String? id, + String? pollId, + String? optionId, + String? answerText, + String? userId, + User? user, + DateTime? createdAt, + DateTime? updatedAt, +}) { + final resolvedOptionId = optionId ?? (answerText == null ? 'test-option-id' : null); + return PollVote( + id: id, + pollId: pollId, + optionId: resolvedOptionId, + answerText: answerText, + userId: userId, + user: user, + createdAt: createdAt ?? testCreatedAt, + updatedAt: updatedAt ?? testUpdatedAt, + ); +} + +/// Creates a [GetMessageResponse] wrapping [message]. +GetMessageResponse createDefaultGetMessageResponse({ + Message? message, + ChannelModel? channel, +}) { + return GetMessageResponse() + ..duration = '10ms' + ..message = message ?? createDefaultMessage() + ..channel = channel; +} + +/// Creates a [SendMessageResponse] wrapping [message]. +SendMessageResponse createDefaultSendMessageResponse({ + Message? message, +}) { + return SendMessageResponse() + ..duration = '10ms' + ..message = message ?? createDefaultMessage(); +} + +/// Creates a [CreateDraftResponse] wrapping [draft]. +CreateDraftResponse createDefaultCreateDraftResponse({ + Draft? draft, +}) { + return CreateDraftResponse() + ..duration = '10ms' + ..draft = draft ?? createDefaultDraft(); +} + +/// Creates a [GetDraftResponse] wrapping [draft]. +GetDraftResponse createDefaultGetDraftResponse({ + Draft? draft, +}) { + return GetDraftResponse() + ..duration = '10ms' + ..draft = draft ?? createDefaultDraft(); +} + +/// Creates a [CreateReminderResponse] wrapping [reminder]. +CreateReminderResponse createDefaultCreateReminderResponse({ + MessageReminder? reminder, +}) { + return CreateReminderResponse() + ..duration = '10ms' + ..reminder = reminder ?? createDefaultMessageReminder(); +} + +/// Creates an [UpdateReminderResponse] wrapping [reminder]. +UpdateReminderResponse createDefaultUpdateReminderResponse({ + MessageReminder? reminder, +}) { + return UpdateReminderResponse() + ..duration = '10ms' + ..reminder = reminder ?? createDefaultMessageReminder(); +} + +/// Creates an [UpdateMessageResponse] wrapping [message]. +UpdateMessageResponse createDefaultUpdateMessageResponse({ + Message? message, +}) { + return UpdateMessageResponse() + ..duration = '10ms' + ..message = message ?? createDefaultMessage(); +} + +/// Creates a [SendActionResponse] wrapping the optional [message]. +SendActionResponse createDefaultSendActionResponse({ + Message? message, +}) { + return SendActionResponse() + ..duration = '10ms' + ..message = message; +} + +/// Creates a [SendReactionResponse] wrapping [message] and [reaction]. +SendReactionResponse createDefaultSendReactionResponse({ + Message? message, + Reaction? reaction, +}) { + return SendReactionResponse() + ..duration = '10ms' + ..message = message ?? createDefaultMessage() + ..reaction = reaction ?? createDefaultReaction(); +} + +/// Creates a [SearchMessagesResponse] wrapping [results]. +SearchMessagesResponse createDefaultSearchMessagesResponse({ + List results = const [], + String? next, + String? previous, +}) { + return SearchMessagesResponse() + ..duration = '10ms' + ..results = results + ..next = next + ..previous = previous; +} + +/// Creates a [QueryRepliesResponse] wrapping [messages]. +QueryRepliesResponse createDefaultQueryRepliesResponse({ + List messages = const [], +}) { + return QueryRepliesResponse() + ..duration = '10ms' + ..messages = messages; +} + +/// Creates a [QueryReactionsResponse] wrapping [reactions]. +QueryReactionsResponse createDefaultQueryReactionsResponse({ + List reactions = const [], + String? next, +}) { + return QueryReactionsResponse() + ..duration = '10ms' + ..reactions = reactions + ..next = next; +} + +/// Creates a [GetMessagesByIdResponse] wrapping [messages]. +GetMessagesByIdResponse createDefaultGetMessagesByIdResponse({ + List messages = const [], +}) { + return GetMessagesByIdResponse() + ..duration = '10ms' + ..messages = messages; +} + +/// Creates a [TranslateMessageResponse] wrapping [message]. +TranslateMessageResponse createDefaultTranslateMessageResponse({ + Message? message, +}) { + return TranslateMessageResponse() + ..duration = '10ms' + ..message = message ?? createDefaultMessage(); +} + +/// Creates a [QueryChannelsResponse] wrapping [channels] and the optional +/// server-resolved [predefinedFilter] spec. +QueryChannelsResponse createDefaultQueryChannelsResponse({ + List channels = const [], + PredefinedFilter? predefinedFilter, +}) { + return QueryChannelsResponse() + ..duration = '10ms' + ..channels = channels + ..predefinedFilter = predefinedFilter; +} + +/// Creates a [SyncResponse] wrapping [events]. +SyncResponse createDefaultSyncResponse({ + List events = const [], +}) { + return SyncResponse() + ..duration = '10ms' + ..events = events; +} + +/// Creates a [PartialUpdateChannelResponse] wrapping [channel]. +PartialUpdateChannelResponse createDefaultPartialUpdateChannelResponse({ + ChannelModel? channel, +}) { + return PartialUpdateChannelResponse() + ..duration = '10ms' + ..channel = channel ?? createDefaultChannelModel(); +} + +/// Creates a [PartialUpdateMemberResponse] wrapping [channelMember]. +PartialUpdateMemberResponse createDefaultPartialUpdateMemberResponse({ + Member? channelMember, +}) { + return PartialUpdateMemberResponse() + ..duration = '10ms' + ..channelMember = channelMember ?? createDefaultMember(); +} + +/// Creates an [AddMembersResponse] wrapping [channel], [members] and +/// [message]. +AddMembersResponse createDefaultAddMembersResponse({ + ChannelModel? channel, + List members = const [], + Message? message, +}) { + return AddMembersResponse() + ..duration = '10ms' + ..channel = channel ?? createDefaultChannelModel() + ..members = members + ..message = message; +} + +/// Creates a [GetAppSettingsResponse] with default [AppSettings]. +GetAppSettingsResponse createDefaultGetAppSettingsResponse({ + AppSettings app = const AppSettings(), +}) { + return GetAppSettingsResponse() + ..duration = '10ms' + ..app = app; +} + +/// Creates a [ConnectGuestUserResponse] wrapping [user] and [accessToken]. +/// +/// The default access token is a decodable development token for the guest +/// user's id. +ConnectGuestUserResponse createDefaultConnectGuestUserResponse({ + User? user, + String? accessToken, +}) { + final guestUser = user ?? createDefaultUser(); + return ConnectGuestUserResponse() + ..duration = '10ms' + ..user = guestUser + ..accessToken = accessToken ?? createTestToken(guestUser.id).rawValue; +} + +/// Creates an [UpdatePollResponse] wrapping [poll]. +UpdatePollResponse createDefaultUpdatePollResponse({ + Poll? poll, +}) { + return UpdatePollResponse() + ..duration = '10ms' + ..poll = poll ?? createDefaultPoll(); +} + +/// Creates a [CastPollVoteResponse] wrapping [vote]. +CastPollVoteResponse createDefaultCastPollVoteResponse({ + PollVote? vote, +}) { + return CastPollVoteResponse() + ..duration = '10ms' + ..vote = vote ?? createDefaultPollVote(); +} + +/// Creates an [UpdateUsersResponse] wrapping the updated [users]. +UpdateUsersResponse createDefaultUpdateUsersResponse({ + Map? users, +}) { + final updatedUser = createDefaultUser(); + return UpdateUsersResponse() + ..duration = '10ms' + ..users = users ?? {updatedUser.id: updatedUser}; +} + +/// Creates a [UserBlockResponse] with sensible defaults. +UserBlockResponse createDefaultUserBlockResponse({ + String blockedUserId = 'darth_vader', + String blockedByUserId = 'luke_skywalker', + DateTime? createdAt, +}) { + return UserBlockResponse() + ..duration = '10ms' + ..blockedUserId = blockedUserId + ..blockedByUserId = blockedByUserId + ..createdAt = createdAt ?? testCreatedAt; +} + +/// Creates a [BlockedUsersResponse] wrapping [blocks]. +BlockedUsersResponse createDefaultBlockedUsersResponse({ + List blocks = const [], +}) { + return BlockedUsersResponse() + ..duration = '10ms' + ..blocks = blocks; +} + +/// Creates a [GetUnreadCountResponse] with sensible defaults. +GetUnreadCountResponse createDefaultGetUnreadCountResponse({ + int totalUnreadCount = 0, + int totalUnreadThreadsCount = 0, + List channelType = const [], + List channels = const [], + List threads = const [], +}) { + return GetUnreadCountResponse() + ..duration = '10ms' + ..totalUnreadCount = totalUnreadCount + ..totalUnreadThreadsCount = totalUnreadThreadsCount + ..channelType = channelType + ..channels = channels + ..threads = threads; +} + +/// Creates an [EmptyResponse]. +EmptyResponse createDefaultEmptyResponse() => EmptyResponse()..duration = '10ms'; + +/// Creates an [ErrorResponse] with sensible defaults. +ErrorResponse createDefaultErrorResponse({ + int code = 500, + String message = 'internal server error', + int statusCode = 500, +}) { + return ErrorResponse() + ..code = code + ..message = message + ..statusCode = statusCode; +} + +/// Creates the [StreamApiException] a server-rejected request reports. +/// +/// [statusCode] is what decides whether the SDK retries — 5xx and 408 are +/// retriable, every other verdict is not — so a test wanting one or the other +/// names the status the endpoint would answer with. The default 500 is a +/// server that failed to answer, which is retriable. +StreamApiException createDefaultNetworkError({ + StreamErrorCode code = StreamErrorCode.internalError, + int statusCode = 500, + String message = 'The request was rejected', +}) { + return StreamApiException( + message: message, + statusCode: statusCode, + code: code, + ); +} diff --git a/packages/stream_chat_test/lib/src/testers/base_tester.dart b/packages/stream_chat_test/lib/src/testers/base_tester.dart new file mode 100644 index 0000000000..0b473d69dc --- /dev/null +++ b/packages/stream_chat_test/lib/src/testers/base_tester.dart @@ -0,0 +1,338 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:stream_chat/src/ws/websocket.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart' as test; + +import '../helpers/api_mocker_mixin.dart'; +import '../helpers/mocks.dart'; +import '../helpers/test_data.dart'; +import 'websocket_tester.dart'; + +// The api key and WS base url the test client is configured with. +const _testApiKey = 'test-api-key'; +const _testBaseWsUrl = 'wss://chat.test.stream-io-api.com'; + +// Records are still built, only discarded: the client configures the logger +// globally, so silencing it by priority would stop the lazy message closures +// from running and hide a crash inside one. +const _silentLogConfig = StreamLogConfig( + priority: StreamLogPriority.verbose, + handler: StreamLogHandler.silent, +); + +/// Factory function signature for creating tester instances. +/// +/// All concrete tester factory functions must conform to this signature. +typedef TesterFactory> = + Future Function({ + required S subject, + required User user, + required StreamChatClient client, + required FakeChatApi chatApi, + required WebSocketTester wsTester, + }); + +/// Base class for all test utilities with WebSocket support. +/// +/// Provides common functionality for emitting events, pumping the event queue, +/// and making assertions about the state object being tested. +/// +/// Type parameter [S] is the subject being tested. +abstract base class BaseTester with ApiMockerMixin { + /// Creates a [BaseTester] wiring the [subject] to the harness-provided + /// [user], [client], [chatApi] and [wsTester]. + const BaseTester({ + required this.subject, + required this.user, + required this.chatApi, + required this._client, + required this._wsTester, + }); + + /// The subject being tested. + final S subject; + + /// The user for whom the test client is configured. + /// + /// This is the user used for authentication and for performing all actions + /// through the client. + final User user; + + @override + @protected + final FakeChatApi chatApi; + + /// The underlying [StreamChatClient] from which the [subject] was built. + StreamChatClient get client => _client; + final StreamChatClient _client; + + /// The currently connected user's state, if any. + /// + /// Null until the client has connected. + OwnUser? get currentUser => _client.state.currentUser; + + // WebSocket tester for managing WebSocket interactions. + final WebSocketTester _wsTester; + + /// Every URI the WebSocket attempted to connect with. + /// + /// Reconnection attempts append to this list, so tests can assert both on + /// the auth payload of the initial attempt and on how many attempts were + /// made. + List get connectUris => _wsTester.connectUris; + + /// Configures the fake server to accept a connection attempt for [user], + /// defaulting to the user this tester is configured with. + /// + /// Attempts carrying a different user id, or a token whose `user_id` claim + /// does not match, are rejected with an invalid-token-signature error frame. + /// The connected event echoes [user] back as the `me` payload. + void mockSuccessfulAuth([User? user]) { + return _wsTester.mockSuccessfulAuth(user ?? this.user); + } + + /// Configures the fake server to reject every connection attempt with an + /// error frame carrying [errorCode]. + /// + /// The default 40 ([StreamErrorCode.tokenExpired]) fails the attempt with a + /// [StreamApiException] under the harness's static token; with a token + /// provider it triggers a silent token refresh and reconnect instead. All + /// other codes fail the attempt regardless of the token setup. + void mockFailedAuth({int errorCode = 40}) { + return _wsTester.mockFailedAuth(errorCode: errorCode); + } + + /// Configures the WebSocket transport to fail with [error]. + /// + /// The channel opens, but its stream immediately errors — a broken socket + /// rather than a server that refuses. The connection attempt fails with a + /// retriable [StreamNetworkException]. + void mockConnectionError({Object? error}) { + return _wsTester.mockConnectionError(error: error); + } + + /// Emits a WebSocket [event] and pumps the event loop. + /// + /// The event goes through the engine's real JSON frame decoding before + /// reaching the client, exactly like a production server push. + Future emitEvent(Event event) async { + _wsTester.emitEvent(event); + await pumpEventQueue(); + } + + /// Emits a raw JSON-encodable WebSocket [frame] and pumps the event loop. + /// + /// Escape hatch for frames that cannot be expressed as an [Event] — error + /// frames, malformed payloads, or hand-written maps. + Future emitRawFrame(Object frame) async { + _wsTester.emitRawFrame(frame); + await pumpEventQueue(); + } + + /// Waits for pending asynchronous work by running the event loop [times] + /// times. + Future pumpEventQueue({int times = 20}) { + return test.pumpEventQueue(times: times); + } + + /// Disposes resources held by this tester. + /// + /// Called automatically after each test completes. Overrides must call + /// `super.dispose()`. + @mustCallSuper + Future dispose() async {} +} + +/// Creates a tester instance, registering its disposal as a test tear-down. +/// +/// This function is for internal use by concrete tester factories only. +Future createTester>({ + required T Function() create, +}) async { + final tester = create(); + test.addTearDown(tester.dispose); // Dispose tester after test + return tester; +} + +/// Generic test helper backing the concrete `Test` entry points. +/// +/// Builds a real [StreamChatClient] with three replaced seams — the REST API +/// (a [FakeChatApi] whose sub-APIs are mocks), the WebSocket transport (a +/// mocked channel behind the real [WebSocket] engine) and optionally +/// [chatPersistenceClient] — then runs the phases +/// `connect → setUp → body → verify → tearDown` in a guarded zone. +/// +/// [verify] and [tearDown] run straight after [body], in the same zone and +/// under the same error handling: the split is a readability convention, not +/// an isolation boundary, and a [body] that throws skips both. +/// +/// The default connect phase authenticates [user] with [token], or through +/// [tokenProvider] when given (which takes precedence over [token]), and +/// asserts the connection; [connect] replaces it entirely. [logConfig] and +/// [isLocalUnreadCountEnabled] are forwarded to the client constructor; +/// [skip], [tags] and [timeout] to `test`. [skip] carries the reason to skip +/// this test; omit it to run the test. +/// +/// This function is for internal use by concrete test helpers. +void testWithTester>( + String description, { + User? user, + UserToken? token, + TokenProvider? tokenProvider, + ChatPersistenceClient? chatPersistenceClient, + StreamLogConfig? logConfig, + bool isLocalUnreadCountEnabled = false, + required S Function(StreamChatClient 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, + FutureOr Function(T tester)? tearDown, + String? skip, + Iterable tags = const [], + test.Timeout? timeout, +}) { + return test.test( + description, + skip: skip, + tags: tags, + timeout: timeout, + () async { + await _runZonedGuarded(() async { + registerChatFallbackValues(); + + final testUser = user ?? createDefaultUser(); + final testToken = token ?? createTestToken(testUser.id); + + final chatApi = FakeChatApi(); + final webSocketChannel = MockWebSocketChannel(); + + // Broadcast so the engine can listen again after a disconnect, the + // way a real socket can be reopened. + final serverFrames = StreamController.broadcast(); + test.addTearDown(serverFrames.close); // Close controller after test + + final wsTester = WebSocketTester( + channel: webSocketChannel, + streamController: serverFrames, + ); + + // The client configures the logger process-wide, so put it back + // afterwards rather than leaving every later test in this file silent. + test.addTearDown(StreamLogger.reset); + + // NOTE(hack): the client's TokenManager is private and only wired into + // the WebSocket the client builds itself, so the injected WebSocket + // gets its own manager, pre-loaded with the harness credentials. + // Consequence: the token argument passed to `connectUser` never + // reaches the connect URI — the harness `token:`/`tokenProvider:` is + // what authenticates. The proper fix is a `@visibleForTesting + // TokenManager?` seam on the StreamChatClient constructor so the + // client and the injected WebSocket share one manager (validated: the + // full stream_chat suite passes with it); adopt it together with the + // internals-access re-evaluation. See README "Token handling". + final wsTokenManager = TokenManager.unconfigured() + ..setTokenProvider( + testUser.id, + tokenProvider: tokenProvider ?? TokenProvider.static(testToken), + ); + + late StreamChatClient client; + final ws = WebSocket( + apiKey: _testApiKey, + baseUrl: _testBaseWsUrl, + tokenManager: wsTokenManager, + // Late-bound on purpose: no frame can be decoded before connect(), + // which is only reachable through the client assigned right below. + handler: (event) => client.handleEvent(event), + webSocketChannelProvider: wsTester.channelProvider, + ); + + client = StreamChatClient( + _testApiKey, + chatApi: chatApi, + ws: ws, + logConfig: logConfig ?? _silentLogConfig, + isLocalUnreadCountEnabled: isLocalUnreadCountEnabled, + )..chatPersistenceClient = chatPersistenceClient; + test.addTearDown(client.dispose); // Dispose client after test + + final tester = await createTesterFn( + subject: build.call(client), + user: testUser, + client: client, + chatApi: chatApi, + wsTester: wsTester, + ); + + // Connecting fires a background app-settings fetch; stub it up front + // so custom connect phases don't hit an unstubbed mock. + tester.mockApi( + (api) => api.general.getAppSettings(), + result: createDefaultGetAppSettingsResponse(), + ); + + final connectFn = connect ?? _defaultConnect(token: testToken, tokenProvider: tokenProvider); + await connectFn.call(tester); + + await setUp?.call(tester); + await body(tester); + await verify?.call(tester); + await tearDown?.call(tester); + }); + }, + ); +} + +// Default connect implementation: mock successful auth, connect the client +// and assert the connection was established. +FutureOr Function(BaseTester) _defaultConnect({ + required UserToken token, + TokenProvider? tokenProvider, +}) { + return (tester) async { + // Mock successful authentication for the configured user. + tester.mockSuccessfulAuth(); + + // Connect the client. + final ownUser = await switch (tokenProvider) { + final provider? => tester.client.connectUserWithProvider(tester.user, provider), + _ => tester.client.connectUser(tester.user, token.rawValue), + }; + + // Verify the client is connected. + test.expect(ownUser.id, tester.user.id); + test.expect(tester.client.wsConnectionStatus, ConnectionStatus.connected); + }; +} + +// Runs the test body in a guarded zone. +// +// Errors raised outside the main async chain while the body runs — from event +// handlers, timers and unawaited futures — fail the test instead of escaping +// it. Errors arriving after the body completes do not; see the note below. +// +// NOTE(parity): errors arriving AFTER the body has completed (e.g. a timer +// armed during the test that fires in the teardown window) are silently +// dropped by the `isCompleted` guards below, whereas plain `package:test` +// would report them as "test failed after it had already completed". The +// window is most relevant after `mockConnectionError`, which leaves the +// engine scheduling background reconnect work that only teardown cancels. +// Kept as-is for parity with stream_feeds_test's base_tester; fix in both +// packages together by forwarding post-completion errors to the parent zone. +Future _runZonedGuarded(Future Function() body) { + final completer = Completer(); + runZonedGuarded( + () async { + await body(); + if (!completer.isCompleted) completer.complete(); + }, + (error, stackTrace) { + if (!completer.isCompleted) completer.completeError(error, stackTrace); + }, + ); + return completer.future; +} diff --git a/packages/stream_chat_test/lib/src/testers/channel_tester.dart b/packages/stream_chat_test/lib/src/testers/channel_tester.dart new file mode 100644 index 0000000000..0a2c7bde80 --- /dev/null +++ b/packages/stream_chat_test/lib/src/testers/channel_tester.dart @@ -0,0 +1,176 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart' as test; + +import '../helpers/mocks.dart'; +import '../helpers/test_data.dart'; +import 'base_tester.dart'; +import 'websocket_tester.dart'; + +/// Test helper for testing [Channel] behavior. +/// +/// By default the subject is a fresh, non-initialized channel of +/// [channelType]/[channelId]; [build] constructs it differently (e.g. via +/// [Channel.fromState]). [ChannelTester.watch], typically called from [setUp], +/// seeds the channel with an initial state before emitting events. +/// +/// Example: +/// ```dart +/// channelTest( +/// 'adds a new message on message.new event', +/// setUp: (tester) => tester.watch(), +/// body: (tester) async { +/// final message = createDefaultMessage(id: 'new-message'); +/// await tester.emitEvent( +/// createDefaultEvent( +/// type: EventType.messageNew, +/// cid: tester.channel.cid, +/// message: message, +/// ), +/// ); +/// +/// expect(tester.channelState?.messages, contains(message)); +/// }, +/// ); +/// ``` +@isTest +void channelTest( + String description, { + String channelType = 'messaging', + String channelId = 'test-channel', + Channel Function(StreamChatClient client)? build, + User? user, + UserToken? token, + TokenProvider? tokenProvider, + ChatPersistenceClient? chatPersistenceClient, + StreamLogConfig? logConfig, + bool isLocalUnreadCountEnabled = false, + FutureOr Function(ChannelTester tester)? connect, + FutureOr Function(ChannelTester tester)? setUp, + required FutureOr Function(ChannelTester tester) body, + FutureOr Function(ChannelTester tester)? verify, + FutureOr Function(ChannelTester tester)? tearDown, + String? skip, + Iterable tags = const [], + test.Timeout? timeout, +}) { + return testWithTester( + description, + user: user, + token: token, + tokenProvider: tokenProvider, + chatPersistenceClient: chatPersistenceClient, + logConfig: logConfig, + isLocalUnreadCountEnabled: isLocalUnreadCountEnabled, + build: build ?? (client) => client.channel(channelType, id: channelId), + createTesterFn: _createChannelTester, + connect: connect, + setUp: setUp, + body: body, + verify: verify, + tearDown: tearDown, + skip: skip, + tags: tags, + timeout: timeout, + ); +} + +/// Test utility for [Channel] tests. +/// +/// Provides channel-state aliases and a [watch] seeding method on top of the +/// shared [BaseTester] functionality. +final class ChannelTester extends BaseTester { + const ChannelTester._({ + required super.subject, + required super.user, + required super.client, + required super.chatApi, + required super.wsTester, + }); + + /// The channel being tested. + Channel get channel => subject; + + /// The channel's state. + /// + /// Null until the channel has been initialized, e.g. by calling [watch]. + ChannelClientState? get channelState => channel.state; + + /// Stubs the channel query and watches the channel, seeding it with an + /// initial state. + /// + /// Typically called from the `setUp` phase to seed state before emitting + /// events; unnecessary for tests that only verify API calls. + /// + /// The stub matches the exact request the SDK sends for a watch — including + /// `channelData: channel.extraData` — so it doubles as verification of the + /// request shape. [modifyResponse] adjusts the canonical seeded state: + /// + /// ```dart + /// setUp: (tester) => tester.watch( + /// modifyResponse: (state) => state.copyWith( + /// messages: [createDefaultMessage(id: 'my-message')], + /// ), + /// ), + /// ``` + Future watch({ + ChannelState Function(ChannelState state)? modifyResponse, + }) { + final defaultChannelState = createDefaultChannelState( + channel: createDefaultChannelModel( + cid: '${channel.type}:${channel.id}', + ), + messages: [ + createDefaultMessage(id: 'message-1'), + createDefaultMessage(id: 'message-2'), + createDefaultMessage(id: 'message-3'), + ], + membership: createDefaultMember(user: currentUser ?? user), + ); + + mockApi( + (api) => api.channel.queryChannel( + channel.type, + channelId: channel.id, + channelData: channel.extraData, + state: true, + watch: true, + presence: false, + ), + result: switch (modifyResponse) { + final modifier? => modifier(defaultChannelState), + _ => defaultChannelState, + }, + ); + + return channel.watch(); + } + + @override + Future dispose() async { + channel.dispose(); + await super.dispose(); + } +} + +// Factory function conforming to [TesterFactory] for creating +// [ChannelTester] instances. +Future _createChannelTester({ + required Channel subject, + required User user, + required StreamChatClient client, + required FakeChatApi chatApi, + required WebSocketTester wsTester, +}) { + return createTester( + create: () => ChannelTester._( + subject: subject, + user: user, + client: client, + chatApi: chatApi, + wsTester: wsTester, + ), + ); +} diff --git a/packages/stream_chat_test/lib/src/testers/chat_client_tester.dart b/packages/stream_chat_test/lib/src/testers/chat_client_tester.dart new file mode 100644 index 0000000000..492944e29f --- /dev/null +++ b/packages/stream_chat_test/lib/src/testers/chat_client_tester.dart @@ -0,0 +1,107 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart' as test; + +import '../helpers/mocks.dart'; +import 'base_tester.dart'; +import 'websocket_tester.dart'; + +/// Test helper for testing [StreamChatClient] behavior. +/// +/// Automatically sets up a real client with mocked API and WebSocket seams, +/// connects it (unless [connect] is overridden), and coordinates the test +/// lifecycle: `connect → setUp → body → verify → tearDown`. +/// +/// Example: +/// ```dart +/// chatClientTest( +/// 'should connect successfully', +/// body: (tester) async { +/// expect(tester.connectionStatus, ConnectionStatus.connected); +/// expect(tester.currentUser?.id, tester.user.id); +/// }, +/// ); +/// ``` +@isTest +void chatClientTest( + String description, { + User? user, + UserToken? token, + TokenProvider? tokenProvider, + ChatPersistenceClient? chatPersistenceClient, + StreamLogConfig? logConfig, + bool isLocalUnreadCountEnabled = false, + FutureOr Function(ChatClientTester tester)? connect, + FutureOr Function(ChatClientTester tester)? setUp, + required FutureOr Function(ChatClientTester tester) body, + FutureOr Function(ChatClientTester tester)? verify, + FutureOr Function(ChatClientTester tester)? tearDown, + String? skip, + Iterable tags = const [], + test.Timeout? timeout, +}) { + return testWithTester( + description, + user: user, + token: token, + tokenProvider: tokenProvider, + chatPersistenceClient: chatPersistenceClient, + logConfig: logConfig, + isLocalUnreadCountEnabled: isLocalUnreadCountEnabled, + build: (client) => client, + createTesterFn: _createChatClientTester, + connect: connect, + setUp: setUp, + body: body, + verify: verify, + tearDown: tearDown, + skip: skip, + tags: tags, + timeout: timeout, + ); +} + +/// Test utility for [StreamChatClient] tests. +/// +/// The subject is the client itself; this tester adds client-state aliases on +/// top of the shared [BaseTester] functionality. +final class ChatClientTester extends BaseTester { + const ChatClientTester._({ + required super.subject, + required super.user, + required super.client, + required super.chatApi, + required super.wsTester, + }); + + /// The client's state (current user, channels, unread counts). + ClientState get clientState => client.state; + + /// The client's current WebSocket connection status. + ConnectionStatus get connectionStatus => client.wsConnectionStatus; + + /// The stream of events received by the client. + Stream get events => client.eventStream; +} + +// Factory function conforming to [TesterFactory] for creating +// [ChatClientTester] instances. +Future _createChatClientTester({ + required StreamChatClient subject, + required User user, + required StreamChatClient client, + required FakeChatApi chatApi, + required WebSocketTester wsTester, +}) { + return createTester( + create: () => ChatClientTester._( + subject: subject, + user: user, + client: client, + chatApi: chatApi, + wsTester: wsTester, + ), + ); +} diff --git a/packages/stream_chat_test/lib/src/testers/websocket_tester.dart b/packages/stream_chat_test/lib/src/testers/websocket_tester.dart new file mode 100644 index 0000000000..2a37877d2d --- /dev/null +++ b/packages/stream_chat_test/lib/src/testers/websocket_tester.dart @@ -0,0 +1,253 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import '../helpers/mocks.dart'; +import '../helpers/server_json.dart'; +import '../helpers/test_data.dart'; + +/// Simulates the server side of a WebSocket connection. +/// +/// Only the transport is replaced: [channelProvider] is handed to the real +/// [WebSocket] engine, so URI building, frame decoding, health checks and +/// reconnection logic all run in tests. Chat authenticates through the connect +/// URI, so credentials are validated from the URI captured by +/// [channelProvider] and answered through the channel's stream. +/// +/// Used internally by testers; not intended to be instantiated in test code. +final class WebSocketTester { + /// Creates a [WebSocketTester] configuring the mock [channel] and emitting + /// server frames through [streamController]. + WebSocketTester({ + required this._channel, + required this._streamController, + }); + + // The mock WebSocket channel being configured. + final MockWebSocketChannel _channel; + + // The stream controller used to emit WebSocket frames. + final StreamController _streamController; + + /// Every URI the WebSocket attempted to connect with. + /// + /// Reconnection attempts append to this list, so tests can assert both on + /// the auth payload of the initial attempt and on how many attempts were + /// made. Only the tester appends to it — treat it as read-only in tests. + final List connectUris = []; + + // The configured server reaction to a connection attempt. + void Function(Uri uri)? _onConnectionAttempt; + + // Function to reset previous mock configuration, allowing reconfiguration + // between test scenarios. + WebSocketResetFunction? _resetFunction; + + /// The connection function handed to the real [WebSocket] as its + /// `webSocketChannelProvider`. + /// + /// Records each connect [uri] and schedules the configured server reaction. + WebSocketChannel channelProvider(Uri uri, {Iterable? protocols}) { + connectUris.add(uri); + // The WebSocket subscribes to the channel's stream synchronously after + // this returns, so a microtask always lands after the listener is + // attached. + scheduleMicrotask(() => _onConnectionAttempt?.call(uri)); + return _channel; + } + + /// Configures the fake server to accept a connection attempt for [user]. + /// + /// Like the real backend, the credentials in the connect URI are validated — + /// both the connect payload's user id and the token's `user_id` claim — and + /// a code 43 (invalid token signature) error frame is emitted when either + /// does not match [user]. The token in the URI is always the harness token + /// (see the README's "Token handling" section). + /// + /// The connected event echoes [user] back as the `me` payload, the way the + /// backend returns the connecting user's own details. + void mockSuccessfulAuth(User user) { + final userId = user.id; + _resetFunction?.call(); // Reset previous mocks if any + _resetFunction = _whenListenWebSocket(_channel); + _onConnectionAttempt = (uri) { + final auth = _ConnectAuth.fromUri(uri); + if (auth == null || auth.userId != userId || auth.tokenUserId != userId) { + // Wrong credentials - simulate authentication failure + // (invalid token signature). + return emitRawFrame( + createDefaultConnectionErrorFrame( + code: StreamErrorCode.tokenSignatureInvalid, + message: 'invalid token signature', + ), + ); + } + + // Correct credentials - simulate successful authentication. + return emitEvent( + createDefaultConnectedEvent( + userId: userId, + me: OwnUser.fromUser(user), + ), + ); + }; + } + + /// Configures the fake server to reject every connection attempt with an + /// error frame carrying [errorCode] (HTTP status 401). + /// + /// The default 40 ([StreamErrorCode.tokenExpired]) makes the engine refresh + /// the token and reconnect when a token provider is used; with a static + /// token — and for all other codes (41, 42, 43, 2, 5) — the connection + /// attempt fails with a [StreamApiException]. + void mockFailedAuth({int errorCode = 40}) { + _resetFunction?.call(); // Reset previous mocks if any + _resetFunction = _whenListenWebSocket(_channel); + _onConnectionAttempt = (_) { + // Always emit an authentication failure frame. + emitRawFrame(createDefaultConnectionErrorFrame(code: errorCode)); + }; + } + + /// Configures the WebSocket transport to fail with [error]. + /// + /// The channel opens, but its stream immediately errors — a broken socket + /// rather than a server that refuses. The connection attempt fails with a + /// retriable [StreamNetworkException] and the engine schedules background + /// reconnects, cancelled when the client is disposed by the test teardown. + void mockConnectionError({Object? error}) { + _resetFunction?.call(); // Reset previous mocks if any + _resetFunction = _whenListenWebSocket(_channel); + _onConnectionAttempt = (_) { + _streamController.addError(error ?? WebSocketChannelException('connection refused')); + }; + } + + /// Emits a typed [event] as a server frame. + /// + /// The frame is serialized with server fidelity (see [serverEventJson]) and + /// goes through the engine's real JSON decoding before reaching the client, + /// exactly like a production server push. + void emitEvent(Event event) => emitRawFrame(serverEventJson(event)); + + /// Emits a raw JSON-encodable [frame] to simulate a server message. + /// + /// Escape hatch for frames that cannot be expressed as an [Event] — error + /// frames, malformed payloads, or hand-written maps. + /// + /// Throws a [StateError] if no WebSocket connection is listening, which + /// would otherwise make the frame vanish silently. + void emitRawFrame(Object frame) { + if (!_streamController.hasListener) { + throw StateError( + 'No WebSocket connection is listening for server frames. ' + 'Configure the connection (mockSuccessfulAuth / mockFailedAuth) and ' + 'connect the client before emitting events.', + ); + } + _streamController.add(jsonEncode(frame)); + } + + // Configures the WebSocket channel mocks. + // + // Sets up the channel to stream frames from the tester's stream controller + // and installs a sink spy that acknowledges outgoing health-check pings the + // way a real server does, keeping the connection healthy for the duration + // of a test. + // + // 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) { + final webSocketSink = MockWebSocketSink(); + + // Mock sink close/done, used when the engine tears the channel down. + when(() => webSocketSink.close(any(), any())).thenAnswer((_) => Future.value()); + when(() => webSocketSink.done).thenAnswer((_) => Completer().future); + + // Mock channel ready state. + when(() => webSocketChannel.ready).thenAnswer((_) => Future.value()); + + // Mock channel stream to use our test stream controller. + when(() => webSocketChannel.stream).thenAnswer((_) => _streamController.stream); + + // Mock channel sink to use our test sink. + when(() => webSocketChannel.sink).thenAnswer((_) => webSocketSink); + + // Spy on outgoing frames: acknowledge health-check pings so the engine's + // reconnection monitor keeps seeing recent events. + when(() => webSocketSink.add(any())).thenAnswer((invocation) { + final frame = jsonDecode(invocation.positionalArguments.first as String) as Map; + + if (frame['type'] == EventType.healthCheck && _streamController.hasListener) { + emitEvent( + Event( + type: EventType.healthCheck, + connectionId: frame['connection_id'] as String? ?? 'test-connection-id', + ), + ); + } + }); + + // Return reset function to clear this mock configuration. + return () => reset(webSocketSink); + } +} + +/// A function that resets the current WebSocket mock configuration, allowing +/// reconfiguration between test scenarios. +typedef WebSocketResetFunction = void Function(); + +// The authentication material chat places in the connect URI. +class _ConnectAuth { + const _ConnectAuth({ + required this.userId, + required this.token, + }); + + // Parses the `json` payload from a connect [uri]; returns null when the + // payload is missing or malformed. + static _ConnectAuth? fromUri(Uri uri) { + final payload = uri.queryParameters['json']; + if (payload == null) return null; + + final Object? decoded; + try { + decoded = jsonDecode(payload); + } on FormatException { + return null; + } + if (decoded is! Map) return null; + + if ((decoded['user_id'], decoded['user_token']) case (final String userId, final String token)) { + return _ConnectAuth(userId: userId, token: token); + } + return null; + } + + final String userId; + final String token; + + // The `user_id` claim carried by [token], or null when the token is not a + // decodable JWT or carries no such claim — the fake server's stand-in for + // the backend's signature check. + String? get tokenUserId { + final segments = token.split('.'); + if (segments.length != 3) return null; + + try { + // Tolerate both base64 and base64url payload encodings. + final urlSafe = segments[1].replaceAll('+', '-').replaceAll('/', '_'); + final claimsJson = utf8.decode(base64Url.decode(base64Url.normalize(urlSafe))); + final claims = jsonDecode(claimsJson); + if (claims is! Map) return null; + + final userId = claims['user_id']; + return userId is String ? userId : null; + } on FormatException { + return null; + } + } +} diff --git a/packages/stream_chat_test/lib/stream_chat_test.dart b/packages/stream_chat_test/lib/stream_chat_test.dart new file mode 100644 index 0000000000..c556cb2699 --- /dev/null +++ b/packages/stream_chat_test/lib/stream_chat_test.dart @@ -0,0 +1,22 @@ +/// BDD-style test helpers and utilities for Stream Chat SDK testing. +/// +/// Re-exports `package:test` and `package:mocktail` so a test file only needs +/// two imports: `package:stream_chat/stream_chat.dart` and this library. +library; + +// Re-export test framework and mocking library. +export 'package:mocktail/mocktail.dart'; +export 'package:test/test.dart'; + +// Helpers +export 'src/helpers/api_mocker_mixin.dart'; +export 'src/helpers/matchers.dart'; +export 'src/helpers/mocks.dart'; +export 'src/helpers/server_json.dart'; +export 'src/helpers/test_data.dart'; + +// Testers +export 'src/testers/base_tester.dart'; +export 'src/testers/channel_tester.dart'; +export 'src/testers/chat_client_tester.dart'; +export 'src/testers/websocket_tester.dart'; diff --git a/packages/stream_chat_test/pubspec.yaml b/packages/stream_chat_test/pubspec.yaml new file mode 100644 index 0000000000..285477bdd6 --- /dev/null +++ b/packages/stream_chat_test/pubspec.yaml @@ -0,0 +1,30 @@ +name: stream_chat_test +homepage: https://getstream.io/ +description: Internal BDD-style test helpers and utilities for Stream Chat SDK testing. +version: 0.1.0 +repository: https://github.com/GetStream/stream-chat-flutter +issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues +publish_to: none # This package is not intended for publishing. + +# Note: The environment configuration and dependency versions are managed by Melos. +# +# Do not edit them manually. +# +# Steps to update dependencies: +# 1. Modify the version in the melos.yaml file. +# 2. Run `melos bootstrap` to apply changes. +# +# Steps to add a new dependency: +# 1. Add the dependency to this list. +# 2. Add it to the melos.yaml file for future updates. + +environment: + sdk: ^3.12.0 + +dependencies: + collection: ^1.19.1 + meta: ^1.9.1 + mocktail: ^1.0.5 + stream_chat: ^10.4.0 + test: ^1.31.1 + web_socket_channel: ^3.0.3 diff --git a/packages/stream_chat_test/test/channel_tester_test.dart b/packages/stream_chat_test/test/channel_tester_test.dart new file mode 100644 index 0000000000..7912f677c5 --- /dev/null +++ b/packages/stream_chat_test/test/channel_tester_test.dart @@ -0,0 +1,61 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +void main() { + group('seeding', () { + channelTest( + 'watch seeds the channel with the canonical state', + setUp: (tester) => tester.watch(), + body: (tester) async { + expect(tester.channel.cid, 'messaging:test-channel'); + expect(tester.channelState, isNotNull); + expect(tester.channelState?.messages, hasLength(3)); + expect(tester.channelState?.members, isEmpty); + }, + verify: (tester) => tester.verifyApi( + (api) => api.channel.queryChannel( + 'messaging', + channelId: 'test-channel', + channelData: const {}, + state: true, + watch: true, + presence: false, + ), + ), + ); + + channelTest( + 'watch applies the modifyResponse transformer', + setUp: (tester) => tester.watch( + modifyResponse: (state) => state.copyWith( + messages: [createDefaultMessage(id: 'only-message')], + ), + ), + body: (tester) async { + final messageIds = tester.channelState?.messages.map((message) => message.id); + expect(messageIds, ['only-message']); + }, + ); + }); + + group('events', () { + channelTest( + 'adds a new message on message.new event', + setUp: (tester) => tester.watch(), + body: (tester) async { + final message = createDefaultMessage(id: 'new-message', text: 'A new message'); + + await tester.emitEvent( + createDefaultEvent( + type: EventType.messageNew, + cid: tester.channel.cid, + message: message, + ), + ); + + final messageIds = tester.channelState?.messages.map((message) => message.id); + expect(messageIds, contains('new-message')); + }, + ); + }); +} diff --git a/packages/stream_chat_test/test/chat_client_tester_test.dart b/packages/stream_chat_test/test/chat_client_tester_test.dart new file mode 100644 index 0000000000..c2eb5a41d1 --- /dev/null +++ b/packages/stream_chat_test/test/chat_client_tester_test.dart @@ -0,0 +1,192 @@ +import 'dart:convert'; + +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +void main() { + group('connection', () { + chatClientTest( + 'connects with the default lifecycle', + body: (tester) async { + expect(tester.connectionStatus, ConnectionStatus.connected); + expect(tester.currentUser?.id, tester.user.id); + + // The connect URI carries the credentials chat authenticates with. + final uri = tester.connectUris.single; + expect(uri.queryParameters['api_key'], 'test-api-key'); + expect(uri.queryParameters['authorization'], isNotEmpty); + + final payload = jsonDecode(uri.queryParameters['json']!) as Map; + expect(payload['user_id'], tester.user.id); + expect(payload['user_token'], uri.queryParameters['authorization']); + }, + ); + + chatClientTest( + 'echoes the configured user back as the connected user', + user: User(id: 'darth_vader', name: 'Darth Vader', role: 'admin'), + body: (tester) async { + expect(tester.currentUser?.id, 'darth_vader'); + expect(tester.currentUser?.name, 'Darth Vader'); + expect(tester.currentUser?.role, 'admin'); + }, + ); + + chatClientTest( + 'connects through a token provider', + tokenProvider: TokenProvider.dynamic((userId) async => createTestToken(userId)), + body: (tester) async { + expect(tester.connectionStatus, ConnectionStatus.connected); + expect(tester.currentUser?.id, tester.user.id); + }, + ); + + chatClientTest( + 'fails to connect when authentication is rejected', + connect: (tester) => tester.mockFailedAuth(errorCode: 43), + body: (tester) async { + final token = createTestToken(tester.user.id); + + await expectLater( + tester.client.connectUser(tester.user, token.rawValue), + throwsA(isA()), + ); + + expect(tester.connectionStatus, ConnectionStatus.disconnected); + }, + ); + + chatClientTest( + 'rejects a connection attempt for a different user', + connect: (tester) => tester.mockSuccessfulAuth(User(id: 'darth_vader')), + body: (tester) async { + final token = createTestToken(tester.user.id); + + await expectLater( + tester.client.connectUser(tester.user, token.rawValue), + throwsA(isA()), + ); + }, + ); + + // NOTE(hack): pins a known harness limitation — the token argument passed + // to `connectUser` never reaches the connect URI, because the injected + // WebSocket reads its own pre-loaded TokenManager (the client's is + // private), so the connection succeeds where a real backend would reject + // it. This test starts failing the moment the harness gains token + // fidelity — flip it to expect rejection then. See README "Token + // handling". + chatClientTest( + 'KNOWN LIMITATION: accepts a connectUser token minted for another user', + connect: (tester) => tester.mockSuccessfulAuth(), + body: (tester) async { + final wrongToken = createTestToken('darth_vader'); + + final ownUser = await tester.client.connectUser(tester.user, wrongToken.rawValue); + + expect(ownUser.id, tester.user.id); + expect(tester.connectionStatus, ConnectionStatus.connected); + }, + ); + + chatClientTest( + 'fails to connect when the transport errors', + connect: (tester) => tester.mockConnectionError(), + body: (tester) async { + final token = createTestToken(tester.user.id); + + await expectLater( + tester.client.connectUser(tester.user, token.rawValue), + throwsA(isA()), + ); + }, + ); + + chatClientTest( + 'does not open a socket when connect is overridden to skip it', + connect: (_) {}, + body: (tester) async { + expect(tester.connectUris, isEmpty); + expect(tester.connectionStatus, ConnectionStatus.disconnected); + }, + ); + + chatClientTest( + 'throws when emitting an event without a connection', + connect: (_) {}, + body: (tester) async { + await expectLater( + tester.emitEvent(createDefaultEvent()), + throwsStateError, + ); + }, + ); + }); + + group('api mocking', () { + chatClientTest( + 'stubs and verifies API calls with exact arguments', + body: (tester) async { + final message = createDefaultMessage(id: 'message-id'); + tester.mockApi( + (api) => api.message.getMessage('message-id'), + result: createDefaultGetMessageResponse(message: message), + ); + + final response = await tester.client.getMessage('message-id'); + + expect(response.message.id, 'message-id'); + tester + ..verifyApi((api) => api.message.getMessage('message-id')) + ..verifyNeverCalled((api) => api.message.getMessage('other-id')); + }, + ); + + chatClientTest( + 'captures API call arguments', + body: (tester) async { + tester.mockApi( + (api) => api.message.getMessage(any()), + result: createDefaultGetMessageResponse(), + ); + + await tester.client.getMessage('captured-id'); + + final captured = tester.captureApi( + (api) => api.message.getMessage(captureAny()), + ); + expect(captured.single, 'captured-id'); + }, + ); + + chatClientTest( + 'propagates stubbed API failures', + body: (tester) async { + tester.mockApiFailure((api) => api.message.getMessage('missing')); + + await expectLater( + tester.client.getMessage('missing'), + throwsA(isA()), + ); + }, + ); + }); + + group('events', () { + chatClientTest( + 'delivers emitted events through the real frame decoder', + body: (tester) async { + final eventReceived = expectLater( + tester.events, + emits(isA().having((event) => event.type, 'type', EventType.messageNew)), + ); + + await tester.emitEvent( + createDefaultEvent(type: EventType.messageNew, cid: 'messaging:test-channel'), + ); + + await eventReceived; + }, + ); + }); +} diff --git a/packages/stream_chat_test/test/server_json_test.dart b/packages/stream_chat_test/test/server_json_test.dart new file mode 100644 index 0000000000..5e362de82a --- /dev/null +++ b/packages/stream_chat_test/test/server_json_test.dart @@ -0,0 +1,293 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +// Every case below builds a populated model, serializes it the way the fake +// server does and decodes it again, asserting on the fields the model's own +// `toJson` drops — the whole reason these helpers exist. A helper that stops +// restoring one of them does not fail loudly: the field simply arrives as +// `null` in every event-driven test, which keeps passing. These tests are the +// guard against that, so extend them whenever a model gains a server-sent +// field. +void main() { + group('serverMessageJson', () { + test('restores the fields Message.toJson drops', () { + final user = createDefaultUser(id: 'sender'); + final message = Message( + id: 'message-1', + text: 'hello world', + type: MessageType.deleted, + user: user, + shadowed: true, + replyCount: 3, + command: 'giphy', + i18n: const {'en_text': 'hello world'}, + deletedForMe: true, + threadParticipants: [user], + latestReactions: [createDefaultReaction(messageId: 'message-1')], + ownReactions: [createDefaultReaction(type: 'love', messageId: 'message-1')], + pinned: true, + pinnedAt: DateTime.utc(2021, 5), + pinnedBy: user, + createdAt: DateTime.utc(2021), + updatedAt: DateTime.utc(2021, 2), + deletedAt: DateTime.utc(2021, 3), + messageTextUpdatedAt: DateTime.utc(2021, 4), + quotedMessage: createDefaultMessage(id: 'quoted-1'), + ); + + final decoded = Message.fromJson(serverMessageJson(message)); + + expect(decoded.type, MessageType.deleted); + expect(decoded.user?.id, user.id); + expect(decoded.shadowed, isTrue); + expect(decoded.replyCount, 3); + expect(decoded.command, 'giphy'); + expect(decoded.i18n, message.i18n); + expect(decoded.deletedForMe, isTrue); + expect(decoded.threadParticipants?.single.id, user.id); + expect(decoded.latestReactions?.single.type, 'like'); + expect(decoded.ownReactions?.single.type, 'love'); + expect(decoded.pinnedAt, message.pinnedAt); + expect(decoded.pinnedBy?.id, user.id); + expect(decoded.createdAt, message.createdAt); + expect(decoded.updatedAt, message.updatedAt); + expect(decoded.deletedAt, message.deletedAt); + expect(decoded.messageTextUpdatedAt, message.messageTextUpdatedAt); + // The quoted message goes through the same patching, so its own + // server-assigned fields have to survive too. + expect(decoded.quotedMessage?.id, 'quoted-1'); + expect(decoded.quotedMessage?.user?.id, createDefaultUser().id); + expect(decoded.quotedMessage?.createdAt, createDefaultMessage().createdAt); + }); + + test('restores the moderation, mentioned groups and channel role', () { + final message = createDefaultMessage(id: 'message-1').copyWith( + moderation: const Moderation( + action: ModerationAction.bounce, + originalText: 'original text', + ), + mentionedGroups: [ + UserGroup( + id: 'group-1', + name: 'Engineering', + createdAt: DateTime.utc(2021), + updatedAt: DateTime.utc(2021, 2), + ), + ], + channelRole: 'channel_moderator', + ); + + final decoded = Message.fromJson(serverMessageJson(message)); + + expect(decoded.moderation?.action, ModerationAction.bounce); + expect(decoded.moderation?.originalText, 'original text'); + expect(decoded.mentionedGroups?.single.id, 'group-1'); + expect(decoded.mentionedGroups?.single.name, 'Engineering'); + expect(decoded.channelRole, 'channel_moderator'); + }); + + test('restores a shared location', () { + final message = createDefaultMessage(id: 'message-1').copyWith( + sharedLocation: testLocation(), + ); + + final decoded = Message.fromJson(serverMessageJson(message)); + + expect(decoded.sharedLocation?.messageId, 'message-1'); + expect(decoded.sharedLocation?.userId, message.sharedLocation?.userId); + expect(decoded.sharedLocation?.channelCid, message.sharedLocation?.channelCid); + }); + }); + + group('serverChannelJson', () { + test('restores the fields ChannelModel.toJson drops', () { + final createdBy = createDefaultUser(id: 'creator'); + final channel = createDefaultChannelModel( + cid: 'messaging:server-json', + createdBy: createdBy, + memberCount: 7, + members: [createDefaultMember()], + ownCapabilities: const ['read-events'], + config: createDefaultChannelConfig(readEvents: true), + filterTags: const ['tag-1'], + lastMessageAt: DateTime.utc(2021, 6), + ); + + final decoded = ChannelModel.fromJson(serverChannelJson(channel)); + + expect(decoded.cid, 'messaging:server-json'); + expect(decoded.createdBy?.id, createdBy.id); + expect(decoded.memberCount, 7); + expect(decoded.members?.single.userId, createDefaultMember().userId); + expect(decoded.ownCapabilities, const ['read-events']); + expect(decoded.config.readEvents, isTrue); + expect(decoded.filterTags, const ['tag-1']); + expect(decoded.lastMessageAt, channel.lastMessageAt); + expect(decoded.createdAt, channel.createdAt); + expect(decoded.updatedAt, channel.updatedAt); + }); + }); + + group('serverReactionJson', () { + test('restores the fields Reaction.toJson drops', () { + final reaction = createDefaultReaction(messageId: 'message-1'); + + final decoded = Reaction.fromJson(serverReactionJson(reaction)); + + expect(decoded.messageId, 'message-1'); + expect(decoded.user?.id, reaction.user?.id); + expect(decoded.userId, reaction.userId); + expect(decoded.createdAt, reaction.createdAt); + }); + }); + + group('serverPollJson', () { + test('restores the fields Poll.toJson drops', () { + final createdBy = createDefaultUser(id: 'poll-creator'); + final vote = createDefaultPollVote(pollId: 'poll-1', optionId: 'option-1'); + final poll = Poll( + id: 'poll-1', + name: 'What is your favorite color?', + options: [createDefaultPollOption(text: 'Red')], + createdBy: createdBy, + createdById: createdBy.id, + voteCount: 4, + answersCount: 2, + voteCountsByOption: const {'option-1': 4}, + latestVotesByOption: { + 'option-1': [vote], + }, + ownVotesAndAnswers: [vote], + createdAt: DateTime.utc(2021), + updatedAt: DateTime.utc(2021, 2), + ); + + final decoded = Poll.fromJson(serverPollJson(poll)); + + expect(decoded.voteCount, 4); + expect(decoded.answersCount, 2); + expect(decoded.voteCountsByOption, const {'option-1': 4}); + expect(decoded.latestVotesByOption['option-1']?.single.pollId, 'poll-1'); + expect(decoded.ownVotesAndAnswers.single.optionId, 'option-1'); + expect(decoded.createdBy?.id, createdBy.id); + expect(decoded.createdById, poll.createdById); + expect(decoded.createdAt, poll.createdAt); + expect(decoded.updatedAt, poll.updatedAt); + }); + }); + + group('serverPollVoteJson', () { + test('restores the fields PollVote.toJson drops', () { + final pollVote = createDefaultPollVote(pollId: 'poll-1', optionId: 'option-1'); + + final decoded = PollVote.fromJson(serverPollVoteJson(pollVote)); + + expect(decoded.pollId, 'poll-1'); + expect(decoded.userId, pollVote.userId); + expect(decoded.user?.id, pollVote.user?.id); + expect(decoded.createdAt, pollVote.createdAt); + }); + }); + + group('serverDraftJson', () { + test('restores the nested payloads Draft.toJson serializes lossily', () { + final draft = + createDefaultDraft( + message: DraftMessage( + id: 'draft-1', + text: 'draft text', + command: 'giphy', + mentionedUsers: [createDefaultUser(id: 'mentioned')], + quotedMessage: createDefaultMessage(id: 'quoted-1'), + ), + ).copyWith( + channel: createDefaultChannelModel(cid: 'messaging:draft'), + parentMessage: createDefaultMessage(id: 'parent-1'), + ); + + final decoded = Draft.fromJson(serverDraftJson(draft)); + + expect(decoded.message.text, 'draft text'); + expect(decoded.message.command, 'giphy'); + expect(decoded.message.mentionedUsers.single.id, 'mentioned'); + expect(decoded.message.quotedMessage?.user?.id, createDefaultUser().id); + expect(decoded.channel?.cid, 'messaging:draft'); + expect(decoded.parentMessage?.user?.id, createDefaultUser().id); + }); + }); + + group('serverReminderJson', () { + test('restores the fields MessageReminder.toJson drops', () { + final reminder = createDefaultMessageReminder( + channel: createDefaultChannelModel(cid: 'messaging:reminder'), + message: createDefaultMessage(id: 'reminded-1'), + user: createDefaultUser(id: 'reminder-user'), + ); + + final decoded = MessageReminder.fromJson(serverReminderJson(reminder)); + + expect(decoded.channel?.cid, 'messaging:reminder'); + expect(decoded.message?.user?.id, createDefaultUser().id); + expect(decoded.user?.id, 'reminder-user'); + expect(decoded.createdAt, reminder.createdAt); + expect(decoded.updatedAt, reminder.updatedAt); + }); + }); + + group('serverLocationJson', () { + test('restores the fields Location.toJson drops', () { + final location = testLocation(); + + final decoded = Location.fromJson(serverLocationJson(location)); + + expect(decoded.messageId, 'message-1'); + expect(decoded.userId, location.userId); + expect(decoded.channelCid, location.channelCid); + expect(decoded.createdAt, location.createdAt); + expect(decoded.updatedAt, location.updatedAt); + }); + }); + + group('serverEventJson', () { + test('patches every nested payload the event carries', () { + final event = createDefaultEvent( + type: 'message.new', + cid: 'messaging:test-channel', + message: createDefaultMessage(id: 'message-1'), + channel: createDefaultChannelModel(cid: 'messaging:test-channel'), + reaction: createDefaultReaction(messageId: 'message-1'), + poll: createDefaultPoll(id: 'poll-1'), + pollVote: createDefaultPollVote(pollId: 'poll-1', optionId: 'option-1'), + draft: createDefaultDraft(), + reminder: createDefaultMessageReminder(), + ); + + final decoded = Event.fromJson(serverEventJson(event)); + + // Each nested payload keeps the server-assigned fields its own `toJson` + // would have dropped. + expect(decoded.message?.user?.id, createDefaultUser().id); + expect(decoded.message?.createdAt, createDefaultMessage().createdAt); + expect(decoded.channel?.cid, 'messaging:test-channel'); + expect(decoded.reaction?.messageId, 'message-1'); + expect(decoded.reaction?.user?.id, createDefaultUser().id); + expect(decoded.poll?.createdAt, createDefaultPoll().createdAt); + expect(decoded.pollVote?.pollId, 'poll-1'); + expect(decoded.draft?.message.text, createDefaultDraft().message.text); + expect(decoded.reminder?.createdAt, createDefaultMessageReminder().createdAt); + }); + }); +} + +// Built inline: the package has no `Location` factory, and these two cases +// are its only consumers. +Location testLocation({String messageId = 'message-1'}) => Location( + channelCid: 'messaging:test-channel', + messageId: messageId, + userId: 'luke_skywalker', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + createdAt: DateTime.utc(2021), + updatedAt: DateTime.utc(2021, 2), +); diff --git a/packages/stream_chat_test/test/stream_chat_test_test.dart b/packages/stream_chat_test/test/stream_chat_test_test.dart new file mode 100644 index 0000000000..5be3e45b2d --- /dev/null +++ b/packages/stream_chat_test/test/stream_chat_test_test.dart @@ -0,0 +1,37 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +void main() { + group('stream_chat_test', () { + test('re-exports package:test and package:mocktail', () { + // `expect`, `group` and `test` come from the package:test re-export; + // `Mock` comes from the package:mocktail re-export. + expect(Mock, isNotNull); + }); + + test('provides canonical fixtures with deterministic defaults', () { + final user = createDefaultUser(); + expect(user.id, 'luke_skywalker'); + expect(user.createdAt, testCreatedAt); + expect(user.updatedAt, testUpdatedAt); + + final token = createTestToken(user.id); + expect(token.userId, user.id); + }); + }); + + group('isSameEventAs', () { + final target = Event(type: EventType.typingStart); + + test('matches on type alone by default', () { + expect(Event(type: EventType.typingStart, parentId: 'p1'), isSameEventAs(target)); + expect(Event(type: EventType.typingStop), isNot(isSameEventAs(target))); + }); + + test('compares the parent id when asked to', () { + final matcher = isSameEventAs(target, matchParentId: true); + expect(Event(type: EventType.typingStart), matcher); + expect(Event(type: EventType.typingStart, parentId: 'p1'), isNot(matcher)); + }); + }); +}