From 4beca46c17ce31fc55c9f907e666d05f070bed91 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 9 Sep 2026 11:43:21 +0200 Subject: [PATCH 001/103] test(llc): add stream_chat_test package with BDD-style test helpers Co-Authored-By: Claude Fable 5 --- melos.yaml | 6 + packages/stream_chat_test/CHANGELOG.md | 3 + packages/stream_chat_test/README.md | 173 ++++++++ .../stream_chat_test/analysis_options.yaml | 14 + packages/stream_chat_test/dart_test.yaml | 3 + .../example/stream_chat_test_example.dart | 41 ++ .../lib/src/helpers/api_mocker_mixin.dart | 131 ++++++ .../lib/src/helpers/mocks.dart | 165 ++++++++ .../lib/src/helpers/test_data.dart | 306 ++++++++++++++ .../lib/src/testers/base_tester.dart | 394 ++++++++++++++++++ .../lib/src/testers/channel_tester.dart | 176 ++++++++ .../lib/src/testers/chat_client_tester.dart | 107 +++++ .../lib/src/testers/websocket_tester.dart | 315 ++++++++++++++ .../lib/stream_chat_test.dart | 25 ++ packages/stream_chat_test/pubspec.yaml | 29 ++ .../test/channel_tester_test.dart | 61 +++ .../test/chat_client_tester_test.dart | 182 ++++++++ .../test/stream_chat_test_test.dart | 21 + 18 files changed, 2152 insertions(+) create mode 100644 packages/stream_chat_test/CHANGELOG.md create mode 100644 packages/stream_chat_test/README.md create mode 100644 packages/stream_chat_test/analysis_options.yaml create mode 100644 packages/stream_chat_test/dart_test.yaml create mode 100644 packages/stream_chat_test/example/stream_chat_test_example.dart create mode 100644 packages/stream_chat_test/lib/src/helpers/api_mocker_mixin.dart create mode 100644 packages/stream_chat_test/lib/src/helpers/mocks.dart create mode 100644 packages/stream_chat_test/lib/src/helpers/test_data.dart create mode 100644 packages/stream_chat_test/lib/src/testers/base_tester.dart create mode 100644 packages/stream_chat_test/lib/src/testers/channel_tester.dart create mode 100644 packages/stream_chat_test/lib/src/testers/chat_client_tester.dart create mode 100644 packages/stream_chat_test/lib/src/testers/websocket_tester.dart create mode 100644 packages/stream_chat_test/lib/stream_chat_test.dart create mode 100644 packages/stream_chat_test/pubspec.yaml create mode 100644 packages/stream_chat_test/test/channel_tester_test.dart create mode 100644 packages/stream_chat_test/test/chat_client_tester_test.dart create mode 100644 packages/stream_chat_test/test/stream_chat_test_test.dart diff --git a/melos.yaml b/melos.yaml index 2a130bf5a9..8ea73ec125 100644 --- a/melos.yaml +++ b/melos.yaml @@ -84,6 +84,9 @@ command: media_kit_video: ^2.0.1 meta: ^1.9.1 mime: ^2.0.0 + # Also listed under dev_dependencies; stream_chat_test declares it as a + # regular dependency because its public API surfaces mocktail types. + mocktail: ^1.0.5 package_info_plus: ">=9.0.1 <11.0.0" path: ^1.9.1 path_provider: ^2.1.5 @@ -107,6 +110,9 @@ command: stream_core_flutter: ^0.5.1 stream_thumbnail: ^0.1.0 synchronized: ^3.4.0 + # Also listed under dev_dependencies; stream_chat_test declares it as a + # regular dependency because its public API surfaces package:test types. + test: ^1.31.1 thumblr: ^0.0.4 url_launcher: ^6.3.2 uuid: ^4.5.3 diff --git a/packages/stream_chat_test/CHANGELOG.md b/packages/stream_chat_test/CHANGELOG.md new file mode 100644 index 0000000000..aff8bf7bf6 --- /dev/null +++ b/packages/stream_chat_test/CHANGELOG.md @@ -0,0 +1,3 @@ +# 0.1.0 + +- Initial release of internal BDD-style test helpers for the `stream_chat` package. diff --git a/packages/stream_chat_test/README.md b/packages/stream_chat_test/README.md new file mode 100644 index 0000000000..188c04471d --- /dev/null +++ b/packages/stream_chat_test/README.md @@ -0,0 +1,173 @@ +# 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 fail the +test). 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: (_) {}`. + +```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`. 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). +- `skip:` is typed `bool`, so the skip *reason* required by `STYLE_GUIDE.md` cannot + be provided (`package:test` accepts a String for this). + +## 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. + +## Tags + +Tests declared through `chatClientTest` / `channelTest` are tagged `chat-client` / +`channel` by default (`flutter test --tags channel` filters on them). This package +declares the tags in its own `dart_test.yaml`; consuming packages must declare them +in their own `dart_test.yaml` to avoid `package:test` warnings. Passing `tags:` +explicitly *replaces* the default tag. + +## 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..4f65cacfac --- /dev/null +++ b/packages/stream_chat_test/analysis_options.yaml @@ -0,0 +1,14 @@ +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 + invalid_use_of_protected_member: ignore diff --git a/packages/stream_chat_test/dart_test.yaml b/packages/stream_chat_test/dart_test.yaml new file mode 100644 index 0000000000..1eedd04f42 --- /dev/null +++ b/packages/stream_chat_test/dart_test.yaml @@ -0,0 +1,3 @@ +tags: + chat-client: + channel: 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..d8f8bd3c84 --- /dev/null +++ b/packages/stream_chat_test/lib/src/helpers/api_mocker_mixin.dart @@ -0,0 +1,131 @@ +import 'package:meta/meta.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/stream_chat.dart'; + +import 'mocks.dart'; + +/// Mixin providing convenient API mocking and verification methods. +/// +/// The callback in every method receives the chat API and should invoke the +/// call being stubbed or verified on one of its sub-APIs, passing the **exact** +/// arguments the production code is expected to send. Because a mocktail stub +/// only answers on an argument match, stubbing this way doubles as request +/// verification — prefer exact values over `any()` matchers. +/// +/// Chat sub-APIs return plain futures and **throw** [StreamChatNetworkError] +/// on failure, so [mockApiFailure] configures the call to throw. +/// +/// Note: 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 stub must pass it too. +/// +/// Example: +/// ```dart +/// tester.mockApi( +/// (api) => api.message.sendMessage(message, channelId, channelType), +/// result: createDefaultSendMessageResponse(message: message), +/// ); +/// +/// tester.verifyApi( +/// (api) => api.message.sendMessage(message, channelId, channelType), +/// ); +/// ``` +mixin ApiMockerMixin { + /// The fake chat API whose sub-APIs are mocks. + @protected + FakeChatApi get chatApi; + + /// Mocks an API call to return the given [result]. + /// + /// Example: + /// ```dart + /// tester.mockApi( + /// (api) => api.general.getAppSettings(), + /// result: createDefaultGetAppSettingsResponse(), + /// ); + /// ``` + void mockApi( + Future Function(StreamChatApi api) apiCall, { + required T result, + }) { + return when(() => apiCall(chatApi)).thenAnswer((_) async => 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 a + /// [StreamChatNetworkError] with [ChatErrorCode.internalSystemError]. + /// + /// Example: + /// ```dart + /// tester.mockApiFailure( + /// (api) => api.message.sendMessage(message, channelId, channelType), + /// error: StreamChatNetworkError(ChatErrorCode.internalSystemError), + /// ); + /// ``` + void mockApiFailure( + Future Function(StreamChatApi api) apiCall, { + Object? error, + }) { + final failure = error ?? StreamChatNetworkError(ChatErrorCode.internalSystemError); + return when(() => apiCall(chatApi)).thenAnswer((_) => Future.error(failure)); + } + + /// Verifies that an API call was made exactly once. + /// + /// Example: + /// ```dart + /// tester.verifyApi( + /// (api) => api.message.sendMessage(message, channelId, channelType), + /// ); + /// ``` + void verifyApi(Future Function(StreamChatApi api) apiCall) { + return verifyApiCalled(apiCall, times: 1); + } + + /// Verifies that an API call was made exactly [times] times. + /// + /// Example: + /// ```dart + /// tester.verifyApiCalled( + /// (api) => api.channel.queryChannels(payload: payload), + /// times: 2, + /// ); + /// ``` + 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. + /// + /// Use `captureAny()` / `captureAny(named: ...)` matchers in the callback + /// for the arguments to capture. + /// + /// Example: + /// ```dart + /// final captured = tester.captureApi( + /// (api) => api.channel.queryChannels(payload: captureAny(named: 'payload')), + /// ); + /// final payload = captured.single! as QueryChannelsRequest; + /// ``` + List captureApi(Future Function(StreamChatApi api) apiCall) { + return verify(() => apiCall(chatApi)).captured; + } + + /// Verifies that an API call was never made. + /// + /// Example: + /// ```dart + /// tester.verifyNeverCalled( + /// (api) => api.message.deleteMessage(messageId), + /// ); + /// ``` + VerificationResult verifyNeverCalled(Future Function(StreamChatApi api) apiCall) { + return verifyNever(() => apiCall(chatApi)); + } +} 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..97ed291fee --- /dev/null +++ b/packages/stream_chat_test/lib/src/helpers/mocks.dart @@ -0,0 +1,165 @@ +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 {} + +/// 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. +/// +/// Idempotent; called automatically by the tester lifecycle. +void registerChatFallbackValues() { + registerFallbackValue(_FakeMessage()); + registerFallbackValue(_FakeDraftMessage()); + registerFallbackValue(_FakeAttachmentFile()); + registerFallbackValue(_FakeEvent()); + registerFallbackValue(_FakeUser()); + registerFallbackValue(_FakePollVote()); + registerFallbackValue(_FakeChannelState()); + registerFallbackValue(_FakeMultipartFile()); + registerFallbackValue(const PaginationParams()); + registerFallbackValue(const Filter.empty()); +} 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..cb1dc1398b --- /dev/null +++ b/packages/stream_chat_test/lib/src/helpers/test_data.dart @@ -0,0 +1,306 @@ +// 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 'package:stream_chat/src/core/http/token.dart'; +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 development [Token] for the given [userId], decodable by the +/// SDK's token validation. +Token createTestToken(String userId) => Token.development(userId); + +/// 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 [], + DateTime? createdAt, + DateTime? updatedAt, +}) { + return OwnUser( + id: id, + name: name, + role: role, + totalUnreadCount: totalUnreadCount, + unreadChannels: unreadChannels, + devices: devices, + mutes: mutes, + 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 [ChatErrorCode]); 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, + Reaction? reaction, + Member? member, + ChannelModel? channel, + OwnUser? me, + int? totalUnreadCount, + int? unreadChannels, + DateTime? createdAt, + Map extraData = const {}, +}) { + return Event( + type: type, + cid: cid, + channelId: channelId, + channelType: channelType, + user: user, + message: message, + reaction: reaction, + member: member, + channel: channel, + me: me, + totalUnreadCount: totalUnreadCount, + unreadChannels: unreadChannels, + 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. +ChannelModel createDefaultChannelModel({ + String cid = 'messaging:test-channel', + User? createdBy, + int memberCount = 0, + List? ownCapabilities, + ChannelConfig? config, + DateTime? createdAt, + DateTime? updatedAt, + DateTime? lastMessageAt, + Map extraData = const {}, +}) { + return ChannelModel( + cid: cid, + createdBy: createdBy, + memberCount: memberCount, + ownCapabilities: ownCapabilities, + config: config, + createdAt: createdAt ?? testCreatedAt, + updatedAt: updatedAt ?? testUpdatedAt, + lastMessageAt: lastMessageAt, + extraData: extraData, + ); +} + +/// 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, + int unreadMessages = 0, +}) { + return Read( + user: user ?? createDefaultUser(), + lastRead: lastRead ?? testCreatedAt, + 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 [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 [QueryChannelsResponse] wrapping [channels]. +QueryChannelsResponse createDefaultQueryChannelsResponse({ + List channels = const [], +}) { + return QueryChannelsResponse() + ..duration = '10ms' + ..channels = channels; +} + +/// Creates a [GetAppSettingsResponse] with default [AppSettings]. +GetAppSettingsResponse createDefaultGetAppSettingsResponse({ + AppSettings app = const AppSettings(), +}) { + return GetAppSettingsResponse() + ..duration = '10ms' + ..app = app; +} + +/// Creates an [EmptyResponse]. +EmptyResponse createDefaultEmptyResponse() => EmptyResponse()..duration = '10ms'; 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..ea13dfb220 --- /dev/null +++ b/packages/stream_chat_test/lib/src/testers/base_tester.dart @@ -0,0 +1,394 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:stream_chat/src/core/http/token.dart'; +import 'package:stream_chat/src/core/http/token_manager.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'; + +/// 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. + /// + /// Example: + /// ```dart + /// // Use the configured user's ID for authentication + /// tester.mockSuccessfulAuth(tester.user.id); + /// ``` + final User user; + + @override + @protected + final FakeChatApi chatApi; + + /// The underlying [StreamChatClient] from which the subject was built. + /// + /// Use this to access client-level properties and methods. + /// + /// Note: prefer using [subject] for testing the specific state object. + 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 WebSocket mocks to simulate successful authentication for + /// [userId]. + /// + /// Use this in test setup to configure how the WebSocket should respond to + /// connection attempts. + /// + /// Example: + /// ```dart + /// tester.mockSuccessfulAuth(tester.user.id); + /// await tester.client.connectUser(tester.user, token); // Will succeed + /// ``` + void mockSuccessfulAuth(String userId) { + return _wsTester.mockSuccessfulAuth(userId); + } + + /// Configures WebSocket mocks to simulate authentication failure. + /// + /// The [errorCode] parameter allows customizing the backend error code + /// returned; default is 40 ([ChatErrorCode.tokenExpired]). With the + /// harness's static token the connection attempt fails with a + /// [StreamWebSocketError]; a token *provider* turns code 40 into a silent + /// token refresh and reconnect instead. + /// + /// Use this in test setup when testing error scenarios. + /// + /// Example: + /// ```dart + /// tester.mockFailedAuth(errorCode: 43); + /// await expectLater( + /// tester.client.connectUser(tester.user, token), + /// throwsA(isA()), + /// ); + /// ``` + void mockFailedAuth({int errorCode = 40}) { + return _wsTester.mockFailedAuth(errorCode: errorCode); + } + + /// Configures the WebSocket so that the transport fails. + /// + /// The channel opens, but its stream immediately errors — a broken socket + /// rather than a server that refuses. The connection attempt fails with a + /// retriable [StreamWebSocketError]. + /// + /// Example: + /// ```dart + /// tester.mockConnectionError(); + /// await expectLater( + /// tester.client.connectUser(tester.user, token), + /// throwsA(isA()), + /// ); + /// ``` + 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. The event + /// loop is pumped afterwards to allow async event handlers to run. + /// + /// Example: + /// ```dart + /// await tester.emitEvent( + /// createDefaultEvent(type: EventType.messageNew, cid: channel.cid), + /// ); + /// expect(tester.channelState?.messages, hasLength(1)); + /// ``` + 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 events to be processed. + /// + /// Returns a [Future] that completes after the event loop has run the given + /// number of [times] (20 by default). + /// + /// Awaiting this approximates waiting until all asynchronous work (other + /// than work that's waiting for external resources) completes. + Future pumpEventQueue({int times = 20}) { + return test.pumpEventQueue(times: times); + } + + /// Disposes resources held by this tester. + /// + /// This method is called automatically after each test completes. + /// Subclasses can override this method to perform cleanup of resources such + /// as stream subscriptions, controllers, or other state that needs explicit + /// disposal. + /// + /// Subclasses that override this method should call `super.dispose()` to + /// ensure any base cleanup is performed. + @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 for chat subjects with WebSocket support. +/// +/// Automatically sets up the test client, WebSocket infrastructure, and +/// coordinates the test lifecycle. +/// +/// The client is real; only three seams are replaced: the REST API (a +/// [FakeChatApi] whose sub-APIs are mocks), the WebSocket transport (a mocked +/// channel driven by a [WebSocketTester], with the real [WebSocket] engine in +/// play), and optionally the persistence client. +/// +/// Parameters: +/// - [user]: the user the client is configured for (defaults to +/// luke_skywalker) +/// - [token]: the static token used to connect (defaults to a development +/// token for [user]) +/// - [tokenProvider]: connects through [StreamChatClient.connectUserWithProvider] +/// instead of a static token; also drives the engine's token refresh path +/// - [chatPersistenceClient]: optional persistence client assigned to the +/// client before connecting +/// - [logLevel]: the client's log level (defaults to [Level.OFF] to keep test +/// output quiet) +/// - [build]: constructs the subject under test using the provided client +/// - [createTesterFn]: the concrete tester factory function +/// - [connect]: optional, custom connection logic (defaults to successful +/// auth + connect + connection assertion) +/// - [setUp]: optional, runs before body for setting up mocks and test state +/// - [body]: the test callback that receives a tester for interactions +/// - [verify]: optional, runs after body for verifying API calls +/// - [tearDown]: optional, runs after verify for custom cleanup +/// - [skip], [tags], [timeout]: forwarded to `test` +/// +/// This function is for internal use by concrete test helpers. +void testWithTester>( + String description, { + User? user, + Token? token, + TokenProvider? tokenProvider, + ChatPersistenceClient? chatPersistenceClient, + Level logLevel = Level.OFF, + 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, + // NOTE(parity): typed `bool` to match stream_feeds_test, which prevents + // passing the skip *reason* STYLE_GUIDE.md requires (`package:test` accepts + // a String for exactly that). Widen together with the feeds package. + bool skip = false, + 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, + ); + + // 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(); + await wsTokenManager.setTokenOrProvider( + testUser.id, + token: tokenProvider == null ? testToken : null, + provider: tokenProvider, + ); + + 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, + logLevel: logLevel, + )..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 Token token, + TokenProvider? tokenProvider, +}) { + return (tester) async { + // Mock successful authentication for the configured user. + tester.mockSuccessfulAuth(tester.user.id); + + // 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 to catch all errors. +// +// This ensures that errors from event handlers, timers, and unawaited +// futures are properly caught and reported, not just errors in the +// main async chain. +// +// 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". 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..1745ba3b6e --- /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/src/core/http/token.dart'; +import 'package:stream_chat/src/core/http/token_manager.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]; pass [build] to construct it differently (e.g. +/// via [Channel.fromState]). Use [ChannelTester.watch] in [setUp] to seed 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, + Token? token, + TokenProvider? tokenProvider, + ChatPersistenceClient? chatPersistenceClient, + Level logLevel = Level.OFF, + 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, + bool skip = false, + Iterable tags = const ['channel'], + test.Timeout? timeout, +}) { + return testWithTester( + description, + user: user, + token: token, + tokenProvider: tokenProvider, + chatPersistenceClient: chatPersistenceClient, + logLevel: logLevel, + 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. + /// + /// Call this in event tests to set up initial state before emitting events. + /// Skip this in 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. Use [modifyResponse] to adjust 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..3440016c8b --- /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/src/core/http/token.dart'; +import 'package:stream_chat/src/core/http/token_manager.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, + Token? token, + TokenProvider? tokenProvider, + ChatPersistenceClient? chatPersistenceClient, + Level logLevel = Level.OFF, + 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, + bool skip = false, + Iterable tags = const ['chat-client'], + test.Timeout? timeout, +}) { + return testWithTester( + description, + user: user, + token: token, + tokenProvider: tokenProvider, + chatPersistenceClient: chatPersistenceClient, + logLevel: logLevel, + 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..021609b1c5 --- /dev/null +++ b/packages/stream_chat_test/lib/src/testers/websocket_tester.dart @@ -0,0 +1,315 @@ +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/test_data.dart'; + +/// A test utility for managing WebSocket connection mocking and event emission. +/// +/// This class encapsulates WebSocket channel mocking, authentication simulation, +/// and event emission for testing real-time features. It provides methods to +/// configure WebSocket behavior for both successful and failed connection +/// scenarios. +/// +/// The real [WebSocket] engine is kept in play: [channelProvider] is passed as +/// its `webSocketChannelProvider`, so real URI building, frame decoding, +/// health-check monitoring and reconnection logic all run in tests — only the +/// transport is simulated. +/// +/// Chat authenticates through the connect URI (token and user payload in query +/// parameters) rather than a first frame, so the fake server validates +/// credentials from the URI captured by [channelProvider] and answers through +/// the channel's stream. +/// +/// This class is used internally by testers and should not be instantiated +/// directly in test code. +final class WebSocketTester { + /// Creates a [WebSocketTester] with the given [channel] and + /// [streamController]. + /// + /// The [channel] is the mock WebSocket channel to configure, and + /// [streamController] is used to emit frames that simulate server responses. + 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. + 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 WebSocket mocks to simulate successful authentication. + /// + /// Sets up the WebSocket channel to respond with a connected `health.check` + /// event when a connection is attempted with the specified [userId]. Like + /// the real backend, the fake server checks the credentials in the connect + /// URI — both the connect payload's user id and the `user_id` claim of the + /// token — and emits an error frame with code 43 (invalid token signature) + /// when either does not match [userId]. + /// + /// Be aware that the token in the connect URI is always the one the harness + /// pre-loaded into the injected WebSocket's own token manager — the token + /// argument passed to `connectUser` never reaches it (see the README's + /// "Token handling" section), so the token check here only guards + /// harness-level credential mistakes. + /// + /// Call this before connecting the client in your test setup. + /// + /// Example: + /// ```dart + /// wsTester.mockSuccessfulAuth('user-123'); + /// await client.connectUser(user, token); // Will succeed + /// ``` + void mockSuccessfulAuth(String userId) { + _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: ChatErrorCode.tokenSignatureInvalid.code, + message: 'invalid token signature', + ), + ); + } + + // Correct credentials - simulate successful authentication. + return emitEvent(createDefaultConnectedEvent(userId: userId)); + }; + } + + /// Configures WebSocket mocks to simulate authentication failure. + /// + /// Sets up the WebSocket channel to always respond with an error frame when + /// a connection is attempted, regardless of the provided credentials. + /// + /// The [errorCode] parameter allows customizing the backend error code + /// returned. Default is 40 ([ChatErrorCode.tokenExpired]). All errors use + /// HTTP status code 401. + /// + /// **Backend Error Codes (401 Status):** + /// + /// - `40`: tokenExpired — with a token *provider*, the engine silently + /// refreshes the token and reconnects; with a static token (the harness + /// default) there is no other token to load, so the connection attempt + /// fails with a [StreamWebSocketError]. + /// - `41`: tokenNotValidYet, `42`: tokenUsedBeforeIssuedAt, + /// `43`: tokenSignatureInvalid, `2`: accessKeyError, `5`: authFailed — + /// reported, never retried, because no other token repairs them. + /// + /// Call this before connecting the client when testing error scenarios. + /// + /// Example: + /// ```dart + /// wsTester.mockFailedAuth(errorCode: 43); + /// await expectLater( + /// client.connectUser(user, token), + /// throwsA(isA()), + /// ); + /// ``` + 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 so that the transport fails. + /// + /// The channel opens, but its stream immediately errors — a broken socket + /// rather than a server that refuses. The connection attempt fails with a + /// retriable [StreamWebSocketError] and the engine schedules background + /// reconnects (cancelled when the client is disposed by the test teardown). + /// + /// Call this before connecting the client. + /// + /// Example: + /// ```dart + /// wsTester.mockConnectionError(); + /// await expectLater( + /// client.connectUser(user, token), + /// throwsA(isA()), + /// ); + /// ``` + 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] to simulate a server message. + /// + /// The event goes through the engine's real JSON frame decoding before + /// reaching the client, exactly like a production server push. + /// + /// **Note:** You must call [mockSuccessfulAuth] or [mockFailedAuth] before + /// calling this method. + /// + /// Example: + /// ```dart + /// wsTester.emitEvent( + /// createDefaultEvent(type: EventType.messageNew, cid: 'messaging:123'), + /// ); + /// ``` + void emitEvent(Event event) => emitRawFrame(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 type for resetting WebSocket mock configuration. +/// +/// This allows reconfiguring WebSocket mocks between different test scenarios +/// by resetting the previous mock setup. +typedef WebSocketResetFunction = void Function(); + +// 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..33091841de --- /dev/null +++ b/packages/stream_chat_test/lib/stream_chat_test.dart @@ -0,0 +1,25 @@ +/// 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'; +// NOTE(re-evaluate): re-exported so tests can name `Token` / `TokenProvider` +// without importing `package:stream_chat/src/...` themselves. Re-evaluate once +// stream_chat exposes these from its public barrel or a testing entrypoint. +export 'package:stream_chat/src/core/http/token.dart' show Token; +export 'package:stream_chat/src/core/http/token_manager.dart' show TokenProvider; +export 'package:test/test.dart'; + +// Helpers +export 'src/helpers/api_mocker_mixin.dart'; +export 'src/helpers/mocks.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..7a0de20e01 --- /dev/null +++ b/packages/stream_chat_test/pubspec.yaml @@ -0,0 +1,29 @@ +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: + 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..c24e1a2a0c --- /dev/null +++ b/packages/stream_chat_test/test/chat_client_tester_test.dart @@ -0,0 +1,182 @@ +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( + 'connects through a token provider', + tokenProvider: (userId) async => createTestToken(userId).rawValue, + 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('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(tester.user.id), + 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/stream_chat_test_test.dart b/packages/stream_chat_test/test/stream_chat_test_test.dart new file mode 100644 index 0000000000..53485bbb64 --- /dev/null +++ b/packages/stream_chat_test/test/stream_chat_test_test.dart @@ -0,0 +1,21 @@ +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); + }); + }); +} From 16f16d05593be1c0ac446ed623653fc54de00653 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 9 Sep 2026 13:24:31 +0200 Subject: [PATCH 002/103] test(llc): polish stream_chat_test docs and expose isLocalUnreadCountEnabled Co-Authored-By: Claude Fable 5 --- packages/stream_chat_test/README.md | 4 +- .../stream_chat_test/analysis_options.yaml | 2 + .../lib/src/helpers/api_mocker_mixin.dart | 78 +++------- .../lib/src/helpers/test_data.dart | 2 + .../lib/src/testers/base_tester.dart | 135 +++++------------- .../lib/src/testers/channel_tester.dart | 14 +- .../lib/src/testers/chat_client_tester.dart | 2 + .../lib/src/testers/websocket_tester.dart | 131 ++++------------- 8 files changed, 96 insertions(+), 272 deletions(-) diff --git a/packages/stream_chat_test/README.md b/packages/stream_chat_test/README.md index 188c04471d..1f4fac22d9 100644 --- a/packages/stream_chat_test/README.md +++ b/packages/stream_chat_test/README.md @@ -143,7 +143,9 @@ 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. +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 diff --git a/packages/stream_chat_test/analysis_options.yaml b/packages/stream_chat_test/analysis_options.yaml index 4f65cacfac..55f2c0ff87 100644 --- a/packages/stream_chat_test/analysis_options.yaml +++ b/packages/stream_chat_test/analysis_options.yaml @@ -11,4 +11,6 @@ analyzer: # 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/lib/src/helpers/api_mocker_mixin.dart b/packages/stream_chat_test/lib/src/helpers/api_mocker_mixin.dart index d8f8bd3c84..5558feb713 100644 --- a/packages/stream_chat_test/lib/src/helpers/api_mocker_mixin.dart +++ b/packages/stream_chat_test/lib/src/helpers/api_mocker_mixin.dart @@ -4,32 +4,27 @@ import 'package:stream_chat/stream_chat.dart'; import 'mocks.dart'; -/// Mixin providing convenient API mocking and verification methods. +/// Mixin providing API mocking and verification methods. /// -/// The callback in every method receives the chat API and should invoke the -/// call being stubbed or verified on one of its sub-APIs, passing the **exact** -/// arguments the production code is expected to send. Because a mocktail stub -/// only answers on an argument match, stubbing this way doubles as request -/// verification — prefer exact values over `any()` matchers. +/// 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. /// -/// Chat sub-APIs return plain futures and **throw** [StreamChatNetworkError] -/// on failure, so [mockApiFailure] configures the call to throw. -/// -/// Note: 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 stub must pass it too. +/// 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.sendMessage(message, channelId, channelType), -/// result: createDefaultSendMessageResponse(message: message), +/// (api) => api.message.getMessage('message-id'), +/// result: createDefaultGetMessageResponse(), /// ); /// -/// tester.verifyApi( -/// (api) => api.message.sendMessage(message, channelId, channelType), -/// ); +/// tester.verifyApi((api) => api.message.getMessage('message-id')); /// ``` mixin ApiMockerMixin { /// The fake chat API whose sub-APIs are mocks. @@ -37,14 +32,6 @@ mixin ApiMockerMixin { FakeChatApi get chatApi; /// Mocks an API call to return the given [result]. - /// - /// Example: - /// ```dart - /// tester.mockApi( - /// (api) => api.general.getAppSettings(), - /// result: createDefaultGetAppSettingsResponse(), - /// ); - /// ``` void mockApi( Future Function(StreamChatApi api) apiCall, { required T result, @@ -57,14 +44,6 @@ mixin ApiMockerMixin { /// The returned future completes with the error — matching the real API /// layer, which always fails asynchronously. Defaults to a /// [StreamChatNetworkError] with [ChatErrorCode.internalSystemError]. - /// - /// Example: - /// ```dart - /// tester.mockApiFailure( - /// (api) => api.message.sendMessage(message, channelId, channelType), - /// error: StreamChatNetworkError(ChatErrorCode.internalSystemError), - /// ); - /// ``` void mockApiFailure( Future Function(StreamChatApi api) apiCall, { Object? error, @@ -74,26 +53,11 @@ mixin ApiMockerMixin { } /// Verifies that an API call was made exactly once. - /// - /// Example: - /// ```dart - /// tester.verifyApi( - /// (api) => api.message.sendMessage(message, channelId, channelType), - /// ); - /// ``` void verifyApi(Future Function(StreamChatApi api) apiCall) { return verifyApiCalled(apiCall, times: 1); } /// Verifies that an API call was made exactly [times] times. - /// - /// Example: - /// ```dart - /// tester.verifyApiCalled( - /// (api) => api.channel.queryChannels(payload: payload), - /// times: 2, - /// ); - /// ``` void verifyApiCalled( Future Function(StreamChatApi api) apiCall, { required int times, @@ -103,28 +67,20 @@ mixin ApiMockerMixin { /// Captures the arguments of an API call for detailed assertions. /// - /// Use `captureAny()` / `captureAny(named: ...)` matchers in the callback - /// for the arguments to capture. + /// The arguments to capture are marked with `captureAny()` / + /// `captureAny(named: ...)` matchers in the callback: /// - /// Example: /// ```dart /// final captured = tester.captureApi( - /// (api) => api.channel.queryChannels(payload: captureAny(named: 'payload')), + /// (api) => api.message.getMessage(captureAny()), /// ); - /// final payload = captured.single! as QueryChannelsRequest; + /// 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. - /// - /// Example: - /// ```dart - /// tester.verifyNeverCalled( - /// (api) => api.message.deleteMessage(messageId), - /// ); - /// ``` VerificationResult verifyNeverCalled(Future Function(StreamChatApi api) apiCall) { return verifyNever(() => apiCall(chatApi)); } diff --git a/packages/stream_chat_test/lib/src/helpers/test_data.dart b/packages/stream_chat_test/lib/src/helpers/test_data.dart index cb1dc1398b..e31b593b12 100644 --- a/packages/stream_chat_test/lib/src/helpers/test_data.dart +++ b/packages/stream_chat_test/lib/src/helpers/test_data.dart @@ -238,11 +238,13 @@ Member createDefaultMember({ Read createDefaultRead({ User? user, DateTime? lastRead, + DateTime? lastDeliveredAt, int unreadMessages = 0, }) { return Read( user: user ?? createDefaultUser(), lastRead: lastRead ?? testCreatedAt, + lastDeliveredAt: lastDeliveredAt, unreadMessages: unreadMessages, ); } diff --git a/packages/stream_chat_test/lib/src/testers/base_tester.dart b/packages/stream_chat_test/lib/src/testers/base_tester.dart index ea13dfb220..983c31c76e 100644 --- a/packages/stream_chat_test/lib/src/testers/base_tester.dart +++ b/packages/stream_chat_test/lib/src/testers/base_tester.dart @@ -52,23 +52,13 @@ abstract base class BaseTester with ApiMockerMixin { /// /// This is the user used for authentication and for performing all actions /// through the client. - /// - /// Example: - /// ```dart - /// // Use the configured user's ID for authentication - /// tester.mockSuccessfulAuth(tester.user.id); - /// ``` final User user; @override @protected final FakeChatApi chatApi; - /// The underlying [StreamChatClient] from which the subject was built. - /// - /// Use this to access client-level properties and methods. - /// - /// Note: prefer using [subject] for testing the specific state object. + /// The underlying [StreamChatClient] from which the [subject] was built. StreamChatClient get client => _client; final StreamChatClient _client; @@ -87,57 +77,30 @@ abstract base class BaseTester with ApiMockerMixin { /// made. List get connectUris => _wsTester.connectUris; - /// Configures WebSocket mocks to simulate successful authentication for - /// [userId]. + /// Configures the fake server to accept a connection attempt for [userId]. /// - /// Use this in test setup to configure how the WebSocket should respond to - /// connection attempts. - /// - /// Example: - /// ```dart - /// tester.mockSuccessfulAuth(tester.user.id); - /// await tester.client.connectUser(tester.user, token); // Will succeed - /// ``` + /// 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. void mockSuccessfulAuth(String userId) { return _wsTester.mockSuccessfulAuth(userId); } - /// Configures WebSocket mocks to simulate authentication failure. - /// - /// The [errorCode] parameter allows customizing the backend error code - /// returned; default is 40 ([ChatErrorCode.tokenExpired]). With the - /// harness's static token the connection attempt fails with a - /// [StreamWebSocketError]; a token *provider* turns code 40 into a silent - /// token refresh and reconnect instead. - /// - /// Use this in test setup when testing error scenarios. + /// Configures the fake server to reject every connection attempt with an + /// error frame carrying [errorCode]. /// - /// Example: - /// ```dart - /// tester.mockFailedAuth(errorCode: 43); - /// await expectLater( - /// tester.client.connectUser(tester.user, token), - /// throwsA(isA()), - /// ); - /// ``` + /// The default 40 ([ChatErrorCode.tokenExpired]) fails the attempt with a + /// [StreamWebSocketError] 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 so that the transport fails. + /// 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 [StreamWebSocketError]. - /// - /// Example: - /// ```dart - /// tester.mockConnectionError(); - /// await expectLater( - /// tester.client.connectUser(tester.user, token), - /// throwsA(isA()), - /// ); - /// ``` void mockConnectionError({Object? error}) { return _wsTester.mockConnectionError(error: error); } @@ -145,16 +108,7 @@ abstract base class BaseTester with ApiMockerMixin { /// 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. The event - /// loop is pumped afterwards to allow async event handlers to run. - /// - /// Example: - /// ```dart - /// await tester.emitEvent( - /// createDefaultEvent(type: EventType.messageNew, cid: channel.cid), - /// ); - /// expect(tester.channelState?.messages, hasLength(1)); - /// ``` + /// reaching the client, exactly like a production server push. Future emitEvent(Event event) async { _wsTester.emitEvent(event); await pumpEventQueue(); @@ -169,26 +123,16 @@ abstract base class BaseTester with ApiMockerMixin { await pumpEventQueue(); } - /// Waits for events to be processed. - /// - /// Returns a [Future] that completes after the event loop has run the given - /// number of [times] (20 by default). - /// - /// Awaiting this approximates waiting until all asynchronous work (other - /// than work that's waiting for external resources) completes. + /// 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. /// - /// This method is called automatically after each test completes. - /// Subclasses can override this method to perform cleanup of resources such - /// as stream subscriptions, controllers, or other state that needs explicit - /// disposal. - /// - /// Subclasses that override this method should call `super.dispose()` to - /// ensure any base cleanup is performed. + /// Called automatically after each test completes. Overrides must call + /// `super.dispose()`. @mustCallSuper Future dispose() async {} } @@ -204,36 +148,19 @@ Future createTester>({ return tester; } -/// Generic test helper for chat subjects with WebSocket support. -/// -/// Automatically sets up the test client, WebSocket infrastructure, and -/// coordinates the test lifecycle. +/// Generic test helper backing the concrete `Test` entry points. /// -/// The client is real; only three seams are replaced: the REST API (a -/// [FakeChatApi] whose sub-APIs are mocks), the WebSocket transport (a mocked -/// channel driven by a [WebSocketTester], with the real [WebSocket] engine in -/// play), and optionally the persistence client. +/// 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. /// -/// Parameters: -/// - [user]: the user the client is configured for (defaults to -/// luke_skywalker) -/// - [token]: the static token used to connect (defaults to a development -/// token for [user]) -/// - [tokenProvider]: connects through [StreamChatClient.connectUserWithProvider] -/// instead of a static token; also drives the engine's token refresh path -/// - [chatPersistenceClient]: optional persistence client assigned to the -/// client before connecting -/// - [logLevel]: the client's log level (defaults to [Level.OFF] to keep test -/// output quiet) -/// - [build]: constructs the subject under test using the provided client -/// - [createTesterFn]: the concrete tester factory function -/// - [connect]: optional, custom connection logic (defaults to successful -/// auth + connect + connection assertion) -/// - [setUp]: optional, runs before body for setting up mocks and test state -/// - [body]: the test callback that receives a tester for interactions -/// - [verify]: optional, runs after body for verifying API calls -/// - [tearDown]: optional, runs after verify for custom cleanup -/// - [skip], [tags], [timeout]: forwarded to `test` +/// 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. [logLevel] and +/// [isLocalUnreadCountEnabled] are forwarded to the client constructor; +/// [skip], [tags] and [timeout] to `test`. /// /// This function is for internal use by concrete test helpers. void testWithTester>( @@ -243,6 +170,7 @@ void testWithTester>( TokenProvider? tokenProvider, ChatPersistenceClient? chatPersistenceClient, Level logLevel = Level.OFF, + bool isLocalUnreadCountEnabled = false, required S Function(StreamChatClient client) build, required TesterFactory createTesterFn, FutureOr Function(T tester)? connect, @@ -315,6 +243,7 @@ void testWithTester>( chatApi: chatApi, ws: ws, logLevel: logLevel, + isLocalUnreadCountEnabled: isLocalUnreadCountEnabled, )..chatPersistenceClient = chatPersistenceClient; test.addTearDown(client.dispose); // Dispose client after test @@ -376,8 +305,10 @@ FutureOr Function(BaseTester) _defaultConnect({ // 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". Kept -// as-is for parity with stream_feeds_test's base_tester; fix in both +// 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(); diff --git a/packages/stream_chat_test/lib/src/testers/channel_tester.dart b/packages/stream_chat_test/lib/src/testers/channel_tester.dart index 1745ba3b6e..a1d4be3c2c 100644 --- a/packages/stream_chat_test/lib/src/testers/channel_tester.dart +++ b/packages/stream_chat_test/lib/src/testers/channel_tester.dart @@ -14,9 +14,9 @@ import 'websocket_tester.dart'; /// Test helper for testing [Channel] behavior. /// /// By default the subject is a fresh, non-initialized channel of -/// [channelType]/[channelId]; pass [build] to construct it differently (e.g. -/// via [Channel.fromState]). Use [ChannelTester.watch] in [setUp] to seed the -/// channel with an initial state before emitting events. +/// [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 @@ -48,6 +48,7 @@ void channelTest( TokenProvider? tokenProvider, ChatPersistenceClient? chatPersistenceClient, Level logLevel = Level.OFF, + bool isLocalUnreadCountEnabled = false, FutureOr Function(ChannelTester tester)? connect, FutureOr Function(ChannelTester tester)? setUp, required FutureOr Function(ChannelTester tester) body, @@ -64,6 +65,7 @@ void channelTest( tokenProvider: tokenProvider, chatPersistenceClient: chatPersistenceClient, logLevel: logLevel, + isLocalUnreadCountEnabled: isLocalUnreadCountEnabled, build: build ?? (client) => client.channel(channelType, id: channelId), createTesterFn: _createChannelTester, connect: connect, @@ -101,12 +103,12 @@ final class ChannelTester extends BaseTester { /// Stubs the channel query and watches the channel, seeding it with an /// initial state. /// - /// Call this in event tests to set up initial state before emitting events. - /// Skip this in tests that only verify API calls. + /// 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. Use [modifyResponse] to adjust the canonical seeded state: + /// request shape. [modifyResponse] adjusts the canonical seeded state: /// /// ```dart /// setUp: (tester) => tester.watch( 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 index 3440016c8b..fd6c5f6e69 100644 --- a/packages/stream_chat_test/lib/src/testers/chat_client_tester.dart +++ b/packages/stream_chat_test/lib/src/testers/chat_client_tester.dart @@ -34,6 +34,7 @@ void chatClientTest( TokenProvider? tokenProvider, ChatPersistenceClient? chatPersistenceClient, Level logLevel = Level.OFF, + bool isLocalUnreadCountEnabled = false, FutureOr Function(ChatClientTester tester)? connect, FutureOr Function(ChatClientTester tester)? setUp, required FutureOr Function(ChatClientTester tester) body, @@ -50,6 +51,7 @@ void chatClientTest( tokenProvider: tokenProvider, chatPersistenceClient: chatPersistenceClient, logLevel: logLevel, + isLocalUnreadCountEnabled: isLocalUnreadCountEnabled, build: (client) => client, createTesterFn: _createChatClientTester, connect: connect, diff --git a/packages/stream_chat_test/lib/src/testers/websocket_tester.dart b/packages/stream_chat_test/lib/src/testers/websocket_tester.dart index 021609b1c5..b067404912 100644 --- a/packages/stream_chat_test/lib/src/testers/websocket_tester.dart +++ b/packages/stream_chat_test/lib/src/testers/websocket_tester.dart @@ -8,31 +8,18 @@ import 'package:web_socket_channel/web_socket_channel.dart'; import '../helpers/mocks.dart'; import '../helpers/test_data.dart'; -/// A test utility for managing WebSocket connection mocking and event emission. +/// Simulates the server side of a WebSocket connection. /// -/// This class encapsulates WebSocket channel mocking, authentication simulation, -/// and event emission for testing real-time features. It provides methods to -/// configure WebSocket behavior for both successful and failed connection -/// scenarios. +/// 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. /// -/// The real [WebSocket] engine is kept in play: [channelProvider] is passed as -/// its `webSocketChannelProvider`, so real URI building, frame decoding, -/// health-check monitoring and reconnection logic all run in tests — only the -/// transport is simulated. -/// -/// Chat authenticates through the connect URI (token and user payload in query -/// parameters) rather than a first frame, so the fake server validates -/// credentials from the URI captured by [channelProvider] and answers through -/// the channel's stream. -/// -/// This class is used internally by testers and should not be instantiated -/// directly in test code. +/// Used internally by testers; not intended to be instantiated in test code. final class WebSocketTester { - /// Creates a [WebSocketTester] with the given [channel] and - /// [streamController]. - /// - /// The [channel] is the mock WebSocket channel to configure, and - /// [streamController] is used to emit frames that simulate server responses. + /// Creates a [WebSocketTester] configuring the mock [channel] and emitting + /// server frames through [streamController]. WebSocketTester({ required this._channel, required this._streamController, @@ -48,7 +35,7 @@ final class WebSocketTester { /// /// 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. + /// 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. @@ -71,28 +58,13 @@ final class WebSocketTester { return _channel; } - /// Configures WebSocket mocks to simulate successful authentication. - /// - /// Sets up the WebSocket channel to respond with a connected `health.check` - /// event when a connection is attempted with the specified [userId]. Like - /// the real backend, the fake server checks the credentials in the connect - /// URI — both the connect payload's user id and the `user_id` claim of the - /// token — and emits an error frame with code 43 (invalid token signature) - /// when either does not match [userId]. + /// Configures the fake server to accept a connection attempt for [userId]. /// - /// Be aware that the token in the connect URI is always the one the harness - /// pre-loaded into the injected WebSocket's own token manager — the token - /// argument passed to `connectUser` never reaches it (see the README's - /// "Token handling" section), so the token check here only guards - /// harness-level credential mistakes. - /// - /// Call this before connecting the client in your test setup. - /// - /// Example: - /// ```dart - /// wsTester.mockSuccessfulAuth('user-123'); - /// await client.connectUser(user, token); // Will succeed - /// ``` + /// 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 [userId]. The token in the URI is always the harness token + /// (see the README's "Token handling" section). void mockSuccessfulAuth(String userId) { _resetFunction?.call(); // Reset previous mocks if any _resetFunction = _whenListenWebSocket(_channel); @@ -114,35 +86,13 @@ final class WebSocketTester { }; } - /// Configures WebSocket mocks to simulate authentication failure. - /// - /// Sets up the WebSocket channel to always respond with an error frame when - /// a connection is attempted, regardless of the provided credentials. - /// - /// The [errorCode] parameter allows customizing the backend error code - /// returned. Default is 40 ([ChatErrorCode.tokenExpired]). All errors use - /// HTTP status code 401. + /// Configures the fake server to reject every connection attempt with an + /// error frame carrying [errorCode] (HTTP status 401). /// - /// **Backend Error Codes (401 Status):** - /// - /// - `40`: tokenExpired — with a token *provider*, the engine silently - /// refreshes the token and reconnects; with a static token (the harness - /// default) there is no other token to load, so the connection attempt - /// fails with a [StreamWebSocketError]. - /// - `41`: tokenNotValidYet, `42`: tokenUsedBeforeIssuedAt, - /// `43`: tokenSignatureInvalid, `2`: accessKeyError, `5`: authFailed — - /// reported, never retried, because no other token repairs them. - /// - /// Call this before connecting the client when testing error scenarios. - /// - /// Example: - /// ```dart - /// wsTester.mockFailedAuth(errorCode: 43); - /// await expectLater( - /// client.connectUser(user, token), - /// throwsA(isA()), - /// ); - /// ``` + /// The default 40 ([ChatErrorCode.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 [StreamWebSocketError]. void mockFailedAuth({int errorCode = 40}) { _resetFunction?.call(); // Reset previous mocks if any _resetFunction = _whenListenWebSocket(_channel); @@ -152,23 +102,12 @@ final class WebSocketTester { }; } - /// Configures the WebSocket so that the transport fails. + /// 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 [StreamWebSocketError] and the engine schedules background - /// reconnects (cancelled when the client is disposed by the test teardown). - /// - /// Call this before connecting the client. - /// - /// Example: - /// ```dart - /// wsTester.mockConnectionError(); - /// await expectLater( - /// client.connectUser(user, token), - /// throwsA(isA()), - /// ); - /// ``` + /// 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); @@ -177,20 +116,10 @@ final class WebSocketTester { }; } - /// Emits a typed [event] to simulate a server message. - /// - /// The event goes through the engine's real JSON frame decoding before - /// reaching the client, exactly like a production server push. + /// Emits a typed [event] as a server frame. /// - /// **Note:** You must call [mockSuccessfulAuth] or [mockFailedAuth] before - /// calling this method. - /// - /// Example: - /// ```dart - /// wsTester.emitEvent( - /// createDefaultEvent(type: EventType.messageNew, cid: 'messaging:123'), - /// ); - /// ``` + /// The frame goes through the engine's real JSON decoding before reaching + /// the client, exactly like a production server push. void emitEvent(Event event) => emitRawFrame(event); /// Emits a raw JSON-encodable [frame] to simulate a server message. @@ -256,10 +185,8 @@ final class WebSocketTester { } } -/// A function type for resetting WebSocket mock configuration. -/// -/// This allows reconfiguring WebSocket mocks between different test scenarios -/// by resetting the previous mock setup. +/// 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. From 4bb5540a31389f2c746cd337ddfacb677bc6b534 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 09:45:07 +0200 Subject: [PATCH 003/103] test(llc): wire stream_chat_test as a dev dependency of stream_chat Co-Authored-By: Claude Fable 5 --- packages/stream_chat/dart_test.yaml | 3 +++ packages/stream_chat/pubspec.yaml | 2 ++ 2 files changed, 5 insertions(+) create mode 100644 packages/stream_chat/dart_test.yaml diff --git a/packages/stream_chat/dart_test.yaml b/packages/stream_chat/dart_test.yaml new file mode 100644 index 0000000000..1eedd04f42 --- /dev/null +++ b/packages/stream_chat/dart_test.yaml @@ -0,0 +1,3 @@ +tags: + chat-client: + channel: diff --git a/packages/stream_chat/pubspec.yaml b/packages/stream_chat/pubspec.yaml index 334c69fde7..a1586ad044 100644 --- a/packages/stream_chat/pubspec.yaml +++ b/packages/stream_chat/pubspec.yaml @@ -44,4 +44,6 @@ dev_dependencies: freezed: ^3.0.0 json_serializable: ^6.13.2 mocktail: ^1.0.5 + stream_chat_test: + path: ../stream_chat_test test: ^1.31.1 From b049eb143f17a92ffd7643a783f8031115f27d20 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 09:48:55 +0200 Subject: [PATCH 004/103] test(llc): add matchers, error fixtures and persistence doubles to stream_chat_test Co-Authored-By: Claude Fable 5 --- packages/stream_chat_test/CHANGELOG.md | 3 + .../lib/src/helpers/matchers.dart | 265 ++++++++++++++++++ .../lib/src/helpers/mocks.dart | 94 +++++++ .../lib/src/helpers/test_data.dart | 64 +++++ .../lib/stream_chat_test.dart | 1 + packages/stream_chat_test/pubspec.yaml | 1 + 6 files changed, 428 insertions(+) create mode 100644 packages/stream_chat_test/lib/src/helpers/matchers.dart diff --git a/packages/stream_chat_test/CHANGELOG.md b/packages/stream_chat_test/CHANGELOG.md index aff8bf7bf6..3273201b9f 100644 --- a/packages/stream_chat_test/CHANGELOG.md +++ b/packages/stream_chat_test/CHANGELOG.md @@ -1,3 +1,6 @@ # 0.1.0 - Initial release of internal BDD-style test helpers for the `stream_chat` package. +- Added `isSameXxxAs` matchers for messages, drafts, attachments, users, events and channels. +- Added `createDefaultChannelConfig`, `createDefaultErrorResponse` and `createDefaultNetworkError` fixtures. +- Added `MockPersistenceClient` and `FakePersistenceClient` doubles for the persistence seam. diff --git a/packages/stream_chat_test/lib/src/helpers/matchers.dart b/packages/stream_chat_test/lib/src/helpers/matchers.dart new file mode 100644 index 0000000000..5008c15c08 --- /dev/null +++ b/packages/stream_chat_test/lib/src/helpers/matchers.dart @@ -0,0 +1,265 @@ +import 'package:collection/collection.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +/// 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 { + const _IsSameMultipartFileAs({required this.targetFile}); + + final MultipartFile targetFile; + + @override + Description describe(Description description) => description.add('is same multipartFile as $targetFile'); + + @override + bool matches(covariant MultipartFile file, Map matchState) => file.length == targetFile.length; +} + +/// Matches an [Event] with the same type as [targetEvent]. +/// +/// Usable both as a test matcher and as a mocktail argument matcher via +/// `any(that: isSameEventAs(event))`. +Matcher isSameEventAs(Event targetEvent) => _IsSameEventAs(targetEvent: targetEvent); + +class _IsSameEventAs extends Matcher { + const _IsSameEventAs({required this.targetEvent}); + + final Event targetEvent; + + @override + Description describe(Description description) => description.add('is same event as $targetEvent'); + + @override + bool matches(covariant Event event, Map matchState) => event.type == targetEvent.type; +} + +/// 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, + bool matchReactions = false, + bool matchMessageState = false, + bool matchAttachments = false, + bool matchAttachmentsUploadState = false, + bool matchParentId = false, +}) => _IsSameMessageAs( + targetMessage: targetMessage, + matchText: matchText, + matchReactions: matchReactions, + matchMessageState: matchMessageState, + matchAttachments: matchAttachments, + matchAttachmentsUploadState: matchAttachmentsUploadState, + matchParentId: matchParentId, +); + +class _IsSameMessageAs extends Matcher { + const _IsSameMessageAs({ + required this.targetMessage, + this.matchText = false, + this.matchReactions = false, + this.matchMessageState = false, + this.matchAttachments = false, + this.matchAttachmentsUploadState = false, + this.matchParentId = false, + }); + + final Message targetMessage; + final bool matchText; + final bool matchReactions; + final bool matchMessageState; + final bool matchAttachments; + final bool matchAttachmentsUploadState; + final bool matchParentId; + + @override + Description describe(Description description) => description.add('is same message as $targetMessage'); + + @override + bool matches(covariant Message message, Map matchState) { + var matches = message.id == targetMessage.id; + if (matchText) { + matches &= message.text == targetMessage.text; + } + if (matchMessageState) { + matches &= message.state == targetMessage.state; + } + if (matchReactions) { + matches &= const ListEquality().equals( + message.ownReactions?.map((it) => '${it.type}-${it.messageId}').toList(), + targetMessage.ownReactions?.map((it) => '${it.type}-${it.messageId}').toList(), + ); + matches &= const ListEquality().equals( + message.latestReactions?.map((it) => '${it.type}-${it.messageId}').toList(), + targetMessage.latestReactions?.map((it) => '${it.type}-${it.messageId}').toList(), + ); + } + if (matchAttachments) { + bool matchAttachments() { + final attachments = message.attachments; + final targetAttachments = targetMessage.attachments; + if (identical(attachments, targetAttachments)) return true; + final length = attachments.length; + if (length != targetAttachments.length) return false; + for (var i = 0; i < length; i++) { + if (!isSameAttachmentAs( + attachments[i], + matchUploadState: matchAttachmentsUploadState, + ).matches(targetAttachments[i], matchState)) { + return false; + } + } + return true; + } + + matches &= matchAttachments(); + } + if (matchParentId) { + matches &= message.parentId == targetMessage.parentId; + } + return matches; + } +} + +/// 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, + bool matchAttachments = false, + bool matchParentId = false, +}) => _IsSameDraftMessageAs( + targetMessage: targetMessage, + matchText: matchText, + matchAttachments: matchAttachments, + matchParentId: matchParentId, +); + +class _IsSameDraftMessageAs extends Matcher { + const _IsSameDraftMessageAs({ + required this.targetMessage, + this.matchText = false, + this.matchAttachments = false, + this.matchParentId = false, + }); + + final DraftMessage targetMessage; + final bool matchText; + final bool matchAttachments; + final bool matchParentId; + + @override + Description describe(Description description) => description.add('is same draft message as $targetMessage'); + + @override + bool matches(covariant DraftMessage message, Map matchState) { + var matches = message.id == targetMessage.id; + if (matchText) matches &= message.text == targetMessage.text; + if (matchParentId) matches &= message.parentId == targetMessage.parentId; + if (matchAttachments) { + bool matchAttachments() { + final attachments = message.attachments; + final targetAttachments = targetMessage.attachments; + if (identical(attachments, targetAttachments)) return true; + final length = attachments.length; + if (length != targetAttachments.length) return false; + for (var i = 0; i < length; i++) { + if (!isSameAttachmentAs( + attachments[i], + ).matches(targetAttachments[i], matchState)) { + return false; + } + } + return true; + } + + matches &= matchAttachments(); + } + + return matches; + } +} + +/// 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, +}) => _IsSameAttachmentAs( + targetAttachment: targetAttachment, + matchUploadState: matchUploadState, +); + +class _IsSameAttachmentAs extends Matcher { + const _IsSameAttachmentAs({ + required this.targetAttachment, + this.matchUploadState = false, + }); + + final Attachment targetAttachment; + final bool matchUploadState; + + @override + Description describe(Description description) => description.add('is same attachment as $targetAttachment'); + + @override + bool matches(covariant Attachment attachment, Map matchState) { + var matches = attachment.id == targetAttachment.id; + if (matchUploadState) { + matches &= attachment.uploadState == targetAttachment.uploadState; + } + return matches; + } +} + +/// 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 { + const _IsSameUserAs({required this.targetUser}); + + final User targetUser; + + @override + Description describe(Description description) => description.add('is same user as $targetUser'); + + @override + 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 { + const _IsCorrectChannelFor({required this.channelState}); + + final ChannelState channelState; + + @override + Description describe(Description description) => description.add('is correct channel for $channelState'); + + @override + bool matches(covariant Channel channel, Map matchState) => channel.cid == channelState.channel?.cid; +} diff --git a/packages/stream_chat_test/lib/src/helpers/mocks.dart b/packages/stream_chat_test/lib/src/helpers/mocks.dart index 97ed291fee..0c119f2ac3 100644 --- a/packages/stream_chat_test/lib/src/helpers/mocks.dart +++ b/packages/stream_chat_test/lib/src/helpers/mocks.dart @@ -59,6 +59,100 @@ 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; + + @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 = []; + } + + @override + Future getLastSyncAt() async => _lastSyncAt; + + @override + Future updateLastSyncAt(DateTime lastSyncAt) async { + _lastSyncAt = lastSyncAt; + } + + @override + Future> getChannelCids() async => _channelCids; +} + /// A [StreamChatApi] whose sub-APIs are lazily-created mocks. /// /// Injected into [StreamChatClient] as the `chatApi:` seam so that every REST diff --git a/packages/stream_chat_test/lib/src/helpers/test_data.dart b/packages/stream_chat_test/lib/src/helpers/test_data.dart index e31b593b12..da6fbeebdc 100644 --- a/packages/stream_chat_test/lib/src/helpers/test_data.dart +++ b/packages/stream_chat_test/lib/src/helpers/test_data.dart @@ -55,6 +55,7 @@ OwnUser createDefaultOwnUser({ int unreadChannels = 0, List devices = const [], List mutes = const [], + PrivacySettings? privacySettings, DateTime? createdAt, DateTime? updatedAt, }) { @@ -66,6 +67,7 @@ OwnUser createDefaultOwnUser({ unreadChannels: unreadChannels, devices: devices, mutes: mutes, + privacySettings: privacySettings, createdAt: createdAt ?? testCreatedAt, updatedAt: updatedAt ?? testUpdatedAt, ); @@ -198,6 +200,32 @@ ChannelModel createDefaultChannelModel({ ); } +/// 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, + List commands = const [], + DateTime? createdAt, + DateTime? updatedAt, +}) { + return ChannelConfig( + readEvents: readEvents, + typingEvents: typingEvents, + reactions: reactions, + replies: replies, + mutes: mutes, + commands: commands, + createdAt: createdAt ?? testCreatedAt, + updatedAt: updatedAt ?? testUpdatedAt, + ); +} + /// Creates a [ChannelState] with sensible defaults. ChannelState createDefaultChannelState({ ChannelModel? channel, @@ -306,3 +334,39 @@ GetAppSettingsResponse createDefaultGetAppSettingsResponse({ /// 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 a [StreamChatNetworkError] for [errorCode]. +/// +/// Non-retriable by default: the error carries an [ErrorResponse], the shape a +/// server-rejected request produces, which the SDK's default retry policy does +/// not retry. Pass `retriable: true` to omit the response data, making the +/// error retriable and eligible for the SDK's retry queue. +StreamChatNetworkError createDefaultNetworkError({ + ChatErrorCode errorCode = ChatErrorCode.internalSystemError, + int? statusCode, + bool retriable = false, +}) { + return StreamChatNetworkError( + errorCode, + statusCode: statusCode, + data: retriable + ? null + : createDefaultErrorResponse( + code: errorCode.code, + message: errorCode.message, + statusCode: statusCode ?? 500, + ), + ); +} diff --git a/packages/stream_chat_test/lib/stream_chat_test.dart b/packages/stream_chat_test/lib/stream_chat_test.dart index 33091841de..c6d41cdafb 100644 --- a/packages/stream_chat_test/lib/stream_chat_test.dart +++ b/packages/stream_chat_test/lib/stream_chat_test.dart @@ -15,6 +15,7 @@ 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/test_data.dart'; diff --git a/packages/stream_chat_test/pubspec.yaml b/packages/stream_chat_test/pubspec.yaml index 7a0de20e01..285477bdd6 100644 --- a/packages/stream_chat_test/pubspec.yaml +++ b/packages/stream_chat_test/pubspec.yaml @@ -22,6 +22,7 @@ environment: sdk: ^3.12.0 dependencies: + collection: ^1.19.1 meta: ^1.9.1 mocktail: ^1.0.5 stream_chat: ^10.4.0 From 8ad28a3bf615e3d75f537ab3ed5eba79e4b11039 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 09:52:28 +0200 Subject: [PATCH 005/103] test(llc): migrate typing event tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_client_state_test.dart | 74 -------------- .../channel/state/typing_events_test.dart | 99 +++++++++++++++++++ 2 files changed, 99 insertions(+), 74 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/state/typing_events_test.dart 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 bd5f271622..e6d1311b87 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 @@ -42,80 +42,6 @@ void main() { ).thenAnswer((_) async {}); }); - group('Typing 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('${EventType.typingStart} from another user is added to typingEvents', () async { - final otherUser = User(id: 'other-user'); - - client.addEvent( - Event(cid: channel.cid, type: EventType.typingStart, user: otherUser), - ); - await Future.delayed(Duration.zero); - - expect(channel.state!.typingEvents.keys.map((u) => u.id), ['other-user']); - expect(channel.state!.typingEvents[otherUser]?.type, EventType.typingStart); - }); - - test('${EventType.typingStop} removes only the stopping user', () async { - final user1 = User(id: 'other-user-1'); - final user2 = User(id: 'other-user-2'); - - 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)); - - client.addEvent(Event(cid: channel.cid, type: EventType.typingStop, user: user1)); - await Future.delayed(Duration.zero); - - expect(channel.state!.typingEvents.keys.map((u) => u.id), ['other-user-2']); - }); - - test('${EventType.typingStart} from the current user is ignored', () async { - final currentUser = User(id: client.state.currentUser!.id); - - client.addEvent(Event(cid: channel.cid, type: EventType.typingStart, user: currentUser)); - await Future.delayed(Duration.zero); - - expect(channel.state!.typingEvents, isEmpty); - }); - - test('${EventType.typingStart} without a user is ignored', () async { - client.addEvent(Event(cid: channel.cid, type: EventType.typingStart)); - await Future.delayed(Duration.zero); - - expect(channel.state!.typingEvents, isEmpty); - }); - - test('${EventType.typingStop} from the current user is ignored', () async { - final currentUser = User(id: client.state.currentUser!.id); - - client.addEvent(Event(cid: channel.cid, type: EventType.typingStop, user: currentUser)); - await Future.delayed(Duration.zero); - - expect(channel.state!.typingEvents, isEmpty); - }); - - test('${EventType.typingStop} without a user is ignored', () async { - client.addEvent(Event(cid: channel.cid, type: EventType.typingStop)); - await Future.delayed(Duration.zero); - - expect(channel.state!.typingEvents, isEmpty); - }); - }); - group( '${EventType.messageNew} or ${EventType.notificationMessageNew}', () { diff --git a/packages/stream_chat/test/src/client/channel/state/typing_events_test.dart b/packages/stream_chat/test/src/client/channel/state/typing_events_test.dart new file mode 100644 index 0000000000..65925e6f63 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/state/typing_events_test.dart @@ -0,0 +1,99 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +void main() { + 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'); + + await tester.emitEvent( + createDefaultEvent(type: EventType.typingStart, cid: tester.channel.cid, user: otherUser), + ); + + final typingEvents = tester.channelState!.typingEvents; + expect(typingEvents.keys.map((u) => u.id), ['other-user']); + expect(typingEvents.values.single.type, EventType.typingStart); + }, + ); + + 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'); + + 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)); + + await tester.emitEvent(createDefaultEvent(type: EventType.typingStop, cid: tester.channel.cid, user: user1)); + + expect(tester.channelState!.typingEvents.keys.map((u) => u.id), ['other-user-2']); + }, + ); + + 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); + + 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); + }, + ); + + 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)); + + expect(tester.channelState!.typingEvents, isEmpty); + }, + ); + }); +} From 1ec4aede5ccbd37ec9d58ef8b4f3b0e319663138 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 09:59:56 +0200 Subject: [PATCH 006/103] test(llc): serialize emitted events with server fidelity in stream_chat_test Co-Authored-By: Claude Fable 5 --- packages/stream_chat_test/CHANGELOG.md | 1 + packages/stream_chat_test/README.md | 5 +- .../lib/src/helpers/server_json.dart | 63 +++++++++++++++++++ .../lib/src/testers/websocket_tester.dart | 8 ++- .../lib/stream_chat_test.dart | 1 + 5 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 packages/stream_chat_test/lib/src/helpers/server_json.dart diff --git a/packages/stream_chat_test/CHANGELOG.md b/packages/stream_chat_test/CHANGELOG.md index 3273201b9f..b5b019a364 100644 --- a/packages/stream_chat_test/CHANGELOG.md +++ b/packages/stream_chat_test/CHANGELOG.md @@ -4,3 +4,4 @@ - Added `isSameXxxAs` matchers for messages, drafts, attachments, users, events and channels. - Added `createDefaultChannelConfig`, `createDefaultErrorResponse` and `createDefaultNetworkError` fixtures. - Added `MockPersistenceClient` and `FakePersistenceClient` doubles for the persistence seam. +- Emitted events are now serialized with server fidelity, so message and reaction payloads keep their server-assigned fields (sender, timestamps, reactions). diff --git a/packages/stream_chat_test/README.md b/packages/stream_chat_test/README.md index 1f4fac22d9..12f0b569b1 100644 --- a/packages/stream_chat_test/README.md +++ b/packages/stream_chat_test/README.md @@ -108,7 +108,10 @@ with alias getters and a seeding method, and a private factory matching a mocktail stub only answers on a match, so stubbing doubles as request verification. - `WebSocketTester` — `mockSuccessfulAuth` / `mockFailedAuth` / `mockConnectionError` / - `emitEvent` / `emitRawFrame`. Chat authenticates through the connect URI (token and + `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. 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..a843f5a588 --- /dev/null +++ b/packages/stream_chat_test/lib/src/helpers/server_json.dart @@ -0,0 +1,63 @@ +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.message case final message?) 'message': serverMessageJson(message), + if (event.reaction case final reaction?) 'reaction': serverReactionJson(reaction), + }; +} + +/// Serializes [message] the way a server sends it, restoring the +/// server-assigned fields that `Message.toJson` omits. +Map serverMessageJson(Message message) { + return { + ...message.toJson(), + 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': poll.toJson(), + if (message.draft case final draft?) 'draft': draft.toJson(), + if (message.reminder case final reminder?) 'reminder': reminder.toJson(), + if (message.i18n case final i18n?) 'i18n': i18n, + 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, + }; +} + +/// 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/testers/websocket_tester.dart b/packages/stream_chat_test/lib/src/testers/websocket_tester.dart index b067404912..1055a51d1a 100644 --- a/packages/stream_chat_test/lib/src/testers/websocket_tester.dart +++ b/packages/stream_chat_test/lib/src/testers/websocket_tester.dart @@ -6,6 +6,7 @@ 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. @@ -118,9 +119,10 @@ final class WebSocketTester { /// Emits a typed [event] as a server frame. /// - /// The frame goes through the engine's real JSON decoding before reaching - /// the client, exactly like a production server push. - void emitEvent(Event event) => emitRawFrame(event); + /// 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. /// diff --git a/packages/stream_chat_test/lib/stream_chat_test.dart b/packages/stream_chat_test/lib/stream_chat_test.dart index c6d41cdafb..4f33be9398 100644 --- a/packages/stream_chat_test/lib/stream_chat_test.dart +++ b/packages/stream_chat_test/lib/stream_chat_test.dart @@ -17,6 +17,7 @@ export 'package:test/test.dart'; 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 From 1b2fc782be7f75c7494d571bf9927f63f56da361 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 10:08:13 +0200 Subject: [PATCH 007/103] test(llc): migrate message.new event tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_client_state_test.dart | 572 ----------------- .../state/message_new_events_test.dart | 590 ++++++++++++++++++ .../lib/src/helpers/server_json.dart | 3 + .../lib/src/helpers/test_data.dart | 2 + 4 files changed, 595 insertions(+), 572 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/state/message_new_events_test.dart 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 e6d1311b87..bc57af444e 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 @@ -41,578 +41,6 @@ void main() { () => client.channelDeliveryReporter.submitForDelivery(any()), ).thenAnswer((_) async {}); }); - - 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, - ); - - channel = Channel.fromState(client, channelState); - }); - - tearDown(() => channel.dispose()); - - Event createNewMessageEvent(Message message) { - return Event( - cid: channel.cid, - type: EventType.messageNew, - message: message, - ); - } - - test( - "should update 'channel.lastMessageAt'", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - - final message = Message( - id: 'test-message-id', - user: client.state.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); - - expect(channel.lastMessageAt, equals(message.createdAt)); - expect(channel.lastMessageAt, isNot(initialLastMessageAt)); - }, - ); - - test( - "should update 'channel.lastMessageAt' when Message has restricted visibility only for the current user", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - - 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)), - ); - - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - expect(channel.lastMessageAt, equals(message.createdAt)); - expect(channel.lastMessageAt, isNot(initialLastMessageAt)); - }, - ); - - test( - "should not update 'channel.lastMessageAt' when 'message.createdAt' is older", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - // Older than the current 'channel.lastMessageAt'. - createdAt: initialLastMessageAt.subtract(const Duration(days: 1)), - ); - - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - }, - ); - - test( - "should not update 'channel.lastMessageAt' when Message is shadowed", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - shadowed: true, - 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); - - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - }, - ); - - test( - "should not update 'channel.lastMessageAt' when Message is ephemeral", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - - final message = Message( - type: MessageType.ephemeral, - id: 'test-message-id', - user: client.state.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); - - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - }, - ); - - test( - "should not update 'channel.lastMessageAt' when Message has restricted visibility but not for the current user", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - - 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)), - ); - - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - }, - ); - - 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); - - final message = Message( - type: MessageType.system, - id: 'test-message-id', - user: client.state.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); - - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - }, - ); - - test("should update 'unreadCount'", () async { - expect(channel.state?.unreadCount, equals(0)); - - final message = Message( - id: 'test-message-id', - 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); - - 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 newMessage2Event = createNewMessageEvent(message2); - client.addEvent(newMessage2Event); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.unreadCount, equals(2)); - }); - - group("should not update 'unreadCount'", () { - test( - 'when the message is silent', - () async { - expect(channel.state?.unreadCount, equals(0)); - - final message = Message( - id: 'test-message-id', - 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); - - expect(channel.state?.unreadCount, equals(0)); - }, - ); - - test( - 'when the message is shadowed', - () async { - expect(channel.state?.unreadCount, equals(0)); - - final message = Message( - id: 'test-message-id', - 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); - - expect(channel.state?.unreadCount, equals(0)); - }, - ); - - 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)), - ); - - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.unreadCount, equals(0)); - }, - ); - - test( - 'when the message is a thread reply', - () async { - expect(channel.state?.unreadCount, equals(0)); - - 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)), - ); - - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.unreadCount, equals(0)); - }, - ); - - test( - 'when the message is a thread reply', - () async { - expect(channel.state?.unreadCount, equals(0)); - - 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)), - ); - - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.unreadCount, equals(0)); - }, - ); - - test( - 'when the message is from the current user', - () async { - expect(channel.state?.unreadCount, equals(0)); - - final message = Message( - id: 'test-message-id', - user: client.state.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); - - expect(channel.state?.unreadCount, equals(0)); - }, - ); - - test( - 'when the message is not restricted for the current user', - () async { - expect(channel.state?.unreadCount, equals(0)); - - final message = Message( - id: 'test-message-id', - 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); - - expect(channel.state?.unreadCount, equals(0)); - }, - ); - }); - - 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)), - ); - - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - // Verify submitForDelivery was called - verify( - () => client.channelDeliveryReporter.submitForDelivery([channel]), - ).called(1); - }, - ); - - 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, - ); - - // 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)), - ); - client.addEvent(createNewMessageEvent(serverMessage)); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - // 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)); - }, - ); - - 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, - ); - - final sendMessageResponse = SendMessageResponse() - ..message = localMessage.copyWith(state: MessageState.sent); - when(() => client.sendMessage(any(), channelId, channelType)).thenAnswer((_) async => sendMessageResponse); - - await 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)), - ); - client.addEvent(createNewMessageEvent(otherMessage)); - await Future.delayed(Duration.zero); - - // 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)), - ); - client.addEvent(createNewMessageEvent(serverEcho)); - await Future.delayed(Duration.zero); - - final localMatches = channel.state!.messages.where((it) => it.id == localMessage.id); - expect(localMatches, hasLength(1)); - expect(channel.state!.messages, hasLength(2)); - }, - ); - - 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)), - ); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.notificationMessageNew, - message: message, - ), - ); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages.map((m) => m.id), ['notified-message-id']); - expect(channel.state!.unreadCount, 1); - }, - ); - - test( - 'a channel message is not appended while the channel is not up to date', - () async { - channel.state!.isUpToDate = false; - - final message = Message( - id: 'below-window-message-id', - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); - - client.addEvent(createNewMessageEvent(message)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages, isEmpty); - expect(channel.state!.unreadCount, 1); - }, - ); - - 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)), - ); - - client.addEvent(createNewMessageEvent(reply)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages, isEmpty); - expect(channel.state!.threads['parent-message-id']?.map((m) => m.id), ['thread-reply-id']); - }, - ); - - test('an event without a message is ignored', () async { - client.addEvent(Event(cid: channel.cid, type: EventType.messageNew)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages, isEmpty); - expect(channel.lastMessageAt, initialLastMessageAt); - }); - }, - ); - group( EventType.messageUpdated, () { diff --git a/packages/stream_chat/test/src/client/channel/state/message_new_events_test.dart b/packages/stream_chat/test/src/client/channel/state/message_new_events_test.dart new file mode 100644 index 0000000000..88bccf7ef9 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/state/message_new_events_test.dart @@ -0,0 +1,590 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +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, + ), + ); +} + +Event _newMessageEvent(Message message) => createDefaultEvent( + type: EventType.messageNew, + cid: _channelCid, + message: message, +); + +void main() { + group('${EventType.messageNew} or ${EventType.notificationMessageNew}', () { + channelTest( + "should update 'channel.lastMessageAt'", + 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, + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channel.lastMessageAt, equals(message.createdAt)); + expect(tester.channel.lastMessageAt, isNot(_initialLastMessageAt)); + }, + ); + + 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)); + + 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)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channel.lastMessageAt, equals(message.createdAt)); + expect(tester.channel.lastMessageAt, isNot(_initialLastMessageAt)); + }, + ); + + 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)); + + expect(tester.channel.lastMessageAt, isNot(message.createdAt)); + expect(tester.channel.lastMessageAt, equals(_initialLastMessageAt)); + }, + ); + + 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)); + + final message = Message( + id: 'test-message-id', + user: tester.currentUser, + shadowed: true, + 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 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)), + ); + + 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 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)); + + 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)), + ); + + 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', + silent: true, + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channelState?.unreadCount, equals(0)); + }, + ); + + 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', + shadowed: true, + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channelState?.unreadCount, equals(0)); + }, + ); + + 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', + type: MessageType.ephemeral, + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channelState?.unreadCount, equals(0)); + }, + ); + + 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', + parentId: 'test-parent-id', + showInChannel: false, + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channelState?.unreadCount, equals(0)); + }, + ); + + 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', + parentId: 'test-parent-id', + showInChannel: false, + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channelState?.unreadCount, equals(0)); + }, + ); + + 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: tester.currentUser, + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channelState?.unreadCount, equals(0)); + }, + ); + + 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( + id: 'test-message-id', + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + restrictedVisibility: const ['other-user-2'], + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channelState?.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)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + // The delivery reporter batches receipts behind a 1s trailing throttle. + await Future.delayed(const Duration(milliseconds: 1100)); + + 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'); + }, + ); + + 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, + ); + + // 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)), + ); + + await tester.channel.sendMessage(localMessage); + + expect(tester.channelState!.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)), + ); + await tester.emitEvent(_newMessageEvent(serverMessage)); + + // 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)); + }, + ); + + 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)); + + // 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)); + }, + ); + + 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)), + ); + + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.notificationMessageNew, + message: message, + ), + ); + + expect(tester.channelState!.messages.map((m) => m.id), ['notified-message-id']); + expect(tester.channelState!.unreadCount, 1); + }, + ); + + 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; + + final message = Message( + id: 'below-window-message-id', + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channelState!.messages, isEmpty); + expect(tester.channelState!.unreadCount, 1); + }, + ); + + 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; + + final reply = Message( + id: 'thread-reply-id', + parentId: 'parent-message-id', + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + await tester.emitEvent(_newMessageEvent(reply)); + + expect(tester.channelState!.messages, isEmpty); + expect(tester.channelState!.threads['parent-message-id']?.map((m) => m.id), ['thread-reply-id']); + }, + ); + + 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)); + + expect(tester.channelState!.messages, isEmpty); + expect(tester.channel.lastMessageAt, _initialLastMessageAt); + }, + ); + }); +} diff --git a/packages/stream_chat_test/lib/src/helpers/server_json.dart b/packages/stream_chat_test/lib/src/helpers/server_json.dart index a843f5a588..60087c05e9 100644 --- a/packages/stream_chat_test/lib/src/helpers/server_json.dart +++ b/packages/stream_chat_test/lib/src/helpers/server_json.dart @@ -21,6 +21,9 @@ Map serverEventJson(Event event) { 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(), diff --git a/packages/stream_chat_test/lib/src/helpers/test_data.dart b/packages/stream_chat_test/lib/src/helpers/test_data.dart index da6fbeebdc..ffe19d8a7d 100644 --- a/packages/stream_chat_test/lib/src/helpers/test_data.dart +++ b/packages/stream_chat_test/lib/src/helpers/test_data.dart @@ -210,6 +210,7 @@ ChannelConfig createDefaultChannelConfig({ bool reactions = false, bool replies = false, bool mutes = false, + bool skipLastMsgUpdateForSystemMsgs = false, List commands = const [], DateTime? createdAt, DateTime? updatedAt, @@ -220,6 +221,7 @@ ChannelConfig createDefaultChannelConfig({ reactions: reactions, replies: replies, mutes: mutes, + skipLastMsgUpdateForSystemMsgs: skipLastMsgUpdateForSystemMsgs, commands: commands, createdAt: createdAt ?? testCreatedAt, updatedAt: updatedAt ?? testUpdatedAt, From 97aa8a2c221b97d0ab2ee8c086ed48a0e167e3e0 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 10:21:24 +0200 Subject: [PATCH 008/103] test(llc): migrate message.updated event tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_client_state_test.dart | 345 ------------------ .../state/message_updated_events_test.dart | 344 +++++++++++++++++ 2 files changed, 344 insertions(+), 345 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/state/message_updated_events_test.dart 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 bc57af444e..f6c09821d0 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 @@ -41,351 +41,6 @@ void main() { () => client.channelDeliveryReporter.submitForDelivery(any()), ).thenAnswer((_) async {}); }); - 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); - }); - - 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)); - }, - ); - - 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), - ); - }, - ); - - 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); - }, - ); - - // 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 diff --git a/packages/stream_chat/test/src/client/channel/state/message_updated_events_test.dart b/packages/stream_chat/test/src/client/channel/state/message_updated_events_test.dart new file mode 100644 index 0000000000..1770dd8496 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/state/message_updated_events_test.dart @@ -0,0 +1,344 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); +} + +Event _updateMessageEvent(Message message) => createDefaultEvent( + type: EventType.messageUpdated, + cid: _channelCid, + message: message, +); + +void main() { + group(EventType.messageUpdated, () { + 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, + ); + + await tester.emitEvent(_updateMessageEvent(message)); + + expect(tester.channelState?.pinnedMessages.length, equals(1)); + expect(tester.channelState?.pinnedMessages.first.id, equals(messageId)); + }, + ); + + 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, + ); + + await tester.emitEvent(_updateMessageEvent(message)); + + expect(tester.channelState?.pinnedMessages.length, equals(1)); + expect(tester.channelState?.pinnedMessages.first.id, equals(messageId)); + expect(tester.channelState?.pinnedMessages.first.text, equals(oldText)); + + final updatedMessage = message.copyWith(text: newText); + await tester.emitEvent(_updateMessageEvent(updatedMessage)); + + expect(tester.channelState?.pinnedMessages.length, equals(1)); + expect(tester.channelState?.pinnedMessages.first.id, equals(messageId)); + expect(tester.channelState?.pinnedMessages.first.text, equals(newText)); + }, + ); + + 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); + + await tester.emitEvent(_updateMessageEvent(firstMessage)); + + expect(tester.channelState?.pinnedMessages.length, equals(1)); + expect( + tester.channelState?.pinnedMessages.first.id, + equals(firstMessageId), + ); + + await tester.emitEvent(_updateMessageEvent(secondMessage)); + + expect(tester.channelState?.pinnedMessages.length, equals(2)); + expect( + tester.channelState?.pinnedMessages.first.id, + equals(firstMessageId), + ); + expect( + tester.channelState?.pinnedMessages[1].id, + equals(secondMessageId), + ); + }, + ); + + 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, + ); + + await tester.emitEvent(_updateMessageEvent(pinnedMessage)); + + expect(tester.channelState?.pinnedMessages.length, equals(1)); + expect(tester.channelState?.pinnedMessages.first.id, equals(messageId)); + + final unpinnedMessage = pinnedMessage.copyWith(pinned: false); + await tester.emitEvent(_updateMessageEvent(unpinnedMessage)); + + expect(tester.channelState?.pinnedMessages, isEmpty); + }, + ); + + // 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', () { + 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)), + ), + ); + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith(messages: tail), + ); + 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)); + + // 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); + }, + ); + + 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 edited = seeded.copyWith(text: 'new'); + await tester.emitEvent(_updateMessageEvent(edited)); + + final stored = tester.channelState!.messages.singleWhere((m) => m.id == messageId); + expect(stored.text, equals('new')); + }, + ); + + 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); + + const messageId = 'pin-me'; + final pinned = Message( + id: messageId, + user: tester.currentUser, + pinned: true, + ); + await tester.emitEvent(_updateMessageEvent(pinned)); + + expect(tester.channelState!.messages, isEmpty); + expect(tester.channelState!.pinnedMessages.length, equals(1)); + expect(tester.channelState!.pinnedMessages.first.id, equals(messageId)); + }, + ); + + 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)); + + tester.channelState!.isUpToDate = false; + + final phantomReply = Message( + id: 'other-reply', + parentId: parentId, + user: tester.currentUser, + text: 'edited', + createdAt: DateTime.utc(2026, 1, 2), + ); + await tester.emitEvent(_updateMessageEvent(phantomReply)); + + expect(tester.channelState!.threads[parentId]!.map((m) => m.id), ['known-reply']); + }, + ); + + 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); + + tester.channelState!.isUpToDate = false; + + final phantomReply = Message( + id: 'phantom-reply', + parentId: parentId, + user: tester.currentUser, + text: 'edited', + createdAt: DateTime.utc(2026, 1, 2), + ); + await tester.emitEvent(_updateMessageEvent(phantomReply)); + + // The dropped reply must not leave behind an empty thread entry. + expect(tester.channelState!.threads.containsKey(parentId), isFalse); + }, + ); + + 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)), + ); + + // 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)), + ), + ); + // NOTE(migration): 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 isn't loaded. Apply the update exactly as + // the `message.updated` listener would, to keep pinning the + // state-layer guard the original test covered. + tester.channelState!.updateMessage(expiredMessage, upsert: false); + await Future.delayed(Duration.zero); + + expect(tester.channelState!.messages, isEmpty); + expect(tester.channelState!.activeLiveLocations, isEmpty); + }, + ); + }); + }); +} From f855d9e39cc8d3c0a5c7694b6d68cff23f0373e3 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 10:23:37 +0200 Subject: [PATCH 009/103] test(llc): migrate message.deleted event tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_client_state_test.dart | 223 ---------------- .../state/message_deleted_events_test.dart | 244 ++++++++++++++++++ .../lib/src/helpers/server_json.dart | 14 + .../lib/src/helpers/test_data.dart | 4 + 4 files changed, 262 insertions(+), 223 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/state/message_deleted_events_test.dart 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 f6c09821d0..1812054b12 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 @@ -688,229 +688,6 @@ void main() { }); }); - // 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 { - final message = Message( - id: 'doomed-message-id', - user: User(id: 'other-user'), - createdAt: DateTime.now(), - ); - channel.state!.updateMessage(message); - expect(channel.state!.messages, hasLength(1)); - - client.addEvent(createDeleteMessageEvent(message, hardDelete: true)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages, isEmpty); - }); - - test('deletedForMe is propagated to the stored message', () async { - final message = Message( - id: 'deleted-for-me-message-id', - user: User(id: 'other-user'), - createdAt: DateTime.now(), - ); - channel.state!.updateMessage(message); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.messageDeleted, - deletedForMe: true, - message: message.copyWith( - type: MessageType.deleted, - deletedAt: DateTime.timestamp(), - ), - ), - ); - await Future.delayed(Duration.zero); - - final stored = channel.state!.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'; diff --git a/packages/stream_chat/test/src/client/channel/state/message_deleted_events_test.dart b/packages/stream_chat/test/src/client/channel/state/message_deleted_events_test.dart new file mode 100644 index 0000000000..6a77ab69a0 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/state/message_deleted_events_test.dart @@ -0,0 +1,244 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +final _deletedAt = DateTime.utc(2026, 7); + +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); +} + +Event _deleteMessageEvent(Message message, {bool hardDelete = false}) { + return createDefaultEvent( + cid: _channelCid, + type: EventType.messageDeleted, + message: message.copyWith( + type: MessageType.deleted, + deletedAt: _deletedAt, + ), + hardDelete: hardDelete, + ); +} + +void main() { + // 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, () { + // 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)); + + final olderPage = Message( + id: 'older-page-msg', + user: tester.currentUser, + text: 'gone', + createdAt: DateTime.utc(2025, 1, 1), + ); + await tester.emitEvent(_deleteMessageEvent(olderPage)); + + expect(tester.channelState!.messages.map((m) => m.id), ['tail-0', 'tail-1', 'tail-2']); + }, + ); + + 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; + + await tester.emitEvent(_deleteMessageEvent(seeded)); + + final stored = tester.channelState!.messages.singleWhere((m) => m.id == messageId); + expect(stored.type, equals(MessageType.deleted)); + expect(stored.deletedAt, isNotNull); + }, + ); + + 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); + }, + ); + + 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)), + ); + + // 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); + }, + ); + + 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)); + + expect(tester.channelState!.messages, isEmpty); + expect(tester.channelState!.pinnedMessages, isEmpty); + }, + ); + }); + + 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.utc(2026), + ); + tester.channelState!.updateMessage(message); + expect(tester.channelState!.messages, hasLength(1)); + + await tester.emitEvent(_deleteMessageEvent(message, hardDelete: true)); + + expect(tester.channelState!.messages, isEmpty); + }, + ); + + 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.utc(2026), + ); + tester.channelState!.updateMessage(message); + + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.messageDeleted, + deletedForMe: true, + message: message.copyWith( + type: MessageType.deleted, + deletedAt: _deletedAt, + ), + ), + ); + + final stored = tester.channelState!.messages.single; + expect(stored.deletedForMe, isTrue); + expect(stored.type, MessageType.deleted); + }, + ); + }); +} diff --git a/packages/stream_chat_test/lib/src/helpers/server_json.dart b/packages/stream_chat_test/lib/src/helpers/server_json.dart index 60087c05e9..0cff2b526c 100644 --- a/packages/stream_chat_test/lib/src/helpers/server_json.dart +++ b/packages/stream_chat_test/lib/src/helpers/server_json.dart @@ -49,6 +49,20 @@ Map serverMessageJson(Message message) { '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 [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(), }; } diff --git a/packages/stream_chat_test/lib/src/helpers/test_data.dart b/packages/stream_chat_test/lib/src/helpers/test_data.dart index ffe19d8a7d..57d42e6313 100644 --- a/packages/stream_chat_test/lib/src/helpers/test_data.dart +++ b/packages/stream_chat_test/lib/src/helpers/test_data.dart @@ -124,6 +124,8 @@ Event createDefaultEvent({ OwnUser? me, int? totalUnreadCount, int? unreadChannels, + bool? hardDelete, + bool? deletedForMe, DateTime? createdAt, Map extraData = const {}, }) { @@ -140,6 +142,8 @@ Event createDefaultEvent({ me: me, totalUnreadCount: totalUnreadCount, unreadChannels: unreadChannels, + hardDelete: hardDelete, + deletedForMe: deletedForMe, createdAt: createdAt ?? testCreatedAt, extraData: extraData, ); From 2908ed8266e0f7615921318a2b6bcc7881931bc0 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 10:38:37 +0200 Subject: [PATCH 010/103] test(llc): migrate channel truncated event tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_client_state_test.dart | 74 --------------- .../state/channel_truncated_events_test.dart | 93 +++++++++++++++++++ .../lib/src/helpers/server_json.dart | 22 +++++ .../lib/src/helpers/test_data.dart | 2 + 4 files changed, 117 insertions(+), 74 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/state/channel_truncated_events_test.dart 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 1812054b12..2bc92cab9e 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 @@ -748,80 +748,6 @@ void main() { }); }); - 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); - }); - - tearDown(() { - channel.dispose(); - // Reset so the remaining groups run without a persistence layer. - when(() => client.chatPersistenceClient).thenReturn(null); - }); - - Message seedMessage(String id) { - final message = Message( - id: id, - user: User(id: 'other-user'), - text: 'to be truncated', - createdAt: DateTime.now(), - ); - channel.state!.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); - }); - - test('${EventType.notificationChannelTruncated} keeps the event system message', () async { - seedMessage('truncated-message-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); - - expect(channel.state!.messages.map((m) => m.id), ['system-message-id']); - }); - }); - group('Member Events', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; diff --git a/packages/stream_chat/test/src/client/channel/state/channel_truncated_events_test.dart b/packages/stream_chat/test/src/client/channel/state/channel_truncated_events_test.dart new file mode 100644 index 0000000000..41ccd60a70 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/state/channel_truncated_events_test.dart @@ -0,0 +1,93 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +final _seededCreatedAt = DateTime.utc(2021, 3); + +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(ChannelTester tester, String id, {DateTime? createdAt}) { + final message = Message( + id: id, + user: User(id: 'other-user'), + text: 'to be truncated', + createdAt: createdAt ?? _seededCreatedAt, + ); + tester.channelState!.updateMessage(message); + return message; +} + +void main() { + group('Channel truncated events', () { + final truncatedPersistence = _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), + ), + ); + + expect(tester.channelState!.messages, isEmpty); + verify(() => truncatedPersistence.deleteMessageByCid(tester.channel.cid!)).called(1); + }, + ); + + final notifiedPersistence = _createPersistenceClient(); + 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(tester.channelState!.messages.map((m) => m.id), ['system-message-id']); + }, + ); + }); +} diff --git a/packages/stream_chat_test/lib/src/helpers/server_json.dart b/packages/stream_chat_test/lib/src/helpers/server_json.dart index 0cff2b526c..d8fb8b93cf 100644 --- a/packages/stream_chat_test/lib/src/helpers/server_json.dart +++ b/packages/stream_chat_test/lib/src/helpers/server_json.dart @@ -11,11 +11,33 @@ import 'package:stream_chat/stream_chat.dart'; 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), }; } +/// 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) { diff --git a/packages/stream_chat_test/lib/src/helpers/test_data.dart b/packages/stream_chat_test/lib/src/helpers/test_data.dart index 57d42e6313..c0b2f34069 100644 --- a/packages/stream_chat_test/lib/src/helpers/test_data.dart +++ b/packages/stream_chat_test/lib/src/helpers/test_data.dart @@ -184,6 +184,7 @@ ChannelModel createDefaultChannelModel({ String cid = 'messaging:test-channel', User? createdBy, int memberCount = 0, + List? members, List? ownCapabilities, ChannelConfig? config, DateTime? createdAt, @@ -195,6 +196,7 @@ ChannelModel createDefaultChannelModel({ cid: cid, createdBy: createdBy, memberCount: memberCount, + members: members, ownCapabilities: ownCapabilities, config: config, createdAt: createdAt ?? testCreatedAt, From 6b0f2ef79ce8670763e3c5c7bdad216932cb505c Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 10:40:13 +0200 Subject: [PATCH 011/103] test(llc): migrate channel updated event tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_client_state_test.dart | 60 ----------------- .../state/channel_updated_events_test.dart | 67 +++++++++++++++++++ 2 files changed, 67 insertions(+), 60 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/state/channel_updated_events_test.dart 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 2bc92cab9e..473cfbc198 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 @@ -688,66 +688,6 @@ void main() { }); }); - 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(); - }); - - 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'}, - ), - ), - ); - await Future.delayed(Duration.zero); - - expect(channel.memberCount, 42); - expect(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'), - ], - ), - ); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.channelUpdated, - channel: ChannelModel( - id: channelId, - type: channelType, - members: [Member(userId: 'member-3')], - ), - ), - ); - await Future.delayed(Duration.zero); - - expect(channel.state!.channelState.members?.map((m) => m.userId), ['member-3']); - }); - }); - group('Member Events', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; diff --git a/packages/stream_chat/test/src/client/channel/state/channel_updated_events_test.dart b/packages/stream_chat/test/src/client/channel/state/channel_updated_events_test.dart new file mode 100644 index 0000000000..4aa434cae1 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/state/channel_updated_events_test.dart @@ -0,0 +1,67 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), +); + +void main() { + group('Channel updated events', () { + 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'}, + ), + ), + ); + + expect(tester.channel.memberCount, 42); + expect(tester.channel.extraData['name'], 'updated-name'); + }, + ); + + 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'), + ], + ), + ); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.channelUpdated, + channel: createDefaultChannelModel( + cid: _channelCid, + members: [Member(userId: 'member-3')], + ), + ), + ); + + expect(tester.channelState!.channelState.members?.map((m) => m.userId), ['member-3']); + }, + ); + }); +} From aa7c64f1593a2c960cb4b5a9f1015ac8d9898da4 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 10:41:46 +0200 Subject: [PATCH 012/103] test(llc): migrate reaction event tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_client_state_test.dart | 143 --------------- .../channel/state/reaction_events_test.dart | 166 ++++++++++++++++++ 2 files changed, 166 insertions(+), 143 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/state/reaction_events_test.dart 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 473cfbc198..76389b47e0 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 @@ -218,149 +218,6 @@ void main() { }, ); - 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 []}) { - final message = Message( - id: messageId, - parentId: parentId, - user: User(id: 'other-user'), - text: 'react to me', - createdAt: createdAt, - ownReactions: ownReactions, - ); - channel.state!.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], - ), - ), - ); - 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 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, - ), - ), - ); - await Future.delayed(Duration.zero); - - final stored = channel.state!.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, - ), - ), - ); - 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.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, - ), - ), - ); - await Future.delayed(Duration.zero); - - final stored = channel.state!.threads[parentId]!.firstWhere((it) => it.id == messageId); - expect(stored.ownReactions, isEmpty); - }); - }); - group('Poll events', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; diff --git a/packages/stream_chat/test/src/client/channel/state/reaction_events_test.dart b/packages/stream_chat/test/src/client/channel/state/reaction_events_test.dart new file mode 100644 index 0000000000..a72d7da0d2 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/state/reaction_events_test.dart @@ -0,0 +1,166 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; +const _messageId = 'reaction-message-id'; + +// Pinned createdAt keeps message index lookups stable in `updateMessage`. +final _createdAt = DateTime.utc(2021, 3); + +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); +} + +Message _seedMessage(ChannelTester tester, {String? parentId, List ownReactions = const []}) { + final message = Message( + id: _messageId, + parentId: parentId, + user: User(id: 'other-user'), + text: 'react to me', + createdAt: _createdAt, + ownReactions: ownReactions, + ); + tester.channelState!.updateMessage(message); + return message; +} + +void main() { + group('Reaction events', () { + 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], + ), + ), + ); + + final stored = tester.channelState!.messages.firstWhere((it) => it.id == _messageId); + expect(stored.ownReactions?.map((r) => r.type), ['like']); + }, + ); + + 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, + ), + ), + ); + + final stored = tester.channelState!.threads[parentId]!.firstWhere((it) => it.id == _messageId); + expect(stored.ownReactions?.map((r) => r.type), ['like']); + }, + ); + + 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, + ), + ), + ); + + final stored = tester.channelState!.messages.firstWhere((it) => it.id == _messageId); + expect(stored.ownReactions?.map((r) => r.type), ['like']); + }, + ); + + 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, + ), + ), + ); + + final stored = tester.channelState!.threads[parentId]!.firstWhere((it) => it.id == _messageId); + expect(stored.ownReactions, isEmpty); + }, + ); + }); +} From f589f94dfd4babdc5fd09a38f7daf09dfef8aa3f Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 10:43:20 +0200 Subject: [PATCH 013/103] test(llc): migrate shown thread reply event tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_client_state_test.dart | 177 ----------------- .../state/shown_thread_reply_events_test.dart | 181 ++++++++++++++++++ 2 files changed, 181 insertions(+), 177 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/state/shown_thread_reply_events_test.dart 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 76389b47e0..0f4e2c92d6 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 @@ -41,183 +41,6 @@ void main() { () => client.channelDeliveryReporter.submitForDelivery(any()), ).thenAnswer((_) async {}); }); - // 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], - ); - 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; - } - - 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], - ), - ), - ); - - await Future.delayed(Duration.zero); - - final stored = channel.state!.messages.firstWhere((it) => it.id == replyId); - expect(stored.ownReactions, [ownReaction]); - }, - ); - - 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 Future.delayed(Duration.zero); - - final stored = channel.state!.messages.firstWhere((it) => it.id == replyId); - expect(stored.ownReactions, [kept]); - }, - ); - - 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', - ), - ), - ); - - await Future.delayed(Duration.zero); - - 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); - }, - ); - }, - ); - group('Poll events', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; diff --git a/packages/stream_chat/test/src/client/channel/state/shown_thread_reply_events_test.dart b/packages/stream_chat/test/src/client/channel/state/shown_thread_reply_events_test.dart new file mode 100644 index 0000000000..12f40bae78 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/state/shown_thread_reply_events_test.dart @@ -0,0 +1,181 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; +const _replyId = 'mirrored-reply-id'; +const _parentId = 'parent-message-id'; + +// Pinned createdAt keeps oldIndex lookups stable in `updateMessage`. +final _createdAt = DateTime.utc(2026); + +ChannelState _seedChannel(ChannelState _) { + return createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); +} + +// 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, + ); + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith(messages: [reply]), + ); + return reply; +} + +void main() { + // 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', () { + 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'), + ); + 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], + ), + ), + ); + + final stored = tester.channelState!.messages.firstWhere((it) => it.id == _replyId); + expect(stored.ownReactions, [ownReaction]); + }, + ); + + 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, + ), + ), + ); + + final stored = tester.channelState!.messages.firstWhere((it) => it.id == _replyId); + expect(stored.ownReactions, [kept]); + }, + ); + + 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); + + 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', + ), + ), + ); + + 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); + }, + ); + }); +} From ece26700171befdccf83885a0773b98962b27224 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 10:54:21 +0200 Subject: [PATCH 014/103] test(llc): migrate watching event tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_client_state_test.dart | 217 ---------------- .../channel/state/watching_events_test.dart | 234 ++++++++++++++++++ .../lib/src/helpers/server_json.dart | 37 ++- .../lib/src/helpers/test_data.dart | 6 + 4 files changed, 276 insertions(+), 218 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/state/watching_events_test.dart 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 0f4e2c92d6..a51c3555bc 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 @@ -639,223 +639,6 @@ void main() { }); }); - 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); - }); - - tearDown(() => channel.dispose()); - - test( - '${EventType.userWatchingStart} adds the watcher and updates watcherCount', - () async { - final watcher = User(id: 'watcher-1'); - - client.addEvent( - Event( - cid: 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( - channel.state!.channelState.watchers?.map((it) => it.id), - contains('watcher-1'), - ); - }, - ); - - test( - '${EventType.userWatchingStop} removes the watcher and updates watcherCount', - () async { - final watcher = User(id: 'watcher-1'); - - // The watcher starts watching first (count = 2). - client.addEvent( - Event( - cid: channel.cid, - type: EventType.userWatchingStart, - user: watcher, - watcherCount: 2, - ), - ); - await Future.delayed(Duration.zero); - expect(channel.state!.watcherCount, 2); - expect( - channel.state!.channelState.watchers?.map((it) => it.id), - contains('watcher-1'), - ); - - // Then stops watching (count = 1). - client.addEvent( - Event( - cid: channel.cid, - type: EventType.userWatchingStop, - user: watcher, - watcherCount: 1, - ), - ); - await Future.delayed(Duration.zero); - - expect(channel.state!.watcherCount, 1); - expect( - channel.state!.channelState.watchers?.map((it) => it.id), - isNot(contains('watcher-1')), - ); - }, - ); - - test( - 'watching event without watcherCount preserves the existing count', - () async { - // Seed an initial watcher count. - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(watcherCount: 5), - ); - expect(channel.state!.watcherCount, 5); - - // A watching event that omits watcher_count must not wipe the count. - client.addEvent( - Event( - cid: channel.cid, - type: EventType.userWatchingStart, - user: User(id: 'watcher-2'), - ), - ); - await Future.delayed(Duration.zero); - - expect(channel.state!.watcherCount, 5); - expect( - channel.state!.channelState.watchers?.map((it) => it.id), - contains('watcher-2'), - ); - }, - ); - - test( - '${EventType.messageNew} updates watcherCount from the event', - () async { - expect(channel.state!.watcherCount, isNull); - - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - createdAt: DateTime.now(), - ); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.messageNew, - message: message, - watcherCount: 7, - ), - ); - await Future.delayed(Duration.zero); - - expect(channel.state!.watcherCount, 7); - }, - ); - - test( - '${EventType.messageNew} without watcherCount preserves the existing count', - () async { - // Seed an initial watcher count. - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(watcherCount: 4), - ); - expect(channel.state!.watcherCount, 4); - - // A local/optimistic message.new without watcher_count must not - // reset the count. - client.addEvent( - Event( - cid: channel.cid, - type: EventType.messageNew, - message: Message( - id: 'test-message-id-2', - user: client.state.currentUser, - createdAt: DateTime.now(), - ), - ), - ); - await Future.delayed(Duration.zero); - - expect(channel.state!.watcherCount, 4); - }, - ); - - test( - '${EventType.notificationMessageNew} does not overwrite watcherCount', - () async { - // Seed a known watcher count. - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(watcherCount: 5), - ); - expect(channel.state!.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, - type: EventType.notificationMessageNew, - message: Message( - id: 'notif-message-id', - user: User(id: 'other-user'), - createdAt: DateTime.now(), - ), - watcherCount: 0, - ), - ); - await Future.delayed(Duration.zero); - - expect(channel.state!.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); - - expect(channel.state!.channelState.watchers, isEmpty); - expect(channel.state!.watcherCount, 5); - }); - }); - group('Read Events', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; diff --git a/packages/stream_chat/test/src/client/channel/state/watching_events_test.dart b/packages/stream_chat/test/src/client/channel/state/watching_events_test.dart new file mode 100644 index 0000000000..5fc63e1ee7 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/state/watching_events_test.dart @@ -0,0 +1,234 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +final _messageCreatedAt = DateTime.utc(2021, 3); + +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); +} + +void main() { + group('Watching Events', () { + channelTest( + '${EventType.userWatchingStart} adds the watcher and updates watcherCount', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final watcher = User(id: 'watcher-1'); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userWatchingStart, + user: watcher, + watcherCount: 3, + ), + ); + + expect(tester.channelState!.watcherCount, 3); + expect( + tester.channelState!.channelState.watchers?.map((it) => it.id), + contains('watcher-1'), + ); + }, + ); + + channelTest( + '${EventType.userWatchingStop} removes the watcher and updates watcherCount', + 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). + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userWatchingStart, + user: watcher, + watcherCount: 2, + ), + ); + expect(tester.channelState!.watcherCount, 2); + expect( + tester.channelState!.channelState.watchers?.map((it) => it.id), + contains('watcher-1'), + ); + + // Then stops watching (count = 1). + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userWatchingStop, + user: watcher, + watcherCount: 1, + ), + ); + + expect(tester.channelState!.watcherCount, 1); + expect( + tester.channelState!.channelState.watchers?.map((it) => it.id), + isNot(contains('watcher-1')), + ); + }, + ); + + channelTest( + 'watching event without watcherCount preserves the existing count', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + // Seed an initial watcher count. + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith(watcherCount: 5), + ); + expect(tester.channelState!.watcherCount, 5); + + // A watching event that omits watcher_count must not wipe the count. + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userWatchingStart, + user: User(id: 'watcher-2'), + ), + ); + + expect(tester.channelState!.watcherCount, 5); + expect( + tester.channelState!.channelState.watchers?.map((it) => it.id), + contains('watcher-2'), + ); + }, + ); + + channelTest( + '${EventType.messageNew} updates watcherCount from the event', + 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: tester.currentUser, + createdAt: _messageCreatedAt, + ); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageNew, + message: message, + watcherCount: 7, + ), + ); + + expect(tester.channelState!.watcherCount, 7); + }, + ); + + channelTest( + '${EventType.messageNew} without watcherCount preserves the existing count', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + // Seed an initial watcher count. + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith(watcherCount: 4), + ); + expect(tester.channelState!.watcherCount, 4); + + // A local/optimistic message.new without watcher_count must not + // reset the count. + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageNew, + message: Message( + id: 'test-message-id-2', + user: tester.currentUser, + createdAt: _messageCreatedAt, + ), + ), + ); + + expect(tester.channelState!.watcherCount, 4); + }, + ); + + channelTest( + '${EventType.notificationMessageNew} does not overwrite watcherCount', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + // Seed a known watcher count. + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith(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. + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.notificationMessageNew, + message: Message( + id: 'notif-message-id', + user: User(id: 'other-user'), + createdAt: _messageCreatedAt, + ), + watcherCount: 0, + ), + ); + + expect(tester.channelState!.watcherCount, 5); + }, + ); + + 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); + + 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); + }, + ); + }); +} diff --git a/packages/stream_chat_test/lib/src/helpers/server_json.dart b/packages/stream_chat_test/lib/src/helpers/server_json.dart index d8fb8b93cf..8250fb367c 100644 --- a/packages/stream_chat_test/lib/src/helpers/server_json.dart +++ b/packages/stream_chat_test/lib/src/helpers/server_json.dart @@ -14,6 +14,8 @@ Map serverEventJson(Event event) { 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), }; } @@ -63,7 +65,7 @@ Map serverMessageJson(Message message) { 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': poll.toJson(), + if (message.poll case final poll?) 'poll': serverPollJson(poll), if (message.draft case final draft?) 'draft': draft.toJson(), if (message.reminder case final reminder?) 'reminder': reminder.toJson(), if (message.i18n case final i18n?) 'i18n': i18n, @@ -88,6 +90,39 @@ Map serverLocationJson(Location location) { }; } +/// 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) { diff --git a/packages/stream_chat_test/lib/src/helpers/test_data.dart b/packages/stream_chat_test/lib/src/helpers/test_data.dart index c0b2f34069..803d6ecb62 100644 --- a/packages/stream_chat_test/lib/src/helpers/test_data.dart +++ b/packages/stream_chat_test/lib/src/helpers/test_data.dart @@ -118,12 +118,15 @@ Event createDefaultEvent({ String? channelType, User? user, Message? message, + Poll? poll, + PollVote? pollVote, Reaction? reaction, Member? member, ChannelModel? channel, OwnUser? me, int? totalUnreadCount, int? unreadChannels, + int? watcherCount, bool? hardDelete, bool? deletedForMe, DateTime? createdAt, @@ -136,12 +139,15 @@ Event createDefaultEvent({ channelType: channelType, user: user, message: message, + poll: poll, + pollVote: pollVote, reaction: reaction, member: member, channel: channel, me: me, totalUnreadCount: totalUnreadCount, unreadChannels: unreadChannels, + watcherCount: watcherCount, hardDelete: hardDelete, deletedForMe: deletedForMe, createdAt: createdAt ?? testCreatedAt, From 02f84fcca55f809993bb9643a9d5611088019925 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 10:55:56 +0200 Subject: [PATCH 015/103] test(llc): migrate member event tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_client_state_test.dart | 271 ---------------- .../channel/state/member_events_test.dart | 303 ++++++++++++++++++ 2 files changed, 303 insertions(+), 271 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/state/member_events_test.dart 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 a51c3555bc..23c47be852 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 @@ -368,277 +368,6 @@ void main() { }); }); - 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(); - }); - - test( - 'should update membership when member is updated and is current user', - () async { - final currentUser = client.state.currentUser; - final currentMember = Member(user: currentUser); - final now = DateTime.now(); - - // Setup initial membership - channel.state?.updateChannelState( - channel.state!.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); - - // Create updated member with same userId but updated properties - final updatedMember = currentMember.copyWith( - channelRole: 'moderator', - isModerator: true, - pinnedAt: now, - archivedAt: now, - ); - - // Create member updated event - final memberUpdatedEvent = Event( - cid: 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); - - // 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); - }, - ); - - test( - 'should update membership user when any event containing user is updated', - () async { - final currentUser = client.state.currentUser; - final currentMember = Member(user: currentUser); - - // Setup initial membership - channel.state?.updateChannelState( - channel.state!.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)); - - // 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, - type: EventType.any, - user: updatedUser, - ); - - // Dispatch event - client.addEvent(anyEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // 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)); - }, - ); - - 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); - - expect(channel.state!.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'), - ], - ), - ); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.memberRemoved, - user: User(id: 'member-1'), - ), - ); - await Future.delayed(Duration.zero); - - expect(channel.state!.channelState.members?.map((m) => m.userId), ['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')], - ), - ); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.memberUpdated, - member: Member(userId: 'member-1', channelRole: 'channel_moderator'), - ), - ); - await Future.delayed(Duration.zero); - - expect(channel.state!.channelState.members?.single.channelRole, 'channel_moderator'); - }); - - 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'), - ), - ], - ), - ); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.userUpdated, - user: User(id: 'stranger', name: 'new-name'), - ), - ); - await Future.delayed(Duration.zero); - - expect(channel.state!.channelState.members?.single.user?.name, 'old-name'); - }); - - group('user banned/unbanned events', () { - setUp(() { - clearInteractions(client); - - channel.state!.updateChannelState( - channel.state!.channelState.copyWith( - members: [ - Member( - userId: 'bad-user', - user: User(id: 'bad-user'), - channelRole: 'channel_member', - ), - ], - ), - ); - - when( - () => client.queryMembers( - channelType, - channelId: channelId, - filter: Filter.equal('id', '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')], - ); - }); - - 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); - - expect(channel.state!.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); - - expect(channel.state!.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); - - 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'), - ), - ); - }); - }); - }); - group('Read Events', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; diff --git a/packages/stream_chat/test/src/client/channel/state/member_events_test.dart b/packages/stream_chat/test/src/client/channel/state/member_events_test.dart new file mode 100644 index 0000000000..27fb0685f0 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/state/member_events_test.dart @@ -0,0 +1,303 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); +} + +void main() { + group('Member Events', () { + channelTest( + 'should update membership when member is updated and is current user', + 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.utc(2021, 3); + + // Setup initial membership + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + members: [currentMember], + membership: currentMember, + ), + ); + + // Verify initial state + 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( + channelRole: 'moderator', + isModerator: true, + pinnedAt: now, + archivedAt: now, + ); + + // Create member updated event + final memberUpdatedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberUpdated, + user: currentUser, + member: updatedMember, + ); + + // Dispatch event + await tester.emitEvent(memberUpdatedEvent); + + // Verify membership is updated with new properties + 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); + }, + ); + + channelTest( + 'should update membership user when any event containing user is updated', + 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 + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + members: [currentMember], + membership: currentMember, + ), + ); + + // Verify initial state + 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 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.any, + user: updatedUser, + ); + + // Dispatch event + await tester.emitEvent(anyEvent); + + // Verify membership is updated with new properties + expect(tester.channel.membership, isNotNull); + expect(tester.channel.membership?.user?.id, equals(updatedUser?.id)); + expect(tester.channel.membership?.user?.role, equals(updatedUser?.role)); + }, + ); + + 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(tester.channelState!.channelState.members?.map((m) => m.userId), ['new-member']); + }, + ); + + channelTest( + '${EventType.memberRemoved} removes the 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'), + Member(userId: 'member-2'), + ], + ), + ); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberRemoved, + user: User(id: 'member-1'), + ), + ); + + expect(tester.channelState!.channelState.members?.map((m) => m.userId), ['member-2']); + }, + ); + + 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')], + ), + ); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberUpdated, + member: Member(userId: 'member-1', channelRole: 'channel_moderator'), + ), + ); + + 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'), + ), + ], + ), + ); + + 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'); + }, + ); + + group('user banned/unbanned events', () { + Future setUpBannedMember(ChannelTester tester) async { + await tester.watch(modifyResponse: _seedChannel()); + + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith( + members: [ + Member( + userId: 'bad-user', + user: User(id: 'bad-user'), + channelRole: 'channel_member', + ), + ], + ), + ); + + tester.mockApi( + (api) => api.general.queryMembers( + _channelType, + channelId: _channelId, + filter: Filter.equal('id', 'bad-user'), + members: any(named: 'members'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + result: QueryMembersResponse()..members = [Member(userId: 'bad-user', channelRole: 'channel_banned')], + ); + } + + 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(tester.channelState!.channelState.members?.single.channelRole, 'channel_banned'); + }, + ); + + 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(tester.channelState!.channelState.members?.single.channelRole, 'channel_banned'); + }, + ); + + 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(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'), + ), + ); + }, + ); + }); + }); +} From ff4f308720affb0bb513ba1aea2e1476c5539fc7 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 10:57:27 +0200 Subject: [PATCH 016/103] test(llc): migrate poll event tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_client_state_test.dart | 327 --------------- .../channel/state/poll_events_test.dart | 385 ++++++++++++++++++ 2 files changed, 385 insertions(+), 327 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/state/poll_events_test.dart 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 23c47be852..741b6f0cd5 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 @@ -41,333 +41,6 @@ void main() { () => client.channelDeliveryReporter.submitForDelivery(any()), ).thenAnswer((_) async {}); }); - 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); - }); - - tearDown(() { - channel.dispose(); - }); - - Poll createPoll({ - String name = 'Favorite color?', - List latestAnswers = const [], - List ownVotesAndAnswers = const [], - }) { - return Poll( - id: pollId, - name: name, - options: const [ - PollOption(id: 'option-a', text: 'A'), - PollOption(id: 'option-b', text: 'B'), - ], - latestAnswers: latestAnswers, - ownVotesAndAnswers: ownVotesAndAnswers, - ); - } - - Message seedPollMessage({ - String? parentId, - List latestAnswers = const [], - List ownVotesAndAnswers = const [], - }) { - final message = Message( - id: pollMessageId, - parentId: parentId, - user: User(id: 'other-user'), - createdAt: createdAt, - poll: createPoll( - latestAnswers: latestAnswers, - ownVotesAndAnswers: ownVotesAndAnswers, - ), - ); - channel.state!.updateMessage(message); - return message; - } - - Message storedPollMessage() { - return channel.state!.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(), - ), - ), - ); - await Future.delayed(Duration.zero); - - expect(storedPollMessage().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, - ), - ), - ); - await Future.delayed(Duration.zero); - - expect(channel.state!.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]); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.pollUpdated, - poll: createPoll(name: 'Renamed'), - ), - ); - await Future.delayed(Duration.zero); - - final stored = storedPollMessage(); - expect(stored.poll?.name, 'Renamed'); - expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-vote-id']); - }); - - 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')], - ), - ), - ); - await Future.delayed(Duration.zero); - - expect(storedPollMessage().poll?.name, 'Favorite color?'); - }); - - test('${EventType.pollUpdated} without a poll is ignored', () async { - seedPollMessage(); - - client.addEvent(Event(cid: channel.cid, type: EventType.pollUpdated)); - await Future.delayed(Duration.zero); - - expect(storedPollMessage().poll?.name, 'Favorite color?'); - }); - - test('${EventType.pollUpdated} updates a poll on a thread message', () async { - const parentId = 'poll-parent-id'; - seedPollMessage(parentId: parentId); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.pollUpdated, - poll: createPoll(name: 'Renamed'), - ), - ); - await Future.delayed(Duration.zero); - - final stored = channel.state!.threads[parentId]!.firstWhere((it) => it.id == pollMessageId); - expect(stored.poll?.name, 'Renamed'); - }); - - test('${EventType.pollClosed} closes the poll and keeps the cached data', () async { - seedPollMessage(); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.pollClosed, - poll: createPoll(name: 'Renamed'), - ), - ); - await Future.delayed(Duration.zero); - - final stored = storedPollMessage(); - expect(stored.poll?.isClosed, isTrue); - expect(stored.poll?.name, 'Favorite color?'); - }); - - test('${EventType.pollAnswerCasted} adds own answers only for the current user', () async { - seedPollMessage(); - - 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); - - 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']); - - 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); - - 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']); - }); - - test('${EventType.pollVoteCasted} adds own votes only for the current user', () async { - seedPollMessage(); - - final ownVote = PollVote( - id: 'own-vote-id', - optionId: 'option-a', - userId: client.state.currentUser!.id, - ); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.pollVoteCasted, - poll: createPoll(), - pollVote: ownVote, - ), - ); - await Future.delayed(Duration.zero); - - var stored = storedPollMessage(); - expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-vote-id']); - - 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, - ), - ); - await Future.delayed(Duration.zero); - - stored = storedPollMessage(); - expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-vote-id']); - }); - - test('${EventType.pollVoteChanged} upserts the current user vote', () async { - seedPollMessage(); - - final changedVote = PollVote( - id: 'changed-vote-id', - optionId: 'option-b', - userId: client.state.currentUser!.id, - ); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.pollVoteChanged, - poll: createPoll(), - pollVote: changedVote, - ), - ); - await Future.delayed(Duration.zero); - - expect( - storedPollMessage().poll?.ownVotesAndAnswers.map((v) => v.id), - contains('changed-vote-id'), - ); - }); - - 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, - ), - ); - await Future.delayed(Duration.zero); - - final stored = storedPollMessage(); - expect(stored.poll?.latestAnswers, isEmpty); - expect(stored.poll?.ownVotesAndAnswers, isEmpty); - }); - - 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, - ), - ); - await Future.delayed(Duration.zero); - - expect(storedPollMessage().poll?.ownVotesAndAnswers, isEmpty); - }); - }); - group('Read Events', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; diff --git a/packages/stream_chat/test/src/client/channel/state/poll_events_test.dart b/packages/stream_chat/test/src/client/channel/state/poll_events_test.dart new file mode 100644 index 0000000000..af2574c9ee --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/state/poll_events_test.dart @@ -0,0 +1,385 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; +const _pollMessageId = 'poll-message-id'; +const _pollId = 'poll-id'; + +final _createdAt = DateTime.utc(2021, 3); + +ChannelState _seedChannel(ChannelState _) => + createDefaultChannelState(channel: createDefaultChannelModel(cid: _channelCid)); + +Poll _createPoll({ + String name = 'Favorite color?', + List latestAnswers = const [], + List ownVotesAndAnswers = const [], +}) { + return Poll( + id: _pollId, + name: name, + options: const [ + PollOption(id: 'option-a', text: 'A'), + PollOption(id: 'option-b', text: 'B'), + ], + latestAnswers: latestAnswers, + ownVotesAndAnswers: ownVotesAndAnswers, + ); +} + +Message _seedPollMessage( + ChannelTester tester, { + String? parentId, + List latestAnswers = const [], + List ownVotesAndAnswers = const [], +}) { + final message = Message( + id: _pollMessageId, + parentId: parentId, + user: User(id: 'other-user'), + createdAt: _createdAt, + poll: _createPoll( + latestAnswers: latestAnswers, + ownVotesAndAnswers: ownVotesAndAnswers, + ), + ); + tester.channelState!.updateMessage(message); + return message; +} + +Message _storedPollMessage(ChannelTester tester) { + return tester.channelState!.messages.firstWhere((it) => it.id == _pollMessageId); +} + +void main() { + group('Poll events', () { + 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(), + ), + ), + ); + + expect(_storedPollMessage(tester).poll?.id, _pollId); + }, + ); + + 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, + ), + ), + ); + + expect(tester.channelState!.messages, isEmpty); + }, + ); + + 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'), + ), + ); + + final stored = _storedPollMessage(tester); + 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')], + ), + ), + ); + + 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'), + ), + ); + + final stored = tester.channelState!.threads[parentId]!.firstWhere((it) => it.id == _pollMessageId); + expect(stored.poll?.name, 'Renamed'); + }, + ); + + 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'), + ), + ); + + final stored = _storedPollMessage(tester); + expect(stored.poll?.isClosed, isTrue); + expect(stored.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, + ), + ); + + 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']); + + 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, + ), + ); + + 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']); + }, + ); + + 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, + ), + ); + + var stored = _storedPollMessage(tester); + expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-vote-id']); + + 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, + ), + ); + + stored = _storedPollMessage(tester); + expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-vote-id']); + }, + ); + + 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, + ), + ); + + expect( + _storedPollMessage(tester).poll?.ownVotesAndAnswers.map((v) => v.id), + contains('changed-vote-id'), + ); + }, + ); + + 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, + ), + ); + + 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); + }, + ); + }); +} From b9cf801362559be8f27560ca1d83d889c02952cf Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 11:06:34 +0200 Subject: [PATCH 017/103] test(llc): migrate reminder event tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_client_state_test.dart | 353 ----------------- .../channel/state/reminder_events_test.dart | 373 ++++++++++++++++++ .../lib/src/helpers/server_json.dart | 43 +- .../lib/src/helpers/test_data.dart | 44 +++ 4 files changed, 458 insertions(+), 355 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/state/reminder_events_test.dart 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 741b6f0cd5..b3ff486f6f 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 @@ -987,359 +987,6 @@ void main() { }); }); - 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); - }); - - 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, - ); - - channel.state?.updateMessage(message); - - // 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(), - ); - - // Create reminder.updated event - final reminderUpdatedEvent = Event( - cid: channel.cid, - type: EventType.reminderUpdated, - reminder: updatedReminder, - ); - - // Dispatch event - client.addEvent(reminderUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // 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); - }); - - test('should handle reminder.deleted 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, - ); - - channel.state?.updateMessage(message); - - // Verify initial state - final initialMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNotNull); - - // Create reminder.deleted event - final reminderDeletedEvent = Event( - cid: channel.cid, - type: EventType.reminderDeleted, - reminder: initialReminder, - ); - - // Dispatch event - client.addEvent(reminderDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify message reminder was removed - final updatedMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNull); - }); - - test('should handle reminder.created event for thread messages', () 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: 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(), - ); - - channel.state?.updateMessage(threadMessage); - - // 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, - ); - - // 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 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); - }); - - 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, - ); - - 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(), - ); - - channel.state?.updateMessage(threadMessage); - - // 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(), - ); - - // Create reminder.updated event - final reminderUpdatedEvent = Event( - cid: channel.cid, - type: EventType.reminderUpdated, - reminder: updatedReminder, - ); - - // Dispatch event - client.addEvent(reminderUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // 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); - }); - - 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, - ); - - 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(), - ); - - channel.state?.updateMessage(threadMessage); - - // Verify initial state - final initialMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNotNull); - - // Create reminder.deleted event - final reminderDeletedEvent = Event( - cid: channel.cid, - type: EventType.reminderDeleted, - reminder: initialReminder, - ); - - // Dispatch event - client.addEvent(reminderDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify thread message reminder was removed - final updatedMessage = channel.state?.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'; - - final message = Message( - id: messageId, - user: client.state.currentUser, - text: 'Test message', - ); - channel.state?.updateMessage(message); - - client.addEvent(Event(cid: channel.cid, type: EventType.reminderCreated)); - await Future.delayed(Duration.zero); - - final storedMessage = channel.state?.messages.firstWhere((m) => m.id == messageId); - expect(storedMessage?.reminder, isNull); - }); - }); - group('Location events', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; diff --git a/packages/stream_chat/test/src/client/channel/state/reminder_events_test.dart b/packages/stream_chat/test/src/client/channel/state/reminder_events_test.dart new file mode 100644 index 0000000000..d2e9947406 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/state/reminder_events_test.dart @@ -0,0 +1,373 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +// 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); + +ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), +); + +void main() { + group('Reminder events', () { + channelTest( + 'should handle reminder.created event', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + const messageId = 'test-message-id'; + + // Setup initial state with a message without reminder + final message = Message( + id: messageId, + user: tester.currentUser, + text: 'Test message', + ); + + tester.channelState?.updateMessage(message); + + // Verify initial state - no reminder + final initialMessage = tester.channelState?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNull); + + // Create reminder + final reminder = createDefaultMessageReminder( + messageId: messageId, + channelCid: tester.channel.cid!, + userId: 'test-user-id', + remindAt: _now.add(const Duration(days: 30)), + ); + + // Emit reminder.created event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reminderCreated, + reminder: reminder, + ), + ); + + // 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); + }, + ); + + channelTest( + 'should handle reminder.updated event', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + const messageId = 'test-message-id'; + + // 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, + ); + + final message = Message( + id: messageId, + user: tester.currentUser, + text: 'Test message', + reminder: initialReminder, + ); + + tester.channelState?.updateMessage(message); + + // Verify initial state + final initialMessage = tester.channelState?.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: _now, + ); + + // Emit reminder.updated event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reminderUpdated, + reminder: updatedReminder, + ), + ); + + // 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); + }, + ); + + 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 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 message = Message( + id: messageId, + user: tester.currentUser, + text: 'Test message', + reminder: initialReminder, + ); + + tester.channelState?.updateMessage(message); + + // Verify initial state + final initialMessage = tester.channelState?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); + + // Emit reminder.deleted event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reminderDeleted, + reminder: initialReminder, + ), + ); + + // Verify message reminder was removed + final updatedMessage = tester.channelState?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNull); + }, + ); + + 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, + ); + + tester.channelState?.updateMessage(threadMessage); + + // Verify initial state - no reminder + final initialMessage = tester.channelState?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNull); + + // 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, + ); + + // Emit reminder.created event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reminderCreated, + reminder: reminder, + ), + ); + + // 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); + }, + ); + + 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, + ); + + tester.channelState?.updateMessage(threadMessage); + + // Verify initial state + final initialMessage = tester.channelState?.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: _now, + ); + + // Emit reminder.updated event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reminderUpdated, + reminder: updatedReminder, + ), + ); + + // 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); + }, + ); + + 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, + ); + + tester.channelState?.updateMessage(threadMessage); + + // Verify initial state + final initialMessage = tester.channelState?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); + + // 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 = tester.channelState?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNull); + }, + ); + + 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: tester.currentUser, + text: 'Test message', + ); + tester.channelState?.updateMessage(message); + + await tester.emitEvent(createDefaultEvent(cid: tester.channel.cid, type: EventType.reminderCreated)); + + final storedMessage = tester.channelState?.messages.firstWhere((m) => m.id == messageId); + expect(storedMessage?.reminder, isNull); + }, + ); + }); +} diff --git a/packages/stream_chat_test/lib/src/helpers/server_json.dart b/packages/stream_chat_test/lib/src/helpers/server_json.dart index 8250fb367c..6634647f6f 100644 --- a/packages/stream_chat_test/lib/src/helpers/server_json.dart +++ b/packages/stream_chat_test/lib/src/helpers/server_json.dart @@ -16,6 +16,8 @@ Map serverEventJson(Event event) { 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), }; } @@ -66,8 +68,8 @@ Map serverMessageJson(Message message) { 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': draft.toJson(), - if (message.reminder case final reminder?) 'reminder': reminder.toJson(), + 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.messageTextUpdatedAt case final textUpdatedAt?) 'message_text_updated_at': textUpdatedAt.toIso8601String(), @@ -77,6 +79,43 @@ Map serverMessageJson(Message message) { }; } +/// 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) { diff --git a/packages/stream_chat_test/lib/src/helpers/test_data.dart b/packages/stream_chat_test/lib/src/helpers/test_data.dart index 803d6ecb62..2aafdfe22f 100644 --- a/packages/stream_chat_test/lib/src/helpers/test_data.dart +++ b/packages/stream_chat_test/lib/src/helpers/test_data.dart @@ -123,12 +123,20 @@ Event createDefaultEvent({ Reaction? reaction, Member? member, ChannelModel? channel, + Draft? draft, + MessageReminder? reminder, OwnUser? me, int? totalUnreadCount, int? unreadChannels, int? watcherCount, bool? hardDelete, bool? deletedForMe, + Thread? thread, + DateTime? lastReadAt, + int? unreadMessages, + String? lastReadMessageId, + DateTime? lastDeliveredAt, + String? lastDeliveredMessageId, DateTime? createdAt, Map extraData = const {}, }) { @@ -144,12 +152,20 @@ Event createDefaultEvent({ reaction: reaction, member: member, channel: channel, + draft: draft, + reminder: reminder, me: me, totalUnreadCount: totalUnreadCount, unreadChannels: unreadChannels, watcherCount: watcherCount, hardDelete: hardDelete, deletedForMe: deletedForMe, + thread: thread, + lastReadAt: lastReadAt, + unreadMessages: unreadMessages, + lastReadMessageId: lastReadMessageId, + lastDeliveredAt: lastDeliveredAt, + lastDeliveredMessageId: lastDeliveredMessageId, createdAt: createdAt ?? testCreatedAt, extraData: extraData, ); @@ -308,6 +324,34 @@ Reaction createDefaultReaction({ ); } +/// 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 [GetMessageResponse] wrapping [message]. GetMessageResponse createDefaultGetMessageResponse({ Message? message, From 842b1a6da3a774388055e6e8f7a385962d7c918a Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 11:08:10 +0200 Subject: [PATCH 018/103] test(llc): migrate draft event tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_client_state_test.dart | 260 ----------------- .../channel/state/draft_events_test.dart | 272 ++++++++++++++++++ 2 files changed, 272 insertions(+), 260 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/state/draft_events_test.dart 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 b3ff486f6f..166220d05c 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 @@ -727,266 +727,6 @@ void main() { ); }); - 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, - ), - ); - - // Verify initial state - expect(channel.state?.threadDraft(threadParentMessageId), isNull); - - // Create thread Draft - final draft = Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - parentId: threadParentMessageId, - message: DraftMessage(text: 'thread reply'), - ); - - // 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 thread draft was updated - final threadDraft = channel.state?.threadDraft(threadParentMessageId); - expect(threadDraft, isNotNull); - expect(threadDraft?.message.text, 'thread reply'); - }); - - 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'), - ), - ), - ); - - // 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, - ); - - // 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, isNull); - }); - - test('should handle draft.deleted event for thread drafts', () async { - const threadParentMessageId = 'thread-parent-id'; - - // 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 threadDraft = channel.state?.threadDraft(threadParentMessageId); - expect(threadDraft, isNotNull); - expect(threadDraft?.message.text, 'thread reply'); - - // Create draft.deleted event - final draftDeletedEvent = Event( - cid: channel.cid, - type: EventType.draftDeleted, - draft: threadDraft, - ); - - // Dispatch event - client.addEvent(draftDeletedEvent); - - // Allow event to be processed - await Future.delayed(Duration.zero); - - // Verify thread draft was removed - expect(channel.state?.threadDraft(threadParentMessageId), isNull); - }); - - test( - 'should update current channel draft if draft.updated event is emitted', - () async { - // Setup initial state with a draft - final initialDraft = Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - message: DraftMessage(text: 'test message'), - ); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - draft: initialDraft, - ), - ); - - // Verify initial state - expect(channel.state?.draft, isNotNull); - expect(channel.state?.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, - ); - - // 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'); - }, - ); - - test( - 'should update current thread draft if draft.updated event is emitted', - () async { - const threadParentMessageId = 'thread-parent-id'; - - // Setup initial state with a thread draft - final initialDraft = Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - parentId: threadParentMessageId, - message: DraftMessage(text: 'thread reply'), - ); - - channel.state?.updateMessage( - Message( - id: threadParentMessageId, - user: client.state.currentUser, - draft: initialDraft, - ), - ); - - // Verify initial state - final draft = channel.state?.threadDraft(threadParentMessageId); - expect(draft, isNotNull); - expect(draft?.message.text, 'thread reply'); - - // Create Draft - final updatedDraft = initialDraft.copyWith( - message: DraftMessage(text: 'updated thread reply'), - ); - - // Create draft.updated event - final draftUpdatedEvent = Event( - cid: 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); - 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); - - expect(channel.state?.draft, isNull); - }); - }); - group('Location events', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; diff --git a/packages/stream_chat/test/src/client/channel/state/draft_events_test.dart b/packages/stream_chat/test/src/client/channel/state/draft_events_test.dart new file mode 100644 index 0000000000..f90f680ad1 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/state/draft_events_test.dart @@ -0,0 +1,272 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +final _draftCreatedAt = DateTime.utc(2021, 3); + +ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), +); + +void main() { + group('Draft events', () { + 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 + final draft = Draft( + channelCid: tester.channel.cid!, + createdAt: _draftCreatedAt, + message: DraftMessage(text: 'test message'), + ); + + // 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(tester.channelState?.draft, isNotNull); + expect(tester.channelState?.draft?.message.text, 'test message'); + }, + ); + + 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'; + + // Setup initial state with a regular message + tester.channelState?.updateMessage( + Message( + id: threadParentMessageId, + user: tester.currentUser, + ), + ); + + // Verify initial state + expect(tester.channelState?.threadDraft(threadParentMessageId), isNull); + + // Create thread Draft + final draft = Draft( + channelCid: tester.channel.cid!, + createdAt: _draftCreatedAt, + parentId: threadParentMessageId, + message: DraftMessage(text: 'thread reply'), + ); + + // Create and dispatch draft.updated event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.draftUpdated, + draft: draft, + ), + ); + + // Verify thread draft was updated + final threadDraft = tester.channelState?.threadDraft(threadParentMessageId); + expect(threadDraft, isNotNull); + expect(threadDraft?.message.text, 'thread reply'); + }, + ); + + 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'), + ), + ), + ); + + // 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 channel draft was updated + expect(tester.channelState?.draft, isNull); + }, + ); + + 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'; + + // 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'), + ), + ), + ); + + // 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(tester.channelState?.threadDraft(threadParentMessageId), isNull); + }, + ); + + channelTest( + 'should update current channel draft if draft.updated event is emitted', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + // Setup initial state with a draft + final initialDraft = Draft( + channelCid: tester.channel.cid!, + createdAt: _draftCreatedAt, + message: DraftMessage(text: 'test message'), + ); + + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + draft: initialDraft, + ), + ); + + // Verify initial state + 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 and dispatch draft.updated event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.draftUpdated, + draft: updatedDraft, + ), + ); + + // Verify channel draft was updated + expect(tester.channelState?.draft, isNotNull); + expect(tester.channelState?.draft?.message.text, 'updated message'); + }, + ); + + channelTest( + 'should update current thread draft if draft.updated event is emitted', + 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: tester.channel.cid!, + createdAt: _draftCreatedAt, + parentId: threadParentMessageId, + message: DraftMessage(text: 'thread reply'), + ); + + tester.channelState?.updateMessage( + Message( + id: threadParentMessageId, + user: tester.currentUser, + draft: initialDraft, + ), + ); + + // Verify initial state + final draft = tester.channelState?.threadDraft(threadParentMessageId); + expect(draft, isNotNull); + expect(draft?.message.text, 'thread reply'); + + // Create Draft + final updatedDraft = initialDraft.copyWith( + message: DraftMessage(text: 'updated thread reply'), + ); + + // Create and dispatch draft.updated event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.draftUpdated, + draft: updatedDraft, + ), + ); + + // Verify thread draft was updated + final threadDraft = tester.channelState?.threadDraft(threadParentMessageId); + expect(threadDraft, isNotNull); + expect(threadDraft?.message.text, 'updated thread reply'); + }, + ); + + 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(tester.channelState?.draft, isNull); + }, + ); + }); +} From 02043905d05ba5b775225dd878ad9b011d485bad Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 11:11:42 +0200 Subject: [PATCH 019/103] test(llc): migrate read event tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_client_state_test.dart | 686 ---------------- .../channel/state/read_events_test.dart | 753 ++++++++++++++++++ 2 files changed, 753 insertions(+), 686 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/state/read_events_test.dart 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 166220d05c..74d0c6912a 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 @@ -41,692 +41,6 @@ void main() { () => client.channelDeliveryReporter.submitForDelivery(any()), ).thenAnswer((_) async {}); }); - group('Read Events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ); - - 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, - ); - - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.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', - ); - - // Dispatch event - client.addEvent(messageReadEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // 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); - }); - - test( - 'should add a new read state if not exist on message read event', - () async { - // Create the current read state - final currentUser = User(id: 'test-user'); - - // Verify initial state - final read = channel.state?.read; - expect(read, isEmpty); - - // Create mark read notification event - final markReadEvent = Event( - cid: channel.cid, - type: EventType.messageRead, - user: currentUser, - createdAt: DateTime(2022), - unreadMessages: 0, - lastReadMessageId: 'message-123', - ); - - // Dispatch event - client.addEvent(markReadEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify read list has not changed - final updated = channel.state?.read; - expect(updated?.length, 1); - expect(updated?.any((r) => r.user.id == currentUser.id), isTrue); - }, - ); - - test( - 'should not update channel read state on thread message read event', - () async { - final currentUser = User(id: 'test-user'); - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, - lastReadMessageId: 'channel-msg-1', - ); - - // Setup initial channel read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); - - // Verify initial state - final read = channel.state?.read.first; - expect(read?.unreadMessages, 10); - expect(read?.lastReadMessageId, 'channel-msg-1'); - expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); - - // Create a thread-scoped message.read event (thread != null) - final threadMessageReadEvent = Event( - cid: channel.cid, - type: EventType.messageRead, - user: currentUser, - createdAt: DateTime(2022), - lastReadMessageId: 'thread-reply-99', - thread: Thread( - channelCid: channel.cid!, - parentMessageId: 'parent-msg-1', - createdByUserId: currentUser.id, - replyCount: 3, - participantCount: 2, - ), - ); - - // Dispatch event - client.addEvent(threadMessageReadEvent); - - // 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; - expect(after?.unreadMessages, 10); - expect(after?.lastReadMessageId, 'channel-msg-1'); - expect(after?.lastRead.isAtSameMomentAs(DateTime(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, - ); - - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.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', - ); - - // Dispatch event - client.addEvent(markUnreadEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // 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); - }); - - test( - 'should add a new read state if not exist on notification mark unread', - () async { - // Verify initial state - final read = channel.state?.read; - expect(read, isEmpty); - - // Create event for non-existing user - final markUnreadEvent = Event( - cid: channel.cid, - type: EventType.notificationMarkUnread, - user: User(id: 'non-existing-user'), - lastReadAt: DateTime(2019), - unreadMessages: 15, - lastReadMessageId: 'message-100', - ); - - // Dispatch event - client.addEvent(markUnreadEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify read list has not changed - final updated = channel.state?.read; - expect(updated?.length, 1); - expect(updated?.any((r) => r.user.id == 'non-existing-user'), isTrue); - }, - ); - - test( - 'should preserve delivery info on message read event', - () async { - final currentUser = User(id: 'test-user'); - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, - lastDeliveredAt: DateTime(2021), - lastDeliveredMessageId: 'delivered-msg-456', - ); - - // Setup initial read state with delivery info - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); - - // Verify initial state - final read = channel.state?.read.first; - expect(read?.lastDeliveredAt, isNotNull); - expect( - read?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)), - isTrue, - ); - expect(read?.lastDeliveredMessageId, 'delivered-msg-456'); - - // Create message read event (doesn't include delivery info) - final messageReadEvent = Event( - cid: channel.cid, - type: EventType.messageRead, - user: currentUser, - createdAt: DateTime(2022), - unreadMessages: 0, - lastReadMessageId: 'message-123', - ); - - // Dispatch event - client.addEvent(messageReadEvent); - - // 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; - expect(updatedRead?.user.id, 'test-user'); - expect(updatedRead?.unreadMessages, 0); - expect(updatedRead?.lastReadMessageId, 'message-123'); - expect( - updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)), - isTrue, - ); - // Delivery info should be preserved - expect(updatedRead?.lastDeliveredAt, isNotNull); - expect( - updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)), - isTrue, - ); - expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); - }, - ); - - test( - '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)); - - when( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).thenAnswer((_) => Future.value()); - - // Create message read event from current user - final messageReadEvent = Event( - cid: channel.cid, - type: EventType.messageRead, - user: currentUser, - createdAt: DateTime(2022), - unreadMessages: 0, - lastReadMessageId: 'message-123', - ); - - // Dispatch event - client.addEvent(messageReadEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify reconcileDelivery was called - verify( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).called(1); - }, - ); - - test( - 'should reset unread count on notification mark read event', - () async { - final currentUser = client.state.currentUser!; - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, - ); - - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); - - when( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).thenAnswer((_) => Future.value()); - - // Verify initial state - expect(channel.state?.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, - type: EventType.notificationMarkRead, - user: currentUser, - createdAt: DateTime(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; - expect(updatedRead?.user.id, currentUser.id); - expect(channel.state?.unreadCount, 0); - expect(updatedRead?.lastReadMessageId, 'message-123'); - expect( - updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)), - isTrue, - ); - }, - ); - - test( - 'should preserve delivery info on notification mark read event', - () async { - final currentUser = User(id: 'test-user'); - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, - lastDeliveredAt: DateTime(2021), - lastDeliveredMessageId: 'delivered-msg-456', - ); - - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.notificationMarkRead, - user: currentUser, - createdAt: DateTime(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; - expect(updatedRead?.unreadMessages, 0); - expect( - updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)), - isTrue, - ); - expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); - }, - ); - - test( - 'should not update channel read state on thread notification mark ' - 'read event', - () async { - final currentUser = User(id: 'test-user'); - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, - lastReadMessageId: 'channel-msg-1', - ); - - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.notificationMarkRead, - user: currentUser, - createdAt: DateTime(2022), - lastReadMessageId: 'thread-reply-99', - thread: Thread( - channelCid: channel.cid!, - parentMessageId: 'parent-msg-1', - createdByUserId: currentUser.id, - replyCount: 3, - participantCount: 2, - ), - ), - ); - - // 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; - expect(after?.unreadMessages, 10); - expect(after?.lastReadMessageId, 'channel-msg-1'); - expect(after?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); - }, - ); - - test( - 'should reconcile delivery when notification mark read event is from ' - 'current user', - () async { - final currentUser = client.state.currentUser; - - when( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).thenAnswer((_) => Future.value()); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.notificationMarkRead, - user: currentUser, - createdAt: DateTime(2022), - lastReadMessageId: 'message-123', - ), - ); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify reconcileDelivery was called - verify( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).called(1); - }, - ); - - 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, - ); - - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.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', - ); - - // Dispatch event - client.addEvent(messageDeliveredEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // 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'); - }); - - test( - 'should add a new read state if not exist on message delivered event', - () async { - final newUser = User(id: 'new-user'); - final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); - - // Verify initial state - final read = channel.state?.read; - expect(read, isEmpty); - - // Create message delivered event for new user - final messageDeliveredEvent = Event( - cid: channel.cid, - type: EventType.messageDelivered, - user: newUser, - lastDeliveredAt: DateTime(2022), - lastDeliveredMessageId: 'message-789', - ); - - // Dispatch event - client.addEvent(messageDeliveredEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify read state was created with delivery info - final updated = channel.state?.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)), - isTrue, - ); - expect(newRead?.lastDeliveredMessageId, 'message-789'); - // lastRead should default to distantPast - expect( - newRead?.lastRead.isAtSameMomentAs(distantPast), - isTrue, - ); - }, - ); - - test( - 'should preserve read info on message delivered event', - () async { - final currentUser = User(id: 'test-user'); - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, - lastReadMessageId: 'read-msg-123', - ); - - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); - - // Verify initial state - final read = channel.state?.read.first; - expect(read?.lastRead.isAtSameMomentAs(DateTime(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, - type: EventType.messageDelivered, - user: currentUser, - lastDeliveredAt: DateTime(2022), - lastDeliveredMessageId: 'delivered-msg-456', - ); - - // Dispatch event - client.addEvent(messageDeliveredEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify delivery state is updated but read info is preserved - final updatedRead = channel.state?.read.first; - expect(updatedRead?.user.id, 'test-user'); - expect( - updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), - isTrue, - ); - expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); - // Read info should be preserved - expect( - updatedRead?.lastRead.isAtSameMomentAs(DateTime(2020)), - isTrue, - ); - expect(updatedRead?.unreadMessages, 10); - expect(updatedRead?.lastReadMessageId, 'read-msg-123'); - }, - ); - - test( - '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)); - - when( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).thenAnswer((_) => Future.value()); - - // Create message delivered event from current user - final messageDeliveredEvent = Event( - cid: channel.cid, - type: EventType.messageDelivered, - user: currentUser, - lastDeliveredAt: DateTime(2022), - lastDeliveredMessageId: 'message-456', - ); - - // Dispatch event - client.addEvent(messageDeliveredEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify reconcileDelivery was called - verify( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).called(1); - }, - ); - }); - group('Location events', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; diff --git a/packages/stream_chat/test/src/client/channel/state/read_events_test.dart b/packages/stream_chat/test/src/client/channel/state/read_events_test.dart new file mode 100644 index 0000000000..9df783ee2f --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/state/read_events_test.dart @@ -0,0 +1,753 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +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, + ); +} + +void main() { + group('Read Events', () { + 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 + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [currentRead], + ), + ); + + // 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); + + // 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', + ); + + // Dispatch event + await tester.emitEvent(messageReadEvent); + + // 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); + }, + ); + + channelTest( + 'should add a new read state if not exist on message read 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'); + + // Verify initial state + final read = tester.channelState?.read; + expect(read, isEmpty); + + // Create mark read notification event + final markReadEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageRead, + user: currentUser, + createdAt: DateTime.utc(2022), + unreadMessages: 0, + lastReadMessageId: 'message-123', + ); + + // Dispatch event + await tester.emitEvent(markReadEvent); + + // Verify read list has not changed + final updated = tester.channelState?.read; + expect(updated?.length, 1); + expect(updated?.any((r) => r.user.id == currentUser.id), isTrue); + }, + ); + + channelTest( + 'should not update channel read state on thread 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, + lastReadMessageId: 'channel-msg-1', + ); + + // Setup initial channel read state + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [currentRead], + ), + ); + + // Verify initial state + final read = tester.channelState?.read.first; + expect(read?.unreadMessages, 10); + expect(read?.lastReadMessageId, 'channel-msg-1'); + expect(read?.lastRead.isAtSameMomentAs(DateTime.utc(2020)), isTrue); + + // Create a thread-scoped message.read event (thread != null) + final threadMessageReadEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageRead, + user: currentUser, + createdAt: DateTime.utc(2022), + lastReadMessageId: 'thread-reply-99', + thread: Thread( + channelCid: tester.channel.cid!, + parentMessageId: 'parent-msg-1', + createdByUserId: currentUser.id, + replyCount: 3, + participantCount: 2, + ), + ); + + // Dispatch event + await tester.emitEvent(threadMessageReadEvent); + + // Channel read state must be untouched — thread reads + // must not clobber the channel-level Read. + final after = tester.channelState?.read.first; + expect(after?.unreadMessages, 10); + expect(after?.lastReadMessageId, 'channel-msg-1'); + expect(after?.lastRead.isAtSameMomentAs(DateTime.utc(2020)), isTrue); + }, + ); + + 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 + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [currentRead], + ), + ); + + // 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); + + // 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', + ); + + // Dispatch event + await tester.emitEvent(markUnreadEvent); + + // 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); + }, + ); + + channelTest( + 'should add a new read state if not exist on notification mark unread', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + // Verify initial state + final read = tester.channelState?.read; + expect(read, isEmpty); + + // Create event for non-existing user + final markUnreadEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.notificationMarkUnread, + user: User(id: 'non-existing-user'), + lastReadAt: DateTime.utc(2019), + unreadMessages: 15, + lastReadMessageId: 'message-100', + ); + + // Dispatch event + await tester.emitEvent(markUnreadEvent); + + // Verify read list has not changed + final updated = tester.channelState?.read; + expect(updated?.length, 1); + expect(updated?.any((r) => r.user.id == 'non-existing-user'), isTrue); + }, + ); + + channelTest( + 'should preserve delivery info 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, + lastDeliveredAt: DateTime.utc(2021), + lastDeliveredMessageId: 'delivered-msg-456', + ); + + // Setup initial read state with delivery info + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [currentRead], + ), + ); + + // Verify initial state + final read = tester.channelState?.read.first; + expect(read?.lastDeliveredAt, isNotNull); + expect( + read?.lastDeliveredAt?.isAtSameMomentAs(DateTime.utc(2021)), + isTrue, + ); + expect(read?.lastDeliveredMessageId, 'delivered-msg-456'); + + // Create message read event (doesn't include delivery info) + final messageReadEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageRead, + user: currentUser, + createdAt: DateTime.utc(2022), + unreadMessages: 0, + lastReadMessageId: 'message-123', + ); + + // Dispatch event + await tester.emitEvent(messageReadEvent); + + // Verify read state is updated but delivery info is preserved + 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, + ); + // Delivery info should be preserved + expect(updatedRead?.lastDeliveredAt, isNotNull); + expect( + updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime.utc(2021)), + isTrue, + ); + expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); + }, + ); + + channelTest( + 'should reconcile delivery when message read event is from current user', + 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(), + ); + + // 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 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageRead, + user: tester.currentUser, + createdAt: DateTime.utc(2022), + unreadMessages: 0, + lastReadMessageId: 'message-123', + ); + + // Dispatch event + await tester.emitEvent(messageReadEvent); + + // 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 the reconciled receipt was never sent + tester.verifyNeverCalled((api) => api.channel.markChannelsDelivered(any())); + }, + ); + + channelTest( + 'should reset unread count on notification mark read event', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final currentUser = tester.currentUser!; + final currentRead = Read( + user: currentUser, + lastRead: DateTime.utc(2020), + unreadMessages: 10, + ); + + // Setup initial read state + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [currentRead], + ), + ); + + // Verify initial state + 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. + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.notificationMarkRead, + user: currentUser, + createdAt: DateTime.utc(2022), + lastReadMessageId: 'message-123', + ), + ); + + // Verify read state is updated + final updatedRead = tester.channelState?.read.first; + expect(updatedRead?.user.id, currentUser.id); + expect(tester.channelState?.unreadCount, 0); + expect(updatedRead?.lastReadMessageId, 'message-123'); + expect( + updatedRead?.lastRead.isAtSameMomentAs(DateTime.utc(2022)), + isTrue, + ); + }, + ); + + channelTest( + 'should preserve delivery info on notification mark 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, + lastDeliveredAt: DateTime.utc(2021), + lastDeliveredMessageId: 'delivered-msg-456', + ); + + // Setup initial read state + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [currentRead], + ), + ); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.notificationMarkRead, + user: currentUser, + createdAt: DateTime.utc(2022), + lastReadMessageId: 'message-123', + ), + ); + + // Verify read state is updated but delivery info is preserved + final updatedRead = tester.channelState?.read.first; + expect(updatedRead?.unreadMessages, 0); + expect( + updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime.utc(2021)), + isTrue, + ); + expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); + }, + ); + + channelTest( + 'should not update channel read state on thread notification mark ' + '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, + lastReadMessageId: 'channel-msg-1', + ); + + // Setup initial read state + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [currentRead], + ), + ); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.notificationMarkRead, + user: currentUser, + createdAt: DateTime.utc(2022), + lastReadMessageId: 'thread-reply-99', + thread: Thread( + channelCid: tester.channel.cid!, + parentMessageId: 'parent-msg-1', + createdByUserId: currentUser.id, + replyCount: 3, + participantCount: 2, + ), + ), + ); + + // Channel read state must be untouched — thread reads + // must not clobber the channel-level Read. + final after = tester.channelState?.read.first; + expect(after?.unreadMessages, 10); + expect(after?.lastReadMessageId, 'channel-msg-1'); + expect(after?.lastRead.isAtSameMomentAs(DateTime.utc(2020)), isTrue); + }, + ); + + channelTest( + 'should reconcile delivery when notification mark read event is from ' + 'current user', + 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(), + ); + + // 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), + ); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.notificationMarkRead, + user: tester.currentUser, + createdAt: DateTime.utc(2022), + lastReadMessageId: 'message-123', + ), + ); + + // 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 the reconciled receipt was never sent + tester.verifyNeverCalled((api) => api.channel.markChannelsDelivered(any())); + }, + ); + + 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 + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [currentRead], + ), + ); + + // 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); + + // Create message delivered event + final messageDeliveredEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageDelivered, + user: currentUser, + lastDeliveredAt: DateTime.utc(2022), + lastDeliveredMessageId: 'message-456', + ); + + // Dispatch event + await tester.emitEvent(messageDeliveredEvent); + + // 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'); + }, + ); + + channelTest( + 'should add a new read state if not exist on message delivered event', + 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 = tester.channelState?.read; + expect(read, isEmpty); + + // Create message delivered event for new user + final messageDeliveredEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageDelivered, + user: newUser, + lastDeliveredAt: DateTime.utc(2022), + lastDeliveredMessageId: 'message-789', + ); + + // Dispatch event + await tester.emitEvent(messageDeliveredEvent); + + // Verify read state was created with delivery info + 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.utc(2022)), + isTrue, + ); + expect(newRead?.lastDeliveredMessageId, 'message-789'); + // lastRead should default to distantPast + expect( + newRead?.lastRead.isAtSameMomentAs(distantPast), + isTrue, + ); + }, + ); + + channelTest( + 'should preserve read info on message delivered 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, + lastReadMessageId: 'read-msg-123', + ); + + // Setup initial read state + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [currentRead], + ), + ); + + // Verify initial state + 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 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageDelivered, + user: currentUser, + lastDeliveredAt: DateTime.utc(2022), + lastDeliveredMessageId: 'delivered-msg-456', + ); + + // Dispatch event + await tester.emitEvent(messageDeliveredEvent); + + // Verify delivery state is updated but read info is preserved + final updatedRead = tester.channelState?.read.first; + expect(updatedRead?.user.id, 'test-user'); + expect( + updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime.utc(2022)), + isTrue, + ); + expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); + // Read info should be preserved + expect( + updatedRead?.lastRead.isAtSameMomentAs(DateTime.utc(2020)), + isTrue, + ); + expect(updatedRead?.unreadMessages, 10); + expect(updatedRead?.lastReadMessageId, 'read-msg-123'); + }, + ); + + channelTest( + 'should reconcile delivery when message delivered event is from current user', + 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(), + ); + + // 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 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageDelivered, + user: tester.currentUser, + lastDeliveredAt: DateTime.utc(2022), + lastDeliveredMessageId: 'message-456', + ); + + // Dispatch event + await tester.emitEvent(messageDeliveredEvent); + + // 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 the reconciled receipt was never sent + tester.verifyNeverCalled((api) => api.channel.markChannelsDelivered(any())); + }, + ); + }); +} From 4ea1c0634b930b79091a266084e0eb192166f1c1 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 11:16:21 +0200 Subject: [PATCH 020/103] test(llc): migrate channel push preference event tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_client_state_test.dart | 100 ---------------- .../channel_push_preference_events_test.dart | 110 ++++++++++++++++++ .../lib/src/helpers/test_data.dart | 2 + 3 files changed, 112 insertions(+), 100 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/state/channel_push_preference_events_test.dart 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 74d0c6912a..0d803be306 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 @@ -600,106 +600,6 @@ void main() { }); }); - group('Channel push preference 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 channel.push_preference.updated event', () async { - // Verify initial state - expect(channel.state?.channelState.pushPreferences, isNull); - - // Create channel push preference - final channelPushPreference = ChannelPushPreference( - chatLevel: ChatLevel.mentions, - disabledUntil: DateTime.now().add(const Duration(hours: 1)), - ); - - // Create channel.push_preference.updated event - final channelPushPreferenceUpdatedEvent = Event( - cid: channel.cid, - type: EventType.channelPushPreferenceUpdated, - channelPushPreference: channelPushPreference, - ); - - // Dispatch event - client.addEvent(channelPushPreferenceUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // 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, - ); - }); - - test('should update existing channel push preferences', () async { - // Set initial push preferences - const initialPushPreference = ChannelPushPreference( - chatLevel: ChatLevel.all, - ); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - pushPreferences: initialPushPreference, - ), - ); - - // Verify initial state - final pushPreferences = channel.state?.channelState.pushPreferences; - expect(pushPreferences?.chatLevel, ChatLevel.all); - expect(pushPreferences?.disabledUntil, isNull); - - // Create updated channel push preference - final updatedPushPreference = ChannelPushPreference( - chatLevel: ChatLevel.none, - disabledUntil: DateTime.now().add(const Duration(hours: 2)), - ); - - // Create channel.push_preference.updated event - final channelPushPreferenceUpdatedEvent = Event( - cid: channel.cid, - type: EventType.channelPushPreferenceUpdated, - channelPushPreference: updatedPushPreference, - ); - - // Dispatch event - client.addEvent(channelPushPreferenceUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify channel push preferences were updated - final updatedPreferences = channel.state?.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); - - expect(channel.state?.channelState.pushPreferences, isNull); - }); - }); - group('User messages deleted event', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; diff --git a/packages/stream_chat/test/src/client/channel/state/channel_push_preference_events_test.dart b/packages/stream_chat/test/src/client/channel/state/channel_push_preference_events_test.dart new file mode 100644 index 0000000000..6d28256ec8 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/state/channel_push_preference_events_test.dart @@ -0,0 +1,110 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), +); + +void main() { + group('Channel push preference events', () { + 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); + + // Create channel push preference + final channelPushPreference = ChannelPushPreference( + chatLevel: ChatLevel.mentions, + disabledUntil: DateTime.utc(2021, 3), + ); + + // Dispatch channel.push_preference.updated event + await tester.emitEvent( + createDefaultEvent( + type: EventType.channelPushPreferenceUpdated, + cid: tester.channel.cid, + channelPushPreference: channelPushPreference, + ), + ); + + // 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, + ); + }, + ); + + 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, + ); + + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + pushPreferences: initialPushPreference, + ), + ); + + // 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 = tester.channelState?.channelState.pushPreferences; + expect(updatedPreferences?.chatLevel, ChatLevel.none); + expect( + updatedPreferences?.disabledUntil, + updatedPushPreference.disabledUntil, + ); + }, + ); + + 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(tester.channelState?.channelState.pushPreferences, isNull); + }, + ); + }); +} diff --git a/packages/stream_chat_test/lib/src/helpers/test_data.dart b/packages/stream_chat_test/lib/src/helpers/test_data.dart index 2aafdfe22f..4c58fce401 100644 --- a/packages/stream_chat_test/lib/src/helpers/test_data.dart +++ b/packages/stream_chat_test/lib/src/helpers/test_data.dart @@ -125,6 +125,7 @@ Event createDefaultEvent({ ChannelModel? channel, Draft? draft, MessageReminder? reminder, + ChannelPushPreference? channelPushPreference, OwnUser? me, int? totalUnreadCount, int? unreadChannels, @@ -154,6 +155,7 @@ Event createDefaultEvent({ channel: channel, draft: draft, reminder: reminder, + channelPushPreference: channelPushPreference, me: me, totalUnreadCount: totalUnreadCount, unreadChannels: unreadChannels, From 59b11a82c392c057430e4071fe12cf4053149b99 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 11:20:06 +0200 Subject: [PATCH 021/103] test(llc): migrate location event tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_client_state_test.dart | 559 ---------------- .../channel/state/location_events_test.dart | 608 ++++++++++++++++++ 2 files changed, 608 insertions(+), 559 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/state/location_events_test.dart 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 0d803be306..fce9ccb1d9 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 @@ -41,565 +41,6 @@ void main() { () => client.channelDeliveryReporter.submitForDelivery(any()), ).thenAnswer((_) async {}); }); - group('Location 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 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, - ); - - // Create location.shared event - final locationSharedEvent = Event( - cid: channel.cid, - type: EventType.locationShared, - message: locationMessage, - ); - - // Dispatch event - client.addEvent(locationSharedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Check if message was added - final messages = channel.state?.messages; - final message = messages?.firstWhere((m) => m.id == 'msg1'); - expect(message, isNotNull); - - // Check if active live location was updated - final activeLiveLocations = channel.state?.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations?.first.messageId, equals('msg1')); - }); - - 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)), - ); - - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - - // Add initial message - channel.state?.addNewMessage(locationMessage); - - // Create updated location - final updatedLocation = liveLocation.copyWith( - latitude: 40.7500, // Updated latitude - longitude: -74.1000, // Updated longitude - ); - - final updatedMessage = locationMessage.copyWith( - sharedLocation: updatedLocation, - ); - - // Create location.updated event - final locationUpdatedEvent = Event( - cid: channel.cid, - type: EventType.locationUpdated, - message: updatedMessage, - ); - - // Dispatch event - client.addEvent(locationUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // 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)); - - // 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)); - }); - - 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)), - ); - - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - - // Add initial message - channel.state?.addNewMessage(locationMessage); - expect(channel.state?.activeLiveLocations, hasLength(1)); - - // Create expired location - final expiredLocation = liveLocation.copyWith( - endAt: DateTime.now().subtract(const Duration(hours: 1)), - ); - - final expiredMessage = locationMessage.copyWith( - sharedLocation: expiredLocation, - ); - - // Create location.expired event - final locationExpiredEvent = Event( - cid: channel.cid, - type: EventType.locationExpired, - message: expiredMessage, - ); - - // Dispatch event - client.addEvent(locationExpiredEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Check if message was updated - final messages = channel.state?.messages; - final message = messages?.firstWhere((m) => m.id == 'msg1'); - expect(message?.sharedLocation?.isExpired, isTrue); - - // Check if active live location was removed - expect(channel.state?.activeLiveLocations, isEmpty); - }); - - 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)), - ); - - 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'); - }); - - 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)), - ); - - channel.state?.addNewMessage( - Message(id: 'msg-own', sharedLocation: ownLocation), - ); - expect(channel.state?.activeLiveLocations, hasLength(1)); - - // 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())); - }); - - 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)), - ); - - // 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'); - }); - - 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 - ); - - final staticMessage = Message( - id: 'msg1', - text: 'Static location shared', - sharedLocation: staticLocation, - ); - - // Create location.shared event - final locationSharedEvent = Event( - cid: channel.cid, - type: EventType.locationShared, - message: staticMessage, - ); - - // Dispatch event - client.addEvent(locationSharedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Check if message was added - final messages = channel.state?.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); - }); - - test( - 'should update active locations when location message is deleted', - () 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, - ); - - // Verify initial state - channel.state?.addNewMessage(locationMessage); - expect(channel.state?.activeLiveLocations, hasLength(1)); - - final messageDeletedEvent = Event( - type: EventType.messageDeleted, - cid: channel.cid, - 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); - }, - ); - - 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, - ); - - // 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 newMessage = Message( - id: 'msg2', - text: 'Updated location', - sharedLocation: newLocation, - ); - - // Create location.shared event for the new message - final locationSharedEvent = Event( - cid: channel.cid, - type: EventType.locationShared, - message: newMessage, - ); - - // Dispatch event - client.addEvent(locationSharedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // 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)); - }); - - test( - 'should handle multiple active locations from different devices', - () 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, - ); - - // Add first location for setup - channel.state?.addNewMessage(locationMessage); - expect(channel.state?.activeLiveLocations, hasLength(1)); - - // Create location from different device - final location2 = Location( - channelCid: channel.cid, - userId: 'user1', // Same user - messageId: 'msg2', - latitude: 34.0522, - longitude: -118.2437, - createdByDeviceId: 'device2', // Different device - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final message2 = Message( - id: 'msg2', - text: 'Location from device 2', - sharedLocation: location2, - ); - - // Create location.shared event for the second message - final locationSharedEvent = Event( - cid: channel.cid, - 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)); - }, - ); - - 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); - - // Update the location - final updatedLocation = liveLocation.copyWith( - latitude: 40.7500, - longitude: -74.1000, - ); - - final updatedThreadMessage = threadLocationMessage.copyWith( - sharedLocation: updatedLocation, - ); - - // Create location.updated event for the thread message - final locationUpdatedEvent = Event( - cid: channel.cid, - type: EventType.locationUpdated, - message: updatedThreadMessage, - ); - - // Dispatch event - client.addEvent(locationUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // 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 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)); - }); - }); - group('User messages deleted event', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; diff --git a/packages/stream_chat/test/src/client/channel/state/location_events_test.dart b/packages/stream_chat/test/src/client/channel/state/location_events_test.dart new file mode 100644 index 0000000000..06b5e402bb --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/state/location_events_test.dart @@ -0,0 +1,608 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), +); + +void main() { + group('Location events', () { + 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); + + // 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)), + ); + + 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, + ), + ); + + // Check if message was added + final messages = tester.channelState?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message, isNotNull); + + // Check if active live location was updated + final activeLiveLocations = tester.channelState?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.messageId, equals('msg1')); + }, + ); + + 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)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Add initial message + tester.channelState?.addNewMessage(locationMessage); + + // Create updated location + final updatedLocation = liveLocation.copyWith( + latitude: 40.7500, // Updated latitude + longitude: -74.1000, // Updated longitude + ); + + final updatedMessage = locationMessage.copyWith( + sharedLocation: updatedLocation, + ); + + // Dispatch location.updated event + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.locationUpdated, + message: updatedMessage, + ), + ); + + // 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)); + + // 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)); + }, + ); + + 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)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Add initial message + tester.channelState?.addNewMessage(locationMessage); + expect(tester.channelState?.activeLiveLocations, hasLength(1)); + + // Create expired location + final expiredLocation = liveLocation.copyWith( + endAt: DateTime.timestamp().subtract(const Duration(hours: 1)), + ); + + final expiredMessage = locationMessage.copyWith( + sharedLocation: expiredLocation, + ); + + // Dispatch location.expired event + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.locationExpired, + message: expiredMessage, + ), + ); + + // Check if message was updated + final messages = tester.channelState?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message?.sharedLocation?.isExpired, isTrue); + + // Check if active live location was removed + expect(tester.channelState?.activeLiveLocations, isEmpty); + }, + ); + + 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)), + ); + + // 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); + + tester.channelState?.addNewMessage( + Message(id: 'msg1', sharedLocation: liveLocation), + ); + expect(tester.channelState?.activeLiveLocations, hasLength(1)); + + // Before endAt no expiry event is emitted. + await Future.delayed(const Duration(milliseconds: 200)); + expect(captured, isEmpty); + + // 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'); + + await subscription.cancel(); + }, + ); + + 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)), + ); + + final captured = []; + final subscription = tester.client.on().listen(captured.add); + + tester.channelState?.addNewMessage( + Message(id: 'msg-own', sharedLocation: ownLocation), + ); + expect(tester.channelState?.activeLiveLocations, hasLength(1)); + + // 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); + + await subscription.cancel(); + }, + ); + + 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)), + ); + + 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. + tester.channelState?.updateChannelState( + ChannelState(messages: const [], activeLiveLocations: [expiredLocation]), + ); + expect(tester.channelState?.activeLiveLocations, hasLength(1)); + + // 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'); + + await subscription.cancel(); + }, + ); + + 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 + ); + + final staticMessage = Message( + id: 'msg1', + text: 'Static location shared', + sharedLocation: staticLocation, + ); + + // Dispatch location.shared event + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.locationShared, + message: staticMessage, + ), + ); + + // 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(tester.channelState?.activeLiveLocations, isEmpty); + }, + ); + + channelTest( + 'should update active locations when location message is deleted', + 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)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Verify initial state + tester.channelState?.addNewMessage(locationMessage); + expect(tester.channelState?.activeLiveLocations, hasLength(1)); + + // Dispatch message.deleted event + await tester.emitEvent( + createDefaultEvent( + type: EventType.messageDeleted, + cid: _channelCid, + message: locationMessage.copyWith( + type: MessageType.deleted, + deletedAt: DateTime.timestamp(), + ), + ), + ); + + // Verify active locations are updated + expect(tester.channelState?.activeLiveLocations, isEmpty); + }, + ); + + 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)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Add initial location for setup + tester.channelState?.addNewMessage(locationMessage); + expect(tester.channelState?.activeLiveLocations, hasLength(1)); + + // 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)), + ); + + final newMessage = Message( + id: 'msg2', + text: 'Updated location', + sharedLocation: newLocation, + ); + + // 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 = tester.channelState?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.messageId, equals('msg2')); + expect(activeLiveLocations?.first.latitude, equals(40.7500)); + }, + ); + + channelTest( + 'should handle multiple active locations from different devices', + 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)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Add first location for setup + tester.channelState?.addNewMessage(locationMessage); + expect(tester.channelState?.activeLiveLocations, hasLength(1)); + + // Create location from different device + final location2 = Location( + channelCid: _channelCid, + userId: 'user1', // Same user + messageId: 'msg2', + latitude: 34.0522, + longitude: -118.2437, + createdByDeviceId: 'device2', // Different device + endAt: DateTime.timestamp().add(const Duration(hours: 1)), + ); + + final message2 = Message( + id: 'msg2', + text: 'Location from device 2', + sharedLocation: location2, + ); + + // Dispatch location.shared event for the second message + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.locationShared, + message: message2, + ), + ); + + // Should have two active locations + expect(tester.channelState?.activeLiveLocations, hasLength(2)); + }, + ); + + 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', + ); + + // Add parent message first for setup + tester.channelState?.addNewMessage(parentMessage); + + 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)), + ); + + final threadLocationMessage = Message( + id: 'thread-msg1', + text: 'Live location in thread', + parentId: 'parent1', + sharedLocation: liveLocation, + ); + + // Dispatch location.shared event for the thread message + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.locationShared, + message: threadLocationMessage, + ), + ); + + // 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 location was added to active locations + final activeLiveLocations = tester.channelState?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.messageId, equals('thread-msg1')); + }, + ); + + 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', + ); + + 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)), + ); + + final threadLocationMessage = Message( + id: 'thread-msg1', + text: 'Live location in thread', + parentId: 'parent1', + sharedLocation: liveLocation, + ); + + // Add messages + tester.channelState?.addNewMessage(parentMessage); + tester.channelState?.addNewMessage(threadLocationMessage); + + // Update the location + final updatedLocation = liveLocation.copyWith( + latitude: 40.7500, + longitude: -74.1000, + ); + + final updatedThreadMessage = threadLocationMessage.copyWith( + sharedLocation: updatedLocation, + ); + + // Dispatch location.updated event for the thread message + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.locationUpdated, + message: updatedThreadMessage, + ), + ); + + // 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)); + + // 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)); + }, + ); + }); +} From 05592d81cebb368fd58021fce82a3855a8cbe0dc Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 11:22:01 +0200 Subject: [PATCH 022/103] test(llc): migrate user messages deleted event tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_client_state_test.dart | 617 ------------------ .../user_messages_deleted_events_test.dart | 613 +++++++++++++++++ 2 files changed, 613 insertions(+), 617 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/state/user_messages_deleted_events_test.dart 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 fce9ccb1d9..0beb898de0 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 @@ -8,623 +8,6 @@ import '../../fakes.dart'; import '../../mocks.dart'; void main() { - group('WS events', () { - late final client = MockStreamChatClient(); - - setUpAll(() { - // Fallback values - registerFallbackValue(FakeMessage()); - registerFallbackValue(FakeAttachmentFile()); - registerFallbackValue(FakeEvent()); - - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - - // client logger - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - - // mock channel delivery reporter - when( - () => client.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); - }); - 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); - when( - () => persistenceClient.deleteMessagesFromUser( - cid: any(named: 'cid'), - userId: any(named: 'userId'), - hardDelete: any(named: 'hardDelete'), - deletedAt: any(named: 'deletedAt'), - ), - ).thenAnswer((_) async {}); - when(() => persistenceClient.deleteMessageByIds(any())).thenAnswer((_) async {}); - when(() => persistenceClient.deletePinnedMessageByIds(any())).thenAnswer((_) async {}); - when(() => persistenceClient.getChannelThreads(any())).thenAnswer((_) async => >{}); - - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test( - 'should soft delete all messages from user when hardDelete is false', - () 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'); - - final message1 = Message( - id: 'msg-1', - text: 'Message from user 1', - user: user1, - ); - final message2 = Message( - id: 'msg-2', - text: 'Another message from user 1', - user: user1, - ); - final message3 = Message( - id: 'msg-3', - text: 'Message from user 2', - user: user2, - ); - - channel.state?.addNewMessage(message1); - channel.state?.addNewMessage(message2); - channel.state?.addNewMessage(message3); - - // Verify initial state - expect(channel.state?.messages.length, equals(3)); - expect( - channel.state?.messages.where((m) => m.user?.id == 'user-1').length, - equals(2), - ); - expect( - channel.state?.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, - 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); - - // 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(deletedMessages?.length, equals(2)); - for (final message in deletedMessages!) { - expect(message.type, equals(MessageType.deleted)); - expect(message.deletedAt, isNotNull); - expect(message.state.isDeleted, isTrue); - } - - // Verify user2's message is unaffected - final user2Message = channel.state?.messages.firstWhere((m) => m.id == 'msg-3'); - expect(user2Message?.type, isNot(MessageType.deleted)); - expect(user2Message?.deletedAt, isNull); - }, - ); - - test( - 'should hard delete all messages from user when hardDelete is true', - () 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'); - - final message1 = Message( - id: 'msg-1', - text: 'Message from user 1', - user: user1, - ); - final message2 = Message( - id: 'msg-2', - text: 'Another message from user 1', - user: user1, - ); - final message3 = Message( - id: 'msg-3', - text: 'Message from user 2', - user: user2, - ); - - channel.state?.addNewMessage(message1); - channel.state?.addNewMessage(message2); - channel.state?.addNewMessage(message3); - - // Verify initial state - expect(channel.state?.messages.length, equals(3)); - - // Create user.messages.deleted event (hard delete) - final userMessagesDeletedEvent = Event( - cid: 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); - - // Verify user1's messages are removed - expect(channel.state?.messages.length, equals(1)); - expect( - channel.state?.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'); - expect(user2Message, isNotNull); - expect(user2Message?.user?.id, equals('user-2')); - }, - ); - - test( - 'should handle thread messages from user', - () 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'); - - final parentMessage = Message( - id: 'parent-msg', - text: 'Parent message', - user: user2, - ); - final threadMessage1 = Message( - id: 'thread-msg-1', - text: 'Thread message from user 1', - user: user1, - parentId: 'parent-msg', - ); - final threadMessage2 = Message( - id: 'thread-msg-2', - text: 'Another thread message from user 1', - user: user1, - parentId: 'parent-msg', - ); - - channel.state?.addNewMessage(parentMessage); - channel.state?.addNewMessage(threadMessage1); - channel.state?.addNewMessage(threadMessage2); - - // Verify initial state - expect(channel.state?.messages.length, equals(1)); - expect(channel.state?.threads['parent-msg']?.length, equals(2)); - - // Create user.messages.deleted event (soft delete) - final userMessagesDeletedEvent = Event( - cid: 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); - - // Verify thread messages are soft deleted - final threadMessages = channel.state?.threads['parent-msg']; - expect(threadMessages?.length, equals(2)); - for (final message in threadMessages!) { - expect(message.type, equals(MessageType.deleted)); - expect(message.state.isDeleted, isTrue); - } - - // Verify parent message is unaffected - final parent = channel.state?.messages.first; - expect(parent?.type, isNot(MessageType.deleted)); - }, - ); - - test( - 'should do nothing when user is null', - () async { - // Setup: Add messages - final user1 = User(id: 'user-1', name: 'User 1'); - final message1 = Message( - id: 'msg-1', - text: 'Message from user 1', - user: user1, - ); - - channel.state?.addNewMessage(message1); - - // Verify initial state - expect(channel.state?.messages.length, equals(1)); - - // Create user.messages.deleted event without user - final userMessagesDeletedEvent = Event( - cid: channel.cid, - type: EventType.userMessagesDeleted, - hardDelete: false, - ); - - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify messages are unaffected - expect(channel.state?.messages.length, equals(1)); - expect( - channel.state?.messages.first.type, - isNot(MessageType.deleted), - ); - }, - ); - - test( - 'should handle empty message list', - () async { - // Setup: Empty channel - expect(channel.state?.messages.length, equals(0)); - - // Create user.messages.deleted event - final userMessagesDeletedEvent = Event( - cid: 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); - - // Verify state is still empty - expect(channel.state?.messages.length, equals(0)); - }, - ); - - test( - 'should delete messages from persistence when hardDelete is true', - () 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'); - - final message1 = Message( - id: 'msg-1', - text: 'Message from user 1', - user: user1, - ); - final message2 = Message( - id: 'msg-2', - text: 'Another message from user 1', - user: user1, - ); - final message3 = Message( - id: 'msg-3', - text: 'Message from user 2', - user: user2, - ); - - channel.state?.addNewMessage(message1); - channel.state?.addNewMessage(message2); - channel.state?.addNewMessage(message3); - - // Verify initial state - expect(channel.state?.messages.length, equals(3)); - - // Create user.messages.deleted event (hard delete) - final userMessagesDeletedEvent = Event( - cid: 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); - - // Verify messages are removed from persistence - verify( - () => persistenceClient.deleteMessageByIds(['msg-1', 'msg-2']), - ).called(1); - verify( - () => persistenceClient.deletePinnedMessageByIds(['msg-1', 'msg-2']), - ).called(1); - - // Verify user1's messages are removed from state - expect(channel.state?.messages.length, equals(1)); - expect( - channel.state?.messages.any((m) => m.user?.id == 'user-1'), - isFalse, - ); - }, - ); - - test( - 'should not delete from persistence when hardDelete is false', - () async { - // Setup: Add messages - final user1 = User(id: 'user-1', name: 'User 1'); - final message1 = Message( - id: 'msg-1', - text: 'Message from user 1', - user: user1, - ); - - channel.state?.addNewMessage(message1); - - // Create user.messages.deleted event (soft delete) - final userMessagesDeletedEvent = Event( - cid: 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); - - // Verify persistence deletion methods were NOT called - verifyNever(() => persistenceClient.deleteMessageByIds(any())); - verifyNever(() => persistenceClient.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)); - }, - ); - - test( - 'should delete all user messages including those only in storage', - () async { - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - - final stateMessage1 = Message( - id: 'msg-1', - text: 'Message from user 1 in state', - user: user1, - pinned: true, - ); - final stateMessage2 = Message( - id: 'msg-2', - text: 'Message from user 2 in state', - user: user2, - ); - final stateThreadMessage1 = Message( - id: 'thread-msg-1', - text: 'Thread message from user 1 in state', - user: user1, - parentId: 'msg-1', - ); - final stateThreadMessage2 = Message( - id: 'thread-msg-2', - text: 'Another thread message from user 2 in state', - user: user2, - parentId: 'msg-1', - ); - - // Load the state with only 2 messages and 1 thread with 2 replies. - // 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); - - // 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)); - - // Create user.messages.deleted event (hard delete) - final userMessagesDeletedEvent = Event( - cid: 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); - - // 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( - channel.state?.messages.any((m) => m.user?.id == 'user-1'), - isFalse, - ); - - expect( - channel.state?.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, - userId: user1.id, - hardDelete: true, - deletedAt: any(named: 'deletedAt'), - ), - ).called(1); - - // Verify in-state messages were also removed from state's persistence - final capturedIds = - verify( - () => persistenceClient.deleteMessageByIds(captureAny()), - ).captured.first - as List; - - expect( - capturedIds, - containsAll([ - 'msg-1', // state message - 'thread-msg-1', // state thread message - ]), - ); - }, - ); - - test( - 'should delete every authored message across threads without ' - 'cross-thread leakage (regression: _updateThreadMessages)', - () 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 - // routes it through a single _updateMessages batch — historically - // this batch was passed unfiltered to every affected thread's - // merge, so replies to thread A leaked into thread B and v.v. - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - - final parentA = Message(id: 'parent-A', text: 'Thread A', user: user2); - final parentB = Message(id: 'parent-B', text: 'Thread B', user: user2); - - final topLevelFromUser1 = Message( - id: 'top-1', - text: 'user-1 top-level message', - user: user1, - ); - final replyA = Message( - id: 'reply-A', - text: 'user-1 reply in thread A', - user: user1, - parentId: 'parent-A', - ); - final replyB = Message( - id: 'reply-B', - text: 'user-1 reply in thread B', - user: user1, - parentId: 'parent-B', - ); - - channel.state?.addNewMessage(parentA); - channel.state?.addNewMessage(parentB); - channel.state?.addNewMessage(topLevelFromUser1); - channel.state?.addNewMessage(replyA); - channel.state?.addNewMessage(replyB); - - // Initial state: each thread has exactly its own reply. - expect( - channel.state?.threads['parent-A']?.map((m) => m.id), - equals(['reply-A']), - ); - expect( - channel.state?.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, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: false, - ); - client.addEvent(userMessagesDeletedEvent); - await Future.delayed(Duration.zero); - - // 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), - equals(['reply-A']), - reason: 'thread A must not contain replies from thread B', - ); - expect( - channel.state?.threads['parent-B']?.map((m) => m.id), - equals(['reply-B']), - reason: 'thread B must not contain replies from thread A', - ); - - // 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, - equals(MessageType.deleted), - reason: 'top-level user-1 message must be deleted', - ); - expect( - channel.state?.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, - 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, - isNot(MessageType.deleted), - ); - expect( - channel.state?.messages.firstWhere((m) => m.id == 'parent-B').type, - isNot(MessageType.deleted), - ); - }, - ); - }); - }); - group('Local unread count', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; diff --git a/packages/stream_chat/test/src/client/channel/state/user_messages_deleted_events_test.dart b/packages/stream_chat/test/src/client/channel/state/user_messages_deleted_events_test.dart new file mode 100644 index 0000000000..6f97922f8b --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/state/user_messages_deleted_events_test.dart @@ -0,0 +1,613 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +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'), + userId: any(named: 'userId'), + hardDelete: any(named: 'hardDelete'), + deletedAt: any(named: 'deletedAt'), + ), + ).thenAnswer((_) async {}); + when(() => persistenceClient.deleteMessageByIds(any())).thenAnswer((_) async {}); + when(() => persistenceClient.deletePinnedMessageByIds(any())).thenAnswer((_) async {}); + return persistenceClient; +} + +void main() { + group('User messages deleted event', () { + final softDeletePersistence = _createPersistenceClient(); + channelTest( + 'should soft delete all messages from user when hardDelete is false', + 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'); + + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + final message2 = Message( + id: 'msg-2', + text: 'Another message from user 1', + user: user1, + ); + final message3 = Message( + id: 'msg-3', + text: 'Message from user 2', + user: user2, + ); + + tester.channelState?.addNewMessage(message1); + tester.channelState?.addNewMessage(message2); + tester.channelState?.addNewMessage(message3); + + // Verify initial state + expect(tester.channelState?.messages.length, equals(3)); + expect( + tester.channelState?.messages.where((m) => m.user?.id == 'user-1').length, + equals(2), + ); + expect( + tester.channelState?.messages.where((m) => m.user?.id == 'user-2').length, + equals(1), + ); + + // Create user.messages.deleted event (soft delete) + final deletedAt = DateTime.utc(2021, 3); + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + createdAt: deletedAt, + ); + + // Dispatch event and wait for it to be processed + await tester.emitEvent(userMessagesDeletedEvent); + + // Verify user1's messages are soft deleted + 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)); + expect(message.deletedAt, isNotNull); + expect(message.state.isDeleted, isTrue); + } + + // Verify user2's message is unaffected + final user2Message = tester.channelState?.messages.firstWhere((m) => m.id == 'msg-3'); + expect(user2Message?.type, isNot(MessageType.deleted)); + expect(user2Message?.deletedAt, isNull); + }, + ); + + final hardDeletePersistence = _createPersistenceClient(); + channelTest( + 'should hard delete all messages from user when hardDelete is true', + 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'); + + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + final message2 = Message( + id: 'msg-2', + text: 'Another message from user 1', + user: user1, + ); + final message3 = Message( + id: 'msg-3', + text: 'Message from user 2', + user: user2, + ); + + tester.channelState?.addNewMessage(message1); + tester.channelState?.addNewMessage(message2); + tester.channelState?.addNewMessage(message3); + + // Verify initial state + expect(tester.channelState?.messages.length, equals(3)); + + // Create user.messages.deleted event (hard delete) + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: true, + ); + + // Dispatch event and wait for it to be processed + await tester.emitEvent(userMessagesDeletedEvent); + + // Verify user1's messages are removed + expect(tester.channelState?.messages.length, equals(1)); + expect( + tester.channelState?.messages.any((m) => m.user?.id == 'user-1'), + isFalse, + ); + + // Verify user2's message still exists + final user2Message = tester.channelState?.messages.firstWhere((m) => m.id == 'msg-3'); + expect(user2Message, isNotNull); + expect(user2Message?.user?.id, equals('user-2')); + }, + ); + + final threadMessagesPersistence = _createPersistenceClient(); + channelTest( + 'should handle thread messages from user', + 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'); + + final parentMessage = Message( + id: 'parent-msg', + text: 'Parent message', + user: user2, + ); + final threadMessage1 = Message( + id: 'thread-msg-1', + text: 'Thread message from user 1', + user: user1, + parentId: 'parent-msg', + ); + final threadMessage2 = Message( + id: 'thread-msg-2', + text: 'Another thread message from user 1', + user: user1, + parentId: 'parent-msg', + ); + + tester.channelState?.addNewMessage(parentMessage); + tester.channelState?.addNewMessage(threadMessage1); + tester.channelState?.addNewMessage(threadMessage2); + + // Verify initial state + 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 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + ); + + // Dispatch event and wait for it to be processed + await tester.emitEvent(userMessagesDeletedEvent); + + // Verify thread messages are soft deleted + final threadMessages = tester.channelState?.threads['parent-msg']; + expect(threadMessages?.length, equals(2)); + for (final message in threadMessages!) { + expect(message.type, equals(MessageType.deleted)); + expect(message.state.isDeleted, isTrue); + } + + // Verify parent message is unaffected + final parent = tester.channelState?.messages.first; + expect(parent?.type, isNot(MessageType.deleted)); + }, + ); + + final nullUserPersistence = _createPersistenceClient(); + channelTest( + 'should do nothing when user is null', + 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( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + + tester.channelState?.addNewMessage(message1); + + // Verify initial state + expect(tester.channelState?.messages.length, equals(1)); + + // Create user.messages.deleted event without user + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userMessagesDeleted, + hardDelete: false, + ); + + // Dispatch event and wait for it to be processed + await tester.emitEvent(userMessagesDeletedEvent); + + // Verify messages are unaffected + expect(tester.channelState?.messages.length, equals(1)); + expect( + tester.channelState?.messages.first.type, + isNot(MessageType.deleted), + ); + }, + ); + + final emptyChannelPersistence = _createPersistenceClient(); + channelTest( + 'should handle empty message list', + channelType: _channelType, + channelId: _channelId, + chatPersistenceClient: emptyChannelPersistence, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + // Setup: Empty channel + expect(tester.channelState?.messages.length, equals(0)); + + // Create user.messages.deleted event + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userMessagesDeleted, + user: User(id: 'user-1'), + hardDelete: false, + ); + + // Dispatch event - should not throw + await tester.emitEvent(userMessagesDeletedEvent); + + // Verify state is still empty + expect(tester.channelState?.messages.length, equals(0)); + }, + ); + + final hardDeleteStoragePersistence = _createPersistenceClient(); + channelTest( + 'should delete messages from persistence when hardDelete is true', + 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'); + + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + final message2 = Message( + id: 'msg-2', + text: 'Another message from user 1', + user: user1, + ); + final message3 = Message( + id: 'msg-3', + text: 'Message from user 2', + user: user2, + ); + + tester.channelState?.addNewMessage(message1); + tester.channelState?.addNewMessage(message2); + tester.channelState?.addNewMessage(message3); + + // Verify initial state + expect(tester.channelState?.messages.length, equals(3)); + + // Create user.messages.deleted event (hard delete) + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: true, + ); + + // Dispatch event and wait for it to be processed + await tester.emitEvent(userMessagesDeletedEvent); + + // Verify messages are removed from persistence + verify( + () => hardDeleteStoragePersistence.deleteMessageByIds(['msg-1', 'msg-2']), + ).called(1); + verify( + () => hardDeleteStoragePersistence.deletePinnedMessageByIds(['msg-1', 'msg-2']), + ).called(1); + + // Verify user1's messages are removed from state + expect(tester.channelState?.messages.length, equals(1)); + expect( + tester.channelState?.messages.any((m) => m.user?.id == 'user-1'), + isFalse, + ); + }, + ); + + final softDeleteStoragePersistence = _createPersistenceClient(); + channelTest( + 'should not delete from persistence when hardDelete is false', + 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( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + + tester.channelState?.addNewMessage(message1); + + // Create user.messages.deleted event (soft delete) + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + ); + + // Dispatch event and wait for it to be processed + await tester.emitEvent(userMessagesDeletedEvent); + + // Verify persistence deletion methods were NOT called + verifyNever(() => softDeleteStoragePersistence.deleteMessageByIds(any())); + verifyNever(() => softDeleteStoragePersistence.deletePinnedMessageByIds(any())); + + // Verify message is soft deleted (still in state) + expect(tester.channelState?.messages.length, equals(1)); + expect(tester.channelState?.messages.first.type, equals(MessageType.deleted)); + }, + ); + + final storageWidePersistence = _createPersistenceClient(); + channelTest( + 'should delete all user messages including those only in storage', + 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'); + + final stateMessage1 = Message( + id: 'msg-1', + text: 'Message from user 1 in state', + user: user1, + pinned: true, + ); + final stateMessage2 = Message( + id: 'msg-2', + text: 'Message from user 2 in state', + user: user2, + ); + final stateThreadMessage1 = Message( + id: 'thread-msg-1', + text: 'Thread message from user 1 in state', + user: user1, + parentId: 'msg-1', + ); + final stateThreadMessage2 = Message( + id: 'thread-msg-2', + text: 'Another thread message from user 2 in state', + user: user2, + parentId: 'msg-1', + ); + + // Load the state with only 2 messages and 1 thread with 2 replies. + // 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. + 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(tester.channelState?.messages.length, equals(2)); + expect(tester.channelState?.threads['msg-1']?.length, equals(2)); + + // Create user.messages.deleted event (hard delete) + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: true, + ); + + // Dispatch event and wait for it to be processed + await tester.emitEvent(userMessagesDeletedEvent); + + // Verify user1's messages are removed from state + expect(tester.channelState?.messages.length, equals(1)); + expect(tester.channelState?.threads['msg-1']?.length, equals(1)); + + expect( + tester.channelState?.messages.any((m) => m.user?.id == 'user-1'), + isFalse, + ); + + expect( + 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( + () => storageWidePersistence.deleteMessagesFromUser( + cid: tester.channel.cid, + userId: user1.id, + hardDelete: true, + deletedAt: any(named: 'deletedAt'), + ), + ).called(1); + + // Verify in-state messages were also removed from state's persistence + final capturedIds = + verify( + () => storageWidePersistence.deleteMessageByIds(captureAny()), + ).captured.first + as List; + + expect( + capturedIds, + containsAll([ + 'msg-1', // state message + 'thread-msg-1', // state thread message + ]), + ); + }, + ); + + final crossThreadPersistence = _createPersistenceClient(); + channelTest( + 'should delete every authored message across threads without ' + 'cross-thread leakage (regression: _updateThreadMessages)', + 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 + // routes it through a single _updateMessages batch — historically + // this batch was passed unfiltered to every affected thread's + // merge, so replies to thread A leaked into thread B and v.v. + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final parentA = Message(id: 'parent-A', text: 'Thread A', user: user2); + final parentB = Message(id: 'parent-B', text: 'Thread B', user: user2); + + final topLevelFromUser1 = Message( + id: 'top-1', + text: 'user-1 top-level message', + user: user1, + ); + final replyA = Message( + id: 'reply-A', + text: 'user-1 reply in thread A', + user: user1, + parentId: 'parent-A', + ); + final replyB = Message( + id: 'reply-B', + text: 'user-1 reply in thread B', + user: user1, + parentId: 'parent-B', + ); + + 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( + tester.channelState?.threads['parent-A']?.map((m) => m.id), + equals(['reply-A']), + ); + expect( + tester.channelState?.threads['parent-B']?.map((m) => m.id), + equals(['reply-B']), + ); + + // Trigger the multi-thread batch via user.messages.deleted. + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + ); + 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( + tester.channelState?.threads['parent-A']?.map((m) => m.id), + equals(['reply-A']), + reason: 'thread A must not contain replies from thread B', + ); + expect( + tester.channelState?.threads['parent-B']?.map((m) => m.id), + equals(['reply-B']), + reason: 'thread B must not contain replies from thread A', + ); + + // 2) Every message authored by user-1 is soft-deleted — top-level + // AND in both threads. The fix must not narrow this scope. + expect( + tester.channelState?.messages.firstWhere((m) => m.id == 'top-1').type, + equals(MessageType.deleted), + reason: 'top-level user-1 message must be deleted', + ); + expect( + tester.channelState?.threads['parent-A']?.first.type, + equals(MessageType.deleted), + reason: 'thread A reply from user-1 must be deleted', + ); + expect( + 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( + tester.channelState?.messages.firstWhere((m) => m.id == 'parent-A').type, + isNot(MessageType.deleted), + ); + expect( + tester.channelState?.messages.firstWhere((m) => m.id == 'parent-B').type, + isNot(MessageType.deleted), + ); + }, + ); + }); +} From b4c13567be891670d3a8244dc6239828e5b581b6 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 11:23:45 +0200 Subject: [PATCH 023/103] test(llc): migrate message enrichment merge tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_client_state_test.dart | 382 ------------------ .../state/message_enrichment_merge_test.dart | 364 +++++++++++++++++ 2 files changed, 364 insertions(+), 382 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/state/message_enrichment_merge_test.dart 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 0beb898de0..10c8f4198e 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 @@ -732,388 +732,6 @@ 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([]); - - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - }); - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - clearInteractions(client); - }); - - test( - 'preserves the `poll` on a quotedMessage when the server omits it during ' - 're-sync (regression: poll quote disappears after foregrounding)', - () async { - final pollUser = User(id: 'poll-author'); - final poll = Poll( - id: 'poll-1', - name: 'Pizza or pasta?', - options: const [ - PollOption(id: 'opt-1', text: 'Pizza'), - PollOption(id: 'opt-2', text: 'Pasta'), - ], - createdById: pollUser.id, - ); - - final pollMessage = Message( - id: 'poll-msg-1', - poll: poll, - pollId: poll.id, - user: pollUser, - createdAt: DateTime.utc(2026, 4, 29, 10), - ); - - final replyToPoll = Message( - id: 'reply-1', - text: 'Voting now', - quotedMessageId: pollMessage.id, - quotedMessage: pollMessage, - user: User(id: 'reply-user'), - createdAt: DateTime.utc(2026, 4, 29, 11), - ); - - // Seed channel state with the fully-enriched messages (mirrors what - // the local DB load produces). - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [pollMessage, replyToPoll], - ), - ); - - // Simulate a re-sync from the API: the server echoes the reply with - // a `quoted_message` that has only `poll_id` (no `poll` object). - // Constructed directly (not via copyWith) because copyWith cannot - // clear `poll` — see Message.copyWith. - final strippedPollSnapshot = Message( - id: pollMessage.id, - pollId: pollMessage.pollId, - user: pollUser, - createdAt: pollMessage.createdAt, - ); - final reSyncedReply = replyToPoll.copyWith(quotedMessage: strippedPollSnapshot); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [reSyncedReply], - ), - ); - - final mergedReply = channel.state?.messages.firstWhere((it) => it.id == replyToPoll.id); - - expect(mergedReply, isNotNull); - expect(mergedReply!.quotedMessage, isNotNull); - expect(mergedReply.quotedMessage!.id, pollMessage.id); - expect(mergedReply.quotedMessage!.poll, isNotNull); - expect(mergedReply.quotedMessage!.poll!.id, poll.id); - expect(mergedReply.quotedMessage!.poll!.name, poll.name); - }, - ); - - test( - '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 { - final pollUser = User(id: 'poll-author'); - final poll = Poll( - id: 'poll-2', - name: 'Coffee or tea?', - options: const [ - PollOption(id: 'opt-a', text: 'Coffee'), - PollOption(id: 'opt-b', text: 'Tea'), - ], - createdById: pollUser.id, - ); - - final pollMessage = Message( - id: 'poll-msg-2', - poll: poll, - pollId: poll.id, - user: pollUser, - createdAt: DateTime.utc(2026, 4, 29, 10), - ); - - final replyToPoll = Message( - id: 'reply-A', - text: 'My pick', - quotedMessageId: pollMessage.id, - quotedMessage: pollMessage, - user: User(id: 'user-a'), - createdAt: DateTime.utc(2026, 4, 29, 11), - ); - - final replyToReply = Message( - id: 'reply-B', - text: 'Same here', - quotedMessageId: replyToPoll.id, - quotedMessage: replyToPoll, - user: User(id: 'user-b'), - createdAt: DateTime.utc(2026, 4, 29, 12), - ); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [pollMessage, replyToPoll, replyToReply], - ), - ); - - // Simulate the server response where: - // - replyA's nested quoted poll is missing the `poll` object. - // - replyB's nested quoted replyA is missing its own `quoted_message` - // (the server typically does not nest two levels deep). - // Stripped poll snapshot is constructed directly because copyWith - // cannot clear `poll` — see Message.copyWith. - final strippedPollSnapshot = Message( - id: pollMessage.id, - pollId: pollMessage.pollId, - user: pollUser, - createdAt: pollMessage.createdAt, - ); - final strippedReplyA = replyToPoll.copyWith(quotedMessage: null); - - final reSyncedReplyA = replyToPoll.copyWith(quotedMessage: strippedPollSnapshot); - final reSyncedReplyB = replyToReply.copyWith(quotedMessage: strippedReplyA); - - channel.state?.updateChannelState( - channel.state!.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); - - // First-level quote (reply A's quote of the poll) must keep the poll. - expect(mergedReplyA?.quotedMessage?.poll, isNotNull); - expect(mergedReplyA?.quotedMessage?.poll?.id, poll.id); - - // Second-level quote (reply B's quote of reply A) must keep reply A's - // own nested quotedMessage so the poll preview still resolves. - expect(mergedReplyB?.quotedMessage, isNotNull); - expect(mergedReplyB?.quotedMessage?.id, replyToPoll.id); - expect(mergedReplyB?.quotedMessage?.quotedMessage, isNotNull); - expect(mergedReplyB?.quotedMessage?.quotedMessage?.id, pollMessage.id); - expect(mergedReplyB?.quotedMessage?.quotedMessage?.poll, isNotNull); - expect(mergedReplyB?.quotedMessage?.quotedMessage?.poll?.id, poll.id); - }, - ); - - test( - 'still preserves quotedMessage when the updated payload has no ' - 'quoted_message at all (existing behavior should not regress)', - () async { - final pollUser = User(id: 'poll-author'); - final poll = Poll( - id: 'poll-3', - name: 'Beach or mountains?', - options: const [ - PollOption(id: 'opt-x', text: 'Beach'), - PollOption(id: 'opt-y', text: 'Mountains'), - ], - createdById: pollUser.id, - ); - - final pollMessage = Message( - id: 'poll-msg-3', - poll: poll, - pollId: poll.id, - user: pollUser, - createdAt: DateTime.utc(2026, 4, 29, 10), - ); - - final replyToPoll = Message( - id: 'reply-3', - text: 'Definitely beach', - quotedMessageId: pollMessage.id, - quotedMessage: pollMessage, - user: User(id: 'reply-user'), - createdAt: DateTime.utc(2026, 4, 29, 11), - ); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [pollMessage, replyToPoll], - ), - ); - - // Simulate an update event that touches the reply but doesn't echo - // the nested quoted_message at all (only quotedMessageId is set). - final reSyncedReply = Message( - id: replyToPoll.id, - text: 'Definitely beach (edited)', - quotedMessageId: pollMessage.id, - user: replyToPoll.user, - createdAt: replyToPoll.createdAt, - ); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [reSyncedReply], - ), - ); - - final mergedReply = channel.state?.messages.firstWhere((it) => it.id == replyToPoll.id); - - expect(mergedReply, isNotNull); - expect(mergedReply!.text, 'Definitely beach (edited)'); - expect(mergedReply.quotedMessage, isNotNull); - expect(mergedReply.quotedMessage!.poll?.id, poll.id); - }, - ); - - test( - '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 { - final pollUser = User(id: 'poll-author'); - final poll = Poll( - id: 'poll-thread', - name: 'What is for lunch?', - options: const [ - PollOption(id: 'opt-1', text: 'Burgers'), - PollOption(id: 'opt-2', text: 'Salads'), - ], - createdById: pollUser.id, - ); - - final pollMessage = Message( - id: 'parent-poll-msg', - poll: poll, - pollId: poll.id, - user: pollUser, - createdAt: DateTime.utc(2026, 4, 29, 10), - replyCount: 0, - ); - - // Seed channel state with the fully-enriched parent poll message. - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [pollMessage], - ), - ); - - // Simulate the `message.updated` event the backend fires for the - // parent after a thread reply is added: bookkeeping fields are bumped - // (`reply_count`, `updated_at`) but the `poll` object is omitted from - // the payload — only `pollId` is set. Constructed directly because - // copyWith cannot clear `poll` — see Message.copyWith. - final strippedParentUpdate = Message( - id: pollMessage.id, - pollId: pollMessage.pollId, - user: pollUser, - createdAt: pollMessage.createdAt, - replyCount: 1, - 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); - - final merged = channel.state?.messages.firstWhere((it) => it.id == pollMessage.id); - - // Parent poll message must remain in the channel state after a thread reply. - expect(merged, isNotNull); - // Bookkeeping fields from the event should still apply. - expect(merged!.replyCount, 1); - // Locally-known poll must be preserved when the server omits it from a - // `message.updated` payload (e.g. when a thread reply bumps reply_count). - expect(merged.poll, isNotNull); - expect(merged.poll!.id, poll.id); - expect(merged.poll!.name, poll.name); - expect(merged.pollId, poll.id); - }, - ); - - test( - '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 { - final pollUser = User(id: 'poll-author'); - final poll = Poll( - id: 'poll-edit', - name: 'Initial name', - options: const [ - PollOption(id: 'opt-1', text: 'Original A'), - ], - createdById: pollUser.id, - ); - - final pollMessage = Message( - id: 'edit-parent', - poll: poll, - pollId: poll.id, - user: pollUser, - createdAt: DateTime.utc(2026, 4, 29, 10), - ); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [pollMessage], - ), - ); - - 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); - - final merged = channel.state?.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. - expect(merged?.poll, isNotNull); - expect(merged?.poll?.name, 'Edited name'); - }, - ); - }); } // region Test Helpers diff --git a/packages/stream_chat/test/src/client/channel/state/message_enrichment_merge_test.dart b/packages/stream_chat/test/src/client/channel/state/message_enrichment_merge_test.dart new file mode 100644 index 0000000000..c62ff822d9 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/state/message_enrichment_merge_test.dart @@ -0,0 +1,364 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); +} + +Event _updateMessageEvent(Message message) => createDefaultEvent( + type: EventType.messageUpdated, + cid: _channelCid, + message: message, +); + +void main() { + group('Message enrichment preservation on merge', () { + channelTest( + 'preserves the `poll` on a quotedMessage when the server omits it during ' + 're-sync (regression: poll quote disappears after foregrounding)', + 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', + name: 'Pizza or pasta?', + options: const [ + PollOption(id: 'opt-1', text: 'Pizza'), + PollOption(id: 'opt-2', text: 'Pasta'), + ], + createdById: pollUser.id, + ); + + final pollMessage = Message( + id: 'poll-msg-1', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + ); + + final replyToPoll = Message( + id: 'reply-1', + text: 'Voting now', + quotedMessageId: pollMessage.id, + quotedMessage: pollMessage, + user: User(id: 'reply-user'), + createdAt: DateTime.utc(2026, 4, 29, 11), + ); + + // Seed channel state with the fully-enriched messages (mirrors what + // the local DB load produces). + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + messages: [pollMessage, replyToPoll], + ), + ); + + // Simulate a re-sync from the API: the server echoes the reply with + // a `quoted_message` that has only `poll_id` (no `poll` object). + // Constructed directly (not via copyWith) because copyWith cannot + // clear `poll` — see Message.copyWith. + final strippedPollSnapshot = Message( + id: pollMessage.id, + pollId: pollMessage.pollId, + user: pollUser, + createdAt: pollMessage.createdAt, + ); + final reSyncedReply = replyToPoll.copyWith(quotedMessage: strippedPollSnapshot); + + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + messages: [reSyncedReply], + ), + ); + + final mergedReply = tester.channelState?.messages.firstWhere((it) => it.id == replyToPoll.id); + + expect(mergedReply, isNotNull); + expect(mergedReply!.quotedMessage, isNotNull); + expect(mergedReply.quotedMessage!.id, pollMessage.id); + expect(mergedReply.quotedMessage!.poll, isNotNull); + expect(mergedReply.quotedMessage!.poll!.id, poll.id); + expect(mergedReply.quotedMessage!.poll!.name, poll.name); + }, + ); + + 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)', + 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', + name: 'Coffee or tea?', + options: const [ + PollOption(id: 'opt-a', text: 'Coffee'), + PollOption(id: 'opt-b', text: 'Tea'), + ], + createdById: pollUser.id, + ); + + final pollMessage = Message( + id: 'poll-msg-2', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + ); + + final replyToPoll = Message( + id: 'reply-A', + text: 'My pick', + quotedMessageId: pollMessage.id, + quotedMessage: pollMessage, + user: User(id: 'user-a'), + createdAt: DateTime.utc(2026, 4, 29, 11), + ); + + final replyToReply = Message( + id: 'reply-B', + text: 'Same here', + quotedMessageId: replyToPoll.id, + quotedMessage: replyToPoll, + user: User(id: 'user-b'), + createdAt: DateTime.utc(2026, 4, 29, 12), + ); + + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + messages: [pollMessage, replyToPoll, replyToReply], + ), + ); + + // Simulate the server response where: + // - replyA's nested quoted poll is missing the `poll` object. + // - replyB's nested quoted replyA is missing its own `quoted_message` + // (the server typically does not nest two levels deep). + // Stripped poll snapshot is constructed directly because copyWith + // cannot clear `poll` — see Message.copyWith. + final strippedPollSnapshot = Message( + id: pollMessage.id, + pollId: pollMessage.pollId, + user: pollUser, + createdAt: pollMessage.createdAt, + ); + final strippedReplyA = replyToPoll.copyWith(quotedMessage: null); + + final reSyncedReplyA = replyToPoll.copyWith(quotedMessage: strippedPollSnapshot); + final reSyncedReplyB = replyToReply.copyWith(quotedMessage: strippedReplyA); + + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + messages: [pollMessage, reSyncedReplyA, reSyncedReplyB], + ), + ); + + 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); + expect(mergedReplyA?.quotedMessage?.poll?.id, poll.id); + + // Second-level quote (reply B's quote of reply A) must keep reply A's + // own nested quotedMessage so the poll preview still resolves. + expect(mergedReplyB?.quotedMessage, isNotNull); + expect(mergedReplyB?.quotedMessage?.id, replyToPoll.id); + expect(mergedReplyB?.quotedMessage?.quotedMessage, isNotNull); + expect(mergedReplyB?.quotedMessage?.quotedMessage?.id, pollMessage.id); + expect(mergedReplyB?.quotedMessage?.quotedMessage?.poll, isNotNull); + expect(mergedReplyB?.quotedMessage?.quotedMessage?.poll?.id, poll.id); + }, + ); + + channelTest( + 'still preserves quotedMessage when the updated payload has no ' + 'quoted_message at all (existing behavior should not regress)', + 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', + name: 'Beach or mountains?', + options: const [ + PollOption(id: 'opt-x', text: 'Beach'), + PollOption(id: 'opt-y', text: 'Mountains'), + ], + createdById: pollUser.id, + ); + + final pollMessage = Message( + id: 'poll-msg-3', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + ); + + final replyToPoll = Message( + id: 'reply-3', + text: 'Definitely beach', + quotedMessageId: pollMessage.id, + quotedMessage: pollMessage, + user: User(id: 'reply-user'), + createdAt: DateTime.utc(2026, 4, 29, 11), + ); + + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + messages: [pollMessage, replyToPoll], + ), + ); + + // Simulate an update event that touches the reply but doesn't echo + // the nested quoted_message at all (only quotedMessageId is set). + final reSyncedReply = Message( + id: replyToPoll.id, + text: 'Definitely beach (edited)', + quotedMessageId: pollMessage.id, + user: replyToPoll.user, + createdAt: replyToPoll.createdAt, + ); + + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + messages: [reSyncedReply], + ), + ); + + final mergedReply = tester.channelState?.messages.firstWhere((it) => it.id == replyToPoll.id); + + expect(mergedReply, isNotNull); + expect(mergedReply!.text, 'Definitely beach (edited)'); + expect(mergedReply.quotedMessage, isNotNull); + expect(mergedReply.quotedMessage!.poll?.id, poll.id); + }, + ); + + 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)', + 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', + name: 'What is for lunch?', + options: const [ + PollOption(id: 'opt-1', text: 'Burgers'), + PollOption(id: 'opt-2', text: 'Salads'), + ], + createdById: pollUser.id, + ); + + final pollMessage = Message( + id: 'parent-poll-msg', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + replyCount: 0, + ); + + // Seed channel state with the fully-enriched parent poll message. + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + messages: [pollMessage], + ), + ); + + // Simulate the `message.updated` event the backend fires for the + // parent after a thread reply is added: bookkeeping fields are bumped + // (`reply_count`, `updated_at`) but the `poll` object is omitted from + // the payload — only `pollId` is set. Constructed directly because + // copyWith cannot clear `poll` — see Message.copyWith. + final strippedParentUpdate = Message( + id: pollMessage.id, + pollId: pollMessage.pollId, + user: pollUser, + createdAt: pollMessage.createdAt, + replyCount: 1, + updatedAt: DateTime.utc(2026, 4, 29, 11), + ); + + await tester.emitEvent(_updateMessageEvent(strippedParentUpdate)); + + 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); + // Bookkeeping fields from the event should still apply. + expect(merged!.replyCount, 1); + // Locally-known poll must be preserved when the server omits it from a + // `message.updated` payload (e.g. when a thread reply bumps reply_count). + expect(merged.poll, isNotNull); + expect(merged.poll!.id, poll.id); + expect(merged.poll!.name, poll.name); + expect(merged.pollId, poll.id); + }, + ); + + 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)', + 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', + name: 'Initial name', + options: const [ + PollOption(id: 'opt-1', text: 'Original A'), + ], + createdById: pollUser.id, + ); + + final pollMessage = Message( + id: 'edit-parent', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + ); + + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + messages: [pollMessage], + ), + ); + + final updatedPoll = poll.copyWith(name: 'Edited name'); + final updatedParent = pollMessage.copyWith(poll: updatedPoll, updatedAt: DateTime.utc(2026, 4, 29, 12)); + + await tester.emitEvent(_updateMessageEvent(updatedParent)); + + 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. + expect(merged?.poll, isNotNull); + expect(merged?.poll?.name, 'Edited name'); + }, + ); + }); +} From b265bb8478d99afe923a8ca8467efe698c6c238e Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 11:25:47 +0200 Subject: [PATCH 024/103] test(llc): migrate updateMessage quoted-rewrite tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_client_state_test.dart | 136 ------------------ .../update_message_quoted_rewrite_test.dart | 129 +++++++++++++++++ 2 files changed, 129 insertions(+), 136 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/state/update_message_quoted_rewrite_test.dart 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 10c8f4198e..f304ff7903 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 @@ -596,142 +596,6 @@ void main() { 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.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - when(() => client.retryPolicy).thenReturn( - RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ), - ); - when(() => client.state).thenReturn(FakeClientState()); - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - 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( - 'rewrites quotedMessage on every quoter when target is deleted', - () { - final now = DateTime.now(); - final target = Message(id: 'target', text: 'hi', createdAt: now); - final quoter1 = Message( - id: 'q1', - text: 'reply', - quotedMessageId: 'target', - quotedMessage: target, - createdAt: now.add(const Duration(seconds: 1)), - ); - final unrelated = Message( - id: 'u1', - text: 'other', - createdAt: now.add(const Duration(seconds: 2)), - ); - final quoter2 = Message( - id: 'q2', - text: 'reply2', - quotedMessageId: 'target', - quotedMessage: target, - createdAt: now.add(const Duration(seconds: 3)), - ); - - final channel = _seededChannel(messages: [target, quoter1, unrelated, quoter2]); - addTearDown(channel.dispose); - - final unrelatedBefore = channel.state!.messages.firstWhere((m) => m.id == 'u1'); - - final deleted = target.copyWith( - type: MessageType.deleted, - deletedAt: now.add(const Duration(seconds: 5)), - ); - channel.state!.updateMessage(deleted); - - final after = channel.state!.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'); - - expect(q1After.quotedMessage?.deletedAt, isNotNull); - expect(q1After.quotedMessage?.type, MessageType.deleted); - expect(q2After.quotedMessage?.deletedAt, isNotNull); - expect(q2After.quotedMessage?.type, MessageType.deleted); - // Unrelated messages must not be rebuilt by the rewrite. - expect(identical(uAfter, unrelatedBefore), isTrue); - }, - ); - - test( - 'preserves messages reference when no message quotes the deleted one', - () { - final now = DateTime.now(); - final target = Message(id: 'target', text: 'hi', createdAt: now); - final unrelated = Message( - id: 'u1', - text: 'other', - createdAt: now.add(const Duration(seconds: 1)), - ); - - final channel = _seededChannel(messages: [target, unrelated]); - addTearDown(channel.dispose); - - final deleted = target.copyWith( - type: MessageType.deleted, - deletedAt: now.add(const Duration(seconds: 5)), - ); - channel.state!.updateMessage(deleted); - - // No message quotes `target`, so `updateIf` 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'); - expect(identical(unrelatedAfter, unrelated), isTrue); - }, - ); - - test( - 'does not rewrite quotes when an existing quoted target is updated ' - 'without being deleted', - () { - final now = DateTime.now(); - final target = Message(id: 'target', text: 'original', createdAt: now); - final quoter = Message( - id: 'q1', - text: 'reply', - quotedMessageId: 'target', - quotedMessage: target, - createdAt: now.add(const Duration(seconds: 1)), - ); - - final channel = _seededChannel(messages: [target, quoter]); - addTearDown(channel.dispose); - - final quoterBefore = channel.state!.messages.firstWhere((m) => m.id == 'q1'); - - // Plain text update — not a deletion. - channel.state!.updateMessage(target.copyWith(text: 'edited')); - - final quoterAfter = channel.state!.messages.firstWhere((m) => m.id == 'q1'); - // `updateIf` is gated on `message.isDeleted`, so the quoter must keep - // its identity (no allocation, no quoted-message overwrite). - expect(identical(quoterAfter, quoterBefore), isTrue); - }, - ); - }); } // region Test Helpers diff --git a/packages/stream_chat/test/src/client/channel/state/update_message_quoted_rewrite_test.dart b/packages/stream_chat/test/src/client/channel/state/update_message_quoted_rewrite_test.dart new file mode 100644 index 0000000000..f5b63f8bcb --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/state/update_message_quoted_rewrite_test.dart @@ -0,0 +1,129 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel({required List messages}) { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + messages: messages, + ); +} + +void main() { + group('updateMessage quoted-rewrite', () { + channelTest( + 'rewrites quotedMessage on every quoter when target is deleted', + 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', + text: 'reply', + quotedMessageId: 'target', + quotedMessage: target, + createdAt: now.add(const Duration(seconds: 1)), + ); + final unrelated = Message( + id: 'u1', + text: 'other', + createdAt: now.add(const Duration(seconds: 2)), + ); + final quoter2 = Message( + id: 'q2', + text: 'reply2', + quotedMessageId: 'target', + quotedMessage: target, + createdAt: now.add(const Duration(seconds: 3)), + ); + + await tester.watch( + modifyResponse: _seedChannel(messages: [target, quoter1, unrelated, quoter2]), + ); + + final unrelatedBefore = tester.channelState!.messages.firstWhere((m) => m.id == 'u1'); + + final deleted = target.copyWith( + type: MessageType.deleted, + deletedAt: now.add(const Duration(seconds: 5)), + ); + tester.channelState!.updateMessage(deleted); + + 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'); + + expect(q1After.quotedMessage?.deletedAt, isNotNull); + expect(q1After.quotedMessage?.type, MessageType.deleted); + expect(q2After.quotedMessage?.deletedAt, isNotNull); + expect(q2After.quotedMessage?.type, MessageType.deleted); + // Unrelated messages must not be rebuilt by the rewrite. + expect(identical(uAfter, unrelatedBefore), isTrue); + }, + ); + + channelTest( + 'preserves messages reference when no message quotes the deleted one', + 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', + text: 'other', + createdAt: now.add(const Duration(seconds: 1)), + ); + + await tester.watch(modifyResponse: _seedChannel(messages: [target, unrelated])); + + final deleted = target.copyWith( + type: MessageType.deleted, + deletedAt: now.add(const Duration(seconds: 5)), + ); + tester.channelState!.updateMessage(deleted); + + // No message quotes `target`, so `updateIf` short-circuits and the + // remaining messages keep their identities (only `target` itself was + // replaced by `sortedUpsert`). + final unrelatedAfter = tester.channelState!.messages.firstWhere((m) => m.id == 'u1'); + expect(identical(unrelatedAfter, unrelated), isTrue); + }, + ); + + channelTest( + 'does not rewrite quotes when an existing quoted target is updated ' + 'without being deleted', + 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', + text: 'reply', + quotedMessageId: 'target', + quotedMessage: target, + createdAt: now.add(const Duration(seconds: 1)), + ); + + await tester.watch(modifyResponse: _seedChannel(messages: [target, quoter])); + + final quoterBefore = tester.channelState!.messages.firstWhere((m) => m.id == 'q1'); + + // Plain text update — not a deletion. + tester.channelState!.updateMessage(target.copyWith(text: 'edited')); + + final quoterAfter = tester.channelState!.messages.firstWhere((m) => m.id == 'q1'); + // `updateIf` is gated on `message.isDeleted`, so the quoter must keep + // its identity (no allocation, no quoted-message overwrite). + expect(identical(quoterAfter, quoterBefore), isTrue); + }, + ); + }); +} From 81a5022aef9bd1739b545ee33afe6e52076cb854 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 11:27:20 +0200 Subject: [PATCH 025/103] test(llc): migrate updateChannelState identity guard tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_client_state_test.dart | 194 ------------------ ...ate_channel_state_identity_guard_test.dart | 164 +++++++++++++++ 2 files changed, 164 insertions(+), 194 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/state/update_channel_state_identity_guard_test.dart 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 f304ff7903..ace751fef8 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 @@ -425,204 +425,10 @@ void main() { }, ); }); - - group('updateChannelState identity guard', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); - - setUpAll(() { - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - when(() => client.retryPolicy).thenReturn( - RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ), - ); - when(() => client.state).thenReturn(FakeClientState()); - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - when( - () => client.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); - }); - - Channel _seededChannel() { - final base = _generateChannelState(channelId, channelType); - final now = DateTime.now(); - final seeded = base.copyWith( - 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))), - ], - ); - return Channel.fromState(client, seeded); - } - - test( - '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), - ); - final after = channel.state!.messages; - - expect(identical(before, after), isTrue); - }, - ); - - test( - 'preserves messages reference when updatedState.messages is identical', - () { - final channel = _seededChannel(); - addTearDown(channel.dispose); - - final before = channel.state!.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( - read: [ - Read( - user: User(id: 'me'), - lastRead: DateTime.now(), - unreadMessages: 1, - ), - ], - ), - ); - final after = channel.state!.messages; - - expect(identical(before, after), isTrue); - }, - ); - - test( - 'still merges messages when updatedState.messages is a different list', - () { - final channel = _seededChannel(); - addTearDown(channel.dispose); - - final newMessage = Message( - id: 'm4', - text: '4', - createdAt: DateTime.now().add(const Duration(seconds: 10)), - ); - channel.state!.updateChannelState( - ChannelState( - channel: channel.state!.channelState.channel, - messages: [newMessage], - ), - ); - - expect( - channel.state!.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'], - ); - }); - - test('cold-path merge runs syncWith on overlapping ids', () { - final channel = _seededChannel(); - addTearDown(channel.dispose); - - 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), - ); - - 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 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); - }); - }); } // 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); -} - Logger _createLogger(String name) { final logger = Logger.detached(name)..level = Level.ALL; logger.onRecord.listen(print); diff --git a/packages/stream_chat/test/src/client/channel/state/update_channel_state_identity_guard_test.dart b/packages/stream_chat/test/src/client/channel/state/update_channel_state_identity_guard_test.dart new file mode 100644 index 0000000000..d17566a405 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/state/update_channel_state_identity_guard_test.dart @@ -0,0 +1,164 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +// Pinned base timestamp for the seeded messages (m1, m2 +1s, m3 +2s). +final _baseCreatedAt = DateTime.utc(2021, 3); + +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + messages: [ + 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))), + ], + ); +} + +void main() { + group('updateChannelState identity guard', () { + channelTest( + 'preserves messages reference when updatedState.messages is null', + 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 = tester.channelState!.messages; + + expect(identical(before, after), isTrue); + }, + ); + + channelTest( + 'preserves messages reference when updatedState.messages is identical', + 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. + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [ + Read( + user: User(id: 'me'), + lastRead: DateTime.utc(2021, 3, 2), + unreadMessages: 1, + ), + ], + ), + ); + final after = tester.channelState!.messages; + + expect(identical(before, after), isTrue); + }, + ); + + channelTest( + 'still merges messages when updatedState.messages is a different list', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final newMessage = Message( + id: 'm4', + text: '4', + createdAt: _baseCreatedAt.add(const Duration(seconds: 10)), + ); + tester.channelState!.updateChannelState( + ChannelState( + channel: tester.channelState!.channelState.channel, + messages: [newMessage], + ), + ); + + expect( + tester.channelState!.messages.map((m) => m.id), + ['m1', 'm2', 'm3', 'm4'], + ); + }, + ); + + 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, + ), + ); + + expect( + tester.channelState!.messages.map((m) => m.id), + ['m1', 'm1.5', 'm2', 'm2.5', 'm3'], + ); + }, + ); + + 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: tester.channelState!.messages.firstWhere((m) => m.id == 'm2').createdAt, + ), + ]; + tester.channelState!.updateChannelState( + ChannelState( + channel: tester.channelState!.channelState.channel, + messages: incoming, + ), + ); + + 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); + }, + ); + }); +} From edb263960a775bc17e836bf0f022c330197d0d2d Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 11:29:21 +0200 Subject: [PATCH 026/103] test(llc): migrate local unread count tests and retire channel_client_state_test monolith Co-Authored-By: Claude Fable 5 --- .../channel/channel_client_state_test.dart | 438 ----------------- .../state/local_unread_count_test.dart | 444 ++++++++++++++++++ 2 files changed, 444 insertions(+), 438 deletions(-) delete mode 100644 packages/stream_chat/test/src/client/channel/channel_client_state_test.dart create mode 100644 packages/stream_chat/test/src/client/channel/state/local_unread_count_test.dart 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 deleted file mode 100644 index ace751fef8..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart +++ /dev/null @@ -1,438 +0,0 @@ -// 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 '../../fakes.dart'; -import '../../mocks.dart'; - -void main() { - 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.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - when(() => client.retryPolicy).thenReturn( - RetryPolicy(shouldRetry: (_, __, ___) => false, delayFactor: Duration.zero), - ); - when(() => client.state).thenReturn(FakeClientState(currentUser: currentUser)); - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - 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, - }) { - final channelState = ChannelState( - channel: ChannelModel( - id: channelId, - type: channelType, - config: ChannelConfig(readEvents: false), - ownCapabilities: const [], // No readEvents capability. - ), - messages: messages, - read: reads, - ); - - final channel = Channel.fromState(overrideClient ?? client, channelState); - addTearDown(channel.dispose); - return channel; - } - - test( - 'increments unreadCount locally for new messages when the channel has ' - 'no read events capability', - () async { - final channel = _createLivestreamChannel(); - expect(channel.state?.unreadCount, equals(0)); - - final message = Message( - id: 'message-1', - text: 'Hello', - user: User(id: 'other-user'), - createdAt: DateTime(2024, 1, 1), - ); - - client.addEvent( - Event(cid: channel.cid, type: EventType.messageNew, message: message), - ); - await Future.delayed(Duration.zero); - - expect(channel.state?.unreadCount, equals(1)); - }, - ); - - test( - 'does not increment unreadCount when local unread count tracking is ' - 'disabled', - () async { - final disabledClient = MockStreamChatClient(); - when(() => disabledClient.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - when(() => disabledClient.retryPolicy).thenReturn( - RetryPolicy(shouldRetry: (_, __, ___) => false), - ); - when(() => disabledClient.state).thenReturn(FakeClientState(currentUser: currentUser)); - when(() => disabledClient.logger).thenReturn(_createLogger('mock-client-logger')); - when( - () => disabledClient.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); - // `isLocalUnreadCountEnabled` defaults to `false` on the mock. - - final channel = _createLivestreamChannel(overrideClient: disabledClient); - - final message = Message( - id: 'message-1', - text: 'Hello', - user: User(id: 'other-user'), - createdAt: DateTime(2024, 1, 1), - ); - - disabledClient.addEvent( - Event(cid: channel.cid, type: EventType.messageNew, message: message), - ); - await Future.delayed(Duration.zero); - - expect(channel.state?.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)), - ), - ], - ); - 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(channel.state?.unreadCount, equals(0)); - }); - - 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)), - ), - ], - ); - 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)); - }); - - test( - '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'), - ), - ); - }, - ); - - test( - '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)); - - await expectLater( - 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()), - ); - }, - ); - - test( - 'markUnread throws when the message is not locally known', - () async { - final channel = _createLivestreamChannel(); - - await expectLater( - channel.markUnread('unknown-message-id'), - throwsA(isA()), - ); - verifyNever( - () => client.markChannelUnread(any(), any(), any()), - ); - }, - ); - - test( - 'markRead reconciles pending delivery receipts', - () async { - final channel = _createLivestreamChannel(); - channel.state!.unreadCount = 2; - - await expectLater(channel.markRead(), completes); - - verify( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).called(1); - }, - ); - - group('local read boundary anchors', () { - final start = DateTime(2024, 1, 1); - final messages = [ - Message( - id: 'm1', - text: '1', - user: User(id: 'other-user'), - createdAt: start, - ), - Message( - id: 'm2', - text: '2', - user: User(id: 'other-user'), - createdAt: start.add(const Duration(minutes: 1)), - ), - Message( - id: 'm3', - text: '3', - user: User(id: 'other-user'), - createdAt: start.add(const Duration(minutes: 2)), - ), - ]; - - test( - '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); - - // 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())); - }, - ); - - test( - '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); - }, - ); - - test( - '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))), - ], - ); - - // Exactly m2's createdAt: m2 stays read, only m3 becomes unread. - await expectLater(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())); - }, - ); - - test( - '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))), - ], - ); - - await byId.markUnread('m2'); - await byTimestamp.markUnreadByTimestamp(messages[1].createdAt); - - // `markUnread` includes m2, `markUnreadByTimestamp` excludes it. - expect(byId.state?.unreadCount, equals(2)); - expect(byTimestamp.state?.unreadCount, equals(1)); - - // ...and they agree once the timestamp is nudged below the anchor. - await byTimestamp.markUnreadByTimestamp( - messages[1].createdAt.subtract(const Duration(microseconds: 1)), - ); - expect(byTimestamp.state?.unreadCount, equals(2)); - expect(byTimestamp.state?.currentUserRead?.lastReadMessageId, equals('m1')); - }, - ); - }); - - test( - 'server payloads do not clobber the locally-tracked read state', - () async { - final channel = _createLivestreamChannel(); - channel.state!.unreadCount = 5; - - final serverRead = Read( - user: currentUser, - lastRead: DateTime.now(), - unreadMessages: 0, - ); - channel.state!.updateChannelStateFromServer( - channel.state!.channelState.copyWith(read: [serverRead]), - ); - - expect(channel.state?.unreadCount, equals(5)); - }, - ); - - test( - 'local (non-remote) state updates are not affected by the server-merge ' - 'guard', - () async { - final channel = _createLivestreamChannel(); - channel.state!.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); - - expect(channel.state?.unreadCount, equals(0)); - }, - ); - }); -} - -// region Test Helpers - -Logger _createLogger(String name) { - final logger = Logger.detached(name)..level = Level.ALL; - logger.onRecord.listen(print); - return logger; -} - -// endregion diff --git a/packages/stream_chat/test/src/client/channel/state/local_unread_count_test.dart b/packages/stream_chat/test/src/client/channel/state/local_unread_count_test.dart new file mode 100644 index 0000000000..37405e7c18 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/state/local_unread_count_test.dart @@ -0,0 +1,444 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +/// 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. +}) { + return createDefaultChannelState( + channel: createDefaultChannelModel( + cid: cid, + config: createDefaultChannelConfig(readEvents: false), + ownCapabilities: ownCapabilities, + ), + messages: messages, + read: read, + ); +} + +void main() { + group('Local unread count', () { + // 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), + ); + + channelTest( + 'increments unreadCount locally for new messages when the channel has ' + 'no read events capability', + 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.utc(2024), + ); + + await tester.emitEvent( + createDefaultEvent(cid: tester.channel.cid, type: EventType.messageNew, message: message), + ); + + expect(tester.channelState?.unreadCount, equals(1)); + }, + ); + + channelTest( + 'does not increment unreadCount when local unread count tracking is ' + 'disabled', + 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.utc(2024), + ); + + await tester.emitEvent( + createDefaultEvent(cid: tester.channel.cid, type: EventType.messageNew, message: message), + ); + + expect(tester.channelState?.unreadCount, equals(0)); + }, + ); + + 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, + ), + ); + + expect(tester.channelState?.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; + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageDeleted, + message: countedMessage, + hardDelete: false, + ), + ); + + expect(tester.channelState?.unreadCount, equals(1)); + }, + ); + + channelTest( + 'markRead resets unreadCount locally without making a network request', + 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')), + ); + }, + ); + + // 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( + 'markUnreadByTimestamp recomputes unreadCount locally without making a ' + 'network request', + 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( + tester.channel.markUnreadByTimestamp(now.add(const Duration(seconds: 30))), + completes, + ); + + // Only m2 and m3 were created after the given timestamp. + expect(tester.channelState?.unreadCount, equals(2)); + registerFallbackValue(DateTime.utc(2024)); + tester.verifyNeverCalled((api) => api.channel.markUnreadByTimestamp(any(), any(), any())); + }, + ); + + channelTest( + 'markUnread throws when the message is not locally known', + channelType: _channelType, + channelId: _channelId, + isLocalUnreadCountEnabled: true, + setUp: (tester) => tester.watch(modifyResponse: (_) => _livestreamChannelState()), + body: (tester) async { + await expectLater( + tester.channel.markUnread('unknown-message-id'), + throwsA(isA()), + ); + tester.verifyNeverCalled((api) => api.channel.markUnread(any(), any(), any())); + }, + ); + + channelTest( + 'markRead reconciles pending delivery receipts', + 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(tester.channel.markRead(), completes); + + // 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.utc(2024); + final messages = [ + Message( + id: 'm1', + text: '1', + user: User(id: 'other-user'), + createdAt: start, + ), + Message( + id: 'm2', + text: '2', + user: User(id: 'other-user'), + createdAt: start.add(const Duration(minutes: 1)), + ), + Message( + id: 'm3', + text: '3', + user: User(id: 'other-user'), + createdAt: start.add(const Duration(minutes: 2)), + ), + ]; + + 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', + 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(tester.channelState?.unreadCount, equals(2)); + expect(tester.channelState?.currentUserRead?.lastReadMessageId, equals('m1')); + tester.verifyNeverCalled((api) => api.channel.markUnread(any(), any(), any())); + }, + ); + + channelTest( + 'markUnread leaves lastReadMessageId null when the anchor is the oldest ' + 'known message', + 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); + }, + ); + + channelTest( + 'markUnreadByTimestamp is exclusive of the boundary and points ' + 'lastReadMessageId at the newest message at or before it', + 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(tester.channel.markUnreadByTimestamp(messages[1].createdAt), completes); + + 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())); + }, + ); + + channelTest( + 'markUnread(id) and markUnreadByTimestamp(createdAt) intentionally ' + 'differ by the anchor message', + 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); + + // `markUnread` includes m2, `markUnreadByTimestamp` excludes it. + expect(byId.state?.unreadCount, equals(2)); + expect(byTimestamp.state?.unreadCount, equals(1)); + + // ...and they agree once the timestamp is nudged below the anchor. + await byTimestamp.markUnreadByTimestamp( + messages[1].createdAt.subtract(const Duration(microseconds: 1)), + ); + expect(byTimestamp.state?.unreadCount, equals(2)); + expect(byTimestamp.state?.currentUserRead?.lastReadMessageId, equals('m1')); + }, + ); + }); + + channelTest( + 'server payloads do not clobber the locally-tracked read state', + channelType: _channelType, + channelId: _channelId, + isLocalUnreadCountEnabled: true, + setUp: (tester) => tester.watch(modifyResponse: (_) => _livestreamChannelState()), + body: (tester) async { + tester.channelState!.unreadCount = 5; + + final serverRead = Read( + user: tester.currentUser!, + lastRead: DateTime.utc(2024, 1, 2), + unreadMessages: 0, + ); + tester.channelState!.updateChannelStateFromServer( + tester.channelState!.channelState.copyWith(read: [serverRead]), + ); + + expect(tester.channelState?.unreadCount, equals(5)); + }, + ); + + channelTest( + 'local (non-remote) state updates are not affected by the server-merge ' + 'guard', + 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(tester.channel.markRead(), completes); + + expect(tester.channelState?.unreadCount, equals(0)); + }, + ); + }); +} From 02761387b6ff391d54a3ef3273ff6d5849fd1989 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 11:42:32 +0200 Subject: [PATCH 027/103] test(llc): migrate channel search and sendAction tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../client/channel/channel_search_test.dart | 106 +++++++++++++++ .../channel/channel_send_action_test.dart | 84 ++++++++++++ .../test/src/client/channel/channel_test.dart | 128 ------------------ .../lib/src/helpers/test_data.dart | 85 ++++++++++++ 4 files changed, 275 insertions(+), 128 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_search_test.dart create mode 100644 packages/stream_chat/test/src/client/channel/channel_send_action_test.dart diff --git a/packages/stream_chat/test/src/client/channel/channel_search_test.dart b/packages/stream_chat/test/src/client/channel/channel_search_test.dart new file mode 100644 index 0000000000..c27b09aa20 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_search_test.dart @@ -0,0 +1,106 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); +} + +void main() { + group('`.search`', () { + final filter = Filter.in_('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'; + const sort = [SortOption.asc('test-sort-field')]; + const pagination = PaginationParams(); + + final results = List.generate(3, (index) => createDefaultGetMessageResponse()); + + tester.mockApi( + (api) => api.general.searchMessages( + filter, + query: query, + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + result: createDefaultSearchMessagesResponse(results: results), + ); + + final res = await tester.channel.search( + query: query, + sort: sort, + paginationParams: pagination, + ); + + expect(res, isNotNull); + expect(res.results.length, results.length); + + tester.verifyApi( + (api) => api.general.searchMessages( + filter, + query: query, + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ); + }, + ); + + channelTest( + 'should work fine with `messageFilters`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final messageFilters = Filter.query('key', 'text'); + const sort = [SortOption.desc('test-sort-field')]; + const pagination = PaginationParams(); + + final results = List.generate(3, (index) => createDefaultGetMessageResponse()); + + tester.mockApi( + (api) => api.general.searchMessages( + filter, + messageFilters: messageFilters, + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + result: createDefaultSearchMessagesResponse(results: results), + ); + + final res = await tester.channel.search( + sort: sort, + paginationParams: pagination, + messageFilters: messageFilters, + ); + + expect(res, isNotNull); + expect(res.results.length, results.length); + + tester.verifyApi( + (api) => api.general.searchMessages( + filter, + messageFilters: messageFilters, + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ); + }, + ); + }); +} diff --git a/packages/stream_chat/test/src/client/channel/channel_send_action_test.dart b/packages/stream_chat/test/src/client/channel/channel_send_action_test.dart new file mode 100644 index 0000000000..26eddc12ce --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_send_action_test.dart @@ -0,0 +1,84 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); +} + +void main() { + group('`.sendAction`', () { + 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 tester.channel.sendAction(message, formData); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.message.sendAction(_channelId, _channelType, message.id, formData), + ); + }, + ); + + 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), + ); + + final messagesEmission = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message, + matchMessageState: true, + ), + ], + ]), + ); + + final res = await tester.channel.sendAction(message, formData); + + expect(res, isNotNull); + expect(res.message?.id, message.id); + + tester.verifyApi( + (api) => api.message.sendAction(_channelId, _channelType, message.id, formData), + ); + + await messagesEmission; + }, + ); + }); +} 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 d7f3b27a86..b11d1d3123 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -2767,84 +2767,6 @@ void main() { ).called(1); }); - group('`.search`', () { - final filter = Filter.in_('cid', const [channelCid]); - - test('should work fine with `query`', () async { - const query = 'test-search-query'; - const sort = [SortOption.asc('test-sort-field')]; - const pagination = PaginationParams(); - - final results = List.generate(3, (index) => GetMessageResponse()); - - when( - () => client.search( - filter, - query: query, - sort: any(named: 'sort'), - paginationParams: any(named: 'paginationParams'), - ), - ).thenAnswer( - (_) async => SearchMessagesResponse()..results = results, - ); - - final res = await channel.search( - query: query, - sort: sort, - paginationParams: pagination, - ); - - expect(res, isNotNull); - expect(res.results.length, results.length); - - verify( - () => client.search( - filter, - query: query, - sort: any(named: 'sort'), - paginationParams: any(named: 'paginationParams'), - ), - ).called(1); - }); - - test('should work fine with `messageFilters`', () async { - final messageFilters = Filter.query('key', 'text'); - const sort = [SortOption.desc('test-sort-field')]; - const pagination = PaginationParams(); - - final results = List.generate(3, (index) => GetMessageResponse()); - - when( - () => client.search( - filter, - messageFilters: messageFilters, - sort: any(named: 'sort'), - paginationParams: any(named: 'paginationParams'), - ), - ).thenAnswer( - (_) async => SearchMessagesResponse()..results = results, - ); - - final res = await channel.search( - sort: sort, - paginationParams: pagination, - messageFilters: messageFilters, - ); - - expect(res, isNotNull); - expect(res.results.length, results.length); - - verify( - () => client.search( - filter, - messageFilters: messageFilters, - sort: any(named: 'sort'), - paginationParams: any(named: 'paginationParams'), - ), - ).called(1); - }); - }); - test('`.deleteFile`', () async { const url = 'test-file-url'; @@ -3903,56 +3825,6 @@ void main() { ).called(1); }); - group('`.sendAction`', () { - test('should work fine', () async { - final message = Message(id: 'test-message-id', text: 'Action Sent'); - const formData = {'key': 'value'}; - - when( - () => client.sendAction(channelId, channelType, message.id, formData), - ).thenAnswer((_) async => SendActionResponse()); - - final res = await channel.sendAction(message, formData); - - expect(res, isNotNull); - - verify( - () => client.sendAction(channelId, channelType, message.id, formData), - ).called(1); - }); - - test('should emit received message if not null', () async { - final message = Message(id: 'test-message-id', text: 'Action Sent'); - const formData = {'key': 'value'}; - - when( - () => client.sendAction(channelId, channelType, message.id, formData), - ).thenAnswer((_) async => SendActionResponse()..message = message); - - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message, - matchMessageState: true, - ), - ], - ]), - ); - - final res = await channel.sendAction(message, formData); - - expect(res, isNotNull); - expect(res.message?.id, message.id); - - verify( - () => client.sendAction(channelId, channelType, message.id, formData), - ).called(1); - }); - }); - group('`.watch`', () { test('should work fine', () async { when( diff --git a/packages/stream_chat_test/lib/src/helpers/test_data.dart b/packages/stream_chat_test/lib/src/helpers/test_data.dart index 4c58fce401..ceeb35d258 100644 --- a/packages/stream_chat_test/lib/src/helpers/test_data.dart +++ b/packages/stream_chat_test/lib/src/helpers/test_data.dart @@ -326,6 +326,24 @@ Reaction createDefaultReaction({ ); } +/// 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 @@ -374,6 +392,73 @@ SendMessageResponse createDefaultSendMessageResponse({ ..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 [SearchMessagesResponse] wrapping [results]. +SearchMessagesResponse createDefaultSearchMessagesResponse({ + List results = const [], + String? next, + String? previous, +}) { + return SearchMessagesResponse() + ..duration = '10ms' + ..results = results + ..next = next + ..previous = previous; +} + /// Creates a [QueryChannelsResponse] wrapping [channels]. QueryChannelsResponse createDefaultQueryChannelsResponse({ List channels = const [], From 941b7b851eda2e5a0424d275984302b3ef1adf5f Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 11:44:08 +0200 Subject: [PATCH 028/103] test(llc): migrate channel pin message tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_pin_message_test.dart | 283 ++++++++++++++++++ .../test/src/client/channel/channel_test.dart | 231 -------------- 2 files changed, 283 insertions(+), 231 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_pin_message_test.dart diff --git a/packages/stream_chat/test/src/client/channel/channel_pin_message_test.dart b/packages/stream_chat/test/src/client/channel/channel_pin_message_test.dart new file mode 100644 index 0000000000..c70d1b99cc --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_pin_message_test.dart @@ -0,0 +1,283 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); +} + +void main() { + group('`.pinMessage`', () { + channelTest( + 'should work fine without passing timeoutOrExpirationDate', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message(id: 'test-message-id'); + + tester.mockApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: {'pinned': true, 'pin_expires': null}, + ), + 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 tester.channel.pinMessage(message); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNull); + + await messagesEmits; + + tester.verifyApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: {'pinned': true, 'pin_expires': null}, + ), + ); + }, + ); + + channelTest( + 'should work fine if passed timeoutOrExpirationDate as num(seconds)', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message(id: 'test-message-id'); + const timeoutOrExpirationDate = 300; // 300 seconds + + // 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'), + ), + result: createDefaultUpdateMessageResponse( + message: message.copyWith( + pinned: true, + pinExpires: DateTime.utc(2021, 3).add( + const Duration(seconds: timeoutOrExpirationDate), + ), + ), + ), + ); + + 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 tester.channel.pinMessage( + message, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNotNull); + + await messagesEmits; + + tester.verifyApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: any(named: 'set'), + ), + ); + }, + ); + + channelTest( + 'should work fine if passed timeoutOrExpirationDate as DateTime', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message(id: 'test-message-id'); + final timeoutOrExpirationDate = DateTime.utc(2021, 3).add(const Duration(days: 3)); // 3 days + + tester.mockApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: { + 'pinned': true, + 'pin_expires': timeoutOrExpirationDate.toUtc().toIso8601String(), + }, + ), + result: createDefaultUpdateMessageResponse( + message: message.copyWith( + pinned: true, + pinExpires: timeoutOrExpirationDate, + ), + ), + ); + + 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 tester.channel.pinMessage( + message, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNotNull); + expect(res.message.pinExpires, timeoutOrExpirationDate.toUtc()); + + await messagesEmits; + + tester.verifyApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: { + 'pinned': true, + 'pin_expires': timeoutOrExpirationDate.toUtc().toIso8601String(), + }, + ), + ); + }, + ); + + channelTest( + 'should throw if invalid timeoutOrExpirationDate is passed', + 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 tester.channel.pinMessage( + message, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + } catch (e) { + expect(e, isA()); + } + }, + ); + }); + + channelTest( + '`.unpinMessage`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message(id: 'test-message-id', pinned: true); + + tester.mockApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: {'pinned': false}, + ), + result: createDefaultUpdateMessageResponse( + message: message.copyWith(pinned: false), + ), + ); + + 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 tester.channel.unpinMessage(message); + + expect(res, isNotNull); + expect(res.message.pinned, isFalse); + + await messagesEmits; + + tester.verifyApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: {'pinned': false}, + ), + ); + }, + ); +} 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 b11d1d3123..14b495cbcf 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -2536,237 +2536,6 @@ void main() { ); }); - group('`.pinMessage`', () { - test('should work fine without passing timeoutOrExpirationDate', () 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, - ), - ); - - 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.pinMessage(message); - - 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); - }); - - test( - 'should work fine if passed timeoutOrExpirationDate as num(seconds)', - () async { - final message = Message(id: 'test-message-id'); - const timeoutOrExpirationDate = 300; // 300 seconds - - when( - () => client.partialUpdateMessage( - message.id, - set: any(named: 'set'), - unset: any(named: 'unset'), - ), - ).thenAnswer( - (_) async => UpdateMessageResponse() - ..message = message.copyWith( - pinned: true, - pinExpires: DateTime.now().add( - const Duration(seconds: timeoutOrExpirationDate), - ), - ), - ); - - 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.pinMessage( - message, - timeoutOrExpirationDate: timeoutOrExpirationDate, - ); - - expect(res, isNotNull); - expect(res.message.pinned, isTrue); - expect(res.message.pinExpires, isNotNull); - - verify( - () => client.partialUpdateMessage( - message.id, - set: any(named: 'set'), - unset: any(named: 'unset'), - ), - ).called(1); - }, - ); - - test( - 'should work fine if passed timeoutOrExpirationDate as DateTime', - () async { - final message = Message(id: 'test-message-id'); - final timeoutOrExpirationDate = DateTime.now().add(const Duration(days: 3)); // 3 days - - when( - () => client.partialUpdateMessage( - message.id, - set: any(named: 'set'), - unset: any(named: 'unset'), - ), - ).thenAnswer( - (_) async => UpdateMessageResponse() - ..message = message.copyWith( - pinned: true, - pinExpires: timeoutOrExpirationDate, - ), - ); - - 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.pinMessage( - message, - timeoutOrExpirationDate: timeoutOrExpirationDate, - ); - - expect(res, isNotNull); - expect(res.message.pinned, isTrue); - expect(res.message.pinExpires, isNotNull); - expect(res.message.pinExpires, timeoutOrExpirationDate.toUtc()); - - verify( - () => client.partialUpdateMessage( - message.id, - set: any(named: 'set'), - unset: any(named: 'unset'), - ), - ).called(1); - }, - ); - - test( - 'should throw if invalid timeoutOrExpirationDate is passed', - () async { - final message = Message(id: 'test-message-id'); - const timeoutOrExpirationDate = 'invalid-value'; - - try { - await channel.pinMessage( - message, - timeoutOrExpirationDate: timeoutOrExpirationDate, - ); - } catch (e) { - expect(e, isA()); - } - }, - ); - }); - - test('`.unpinMessage`', () async { - final message = Message(id: 'test-message-id', pinned: true); - - when( - () => client.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); - }); - test('`.deleteFile`', () async { const url = 'test-file-url'; From 8645a2f4d529084fee9606a457a3dc1758db3fe7 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 11:45:43 +0200 Subject: [PATCH 029/103] test(llc): migrate channel reminder tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_reminders_test.dart | 169 ++++++++++++++++++ .../test/src/client/channel/channel_test.dart | 114 ------------ 2 files changed, 169 insertions(+), 114 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_reminders_test.dart diff --git a/packages/stream_chat/test/src/client/channel/channel_reminders_test.dart b/packages/stream_chat/test/src/client/channel/channel_reminders_test.dart new file mode 100644 index 0000000000..71cc59e079 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_reminders_test.dart @@ -0,0 +1,169 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +Future _seedChannel(ChannelTester tester) => tester.watch( + modifyResponse: (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ), +); + +void main() { + group('`.createReminder`', () { + const messageId = 'test-message-id'; + final reminderRemindAt = DateTime.utc(2024, 6, 15, 14, 30); + + channelTest( + 'should call client.createReminder', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + body: (tester) async { + tester.mockApi( + (api) => api.reminders.createReminder(messageId), + result: createDefaultCreateReminderResponse( + reminder: createDefaultMessageReminder( + messageId: messageId, + channelCid: _channelCid, + remindAt: reminderRemindAt, + ), + ), + ); + + final res = await tester.channel.createReminder(messageId); + + expect(res, isNotNull); + expect(res.reminder.messageId, messageId); + + tester.verifyApi( + (api) => api.reminders.createReminder(messageId), + ); + }, + ); + + channelTest( + 'with remindAt should pass remindAt to client', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + 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, + ), + ), + ); + + final res = await tester.channel.createReminder(messageId, remindAt: remindAt); + + 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'; + final reminderRemindAt = DateTime.utc(2024, 8, 20, 16, 45); + + channelTest( + 'should call client.updateReminder', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + body: (tester) async { + tester.mockApi( + (api) => api.reminders.updateReminder(messageId), + result: createDefaultUpdateReminderResponse( + reminder: createDefaultMessageReminder( + messageId: messageId, + channelCid: _channelCid, + remindAt: reminderRemindAt, + ), + ), + ); + + final res = await tester.channel.updateReminder(messageId); + + expect(res, isNotNull); + expect(res.reminder.messageId, messageId); + + tester.verifyApi( + (api) => api.reminders.updateReminder(messageId), + ); + }, + ); + + channelTest( + 'with remindAt should pass remindAt to client', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + 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, + ), + ), + ); + + final res = await tester.channel.updateReminder(messageId, remindAt: remindAt); + + expect(res, isNotNull); + expect(res.reminder.messageId, messageId); + expect(res.reminder.remindAt, remindAt); + + tester.verifyApi( + (api) => api.reminders.updateReminder(messageId, remindAt: remindAt), + ); + }, + ); + }); + + group('`.deleteReminder`', () { + const messageId = 'test-message-id'; + + channelTest( + 'should call client.deleteReminder', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + body: (tester) async { + tester.mockApi( + (api) => api.reminders.deleteReminder(messageId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.deleteReminder(messageId); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.reminders.deleteReminder(messageId), + ); + }, + ); + }); +} 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 14b495cbcf..2df1e4870a 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -1304,120 +1304,6 @@ void main() { }); }); - 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(), - ), - ); - }); - - test('should call client.createReminder', () async { - final res = await channel.createReminder(messageId); - - expect(res, isNotNull); - expect(res.reminder.messageId, messageId); - - verify(() => channel.client.createReminder(messageId)).called(1); - }); - - 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); - - expect(res, isNotNull); - expect(res.reminder.messageId, messageId); - expect(res.reminder.remindAt, remindAt); - - verify( - () => channel.client.createReminder( - messageId, - remindAt: remindAt, - ), - ).called(1); - }); - }); - - 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(), - ), - ); - }); - - test('should call client.updateReminder', () async { - final res = await channel.updateReminder(messageId); - - expect(res, isNotNull); - expect(res.reminder.messageId, messageId); - - verify(() => channel.client.updateReminder(messageId)).called(1); - }); - - 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); - - expect(res, isNotNull); - expect(res.reminder.messageId, messageId); - expect(res.reminder.remindAt, remindAt); - - verify( - () => channel.client.updateReminder( - messageId, - remindAt: remindAt, - ), - ).called(1); - }); - }); - - group('`.deleteReminder`', () { - const messageId = 'test-message-id'; - - setUp(() { - when(() => client.deleteReminder(messageId)).thenAnswer( - (_) async => EmptyResponse(), - ); - }); - - test('should call client.deleteReminder', () async { - final res = await channel.deleteReminder(messageId); - - expect(res, isNotNull); - - verify(() => channel.client.deleteReminder(messageId)).called(1); - }); - }); - group('`.updateMessage`', () { test('should work fine', () async { final message = Message( From f39ebcbf3591f9f6b6c48141cf39c381ba02955f Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 11:47:16 +0200 Subject: [PATCH 030/103] test(llc): migrate channel draft tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../client/channel/channel_drafts_test.dart | 144 ++++++++++++++++++ .../test/src/client/channel/channel_test.dart | 127 --------------- 2 files changed, 144 insertions(+), 127 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_drafts_test.dart diff --git a/packages/stream_chat/test/src/client/channel/channel_drafts_test.dart b/packages/stream_chat/test/src/client/channel/channel_drafts_test.dart new file mode 100644 index 0000000000..a7ab77fcc0 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_drafts_test.dart @@ -0,0 +1,144 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +Future _seedChannel(ChannelTester tester) => tester.watch( + modifyResponse: (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ), +); + +void main() { + group('`.createDraft`', () { + final draftMessage = DraftMessage(text: 'Draft message text'); + + channelTest( + 'should call client.createDraft', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + body: (tester) async { + tester.mockApi( + (api) => api.message.createDraft(_channelId, _channelType, draftMessage), + result: createDefaultCreateDraftResponse( + draft: createDefaultDraft(channelCid: _channelCid, message: draftMessage), + ), + ); + + final res = await tester.channel.createDraft(draftMessage); + + expect(res, isNotNull); + expect(res.draft.message, draftMessage); + + tester.verifyApi( + (api) => api.message.createDraft(_channelId, _channelType, draftMessage), + ); + }, + ); + }); + + group('`.getDraft`', () { + final draftMessage = DraftMessage(text: 'Draft message text'); + + channelTest( + 'should call client.getDraft', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + body: (tester) async { + tester.mockApi( + (api) => api.message.getDraft(_channelId, _channelType), + result: createDefaultGetDraftResponse( + draft: createDefaultDraft(channelCid: _channelCid, message: draftMessage), + ), + ); + + final res = await tester.channel.getDraft(); + + expect(res, isNotNull); + expect(res.draft.message, draftMessage); + + tester.verifyApi( + (api) => api.message.getDraft(_channelId, _channelType), + ); + }, + ); + + channelTest( + 'with parentId should pass parentId to client', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + 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), + ), + ); + + final res = await tester.channel.getDraft(parentId: parentId); + + expect(res, isNotNull); + expect(res.draft.message, draftMessage); + + tester.verifyApi( + (api) => api.message.getDraft(_channelId, _channelType, parentId: parentId), + ); + }, + ); + }); + + group('`.deleteDraft`', () { + channelTest( + 'should call client.deleteDraft', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + body: (tester) async { + tester.mockApi( + (api) => api.message.deleteDraft(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.deleteDraft(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.message.deleteDraft(_channelId, _channelType), + ); + }, + ); + + channelTest( + 'with parentId should pass parentId to client', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + body: (tester) async { + const parentId = 'parent-123'; + tester.mockApi( + (api) => api.message.deleteDraft(_channelId, _channelType, parentId: parentId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.deleteDraft(parentId: parentId); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.message.deleteDraft(_channelId, _channelType, parentId: parentId), + ); + }, + ); + }); +} 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 2df1e4870a..789e3d8ec3 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -1177,133 +1177,6 @@ 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, - ), - ); - }); - - test('should call client.createDraft', () async { - final res = await channel.createDraft(draftMessage); - - expect(res, isNotNull); - expect(res.draft.message, draftMessage); - - verify( - () => channel.client.createDraft( - draftMessage, - channelId, - channelType, - ), - ).called(1); - }); - }); - - 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, - ), - ); - }); - - test('should call client.getDraft', () async { - final res = await channel.getDraft(); - - expect(res, isNotNull); - expect(res.draft.message, draftMessage); - - verify( - () => channel.client.getDraft( - channelId, - channelType, - ), - ).called(1); - }); - - test('with parentId should pass parentId to client', () async { - const parentId = 'parent-123'; - final res = await channel.getDraft(parentId: parentId); - - expect(res, isNotNull); - expect(res.draft.message, draftMessage); - - verify( - () => channel.client.getDraft( - channelId, - channelType, - parentId: parentId, - ), - ).called(1); - }); - }); - - group('`.deleteDraft`', () { - setUp(() { - when( - () => client.deleteDraft( - channelId, - channelType, - parentId: any(named: 'parentId'), - ), - ).thenAnswer((_) async => EmptyResponse()); - }); - - test('should call client.deleteDraft', () async { - final res = await channel.deleteDraft(); - - expect(res, isNotNull); - - verify( - () => channel.client.deleteDraft( - channelId, - channelType, - ), - ).called(1); - }); - - test('with parentId should pass parentId to client', () async { - const parentId = 'parent-123'; - final res = await channel.deleteDraft(parentId: parentId); - - expect(res, isNotNull); - - verify( - () => channel.client.deleteDraft( - channelId, - channelType, - parentId: parentId, - ), - ).called(1); - }); - }); - group('`.updateMessage`', () { test('should work fine', () async { final message = Message( From d75280b4090c7a85219748322b5beaa8096f123d Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 12:07:19 +0200 Subject: [PATCH 031/103] test(llc): migrate channel misc operation tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_misc_operations_test.dart | 126 ++++++++++++++++++ .../test/src/client/channel/channel_test.dart | 64 --------- 2 files changed, 126 insertions(+), 64 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_misc_operations_test.dart diff --git a/packages/stream_chat/test/src/client/channel/channel_misc_operations_test.dart b/packages/stream_chat/test/src/client/channel/channel_misc_operations_test.dart new file mode 100644 index 0000000000..5ec8843ade --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_misc_operations_test.dart @@ -0,0 +1,126 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); +} + +void main() { + channelTest( + '`.deleteFile`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const url = 'test-file-url'; + + tester.mockApi( + (api) => api.fileUploader.deleteFile(url, _channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.deleteFile(url); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.fileUploader.deleteFile(url, _channelId, _channelType), + ); + }, + ); + + channelTest( + '`.deleteImage`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const url = 'test-image-url'; + + tester.mockApi( + (api) => api.fileUploader.deleteImage(url, _channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.deleteImage(url); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.fileUploader.deleteImage(url, _channelId, _channelType), + ); + }, + ); + + channelTest( + '`.stopAIResponse`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + 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 tester.channel.stopAIResponse(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(stopAIEvent)), + ), + ); + }, + ); + + channelTest( + '`.sendEvent`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final event = Event(type: 'event.local'); + + tester.mockApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(event)), + ), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.sendEvent(event); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(event)), + ), + ); + }, + ); +} 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 789e3d8ec3..69adc518ce 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -2295,70 +2295,6 @@ void main() { ); }); - test('`.deleteFile`', () async { - const url = 'test-file-url'; - - when( - () => client.deleteFile(url, channelId, channelType, cancelToken: any(named: 'cancelToken')), - ).thenAnswer((_) async => EmptyResponse()); - - final res = await channel.deleteFile(url); - - expect(res, isNotNull); - - verify(() => client.deleteFile(url, channelId, channelType, cancelToken: any(named: 'cancelToken'))).called(1); - }); - - test('`.deleteImage`', () async { - const url = 'test-image-url'; - - when( - () => client.deleteImage(url, channelId, channelType, cancelToken: any(named: 'cancelToken')), - ).thenAnswer((_) async => EmptyResponse()); - - final res = await channel.deleteImage(url); - - expect(res, isNotNull); - - verify(() => client.deleteImage(url, channelId, channelType, cancelToken: any(named: 'cancelToken'))).called(1); - }); - - test('`.stopAIResponse`', () async { - final stopAIEvent = Event(type: EventType.aiIndicatorStop); - - when( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(stopAIEvent)), - ), - ).thenAnswer((_) async => EmptyResponse()); - - final res = await channel.stopAIResponse(); - - expect(res, isNotNull); - - verify( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(stopAIEvent)), - ), - ).called(1); - }); - - test('`.sendEvent`', () async { - final event = Event(type: 'event.local'); - - when(() => client.sendEvent(channelId, channelType, event)).thenAnswer((_) async => EmptyResponse()); - - final res = await channel.sendEvent(event); - - expect(res, isNotNull); - - verify(() => client.sendEvent(channelId, channelType, event)).called(1); - }); - group('`.sendReaction`', () { test('should work fine', () async { final message = Message( From b00263611bac96e9991e5731c25acb07bb36fc1f Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 12:08:50 +0200 Subject: [PATCH 032/103] test(llc): migrate channel delete message tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_delete_message_test.dart | 269 ++++++++++++++++++ .../test/src/client/channel/channel_test.dart | 195 ------------- 2 files changed, 269 insertions(+), 195 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_delete_message_test.dart diff --git a/packages/stream_chat/test/src/client/channel/channel_delete_message_test.dart b/packages/stream_chat/test/src/client/channel/channel_delete_message_test.dart new file mode 100644 index 0000000000..df0d422f46 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_delete_message_test.dart @@ -0,0 +1,269 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); +} + +void main() { + group('`.deleteMessage`', () { + channelTest( + 'should work fine', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + createdAt: DateTime.utc(2021, 3), + state: MessageState.sent, + ); + + tester.mockApi( + (api) => api.message.deleteMessage(messageId, hard: false), + 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. + tester.channelState?.addNewMessage(message); + + 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, + ), + ], + ]), + ); + + final res = await tester.channel.deleteMessage(message); + + expect(res, isNotNull); + + await messagesEmits; + + tester.verifyApi( + (api) => api.message.deleteMessage(messageId, hard: false), + ); + }, + ); + + channelTest( + 'should delete attachments for hard delete', + 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'), + imageUrl: index.isEven ? 'test-image-url-$index' : null, + assetUrl: index.isOdd ? 'test-asset-url-$index' : null, + uploadState: const UploadState.success(), + ), + ); + + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + attachments: attachments, + createdAt: DateTime.utc(2021, 3), + state: MessageState.sent, + ); + + 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(), + ); + + final res = await tester.channel.deleteMessage(message, hard: true); + expect(res, isNotNull); + + 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), + ); + }, + ); + + channelTest( + 'should hard delete the message if the state is sending or failed', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + text: 'Hello World!', + state: MessageState.sending, + ); + + final messagesEmits = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.sending), + matchMessageState: true, + ), + ], + const [], // message is hard deleted from state + ]), + ); + + // Add message to channel state first + tester.channelState?.addNewMessage(message); + + final res = await tester.channel.deleteMessage(message); + + expect(res, isNotNull); + + await messagesEmits; + + tester.verifyNeverCalled( + (api) => api.message.deleteMessage(messageId, hard: false), + ); + }, + ); + }); + + group('`.deleteMessageForMe`', () { + channelTest( + 'should work fine', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + createdAt: DateTime.utc(2021, 3), + state: MessageState.sent, + ); + + 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. + tester.channelState?.addNewMessage(message); + + 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 tester.channel.deleteMessageForMe(message); + + expect(res, isNotNull); + + await messagesEmits; + + tester.verifyApi( + (api) => api.message.deleteMessage(messageId, deleteForMe: true), + ); + }, + ); + + channelTest( + 'should hard delete the message if the state is sending or failed', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + text: 'Hello World!', + state: MessageState.sending, + ); + + final messagesEmits = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.sending), + matchMessageState: true, + ), + ], + const [], // message is hard deleted from state + ]), + ); + + // Add message to channel state first + tester.channelState?.addNewMessage(message); + + final res = await tester.channel.deleteMessageForMe(message); + + expect(res, isNotNull); + + await messagesEmits; + + tester.verifyNeverCalled( + (api) => api.message.deleteMessage(messageId, deleteForMe: true), + ); + }, + ); + }); +} 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 69adc518ce..3b58ea650e 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -2100,201 +2100,6 @@ void main() { }); }); - group('`.deleteMessage`', () { - test('should work fine', () async { - const messageId = 'test-message-id'; - final message = Message( - id: messageId, - createdAt: DateTime.now(), - state: MessageState.sent, - ); - - when(() => client.deleteMessage(messageId)).thenAnswer((_) async => EmptyResponse()); - - // 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); - - 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, - ), - ], - ]), - ); - - final res = await channel.deleteMessage(message); - - expect(res, isNotNull); - - verify(() => client.deleteMessage(messageId)).called(1); - }); - - 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(), - ), - ); - - const messageId = 'test-message-id'; - final message = Message( - id: messageId, - attachments: attachments, - createdAt: DateTime.now(), - state: MessageState.sent, - ); - - when( - () => client.deleteMessage(messageId, hard: true), - ).thenAnswer((_) async => EmptyResponse()); - - when( - () => client.deleteImage(any(), channelId, channelType), - ).thenAnswer((_) async => EmptyResponse()); - - when( - () => client.deleteFile(any(), channelId, channelType), - ).thenAnswer((_) async => EmptyResponse()); - - final res = await channel.deleteMessage(message, hard: true); - expect(res, isNotNull); - - verify(() => client.deleteMessage(messageId, hard: true)).called(1); - - verify(() => client.deleteImage(any(), channelId, channelType)).called(2); - - verify(() => client.deleteFile(any(), channelId, channelType)).called(1); - }); - - test( - 'should hard delete the message if the state is sending or failed', - () async { - const messageId = 'test-message-id'; - final message = Message( - id: messageId, - text: 'Hello World!', - state: MessageState.sending, - ); - - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith(state: MessageState.sending), - matchMessageState: true, - ), - ], - const [], // message is hard deleted from state - ]), - ); - - // Add message to channel state first - channel.state?.addNewMessage(message); - - final res = await channel.deleteMessage(message); - - expect(res, isNotNull); - verifyNever(() => client.deleteMessage(messageId)); - }, - ); - }); - - group('`.deleteMessageForMe`', () { - test('should work fine', () async { - const messageId = 'test-message-id'; - final message = Message( - id: messageId, - createdAt: DateTime.now(), - state: MessageState.sent, - ); - - when(() => client.deleteMessageForMe(messageId)).thenAnswer((_) async => EmptyResponse()); - - // 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); - - 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 res = await channel.deleteMessageForMe(message); - - expect(res, isNotNull); - - verify(() => client.deleteMessageForMe(messageId)).called(1); - }); - - test( - 'should hard delete the message if the state is sending or failed', - () async { - const messageId = 'test-message-id'; - final message = Message( - id: messageId, - text: 'Hello World!', - state: MessageState.sending, - ); - - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith(state: MessageState.sending), - matchMessageState: true, - ), - ], - const [], // message is hard deleted from state - ]), - ); - - // Add message to channel state first - channel.state?.addNewMessage(message); - - final res = await channel.deleteMessageForMe(message); - - expect(res, isNotNull); - verifyNever(() => client.deleteMessageForMe(messageId)); - }, - ); - }); - group('`.sendReaction`', () { test('should work fine', () async { final message = Message( From ef3a1f84622d48d8b082ecf32a5cba01c96b7f86 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 12:10:25 +0200 Subject: [PATCH 033/103] test(llc): migrate channel update message tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../test/src/client/channel/channel_test.dart | 923 ------------- .../channel/channel_update_message_test.dart | 1145 +++++++++++++++++ .../lib/src/helpers/api_mocker_mixin.dart | 25 + 3 files changed, 1170 insertions(+), 923 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_update_message_test.dart 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 3b58ea650e..a9ad6a95c6 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -1177,929 +1177,6 @@ void main() { ); }); - group('`.updateMessage`', () { - test('should work fine', () async { - final message = Message( - id: 'test-message-id', - state: MessageState.sent, - ); - - final updateMessageResponse = UpdateMessageResponse()..message = message; - - when( - () => client.updateMessage(any(that: isSameMessageAs(message))), - ).thenAnswer((_) async => updateMessageResponse); - - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith(state: MessageState.updating), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith(state: MessageState.updated), - matchMessageState: true, - ), - ], - ]), - ); - - final res = await channel.updateMessage(message); - - expect(res, isNotNull); - expect(res.message.id, message.id); - - verify( - () => client.updateMessage( - any(that: isSameMessageAs(message)), - ), - ).called(1); - }); - - test('with attachments should work just fine', () async { - final attachments = List.generate( - 3, - (index) => Attachment( - id: 'test-attachment-id-$index', - type: index.isEven ? 'image' : 'file', - file: AttachmentFile(size: index * 33, path: 'test-file-path'), - ), - ); - - final message = Message( - id: 'test-message-id', - attachments: attachments, - ); - - final sendImageResponse = SendImageResponse()..file = 'test-image-url'; - final sendFileResponse = SendFileResponse()..file = 'test-file-url'; - - when( - () => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - 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); - - 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), - ), - ); - - expectLater( - // skipping first seed message list -> [] messages - channel.state?.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, - ), - ], - ], - ), - ); - - final res = await channel.updateMessage(message); - - expect(res, isNotNull); - expect(res.message.id, message.id); - expect(res.message.attachments.length, message.attachments.length); - expect( - res.message.attachments.every( - (it) => it.uploadState == const UploadState.success(), - ), - isTrue, - ); - - verify( - () => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - 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); - - verify( - () => client.updateMessage( - any(that: isSameMessageAs(message)), - ), - ).called(1); - }); - - test('should update message state even when error is not StreamChatNetworkError', () async { - final message = Message( - id: 'test-message-id-error-1', - state: MessageState.sent, - ); - - when( - () => client.updateMessage( - any(that: isSameMessageAs(message)), - skipEnrichUrl: true, - ), - ).thenThrow(ArgumentError('Invalid argument')); - - 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, - ), - ), - matchMessageState: true, - ), - ], - ]), - ); - - try { - await channel.updateMessage(message, skipEnrichUrl: true); - } catch (e) { - expect(e, isA()); - } - }); - - test( - 'should add message to retry queue when retriable StreamChatNetworkError occurs with skipPush: false, skipEnrichUrl: true', - () async { - final message = Message( - id: 'test-message-id-retry-1', - state: MessageState.sent, - ); - - // Create a retriable error (data == null) - when( - () => client.updateMessage( - any(that: isSameMessageAs(message)), - skipEnrichUrl: true, - ), - ).thenThrow( - StreamChatNetworkError.raw( - code: ChatErrorCode.requestTimeout.code, - message: 'Request timed out', - ), - ); - - 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, - ), - ), - matchMessageState: true, - ), - ], - ]), - ); - - try { - await channel.updateMessage(message, skipEnrichUrl: true); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.requestTimeout.code)); - expect(networkError.isRetriable, isTrue); - } - }, - ); - - test( - 'should add message to retry queue when retriable StreamChatNetworkError occurs with skipPush: true, skipEnrichUrl: false', - () async { - final message = Message( - id: 'test-message-id-retry-2', - state: MessageState.sent, - ); - - // Create a retriable error (data == null) - when( - () => client.updateMessage( - any(that: isSameMessageAs(message)), - skipPush: true, - ), - ).thenThrow( - StreamChatNetworkError.raw( - code: ChatErrorCode.internalSystemError.code, - message: 'Internal system error', - ), - ); - - 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: false, - ), - ), - matchMessageState: true, - ), - ], - ]), - ); - - try { - await channel.updateMessage(message, skipPush: true); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.internalSystemError.code)); - expect(networkError.isRetriable, isTrue); - } - }, - ); - - test('should handle non-retriable StreamChatNetworkError with skipPush: true, skipEnrichUrl: true', () async { - final message = Message( - id: 'test-message-id-error-2', - state: MessageState.sent, - ); - - when( - () => client.updateMessage( - any(that: isSameMessageAs(message)), - skipPush: true, - skipEnrichUrl: true, - ), - ).thenThrow(StreamChatNetworkError(ChatErrorCode.notAllowed)); - - 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, - ), - ], - ]), - ); - - try { - await channel.updateMessage( - message, - skipPush: true, - skipEnrichUrl: true, - ); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); - } - }); - - test('should handle non-retriable StreamChatNetworkError with skipPush: false, skipEnrichUrl: false', () async { - final message = Message( - id: 'test-message-id-error-3', - state: MessageState.sent, - ); - - when( - () => client.updateMessage( - any(that: isSameMessageAs(message)), - ), - ).thenThrow(StreamChatNetworkError(ChatErrorCode.notAllowed)); - - 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: false, - ), - ), - matchMessageState: true, - ), - ], - ]), - ); - - try { - await channel.updateMessage(message); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); - } - }); - }); - - 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), - ); - - expect(channel.state!.messages, isEmpty); - - channel.state!.updateMessage(message); - - expect(channel.state!.messages.map((m) => m.id), ['unknown-message']); - }); - - 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), - ); - - 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 error is not StreamChatNetworkError', () async { - final message = Message( - id: 'test-message-id-error-partial-1', - state: MessageState.sent, - ); - - // Add message to channel state first - channel.state?.updateMessage(message); - - const set = {'text': 'Update Message text'}; - const unset = ['pinExpires']; - - when( - () => client.partialUpdateMessage( - message.id, - set: set, - unset: unset, - ), - ).thenThrow(ArgumentError('Invalid argument')); - - 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, - ), - ], - ]), - ); - - try { - await channel.partialUpdateMessage( - message, - set: set, - unset: unset, - ); - } catch (e) { - expect(e, isA()); - } - }); - - test( - 'should add message to retry queue when retriable StreamChatNetworkError occurs with skipEnrichUrl: true', - () async { - final message = Message( - id: 'test-message-id-retry-partial-1', - state: MessageState.sent, - ); - - // Add message to channel state first - channel.state?.updateMessage(message); - - const set = {'text': 'Update Message text'}; - const unset = ['pinExpires']; - - // Create a retriable error (data == null) - when( - () => client.partialUpdateMessage( - message.id, - set: set, - unset: unset, - skipEnrichUrl: true, - ), - ).thenThrow( - StreamChatNetworkError.raw( - code: ChatErrorCode.requestTimeout.code, - message: 'Request timed out', - ), - ); - - 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: 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 StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.requestTimeout.code)); - expect(networkError.isRetriable, isTrue); - } - }, - ); - - test( - 'should add message to retry queue when retriable StreamChatNetworkError occurs with skipEnrichUrl: false', - () async { - final message = Message( - id: 'test-message-id-retry-partial-2', - state: MessageState.sent, - ); - - // Add message to channel state first - channel.state?.updateMessage(message); - - const set = {'text': 'Update Message text'}; - const unset = ['pinExpires']; - - // Create a retriable error (data == null) - when( - () => client.partialUpdateMessage( - message.id, - set: set, - unset: unset, - ), - ).thenThrow( - StreamChatNetworkError.raw( - code: ChatErrorCode.internalSystemError.code, - message: 'Internal system error', - ), - ); - - 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, - ), - ], - ]), - ); - - try { - await channel.partialUpdateMessage( - message, - set: set, - unset: unset, - ); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.internalSystemError.code)); - expect(networkError.isRetriable, isTrue); - } - }, - ); - - test('should handle non-retriable StreamChatNetworkError with skipEnrichUrl: true', () async { - final message = Message( - id: 'test-message-id-error-partial-2', - state: MessageState.sent, - ); - - // Add message to channel state first - channel.state?.updateMessage(message); - - const set = {'text': 'Update Message text'}; - const unset = ['pinExpires']; - - when( - () => client.partialUpdateMessage( - message.id, - set: set, - unset: unset, - skipEnrichUrl: true, - ), - ).thenThrow(StreamChatNetworkError(ChatErrorCode.notAllowed)); - - 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: 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 StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); - } - }); - - test('should handle non-retriable StreamChatNetworkError with skipEnrichUrl: false', () async { - final message = Message( - id: 'test-message-id-error-partial-3', - state: MessageState.sent, - ); - - // Add message to channel state first - channel.state?.updateMessage(message); - - const set = {'text': 'Update Message text'}; - const unset = ['pinExpires']; - - when( - () => client.partialUpdateMessage( - message.id, - set: set, - unset: unset, - ), - ).thenThrow(StreamChatNetworkError(ChatErrorCode.notAllowed)); - - 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, - ), - ], - ]), - ); - - try { - await channel.partialUpdateMessage( - message, - set: set, - unset: unset, - ); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); - } - }); - }); - group('`.sendReaction`', () { test('should work fine', () async { final message = Message( diff --git a/packages/stream_chat/test/src/client/channel/channel_update_message_test.dart b/packages/stream_chat/test/src/client/channel/channel_update_message_test.dart new file mode 100644 index 0000000000..c162dc2019 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_update_message_test.dart @@ -0,0 +1,1145 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); +} + +void main() { + group('`.updateMessage`', () { + channelTest( + 'should work fine', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id', + state: MessageState.sent, + ); + + tester.mockApi( + (api) => api.message.updateMessage(any(that: isSameMessageAs(message))), + 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), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.updated), + matchMessageState: true, + ), + ], + ]), + ); + + final res = await tester.channel.updateMessage(message); + + expect(res, isNotNull); + expect(res.message.id, message.id); + + await messagesEmits; + + tester.verifyApi( + (api) => api.message.updateMessage(any(that: isSameMessageAs(message))), + ); + }, + ); + + 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'), + ), + ); + + final message = Message( + id: 'test-message-id', + attachments: attachments, + ); + + 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), + ), + ), + ); + + 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, + ), + ], + ], + ), + ); + + 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, + ); + + 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 error is not StreamChatNetworkError', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + 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), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.updatingFailed( + skipPush: false, + skipEnrichUrl: true, + ), + ), + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.updateMessage(message, skipEnrichUrl: true); + } catch (e) { + expect(e, isA()); + } + + await messagesEmits; + }, + ); + + channelTest( + 'should add message to retry queue when retriable StreamChatNetworkError occurs with skipPush: false, skipEnrichUrl: true', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + 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(errorCode: ChatErrorCode.requestTimeout, retriable: true), + 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), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.updatingFailed( + skipPush: false, + skipEnrichUrl: 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 tester.channel.updateMessage(message, skipEnrichUrl: true); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.requestTimeout.code)); + 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)), + skipEnrichUrl: true, + ), + times: 2, + ); + }, + ); + + channelTest( + 'should add message to retry queue when retriable StreamChatNetworkError occurs with skipPush: true, skipEnrichUrl: false', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id-retry-2', + 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)), + skipPush: true, + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.internalSystemError, retriable: true), + 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), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.updatingFailed( + skipPush: true, + skipEnrichUrl: false, + ), + ), + 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 tester.channel.updateMessage(message, skipPush: true); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.internalSystemError.code)); + 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 non-retriable StreamChatNetworkError with skipPush: true, skipEnrichUrl: true', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id-error-2', + state: MessageState.sent, + ); + + tester.mockApiFailure( + (api) => api.message.updateMessage( + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.notAllowed), + ); + + 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: true, + ), + ), + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.updateMessage( + message, + skipPush: true, + skipEnrichUrl: true, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } + + await messagesEmits; + }, + ); + + channelTest( + 'should handle non-retriable StreamChatNetworkError with skipPush: false, skipEnrichUrl: false', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id-error-3', + state: MessageState.sent, + ); + + tester.mockApiFailure( + (api) => api.message.updateMessage( + any(that: isSameMessageAs(message)), + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.notAllowed), + ); + + 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: false, + skipEnrichUrl: false, + ), + ), + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.updateMessage(message); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } + + await messagesEmits; + }, + ); + }); + + group('`ChannelClientState.updateMessage`', () { + channelTest( + 'upsert: true (default) adds an unknown message', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'unknown-message', + user: tester.currentUser, + text: 'hello', + createdAt: DateTime.utc(2026), + ); + + expect(tester.channelState!.messages, isEmpty); + + tester.channelState!.updateMessage(message); + + 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: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'unknown-message', + user: tester.currentUser, + text: 'hello', + createdAt: DateTime.utc(2026), + ); + + expect(tester.channelState!.messages, isEmpty); + + 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: _seedChannel()), + 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: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id', + state: MessageState.sent, + ); + + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + final updateMessageResponse = createDefaultUpdateMessageResponse( + message: message.copyWith(text: set['text'], pinExpires: null), + ); + + tester.mockApi( + (api) => api.message.partialUpdateMessage(message.id, set: set, unset: unset), + result: updateMessageResponse, + ); + + 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( + updateMessageResponse.message.copyWith( + state: MessageState.updated, + ), + matchText: true, + matchMessageState: true, + ), + ], + ]), + ); + + final res = await tester.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); + + await messagesEmits; + + tester.verifyApi( + (api) => api.message.partialUpdateMessage(message.id, set: set, unset: unset), + ); + }, + ); + + group('`.partialUpdateMessage` error handling', () { + channelTest( + 'should update message state even when error is not StreamChatNetworkError', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id-error-partial-1', + state: MessageState.sent, + ); + + // Add message to channel state first + tester.channelState?.updateMessage(message); + + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + tester.mockApiFailure( + (api) => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + ), + 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, + ), + matchText: true, + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.partialUpdatingFailed( + set: set, + unset: unset, + skipEnrichUrl: false, + ), + ), + matchText: true, + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.partialUpdateMessage( + message, + set: set, + unset: unset, + ); + } catch (e) { + expect(e, isA()); + } + + await messagesEmits; + }, + ); + + channelTest( + 'should add message to retry queue when retriable StreamChatNetworkError occurs with skipEnrichUrl: true', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id-retry-partial-1', + state: MessageState.sent, + ); + + // Add message to channel state first + tester.channelState?.updateMessage(message); + + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + // 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, + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.requestTimeout, retriable: true), + 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, + ), + matchText: true, + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.partialUpdatingFailed( + set: set, + unset: unset, + skipEnrichUrl: true, + ), + ), + matchText: true, + 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 tester.channel.partialUpdateMessage( + message, + set: set, + unset: unset, + skipEnrichUrl: true, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.requestTimeout.code)); + 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, + ); + }, + ); + + channelTest( + 'should add message to retry queue when retriable StreamChatNetworkError occurs with skipEnrichUrl: false', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id-retry-partial-2', + state: MessageState.sent, + ); + + // Add message to channel state first + tester.channelState?.updateMessage(message); + + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + // 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, + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.internalSystemError, retriable: true), + 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, + ), + matchText: true, + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.partialUpdatingFailed( + set: set, + unset: unset, + skipEnrichUrl: false, + ), + ), + matchText: true, + 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 tester.channel.partialUpdateMessage( + message, + set: set, + unset: unset, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.internalSystemError.code)); + 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, + ); + }, + ); + + channelTest( + 'should handle non-retriable StreamChatNetworkError with skipEnrichUrl: true', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id-error-partial-2', + state: MessageState.sent, + ); + + // Add message to channel state first + tester.channelState?.updateMessage(message); + + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + tester.mockApiFailure( + (api) => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + skipEnrichUrl: true, + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.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: true, + ), + ), + matchText: true, + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.partialUpdateMessage( + message, + set: set, + unset: unset, + skipEnrichUrl: true, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } + + await messagesEmits; + }, + ); + + channelTest( + 'should handle non-retriable StreamChatNetworkError with skipEnrichUrl: false', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id-error-partial-3', + state: MessageState.sent, + ); + + // Add message to channel state first + tester.channelState?.updateMessage(message); + + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + tester.mockApiFailure( + (api) => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.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, + ), + ], + ]), + ); + + try { + await tester.channel.partialUpdateMessage( + message, + set: set, + unset: unset, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } + + await messagesEmits; + }, + ); + }); +} 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 index 5558feb713..c4aec912c5 100644 --- a/packages/stream_chat_test/lib/src/helpers/api_mocker_mixin.dart +++ b/packages/stream_chat_test/lib/src/helpers/api_mocker_mixin.dart @@ -52,6 +52,31 @@ mixin ApiMockerMixin { return when(() => apiCall(chatApi)).thenAnswer((_) => 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 a [StreamChatNetworkError] with + /// [ChatErrorCode.internalSystemError], which carries no response data and + /// is therefore 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 ?? StreamChatNetworkError(ChatErrorCode.internalSystemError); + 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); From b2caaf7398e5d46d807409efeb0df9f1201ec5e6 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 12:12:01 +0200 Subject: [PATCH 034/103] test(llc): migrate channel location API tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_location_api_test.dart | 146 ++++++++++++++++++ .../test/src/client/channel/channel_test.dart | 103 ------------ 2 files changed, 146 insertions(+), 103 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_location_api_test.dart diff --git a/packages/stream_chat/test/src/client/channel/channel_location_api_test.dart b/packages/stream_chat/test/src/client/channel/channel_location_api_test.dart new file mode 100644 index 0000000000..305a86201d --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_location_api_test.dart @@ -0,0 +1,146 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); +} + +void main() { + group('`.sendStaticLocation`', () { + const deviceId = 'test-device-id'; + const locationId = 'test-location-id'; + const coordinates = LocationCoordinates( + latitude: 40.7128, + longitude: -74.0060, + ); + + channelTest( + 'should create a static location and call sendMessage', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + 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 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); + + tester.verifyApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(Message(id: locationId))), + ), + ); + }, + ); + }); + + group('`.startLiveLocationSharing`', () { + const deviceId = 'test-device-id'; + const locationId = 'test-location-id'; + final endSharingAt = DateTime.timestamp().add(const Duration(hours: 1)); + const coordinates = LocationCoordinates( + latitude: 40.7128, + longitude: -74.0060, + ); + + channelTest( + 'should create message with live location and call sendMessage', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + 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, + endAt: endSharingAt, + ), + ), + ), + ); + + final response = await tester.channel.startLiveLocationSharing( + id: locationId, + messageText: 'Location shared', + createdByDeviceId: deviceId, + location: coordinates, + endSharingAt: endSharingAt, + 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.message.sharedLocation?.endAt, endSharingAt); + + tester.verifyApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(Message(id: locationId))), + ), + ); + }, + ); + }); +} 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 a9ad6a95c6..67e07cce3a 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -1074,109 +1074,6 @@ void main() { ); }); - group('`.sendStaticLocation`', () { - const deviceId = 'test-device-id'; - const locationId = 'test-location-id'; - const coordinates = LocationCoordinates( - latitude: 40.7128, - 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, - ), - ), - ); - - final response = await 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); - - verify( - () => client.sendMessage(any(), channelId, channelType), - ).called(1); - }); - }); - - group('`.startLiveLocationSharing`', () { - const deviceId = 'test-device-id'; - const locationId = 'test-location-id'; - final endSharingAt = DateTime.timestamp().add(const Duration(hours: 1)); - const coordinates = LocationCoordinates( - latitude: 40.7128, - longitude: -74.0060, - ); - - test( - 'should create message with live 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, - endAt: endSharingAt, - ), - ), - ); - - final response = await channel.startLiveLocationSharing( - id: locationId, - messageText: 'Location shared', - createdByDeviceId: deviceId, - location: coordinates, - endSharingAt: endSharingAt, - 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.message.sharedLocation?.endAt, endSharingAt); - - verify( - () => client.sendMessage(any(), channelId, channelType), - ).called(1); - }, - ); - }); - group('`.sendReaction`', () { test('should work fine', () async { final message = Message( From 96dd5038de753d7f08c202f1b609bbd3b0f9a7b0 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 12:13:29 +0200 Subject: [PATCH 035/103] test(llc): migrate channel send message tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_send_message_test.dart | 923 ++++++++++++++++++ .../test/src/client/channel/channel_test.dart | 794 --------------- 2 files changed, 923 insertions(+), 794 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_send_message_test.dart diff --git a/packages/stream_chat/test/src/client/channel/channel_send_message_test.dart b/packages/stream_chat/test/src/client/channel/channel_send_message_test.dart new file mode 100644 index 0000000000..41df7e0ef7 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_send_message_test.dart @@ -0,0 +1,923 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); +} + +void main() { + group('`.sendMessage`', () { + 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, + ); + + tester.mockApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); + + 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, + ), + ], + ]), + ); + + final res = await tester.channel.sendMessage(message); + + expect(res, isNotNull); + expect(res.message.id, message.id); + + tester.verifyApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + ); + + await messagesEmission; + }, + ); + + channelTest( + 'should handle StreamChatNetworkError by adding message to retry queue with skipPush: true, skipEnrichUrl: false', + 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, + ); + + // 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.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.notAllowed, retriable: true), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); + + 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.sendingFailed( + skipPush: true, + skipEnrichUrl: false, + ), + ), + matchMessageState: true, + ), + ], + // The retry queue re-sends the queued message with the same flags. + [ + isSameMessageAs( + message.copyWith(state: MessageState.sending), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.sent), + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.sendMessage( + message, + skipPush: true, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } + + await messagesEmission; + + // Initial attempt + the retry-queue retry, both with the same flags. + tester.verifyApiCalled( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + ), + times: 2, + ); + }, + ); + + channelTest( + 'should handle StreamChatNetworkError by adding message to retry queue with skipPush: true, skipEnrichUrl: true', + 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: tester.currentUser, + ); + + // 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.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.notAllowed, retriable: true), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); + + 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.sendingFailed( + skipPush: true, + skipEnrichUrl: true, + ), + ), + matchMessageState: true, + ), + ], + // The retry queue re-sends the queued message with the same flags. + [ + isSameMessageAs( + message.copyWith(state: MessageState.sending), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.sent), + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.sendMessage( + message, + skipPush: true, + skipEnrichUrl: true, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } + + await messagesEmission; + + // Initial attempt + the retry-queue retry, both with the same flags. + tester.verifyApiCalled( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + times: 2, + ); + }, + ); + + channelTest( + 'should handle StreamChatNetworkError by adding message to retry queue with skipPush: false, skipEnrichUrl: true', + 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: tester.currentUser, + ); + + // 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.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipEnrichUrl: true, + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.notAllowed, retriable: true), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); + + 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.sendingFailed( + skipPush: false, + skipEnrichUrl: true, + ), + ), + matchMessageState: true, + ), + ], + // The retry queue re-sends the queued message with the same flags. + [ + isSameMessageAs( + message.copyWith(state: MessageState.sending), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.sent), + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.sendMessage( + message, + skipEnrichUrl: true, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } + + await messagesEmission; + + // Initial attempt + the retry-queue retry, both with the same flags. + tester.verifyApiCalled( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipEnrichUrl: true, + ), + times: 2, + ); + }, + ); + + channelTest( + 'should handle StreamChatNetworkError by adding message to retry queue with skipPush: false, skipEnrichUrl: false', + 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: tester.currentUser, + ); + + // 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.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + error: createDefaultNetworkError(errorCode: ChatErrorCode.notAllowed, retriable: true), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); + + 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.sendingFailed( + skipPush: false, + skipEnrichUrl: false, + ), + ), + matchMessageState: true, + ), + ], + // The retry queue re-sends the queued message with the same flags. + [ + isSameMessageAs( + message.copyWith(state: MessageState.sending), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.sent), + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.sendMessage( + message, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } + + await messagesEmission; + + // Initial attempt + the retry-queue retry, both with the same flags. + tester.verifyApiCalled( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + times: 2, + ); + }, + ); + + 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, + ); + + tester.mockApiFailure( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError, statusCode: 400), + ); + + 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.sendingFailed( + skipPush: false, + skipEnrichUrl: false, + ), + ), + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.sendMessage(message); + } catch (e) { + expect(e, isA()); + } + + await messagesEmission; + }, + ); + + 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'), + ), + ); + + final message = Message( + id: 'test-message-id', + attachments: attachments, + ); + + 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, + ), + ), + ); + + 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, + ), + ], + ], + ), + ); + + final res = await tester.channel.sendMessage(message); + + expect(res, isNotNull); + expect(res.message.id, message.id); + expect(res.message.attachments.length, message.attachments.length); + expect( + res.message.attachments.every( + (it) => it.uploadState == const UploadState.success(), + ), + isTrue, + ); + + 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))), + ); + + await messagesEmission; + }, + ); + + 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'); + + await expectLater( + () => tester.channel.sendMessage(message), + throwsA(isA()), + ); + + tester.verifyNeverCalled( + (api) => api.message.sendMessage(_channelId, _channelType, any()), + ); + }, + ); + + channelTest( + 'should not send empty message when all attachments are cancelled', + 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'), + ); + + final message = Message( + id: 'test-message-id', + attachments: [attachment], + ); + + tester.mockApiFailure( + (api) => api.fileUploader.sendImage( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + error: StreamChatNetworkError.raw( + code: 0, + message: 'Request cancelled', + type: StreamChatNetworkErrorType.cancel, + ), + ); + + await expectLater( + () => tester.channel.sendMessage(message), + throwsA(isA()), + ); + + 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()), + ); + }, + ); + + channelTest( + 'should send message when attachment is cancelled but text exists', + 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'), + ); + + final message = Message( + id: 'test-message-id', + text: 'Hello world!', + attachments: [attachment], + ); + + tester + ..mockApiFailure( + (api) => api.fileUploader.sendImage( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + error: StreamChatNetworkError.raw( + code: 0, + message: 'Request cancelled', + type: StreamChatNetworkErrorType.cancel, + ), + ) + ..mockApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + result: createDefaultSendMessageResponse( + message: message.copyWith( + attachments: [], + state: MessageState.sent, + ), + ), + ); + + final res = await tester.channel.sendMessage(message); + + expect(res, isNotNull); + expect(res.message.text, 'Hello world!'); + + 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))), + ); + }, + ); + + channelTest( + 'should send message when attachment is cancelled but quoted message exists', + 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'), + ); + + final quotedMessage = Message( + id: 'quoted-123', + text: 'Original message', + ); + + final message = Message( + id: 'test-message-id', + attachments: [attachment], + quotedMessageId: quotedMessage.id, + ); + + tester + ..mockApiFailure( + (api) => api.fileUploader.sendImage( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + error: StreamChatNetworkError.raw( + code: 0, + message: 'Request cancelled', + type: StreamChatNetworkErrorType.cancel, + ), + ) + ..mockApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + result: createDefaultSendMessageResponse( + message: message.copyWith( + attachments: [], + state: MessageState.sent, + ), + ), + ); + + final res = await tester.channel.sendMessage(message); + + expect(res, isNotNull); + expect(res.message.quotedMessageId, quotedMessage.id); + + 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))), + ); + }, + ); + + channelTest( + 'should send message when attachment is cancelled but poll exists', + 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'), + ); + + final message = Message( + id: 'test-message-id', + attachments: [attachment], + pollId: 'poll-123', + ); + + tester + ..mockApiFailure( + (api) => api.fileUploader.sendImage( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + error: StreamChatNetworkError.raw( + code: 0, + message: 'Request cancelled', + type: StreamChatNetworkErrorType.cancel, + ), + ) + ..mockApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + result: createDefaultSendMessageResponse( + message: message.copyWith( + attachments: [], + state: MessageState.sent, + ), + ), + ); + + final res = await tester.channel.sendMessage(message); + + expect(res, isNotNull); + expect(res.message.pollId, 'poll-123'); + + 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))), + ); + }, + ); + }); +} 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 67e07cce3a..8182b80f75 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -280,800 +280,6 @@ void main() { } }); - group('`.sendMessage`', () { - test('should work fine', () async { - final message = Message( - id: 'test-message-id', - text: 'Hello world!', - user: client.state.currentUser, - ); - - final sendMessageResponse = SendMessageResponse()..message = message.copyWith(state: MessageState.sent); - - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).thenAnswer((_) async => sendMessageResponse); - - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith(state: MessageState.sending), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith(state: MessageState.sent), - matchMessageState: true, - ), - ], - ]), - ); - - final res = await channel.sendMessage(message); - - expect(res, isNotNull); - expect(res.message.id, message.id); - - verify( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).called(1); - }); - - test( - 'should handle StreamChatNetworkError by adding message to retry queue with skipPush: true, skipEnrichUrl: false', - () async { - final message = Message( - id: 'test-message-id', - text: 'Hello world!', - user: client.state.currentUser, - ); - - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - skipPush: true, - ), - ).thenThrow(StreamChatNetworkError(ChatErrorCode.notAllowed)); - - 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: true, - skipEnrichUrl: false, - ), - ), - matchMessageState: true, - ), - ], - ]), - ); - - try { - await channel.sendMessage( - message, - skipPush: true, - ); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); - } - }, - ); - - test( - 'should handle StreamChatNetworkError by adding message to retry queue with skipPush: true, skipEnrichUrl: true', - () async { - final message = Message( - id: 'test-message-id-2', - text: 'Hello world!', - user: client.state.currentUser, - ); - - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - skipPush: true, - skipEnrichUrl: true, - ), - ).thenThrow(StreamChatNetworkError(ChatErrorCode.notAllowed)); - - 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: true, - skipEnrichUrl: true, - ), - ), - matchMessageState: true, - ), - ], - ]), - ); - - try { - await channel.sendMessage( - message, - skipPush: true, - skipEnrichUrl: true, - ); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); - } - }, - ); - - test( - 'should handle StreamChatNetworkError by adding message to retry queue with skipPush: false, skipEnrichUrl: true', - () async { - final message = Message( - id: 'test-message-id-3', - text: 'Hello world!', - user: client.state.currentUser, - ); - - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - skipEnrichUrl: true, - ), - ).thenThrow(StreamChatNetworkError(ChatErrorCode.notAllowed)); - - 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: true, - ), - ), - matchMessageState: true, - ), - ], - ]), - ); - - try { - await channel.sendMessage( - message, - skipEnrichUrl: true, - ); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); - } - }, - ); - - test( - 'should handle StreamChatNetworkError by adding message to retry queue with skipPush: false, skipEnrichUrl: false', - () async { - final message = Message( - id: 'test-message-id-4', - text: 'Hello world!', - user: client.state.currentUser, - ); - - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).thenThrow(StreamChatNetworkError(ChatErrorCode.notAllowed)); - - 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()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); - } - }, - ); - - 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( - StreamChatNetworkError.raw( - code: ChatErrorCode.inputError.code, - message: 'Input error', - data: ErrorResponse() - ..code = ChatErrorCode.inputError.code - ..message = 'Input error' - ..statusCode = 400, - ), - ); - - 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, - ); - - final sendImageResponse = SendImageResponse()..file = 'test-image-url'; - final sendFileResponse = SendFileResponse()..file = 'test-file-url'; - - when( - () => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - 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); - - 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, - ), - ); - - 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 - [ - 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, - ), - ], - ], - ), - ); - - final res = await channel.sendMessage(message); - - expect(res, isNotNull); - expect(res.message.id, message.id); - expect(res.message.attachments.length, message.attachments.length); - expect( - res.message.attachments.every( - (it) => it.uploadState == const UploadState.success(), - ), - isTrue, - ); - - verify( - () => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - 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); - - verify( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).called(1); - }); - - test('should not send if the message is invalid', () async { - final message = Message(id: 'test-message-id'); - - expect( - () => channel.sendMessage(message), - throwsA(isA()), - ); - - verifyNever( - () => client.sendMessage(any(), channelId, channelType), - ); - }); - - test( - 'should not send empty message when all attachments are cancelled', - () async { - final attachment = Attachment( - id: 'test-attachment-id', - type: 'image', - file: AttachmentFile(size: 100, path: 'test-file-path'), - ); - - final message = Message( - id: 'test-message-id', - attachments: [attachment], - ); - - when( - () => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - ).thenAnswer( - (_) async => throw StreamChatNetworkError.raw( - code: 0, - message: 'Request cancelled', - type: StreamChatNetworkErrorType.cancel, - ), - ); - - expect( - () => 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), - ); - }, - ); - - test( - 'should send message when attachment is cancelled but text exists', - () async { - final attachment = Attachment( - id: 'test-attachment-id', - type: 'image', - file: AttachmentFile(size: 100, path: 'test-file-path'), - ); - - final message = Message( - id: 'test-message-id', - text: 'Hello world!', - attachments: [attachment], - ); - - when( - () => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - ).thenAnswer( - (_) async => throw StreamChatNetworkError.raw( - code: 0, - message: 'Request cancelled', - type: StreamChatNetworkErrorType.cancel, - ), - ); - - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).thenAnswer( - (_) async => SendMessageResponse() - ..message = message.copyWith( - attachments: [], - state: MessageState.sent, - ), - ); - - final res = await 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, - ), - ); - }, - ); - - test( - 'should send message when attachment is cancelled but quoted message exists', - () async { - final attachment = Attachment( - id: 'test-attachment-id', - type: 'image', - file: AttachmentFile(size: 100, path: 'test-file-path'), - ); - - final quotedMessage = Message( - id: 'quoted-123', - text: 'Original message', - ); - - final message = Message( - id: 'test-message-id', - attachments: [attachment], - quotedMessageId: quotedMessage.id, - ); - - when( - () => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - ).thenAnswer( - (_) async => throw StreamChatNetworkError.raw( - code: 0, - message: 'Request cancelled', - type: StreamChatNetworkErrorType.cancel, - ), - ); - - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).thenAnswer( - (_) async => SendMessageResponse() - ..message = message.copyWith( - attachments: [], - state: MessageState.sent, - ), - ); - - final res = await 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, - ), - ); - }, - ); - - test( - 'should send message when attachment is cancelled but poll exists', - () async { - final attachment = Attachment( - id: 'test-attachment-id', - type: 'image', - file: AttachmentFile(size: 100, path: 'test-file-path'), - ); - - final message = Message( - id: 'test-message-id', - attachments: [attachment], - pollId: 'poll-123', - ); - - when( - () => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - ).thenAnswer( - (_) async => throw StreamChatNetworkError.raw( - code: 0, - message: 'Request cancelled', - type: StreamChatNetworkErrorType.cancel, - ), - ); - - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).thenAnswer( - (_) async => SendMessageResponse() - ..message = message.copyWith( - attachments: [], - state: MessageState.sent, - ), - ); - - final res = await 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, - ), - ); - }, - ); - }); - group('`.sendReaction`', () { test('should work fine', () async { final message = Message( From 3b22c860059376d77e3b53fa7cd5a8f64a6935d2 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 12:24:21 +0200 Subject: [PATCH 036/103] test(llc): migrate channel watch and message query tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_message_queries_test.dart | 178 +++++++++++++ .../test/src/client/channel/channel_test.dart | 246 ------------------ .../client/channel/channel_watch_test.dart | 177 +++++++++++++ .../lib/src/helpers/test_data.dart | 72 +++++ 4 files changed, 427 insertions(+), 246 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_message_queries_test.dart create mode 100644 packages/stream_chat/test/src/client/channel/channel_watch_test.dart diff --git a/packages/stream_chat/test/src/client/channel/channel_message_queries_test.dart b/packages/stream_chat/test/src/client/channel/channel_message_queries_test.dart new file mode 100644 index 0000000000..066e8b3dbb --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_message_queries_test.dart @@ -0,0 +1,178 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); +} + +void main() { + channelTest( + '`.getReplies`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const parentId = 'test-parent-id'; + + final messages = List.generate( + 3, + (index) => Message( + id: 'test-message-id-$index', + parentId: parentId, + ), + ); + + tester.mockApi( + (api) => api.message.getReplies(parentId), + result: createDefaultQueryRepliesResponse(messages: messages), + ); + + 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); + + tester.verifyApi((api) => api.message.getReplies(parentId)); + }, + ); + + channelTest( + '`.getReplies` keeps the parent message out of the thread', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + 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), + ), + ]; + + tester.mockApi( + (api) => api.message.getReplies(parentId), + result: createDefaultQueryRepliesResponse(messages: messages), + ); + + await tester.channel.getReplies(parentId); + + final threadMessages = tester.channelState!.threads[parentId]; + expect(threadMessages, isNotNull); + expect(threadMessages!.length, messages.length - 1); + expect(threadMessages.any((it) => it.id == parentId), isFalse); + }, + ); + + channelTest( + '`.getReactions`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const messageId = 'test-message-id'; + + final reactions = List.generate( + 3, + (index) => Reaction( + type: 'test-reaction-type-$index', + messageId: messageId, + ), + ); + + tester.mockApi( + (api) => api.message.getReactions(messageId), + result: createDefaultQueryReactionsResponse(reactions: reactions), + ); + + 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); + + tester.verifyApi((api) => api.message.getReactions(messageId)); + }, + ); + + channelTest( + '`.getMessagesById`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + 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); + + tester.mockApi( + (api) => api.message.getMessagesById(_channelId, _channelType, messageIds), + result: createDefaultGetMessagesByIdResponse(messages: messages), + ); + + final res = await tester.channel.getMessagesById(messageIds); + + expect(res, isNotNull); + expect(res.messages.length, messageIds.length); + + tester.verifyApi( + (api) => api.message.getMessagesById(_channelId, _channelType, messageIds), + ); + }, + ); + + channelTest( + '`.translateMessage`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const messageId = 'test-message-id'; + const language = 'hi'; // Hindi + const translatedMessageText = 'नमस्ते'; + + final message = Message(id: messageId, text: 'Hello'); + tester.channelState!.updateMessage(message); + + final translatedMessage = message.copyWith( + i18n: {'language': 'en', '${language}_text': translatedMessageText}, + ); + + tester.mockApi( + (api) => api.message.translateMessage(messageId, language), + result: createDefaultTranslateMessageResponse(message: translatedMessage), + ); + + final res = await tester.channel.translateMessage(messageId, language); + + 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 = tester.channelState!.messages.firstWhere((it) => it.id == messageId); + expect(stateMessage.i18n, translatedMessage.i18n); + + tester.verifyApi((api) => api.message.translateMessage(messageId, language)); + }, + ); +} 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 8182b80f75..1551a374db 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -1274,252 +1274,6 @@ void main() { ).called(1); }); - group('`.watch`', () { - test('should work fine', () async { - when( - () => client.queryChannel( - channelType, - channelId: channelId, - watch: true, - channelData: any(named: 'channelData'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).thenAnswer( - (_) async => _generateChannelState(channelId, channelType), - ); - - final res = await channel.watch(); - - expect(res, isNotNull); - expect(res.channel, isNotNull); - expect(res.channel?.cid, channelCid); - - verify( - () => client.queryChannel( - channelType, - channelId: channelId, - watch: true, - channelData: any(named: 'channelData'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).called(1); - }); - - test('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 StreamChatNetworkError(ChatErrorCode.inputError); - } - return _generateChannelState(channelId, channelType); - }); - - // 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(StreamChatNetworkError(ChatErrorCode.inputError)); - - try { - await channel.watch(); - } catch (e) { - expect(e, isA()); - } - - verify( - () => client.queryChannel( - channelType, - channelId: channelId, - watch: true, - channelData: any(named: 'channelData'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).called(1); - }); - }); - - test('`.stopWatching`', () async { - when(() => client.stopChannelWatching(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); - - final res = await channel.stopWatching(); - - expect(res, isNotNull); - - verify(() => client.stopChannelWatching(channelId, channelType)).called(1); - }); - - test('`.getReplies`', () async { - const parentId = 'test-parent-id'; - - final messages = List.generate( - 3, - (index) => Message( - id: 'test-message-id-$index', - parentId: parentId, - ), - ); - - when(() => client.getReplies(parentId)).thenAnswer( - (_) async => QueryRepliesResponse()..messages = messages, - ); - - final res = await channel.getReplies(parentId); - - expect(res, isNotNull); - expect(res.messages.length, messages.length); - expect(res.messages.every((it) => it.parentId == parentId), isTrue); - - verify(() => client.getReplies(parentId)).called(1); - }); - - test('`.getReplies` keeps the parent message out of the thread', () 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), - ), - ]; - - when(() => client.getReplies(parentId)).thenAnswer( - (_) async => QueryRepliesResponse()..messages = messages, - ); - - await 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); - }); - - test('`.getReactions`', () async { - const messageId = 'test-message-id'; - - final reactions = List.generate( - 3, - (index) => Reaction( - type: 'test-reaction-type-$index', - messageId: messageId, - ), - ); - - when(() => client.getReactions(messageId)).thenAnswer( - (_) async => QueryReactionsResponse()..reactions = reactions, - ); - - final res = await channel.getReactions(messageId); - - expect(res, isNotNull); - expect(res.reactions.length, reactions.length); - expect(res.reactions.every((it) => it.messageId == messageId), isTrue); - - verify(() => client.getReactions(messageId)).called(1); - }); - - test('`.getMessagesById`', () async { - final messages = List.generate( - 3, - (index) => Message(id: 'test-message-id-$index'), - ); - - final messageIds = messages.map((it) => it.id).toList(growable: false); - - when(() => client.getMessagesById(channelId, channelType, messageIds)).thenAnswer( - (_) async => GetMessagesByIdResponse()..messages = messages, - ); - - final res = await channel.getMessagesById(messageIds); - - expect(res, isNotNull); - expect(res.messages.length, messageIds.length); - - verify( - () => client.getMessagesById(channelId, channelType, messageIds), - ).called(1); - }); - - test('`.translateMessage`', () async { - const messageId = 'test-message-id'; - const language = 'hi'; // Hindi - const translatedMessageText = 'नमस्ते'; - - final message = Message(id: messageId, text: 'Hello'); - channel.state!.updateMessage(message); - - final translatedMessage = message.copyWith( - i18n: {'language': 'en', '${language}_text': translatedMessageText}, - ); - - when(() => client.translateMessage(messageId, language)).thenAnswer( - (_) async => TranslateMessageResponse()..message = translatedMessage, - ); - - final res = await channel.translateMessage(messageId, language); - - expect(res, isNotNull); - expect(res.message.i18n, translatedMessage.i18n); - - // 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); - - verify(() => client.translateMessage(messageId, language)).called(1); - }); - group('`.query`', () { test('should work fine', () async { final channelState = _generateChannelState(channelId, channelType); diff --git a/packages/stream_chat/test/src/client/channel/channel_watch_test.dart b/packages/stream_chat/test/src/client/channel/channel_watch_test.dart new file mode 100644 index 0000000000..aa009c36f0 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_watch_test.dart @@ -0,0 +1,177 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); +} + +// 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], + ), + ), + ); +} + +void main() { + group('`.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), + ), + ); + + final res = await tester.channel.watch(); + + 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, + ), + ); + }, + ); + + 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(errorCode: ChatErrorCode.inputError), + result: createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ), + ); + + // 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)); + }, + ); + + 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(errorCode: ChatErrorCode.inputError), + ); + + 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, + ), + ); + }, + ); + }); + + channelTest( + '`.stopWatching`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + tester.mockApi( + (api) => api.channel.stopWatching(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.stopWatching(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.stopWatching(_channelId, _channelType), + ); + }, + ); +} diff --git a/packages/stream_chat_test/lib/src/helpers/test_data.dart b/packages/stream_chat_test/lib/src/helpers/test_data.dart index ceeb35d258..aac8340d72 100644 --- a/packages/stream_chat_test/lib/src/helpers/test_data.dart +++ b/packages/stream_chat_test/lib/src/helpers/test_data.dart @@ -446,6 +446,17 @@ SendActionResponse createDefaultSendActionResponse({ ..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 [], @@ -459,6 +470,44 @@ SearchMessagesResponse createDefaultSearchMessagesResponse({ ..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]. QueryChannelsResponse createDefaultQueryChannelsResponse({ List channels = const [], @@ -468,6 +517,29 @@ QueryChannelsResponse createDefaultQueryChannelsResponse({ ..channels = channels; } +/// Creates a [PartialUpdateChannelResponse] wrapping [channel]. +PartialUpdateChannelResponse createDefaultPartialUpdateChannelResponse({ + ChannelModel? channel, +}) { + return PartialUpdateChannelResponse() + ..duration = '10ms' + ..channel = channel ?? createDefaultChannelModel(); +} + +/// 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(), From b71c595ff9ff3965c3db8e2d085b08e0ed73e961 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 12:25:53 +0200 Subject: [PATCH 037/103] test(llc): migrate channel update and member API tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_member_api_test.dart | 239 ++++++++++++ .../test/src/client/channel/channel_test.dart | 345 ------------------ .../channel/channel_update_api_test.dart | 228 ++++++++++++ 3 files changed, 467 insertions(+), 345 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_member_api_test.dart create mode 100644 packages/stream_chat/test/src/client/channel/channel_update_api_test.dart diff --git a/packages/stream_chat/test/src/client/channel/channel_member_api_test.dart b/packages/stream_chat/test/src/client/channel/channel_member_api_test.dart new file mode 100644 index 0000000000..d623d969c0 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_member_api_test.dart @@ -0,0 +1,239 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +Future _seedChannel(ChannelTester tester) => tester.watch( + modifyResponse: (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ), +); + +void main() { + channelTest( + '`.acceptInvite`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + body: (tester) async { + final message = Message(id: 'test-message-id', text: 'Invite Accepted'); + + final channelModel = createDefaultChannelModel(cid: _channelCid); + + tester.mockApi( + (api) => api.channel.acceptChannelInvite(_channelId, _channelType, message: message), + result: AcceptInviteResponse() + ..channel = channelModel + ..message = message, + ); + + final res = await tester.channel.acceptInvite(message); + + 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), + ); + }, + ); + + channelTest( + '`.rejectInvite`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + body: (tester) async { + final message = Message(id: 'test-message-id', text: 'Invite Rejected'); + + final channelModel = createDefaultChannelModel(cid: _channelCid); + + tester.mockApi( + (api) => api.channel.rejectChannelInvite(_channelId, _channelType, message: message), + result: RejectInviteResponse() + ..channel = channelModel + ..message = message, + ); + + final res = await tester.channel.rejectInvite(message); + + 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), + ); + }, + ); + + channelTest( + '`.addMembers`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + 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 channelModel = createDefaultChannelModel(cid: _channelCid); + + tester.mockApi( + (api) => api.channel.addMembers(_channelId, _channelType, memberIds, message: message), + result: createDefaultAddMembersResponse( + channel: channelModel, + members: members, + message: message, + ), + ); + + final res = await tester.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); + + tester.verifyApi( + (api) => api.channel.addMembers(_channelId, _channelType, memberIds, message: message), + ); + }, + ); + + channelTest( + '`.addMembers` with hideHistoryBefore', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + 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'); + + final channelModel = createDefaultChannelModel(cid: _channelCid); + + tester.mockApi( + (api) => api.channel.addMembers( + _channelId, + _channelType, + memberIds, + message: message, + hideHistoryBefore: hideHistoryBefore, + ), + result: createDefaultAddMembersResponse( + channel: channelModel, + members: members, + message: message, + ), + ); + + final res = await tester.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); + + tester.verifyApi( + (api) => api.channel.addMembers( + _channelId, + _channelType, + memberIds, + message: message, + hideHistoryBefore: hideHistoryBefore, + ), + ); + }, + ); + + channelTest( + '`.inviteMembers`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + 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 = createDefaultChannelModel(cid: _channelCid); + + tester.mockApi( + (api) => api.channel.inviteChannelMembers(_channelId, _channelType, memberIds, message: message), + result: InviteMembersResponse() + ..channel = channelModel + ..members = members + ..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); + + tester.verifyApi( + (api) => api.channel.inviteChannelMembers(_channelId, _channelType, memberIds, message: message), + ); + }, + ); + + channelTest( + '`.removeMembers`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + 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 = createDefaultChannelModel(cid: _channelCid); + + tester.mockApi( + (api) => api.channel.removeMembers(_channelId, _channelType, memberIds, message: message), + result: RemoveMembersResponse() + ..channel = channelModel + ..members = members + ..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); + + tester.verifyApi( + (api) => api.channel.removeMembers(_channelId, _channelType, memberIds, message: message), + ); + }, + ); +} 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 1551a374db..8f74362408 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -929,351 +929,6 @@ void main() { ); }); - test('`.update`', () async { - const channelData = { - 'name': 'Stream Team', - 'profile_image': 'test-profile-image', - }; - final updateMessage = Message( - id: 'test-message-id', - text: 'updated channel', - ); - - final channelModel = ChannelModel( - cid: channelCid, - extraData: channelData, - ); - - when(() => client.updateChannel(channelId, channelType, channelData, message: any(named: 'message'))).thenAnswer( - (_) async => UpdateChannelResponse() - ..channel = channelModel - ..message = updateMessage, - ); - - final res = await channel.update( - channelData, - updateMessage: updateMessage, - ); - - expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect(res.channel.extraData, channelData); - expect(res.message?.id, updateMessage.id); - - verify(() => client.updateChannel(channelId, channelType, channelData, message: any(named: 'message'))).called(1); - }); - - test('`.updateImage`', () async { - const image = 'https://getstream.io/new-image'; - - final channelModel = ChannelModel( - cid: channelCid, - extraData: {'image': image}, - ); - - when( - () => client.updateChannelPartial( - channelId, - channelType, - set: {'image': image}, - ), - ).thenAnswer( - (_) async => PartialUpdateChannelResponse()..channel = channelModel, - ); - - final res = await channel.updateImage(image); - - expect(res, isNotNull); - expect(res.channel.extraData['image'], image); - - verify( - () => client.updateChannelPartial( - channelId, - channelType, - set: {'image': image}, - ), - ).called(1); - }); - - test('`.updateName`', () async { - const name = 'Name'; - - final channelModel = ChannelModel( - cid: channelCid, - extraData: {'name': name}, - ); - - when( - () => client.updateChannelPartial( - channelId, - channelType, - set: {'name': name}, - ), - ).thenAnswer( - (_) async => PartialUpdateChannelResponse()..channel = channelModel, - ); - - final res = await channel.updateName(name); - - expect(res, isNotNull); - expect(res.channel.extraData['name'], name); - - verify( - () => client.updateChannelPartial( - channelId, - channelType, - set: {'name': name}, - ), - ).called(1); - }); - - test('`.updatePartial`', () async { - const set = { - 'name': 'Stream Team', - 'profile_image': 'test-profile-image', - }; - - const unset = ['tag', 'last_name']; - - final channelModel = ChannelModel( - cid: channelCid, - extraData: { - 'coolness': 999, - ...set, - }, - ); - - when( - () => client.updateChannelPartial( - channelId, - channelType, - set: set, - unset: unset, - ), - ).thenAnswer( - (_) async => PartialUpdateChannelResponse()..channel = channelModel, - ); - - 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}, - ); - - verify( - () => client.updateChannelPartial( - channelId, - channelType, - set: set, - unset: unset, - ), - ).called(1); - }); - - test('`.delete`', () async { - when(() => client.deleteChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); - - final res = await channel.delete(); - - expect(res, isNotNull); - - verify(() => client.deleteChannel(channelId, channelType)).called(1); - }); - - test('`.truncate`', () async { - when(() => client.truncateChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); - - final res = await channel.truncate(); - - expect(res, isNotNull); - - verify(() => client.truncateChannel(channelId, channelType)).called(1); - }); - - test('`.acceptInvite`', () async { - final message = Message(id: 'test-message-id', text: 'Invite Accepted'); - - final channelModel = ChannelModel(cid: channelCid); - - when(() => client.acceptChannelInvite(channelId, channelType, message: any(named: 'message'))).thenAnswer( - (_) async => AcceptInviteResponse() - ..channel = channelModel - ..message = message, - ); - - final res = await channel.acceptInvite(message); - - expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect(res.message?.id, message.id); - - verify(() => client.acceptChannelInvite(channelId, channelType, message: any(named: 'message'))).called(1); - }); - - test('`.rejectInvite`', () async { - final message = Message(id: 'test-message-id', text: 'Invite Rejected'); - - final channelModel = ChannelModel(cid: channelCid); - - when(() => client.rejectChannelInvite(channelId, channelType, message: any(named: 'message'))).thenAnswer( - (_) async => RejectInviteResponse() - ..channel = channelModel - ..message = message, - ); - - final res = await channel.rejectInvite(message); - - expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect(res.message?.id, message.id); - - verify(() => client.rejectChannelInvite(channelId, channelType, message: any(named: 'message'))).called(1); - }); - - test('`.addMembers`', () async { - final members = List.generate( - 3, - (index) => Member(userId: 'test-member-id-$index'), - ); - final memberIds = members.map((it) => it.userId).whereType().toList(growable: false); - final message = Message(id: 'test-message-id', text: 'Members Added'); - - final channelModel = ChannelModel(cid: channelCid); - - when( - () => client.addChannelMembers(channelId, channelType, memberIds, message: any(named: 'message')), - ).thenAnswer( - (_) async => AddMembersResponse() - ..channel = channelModel - ..members = members - ..message = message, - ); - - final res = await channel.addMembers(memberIds, message: message); - - expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect(res.members.length, members.length); - expect(res.message?.id, message.id); - - verify( - () => client.addChannelMembers(channelId, channelType, memberIds, message: any(named: 'message')), - ).called(1); - }); - - test('`.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 = ChannelModel(cid: channelCid); - - when( - () => client.addChannelMembers( - channelId, - channelType, - 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); - - verify( - () => client.addChannelMembers( - channelId, - channelType, - memberIds, - message: message, - hideHistoryBefore: hideHistoryBefore, - ), - ).called(1); - }); - - test('`.inviteMembers`', () async { - final members = List.generate( - 3, - (index) => Member(userId: 'test-member-id-$index'), - ); - final memberIds = members.map((it) => it.userId).whereType().toList(growable: false); - final message = Message(id: 'test-message-id', text: 'Members Invited'); - - final channelModel = ChannelModel(cid: channelCid); - - when( - () => client.inviteChannelMembers(channelId, channelType, memberIds, message: any(named: 'message')), - ).thenAnswer( - (_) async => InviteMembersResponse() - ..channel = channelModel - ..members = members - ..message = message, - ); - - final res = await channel.inviteMembers(memberIds, message: message); - - expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect(res.members.length, members.length); - expect(res.message?.id, message.id); - - verify( - () => client.inviteChannelMembers(channelId, channelType, memberIds, message: any(named: 'message')), - ).called(1); - }); - - test('`.removeMembers`', () async { - final members = List.generate( - 3, - (index) => Member(userId: 'test-member-id-$index'), - ); - final memberIds = members.map((it) => it.userId).whereType().toList(growable: false); - final message = Message(id: 'test-message-id', text: 'Members Removed'); - - final channelModel = ChannelModel(cid: channelCid); - - when( - () => client.removeChannelMembers(channelId, channelType, memberIds, message: any(named: 'message')), - ).thenAnswer( - (_) async => RemoveMembersResponse() - ..channel = channelModel - ..members = members - ..message = message, - ); - - final res = await channel.removeMembers(memberIds, message: message); - - expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect(res.members.length, members.length); - expect(res.message?.id, message.id); - - verify( - () => client.removeChannelMembers(channelId, channelType, memberIds, message: any(named: 'message')), - ).called(1); - }); - group('`.query`', () { test('should work fine', () async { final channelState = _generateChannelState(channelId, channelType); diff --git a/packages/stream_chat/test/src/client/channel/channel_update_api_test.dart b/packages/stream_chat/test/src/client/channel/channel_update_api_test.dart new file mode 100644 index 0000000000..ec93be83f6 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_update_api_test.dart @@ -0,0 +1,228 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +Future _seedChannel(ChannelTester tester) => tester.watch( + modifyResponse: (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ), +); + +void main() { + channelTest( + '`.update`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + body: (tester) async { + const channelData = { + 'name': 'Stream Team', + 'profile_image': 'test-profile-image', + }; + final updateMessage = Message( + id: 'test-message-id', + text: 'updated channel', + ); + + final channelModel = createDefaultChannelModel( + cid: _channelCid, + extraData: channelData, + ); + + tester.mockApi( + (api) => api.channel.updateChannel(_channelId, _channelType, channelData, message: updateMessage), + result: UpdateChannelResponse() + ..channel = channelModel + ..message = updateMessage, + ); + + final res = await tester.channel.update( + channelData, + updateMessage: updateMessage, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.channel.extraData, channelData); + expect(res.message?.id, updateMessage.id); + + tester.verifyApi( + (api) => api.channel.updateChannel(_channelId, _channelType, channelData, message: updateMessage), + ); + }, + ); + + channelTest( + '`.updateImage`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + body: (tester) async { + const image = 'https://getstream.io/new-image'; + + final channelModel = createDefaultChannelModel( + cid: _channelCid, + extraData: {'image': image}, + ); + + tester.mockApi( + (api) => api.channel.updateChannelPartial( + _channelId, + _channelType, + set: {'image': image}, + ), + result: createDefaultPartialUpdateChannelResponse(channel: channelModel), + ); + + final res = await tester.channel.updateImage(image); + + expect(res, isNotNull); + expect(res.channel.extraData['image'], image); + + tester.verifyApi( + (api) => api.channel.updateChannelPartial( + _channelId, + _channelType, + set: {'image': image}, + ), + ); + }, + ); + + channelTest( + '`.updateName`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + body: (tester) async { + const name = 'Name'; + + final channelModel = createDefaultChannelModel( + cid: _channelCid, + extraData: {'name': name}, + ); + + tester.mockApi( + (api) => api.channel.updateChannelPartial( + _channelId, + _channelType, + set: {'name': name}, + ), + result: createDefaultPartialUpdateChannelResponse(channel: channelModel), + ); + + final res = await tester.channel.updateName(name); + + expect(res, isNotNull); + expect(res.channel.extraData['name'], name); + + tester.verifyApi( + (api) => api.channel.updateChannelPartial( + _channelId, + _channelType, + set: {'name': name}, + ), + ); + }, + ); + + channelTest( + '`.updatePartial`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + 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, + }, + ); + + tester.mockApi( + (api) => api.channel.updateChannelPartial( + _channelId, + _channelType, + set: set, + unset: unset, + ), + result: createDefaultPartialUpdateChannelResponse(channel: channelModel), + ); + + final res = await tester.channel.updatePartial(set: set, unset: unset); + + 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, + ), + ); + }, + ); + + channelTest( + '`.delete`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + body: (tester) async { + tester.mockApi( + (api) => api.channel.deleteChannel(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.delete(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.deleteChannel(_channelId, _channelType), + ); + }, + ); + + channelTest( + '`.truncate`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannel, + body: (tester) async { + tester.mockApi( + (api) => api.channel.truncateChannel(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.truncate(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.truncateChannel(_channelId, _channelType), + ); + }, + ); +} From 3802b8c7fa836ca836cd11f8702c0c54e7f93ec8 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 12:27:22 +0200 Subject: [PATCH 038/103] test(llc): migrate channel reaction tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_reactions_test.dart | 731 ++++++++++++++++++ .../test/src/client/channel/channel_test.dart | 649 ---------------- 2 files changed, 731 insertions(+), 649 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_reactions_test.dart diff --git a/packages/stream_chat/test/src/client/channel/channel_reactions_test.dart b/packages/stream_chat/test/src/client/channel/channel_reactions_test.dart new file mode 100644 index 0000000000..389fd25f30 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_reactions_test.dart @@ -0,0 +1,731 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); +} + +void main() { + group('`.sendReaction`', () { + channelTest( + 'should work fine', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final message = Message( + id: 'test-message-id', + state: MessageState.sent, + ); + + const type = 'like'; + const emojiCode = '👍'; + const score = 4; + + final reaction = Reaction( + type: type, + messageId: message.id, + emojiCode: emojiCode, + score: score, + user: tester.currentUser, + ); + + tester.mockApi( + (api) => api.message.sendReaction(message.id, reaction), + result: createDefaultSendReactionResponse(message: message, reaction: 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, + ), + ], + ]), + ); + + 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); + + tester.verifyApi((api) => api.message.sendReaction(message.id, reaction)); + + await messagesEmission; + }, + ); + + channelTest( + 'should restore previous message if `client.sendReaction` throws', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const type = 'test-reaction-type'; + final message = Message( + id: 'test-message-id', + state: MessageState.sent, + ); + + final reaction = Reaction( + type: type, + messageId: message.id, + user: tester.currentUser, + ); + + tester.mockApiFailure( + (api) => api.message.sendReaction(message.id, reaction), + error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError), + ); + + 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, + ), + ], + [ + isSameMessageAs( + message, + matchReactions: true, + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.sendReaction(message, reaction); + } catch (e) { + expect(e, isA()); + } + + tester.verifyApi((api) => api.message.sendReaction(message.id, reaction)); + + await messagesEmission; + }, + ); + + channelTest( + 'should override previous reaction if present and `enforceUnique` is true', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const messageId = 'test-message-id'; + const prevType = 'test-reaction-type'; + final prevReaction = Reaction( + type: prevType, + messageId: messageId, + user: tester.currentUser, + ); + final message = Message( + id: messageId, + ownReactions: [prevReaction], + latestReactions: [prevReaction], + reactionGroups: { + prevType: ReactionGroup( + count: 1, + sumScores: 1, + ), + }, + state: MessageState.sent, + ); + + const type = 'test-reaction-type-2'; + final newReaction = Reaction( + type: type, + messageId: messageId, + user: tester.currentUser, + ); + final newMessage = message.copyWith( + ownReactions: [newReaction], + latestReactions: [newReaction], + ); + + const enforceUnique = true; + + tester.mockApi( + (api) => api.message.sendReaction( + messageId, + newReaction, + enforceUnique: enforceUnique, + ), + result: createDefaultSendReactionResponse(message: newMessage, reaction: newReaction), + ); + + final messagesEmission = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + newMessage, + matchReactions: true, + matchMessageState: true, + ), + ], + ]), + ); + + final res = await tester.channel.sendReaction( + message, + newReaction, + enforceUnique: enforceUnique, + ); + + expect(res, isNotNull); + expect(res.reaction.type, type); + expect(res.reaction.messageId, messageId); + + tester.verifyApi( + (api) => api.message.sendReaction( + messageId, + newReaction, + enforceUnique: enforceUnique, + ), + ); + + await messagesEmission; + }, + ); + }); + + group('`.sendReaction in thread`', () { + channelTest( + 'should work fine', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + 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: tester.currentUser, + ); + + tester.mockApi( + (api) => api.message.sendReaction(message.id, reaction), + result: createDefaultSendReactionResponse(message: message, reaction: 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, + ), + ], + ]), + ); + + final res = await tester.channel.sendReaction(message, reaction); + + expect(res, isNotNull); + expect(res.reaction.type, type); + expect(res.reaction.messageId, message.id); + + tester.verifyApi((api) => api.message.sendReaction(message.id, reaction)); + + await threadsEmission; + }, + ); + + channelTest( + 'should restore previous thread message if `client.sendReaction` throws', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + 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, + // `Message.createdAt` falls back to `DateTime.now()` per call + // when not provided, which breaks merge/sort keyed on createdAt. + createdAt: DateTime.utc(2021, 3), + ); + + final reaction = Reaction( + type: type, + messageId: message.id, + user: tester.currentUser, + ); + + tester.mockApiFailure( + (api) => api.message.sendReaction(message.id, reaction), + error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError), + ); + + 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, + reactionGroups: { + type: ReactionGroup( + count: 1, + sumScores: 1, + ), + }, + latestReactions: [reaction], + ownReactions: [reaction], + ), + matchReactions: true, + matchMessageState: true, + matchParentId: true, + ), + ], + [ + isSameMessageAs( + message, + matchReactions: true, + matchMessageState: true, + matchParentId: true, + ), + ], + ]), + ); + + try { + await tester.channel.sendReaction(message, reaction); + } catch (e) { + expect(e, isA()); + } + + tester.verifyApi((api) => api.message.sendReaction(message.id, reaction)); + + await threadsEmission; + }, + ); + + channelTest( + 'should override previous thread reaction if present and `enforceUnique` is true', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + 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: tester.currentUser, + ); + final message = Message( + id: messageId, + parentId: parentId, + ownReactions: [prevReaction], + latestReactions: [prevReaction], + reactionGroups: { + prevType: ReactionGroup( + count: 1, + sumScores: 1, + ), + }, + state: MessageState.sent, + ); + + const type = 'test-reaction-type-2'; + final newReaction = Reaction( + type: type, + messageId: messageId, + user: tester.currentUser, + ); + final newMessage = message.copyWith( + ownReactions: [newReaction], + latestReactions: [newReaction], + ); + + const enforceUnique = true; + + tester.mockApi( + (api) => api.message.sendReaction( + messageId, + newReaction, + enforceUnique: enforceUnique, + ), + result: createDefaultSendReactionResponse(message: newMessage, reaction: newReaction), + ); + + final threadsEmission = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.threadsStream.skip(1).map((event) => event['test-parent-id']), + emitsInOrder([ + [ + isSameMessageAs( + newMessage.copyWith(state: MessageState.sent), + matchReactions: true, + matchMessageState: true, + matchParentId: true, + ), + ], + ]), + ); + + final res = await tester.channel.sendReaction( + message, + newReaction, + enforceUnique: enforceUnique, + ); + + expect(res, isNotNull); + expect(res.reaction.type, type); + expect(res.reaction.messageId, messageId); + + tester.verifyApi( + (api) => api.message.sendReaction( + messageId, + newReaction, + enforceUnique: enforceUnique, + ), + ); + + await threadsEmission; + }, + ); + }); + + group('`.deleteReaction`', () { + channelTest( + 'should work fine', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + 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, + ); + + tester.mockApi( + (api) => api.message.deleteReaction(messageId, type), + result: createDefaultEmptyResponse(), + ); + + 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, + ), + ], + ]), + ); + + final res = await tester.channel.deleteReaction(message, reaction); + + expect(res, isNotNull); + + tester.verifyApi((api) => api.message.deleteReaction(messageId, type)); + + await messagesEmission; + }, + ); + + channelTest( + 'should restore prev message state if `client.deleteReaction` throws', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + 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, + ); + + tester.mockApiFailure( + (api) => api.message.deleteReaction(messageId, type), + error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError), + ); + + 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, + ), + ], + [ + isSameMessageAs( + message, + matchReactions: true, + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.deleteReaction(message, reaction); + } catch (e) { + expect(e, isA()); + } + + tester.verifyApi((api) => api.message.deleteReaction(messageId, type)); + + await messagesEmission; + }, + ); + }); + + group('`.deleteReaction in thread`', () { + channelTest( + 'should work fine', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + 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), + ); + + tester.mockApi( + (api) => api.message.deleteReaction(messageId, type), + result: createDefaultEmptyResponse(), + ); + + 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, + ), + ], + ]), + ); + + final res = await tester.channel.deleteReaction(message, reaction); + + expect(res, isNotNull); + + tester.verifyApi((api) => api.message.deleteReaction(messageId, type)); + + await threadsEmission; + }, + ); + + channelTest( + 'should restore prev message state if `client.deleteReaction` throws', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + 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, + 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), + ); + + tester.mockApiFailure( + (api) => api.message.deleteReaction(messageId, type), + error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError), + ); + + 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, + ), + ], + [ + isSameMessageAs( + message, + matchReactions: true, + matchMessageState: true, + matchParentId: true, + ), + ], + ]), + ); + + try { + await tester.channel.deleteReaction(message, reaction); + } catch (e) { + expect(e, isA()); + } + + tester.verifyApi((api) => api.message.deleteReaction(messageId, type)); + + await threadsEmission; + }, + ); + }); +} 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 8f74362408..0cd136a774 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -280,655 +280,6 @@ void main() { } }); - group('`.sendReaction`', () { - test('should work fine', () async { - final message = Message( - id: 'test-message-id', - state: MessageState.sent, - ); - - const type = 'like'; - const emojiCode = '👍'; - const score = 4; - - final reaction = Reaction( - type: type, - messageId: message.id, - emojiCode: emojiCode, - score: score, - user: client.state.currentUser, - ); - - when(() => client.sendReaction(message.id, reaction)).thenAnswer( - (_) async => SendReactionResponse() - ..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], - ), - matchReactions: true, - matchMessageState: true, - ), - ], - ]), - ); - - final res = await 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); - - verify(() => client.sendReaction(message.id, reaction)).called(1); - }); - - test( - 'should restore previous message if `client.sendReaction` throws', - () async { - const type = 'test-reaction-type'; - final message = Message( - id: 'test-message-id', - state: MessageState.sent, - ); - - final reaction = Reaction( - type: type, - messageId: message.id, - user: client.state.currentUser, - ); - - when( - () => client.sendReaction(message.id, reaction), - ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); - - 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], - ), - matchReactions: true, - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message, - matchReactions: true, - matchMessageState: true, - ), - ], - ]), - ); - - try { - await channel.sendReaction(message, reaction); - } catch (e) { - expect(e, isA()); - } - - verify(() => client.sendReaction(message.id, reaction)).called(1); - }, - ); - - test( - '''should override previous reaction if present and `enforceUnique` is true''', - () async { - const messageId = 'test-message-id'; - const prevType = 'test-reaction-type'; - final prevReaction = Reaction( - type: prevType, - messageId: messageId, - user: client.state.currentUser, - ); - final message = Message( - id: messageId, - ownReactions: [prevReaction], - latestReactions: [prevReaction], - reactionGroups: { - prevType: ReactionGroup( - count: 1, - sumScores: 1, - ), - }, - state: MessageState.sent, - ); - - const type = 'test-reaction-type-2'; - final newReaction = Reaction( - type: type, - messageId: messageId, - user: client.state.currentUser, - ); - final newMessage = message.copyWith( - ownReactions: [newReaction], - latestReactions: [newReaction], - ); - - const enforceUnique = true; - - when( - () => client.sendReaction( - messageId, - newReaction, - enforceUnique: enforceUnique, - ), - ).thenAnswer( - (_) async => SendReactionResponse() - ..message = newMessage - ..reaction = newReaction, - ); - - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - newMessage, - matchReactions: true, - matchMessageState: true, - ), - ], - ]), - ); - - final res = await channel.sendReaction( - message, - newReaction, - enforceUnique: enforceUnique, - ); - - expect(res, isNotNull); - expect(res.reaction.type, type); - expect(res.reaction.messageId, messageId); - - verify( - () => client.sendReaction( - messageId, - newReaction, - enforceUnique: enforceUnique, - ), - ).called(1); - }, - ); - }); - - 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, - ); - - final reaction = Reaction( - type: type, - messageId: message.id, - user: client.state.currentUser, - ); - - when(() => client.sendReaction(message.id, reaction)).thenAnswer( - (_) async => SendReactionResponse() - ..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], - ), - matchReactions: true, - matchMessageState: true, - matchParentId: true, - ), - ], - ]), - ); - - final res = await channel.sendReaction(message, reaction); - - expect(res, isNotNull); - expect(res.reaction.type, type); - expect(res.reaction.messageId, message.id); - - verify(() => client.sendReaction(message.id, reaction)).called(1); - }); - - test( - '''should restore previous thread message if `client.sendReaction` throws''', - () async { - const type = 'test-reaction-type'; - final message = Message( - id: 'test-message-id', - parentId: 'test-parent-id', // is thread message - 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(), - ); - - final reaction = Reaction( - type: type, - messageId: message.id, - user: client.state.currentUser, - ); - - when( - () => client.sendReaction(message.id, reaction), - ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); - - 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, - reactionGroups: { - type: ReactionGroup( - count: 1, - sumScores: 1, - ), - }, - latestReactions: [reaction], - ownReactions: [reaction], - ), - matchReactions: true, - matchMessageState: true, - matchParentId: true, - ), - ], - [ - isSameMessageAs( - message, - matchReactions: true, - matchMessageState: true, - matchParentId: true, - ), - ], - ]), - ); - - try { - await channel.sendReaction(message, reaction); - } catch (e) { - expect(e, isA()); - } - - verify(() => client.sendReaction(message.id, reaction)).called(1); - }, - ); - - test( - '''should override previous thread reaction if present and `enforceUnique` is true''', - () 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, - ); - final message = Message( - id: messageId, - parentId: parentId, - ownReactions: [prevReaction], - latestReactions: [prevReaction], - reactionGroups: { - prevType: ReactionGroup( - count: 1, - sumScores: 1, - ), - }, - state: MessageState.sent, - ); - - const type = 'test-reaction-type-2'; - final newReaction = Reaction( - type: type, - messageId: messageId, - user: client.state.currentUser, - ); - final newMessage = message.copyWith( - ownReactions: [newReaction], - latestReactions: [newReaction], - ); - - const enforceUnique = true; - - when( - () => client.sendReaction( - messageId, - newReaction, - enforceUnique: enforceUnique, - ), - ).thenAnswer( - (_) async => SendReactionResponse() - ..message = newMessage - ..reaction = newReaction, - ); - - expectLater( - // skipping first seed message list -> [] messages - channel.state?.threadsStream.skip(1).map((event) => event['test-parent-id']), - emitsInOrder([ - [ - isSameMessageAs( - newMessage.copyWith(state: MessageState.sent), - matchReactions: true, - matchMessageState: true, - matchParentId: true, - ), - ], - ]), - ); - - final res = await channel.sendReaction( - message, - newReaction, - enforceUnique: enforceUnique, - ); - - expect(res, isNotNull); - expect(res.reaction.type, type); - expect(res.reaction.messageId, messageId); - - verify( - () => client.sendReaction( - messageId, - newReaction, - enforceUnique: enforceUnique, - ), - ).called(1); - }, - ); - }); - - group('`.deleteReaction`', () { - test('should work fine', () async { - const userId = 'test-user-id'; - const messageId = 'test-message-id'; - const type = 'test-reaction-type'; - final reaction = Reaction( - type: type, - messageId: messageId, - userId: userId, - ); - final message = Message( - id: messageId, - ownReactions: [reaction], - latestReactions: [reaction], - reactionGroups: { - type: ReactionGroup( - count: 1, - sumScores: 1, - ), - }, - state: MessageState.sent, - ); - - when(() => client.deleteReaction(messageId, type)).thenAnswer((_) async => EmptyResponse()); - - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith( - state: MessageState.sent, - latestReactions: [], - ownReactions: [], - ), - matchReactions: true, - matchMessageState: true, - ), - ], - ]), - ); - - final res = await channel.deleteReaction(message, reaction); - - expect(res, isNotNull); - - verify(() => client.deleteReaction(messageId, type)).called(1); - }); - - test( - 'should restore prev message state if `client.deleteReaction` throws', - () async { - const userId = 'test-user-id'; - const messageId = 'test-message-id'; - const type = 'test-reaction-type'; - final reaction = Reaction( - type: type, - messageId: messageId, - userId: userId, - ); - final message = Message( - id: messageId, - ownReactions: [reaction], - latestReactions: [reaction], - reactionGroups: { - type: ReactionGroup( - count: 1, - sumScores: 1, - ), - }, - state: MessageState.sent, - ); - - when( - () => client.deleteReaction(messageId, type), - ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); - - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith( - state: MessageState.sent, - latestReactions: [], - ownReactions: [], - ), - matchReactions: true, - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message, - matchReactions: true, - matchMessageState: true, - ), - ], - ]), - ); - - try { - await channel.deleteReaction(message, reaction); - } catch (e) { - expect(e, isA()); - } - - verify(() => client.deleteReaction(messageId, type)).called(1); - }, - ); - }); - - 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(), - ); - - when(() => client.deleteReaction(messageId, type)).thenAnswer((_) async => EmptyResponse()); - - 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: [], - ), - matchReactions: true, - matchMessageState: true, - matchParentId: true, - ), - ], - ]), - ); - - final res = await channel.deleteReaction(message, reaction); - - expect(res, isNotNull); - - verify(() => client.deleteReaction(messageId, type)).called(1); - }); - - test( - 'should restore prev message state if `client.deleteReaction` throws', - () async { - const userId = 'test-user-id'; - const messageId = 'test-message-id'; - const 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, - 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(), - ); - - when( - () => client.deleteReaction(messageId, type), - ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); - - 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: [], - ), - matchReactions: true, - matchMessageState: true, - matchParentId: true, - ), - ], - [ - isSameMessageAs( - message, - matchReactions: true, - matchMessageState: true, - matchParentId: true, - ), - ], - ]), - ); - - try { - await channel.deleteReaction(message, reaction); - } catch (e) { - expect(e, isA()); - } - - verify(() => client.deleteReaction(messageId, type)).called(1); - }, - ); - }); - group('`.query`', () { test('should work fine', () async { final channelState = _generateChannelState(channelId, channelType); From 69043c8478d9229a81eabb4d56a4f11fd8081508 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 14:14:47 +0200 Subject: [PATCH 039/103] test(llc): migrate channel state cleanup tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_state_cleanup_test.dart | 227 ++++++++++++++++++ .../test/src/client/channel/channel_test.dart | 165 ------------- 2 files changed, 227 insertions(+), 165 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_state_cleanup_test.dart diff --git a/packages/stream_chat/test/src/client/channel/channel_state_cleanup_test.dart b/packages/stream_chat/test/src/client/channel/channel_state_cleanup_test.dart new file mode 100644 index 0000000000..4bf4fbd61d --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_state_cleanup_test.dart @@ -0,0 +1,227 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel() { + 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, + ); +} + +List _generateMessages(int count) => List.generate( + count, + (i) => Message( + id: 'msg-$i', + text: 'Hello $i', + createdAt: DateTime.utc(2024).add(Duration(seconds: i)), + ), +); + +void main() { + group('stale error message cleanup', () { + final errorMessage = Message(type: MessageType.error); + final bouncedErrorMessage = Message( + type: MessageType.error, + moderation: const Moderation( + action: ModerationAction.bounce, + originalText: 'original text', + ), + ); + + // 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 + setUp: (tester) => tester.watch( + modifyResponse: (_) => _channelStateWith( + messages: [errorMessage, bouncedErrorMessage], + ), + ), + body: (tester) async { + // Set up the mock response for sending message + final newMessage = Message(text: 'New message'); + + 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 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); + + tester.verifyApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(newMessage))), + ); + }, + ); + }); + + group('`.state.pruneOldest`', () { + 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']); + }, + ); + + 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)); + + final next = tester.channelState!.messagesStream.firstWhere((messages) => messages.length == 3); + + tester.channelState!.pruneOldest(3); + + final emitted = await next; + expect(emitted.map((m) => m.id), ['msg-3', 'msg-4', 'msg-5']); + }, + ); + + 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)); + + tester.channelState!.pruneOldest(5); + expect(tester.channelState!.messages, hasLength(3)); + + tester.channelState!.pruneOldest(3); + expect(tester.channelState!.messages, hasLength(3)); + }, + ); + + 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)); + + tester.channelState!.pruneOldest(0); + expect(tester.channelState!.messages, hasLength(5)); + + tester.channelState!.pruneOldest(-1); + expect(tester.channelState!.messages, hasLength(5)); + }, + ); + + 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)); + }, + ); + + 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), + ), + ]; + + tester.channelState!.updateChannelState( + _channelStateWith(messages: initial, pinnedMessages: pinned), + ); + + tester.channelState!.pruneOldest(3); + + expect(tester.channelState!.messages, hasLength(3)); + expect(tester.channelState!.pinnedMessages, equals(pinned)); + }, + ); + + 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(); + + final emissions = >[]; + final sub = tester.channelState!.messagesStream.skip(1).listen(emissions.add); + addTearDown(sub.cancel); + + 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); + }, + ); + }); +} 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 0cd136a774..d2bf0835c3 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -861,171 +861,6 @@ void main() { return expectLater(channel.on(eventType), emitsInOrder([event])); }); - - group('stale error message cleanup', () { - final channelState = _generateChannelState(channelId, channelType); - - final errorMessage = Message(type: MessageType.error); - final bouncedErrorMessage = Message( - type: MessageType.error, - moderation: const Moderation( - action: ModerationAction.bounce, - originalText: 'original text', - ), - ); - - // Test case: sending a message cleans up stale error messages - test('when sending a new message', () async { - // Channel with 2 error messages - final channel = Channel.fromState( - client, - channelState.copyWith( - messages: [errorMessage, bouncedErrorMessage], - ), - ); - - // 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)); - - // Send a new message - await channel.sendMessage(newMessage); - final messages = channel.state!.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(() => client.sendMessage(any(), channelId, channelType)); - }); - }); - - group('`.state.pruneOldest`', () { - List _generateMessages(int count) => List.generate( - count, - (i) => Message( - id: 'msg-$i', - text: 'Hello $i', - createdAt: DateTime(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), - ); - - final next = channel.state!.messagesStream.firstWhere((messages) => messages.length == 3); - - channel.state!.pruneOldest(3); - - final emitted = await next; - expect(emitted.map((m) => m.id), ['msg-3', 'msg-4', 'msg-5']); - }); - - 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), - ); - - channel.state!.pruneOldest(5); - expect(channel.state!.messages, hasLength(3)); - - channel.state!.pruneOldest(3); - expect(channel.state!.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), - ); - - channel.state!.pruneOldest(0); - expect(channel.state!.messages, hasLength(5)); - - channel.state!.pruneOldest(-1); - expect(channel.state!.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), - ); - - channel.state!.isUpToDate = false; - channel.state!.pruneOldest(3); - expect(channel.state!.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), - ), - ]; - - channel.state!.updateChannelState( - _generateChannelState(channelId, channelType).copyWith( - messages: initial, - pinnedMessages: pinned, - ), - ); - - channel.state!.pruneOldest(3); - - expect(channel.state!.messages, hasLength(3)); - expect(channel.state!.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), - ); - - // Skip the seeded emission from updateChannelState. - await pumpEventQueue(); - - final emissions = >[]; - final sub = channel.state!.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 - - await pumpEventQueue(); - expect(emissions, isEmpty); - }); - }); }); group('Channel State Validation and Cooldown', () { From 8d32d87d45e55d10f7ceb711cf027ee2fd2cfe4c Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 14:16:33 +0200 Subject: [PATCH 040/103] test(llc): migrate channel moderation and display API tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_display_api_test.dart | 208 +++++++++++++ .../channel/channel_moderation_api_test.dart | 263 +++++++++++++++++ .../test/src/client/channel/channel_test.dart | 273 ------------------ .../lib/src/helpers/test_data.dart | 9 + 4 files changed, 480 insertions(+), 273 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_display_api_test.dart create mode 100644 packages/stream_chat/test/src/client/channel/channel_moderation_api_test.dart diff --git a/packages/stream_chat/test/src/client/channel/channel_display_api_test.dart b/packages/stream_chat/test/src/client/channel/channel_display_api_test.dart new file mode 100644 index 0000000000..5c33053a33 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_display_api_test.dart @@ -0,0 +1,208 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); +} + +void main() { + channelTest( + '`.hide`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const clearHistory = true; + + tester.mockApi( + (api) => api.channel.hideChannel( + _channelId, + _channelType, + clearHistory: clearHistory, + ), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.hide(clearHistory: clearHistory); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.hideChannel( + _channelId, + _channelType, + clearHistory: clearHistory, + ), + ); + }, + ); + + channelTest( + '`.show`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + tester.mockApi( + (api) => api.channel.showChannel(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.show(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.showChannel(_channelId, _channelType), + ); + }, + ); + + // testing archiving + channelTest( + '`.archive`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + tester.mockApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: {'archived': true}, + ), + result: createDefaultPartialUpdateMemberResponse(), + ); + + final res = await tester.channel.archive(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: {'archived': true}, + ), + ); + }, + ); + + channelTest( + '`.unarchive`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + tester.mockApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + unset: ['archived'], + ), + result: createDefaultPartialUpdateMemberResponse(), + ); + + final res = await tester.channel.unarchive(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + unset: ['archived'], + ), + ); + }, + ); + + // testing pinning + channelTest( + '`.pin`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + tester.mockApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: {'pinned': true}, + ), + result: createDefaultPartialUpdateMemberResponse(), + ); + + final res = await tester.channel.pin(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: {'pinned': true}, + ), + ); + }, + ); + + channelTest( + '`.unpin`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + tester.mockApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + unset: ['pinned'], + ), + result: createDefaultPartialUpdateMemberResponse(), + ); + + final res = await tester.channel.unpin(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + unset: ['pinned'], + ), + ); + }, + ); + + channelTest( + '`.on`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const eventType = 'test.event'; + final event = createDefaultEvent(type: eventType, cid: _channelCid); + + final eventReceived = expectLater( + tester.channel.on(eventType), + emitsInOrder([isSameEventAs(event)]), + ); + + await tester.emitEvent(event); + + await eventReceived; + }, + ); +} diff --git a/packages/stream_chat/test/src/client/channel/channel_moderation_api_test.dart b/packages/stream_chat/test/src/client/channel/channel_moderation_api_test.dart new file mode 100644 index 0000000000..4cb7bf20c7 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_moderation_api_test.dart @@ -0,0 +1,263 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); +} + +void main() { + channelTest( + '`.mute`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + tester.mockApi( + (api) => api.moderation.muteChannel(_channelCid), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.mute(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.moderation.muteChannel(_channelCid), + ); + }, + ); + + channelTest( + '`.mute with expiration`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + 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(), + ); + + final res = await tester.channel.mute(expiration: expiration); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.moderation.muteChannel(_channelCid, expiration: expiration), + ); + + // wait for expiration + await Future.delayed(expiration); + tester.verifyApi((api) => api.moderation.unmuteChannel(_channelCid)); + }, + ); + + channelTest( + '`.unmute`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + tester.mockApi( + (api) => api.moderation.unmuteChannel(_channelCid), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.unmute(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.moderation.unmuteChannel(_channelCid), + ); + }, + ); + + channelTest( + '`.enableSlowMode`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const cooldown = 10; + + final channelModel = ChannelModel( + cid: _channelCid, + cooldown: cooldown, + ); + + tester.mockApi( + (api) => api.channel.enableSlowdown(_channelId, _channelType, cooldown), + result: createDefaultPartialUpdateChannelResponse(channel: channelModel), + ); + + final res = await tester.channel.enableSlowMode(cooldownInterval: 10); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.enableSlowdown(_channelId, _channelType, cooldown), + ); + }, + ); + + channelTest( + '`.disableSlowMode`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final channelModel = ChannelModel( + cid: _channelCid, + ); + + tester.mockApi( + (api) => api.channel.disableSlowdown(_channelId, _channelType), + result: createDefaultPartialUpdateChannelResponse(channel: channelModel), + ); + + final res = await tester.channel.disableSlowMode(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.disableSlowdown(_channelId, _channelType), + ); + }, + ); + + channelTest( + '`.banUser`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const userId = 'test-user-id'; + const options = {'key': 'value'}; + + tester.mockApi( + (api) => api.moderation.banUser( + userId, + options: {'type': _channelType, 'id': _channelId, ...options}, + ), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.banMember(userId, options); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.moderation.banUser( + userId, + options: {'type': _channelType, 'id': _channelId, ...options}, + ), + ); + }, + ); + + channelTest( + '`.unbanUser`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const userId = 'test-user-id'; + + tester.mockApi( + (api) => api.moderation.unbanUser( + userId, + options: {'type': _channelType, 'id': _channelId}, + ), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.unbanMember(userId); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.moderation.unbanUser( + userId, + options: {'type': _channelType, 'id': _channelId}, + ), + ); + }, + ); + + channelTest( + '`.shadowBan`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const userId = 'test-user-id'; + const options = {'key': 'value'}; + + tester.mockApi( + (api) => api.moderation.banUser( + userId, + options: {'shadow': true, 'type': _channelType, 'id': _channelId, ...options}, + ), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.shadowBan(userId, options); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.moderation.banUser( + userId, + options: {'shadow': true, 'type': _channelType, 'id': _channelId, ...options}, + ), + ); + }, + ); + + channelTest( + '`.removeShadowBan`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + const userId = 'test-user-id'; + + tester.mockApi( + (api) => api.moderation.unbanUser( + userId, + options: {'shadow': true, 'type': _channelType, 'id': _channelId}, + ), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.removeShadowBan(userId); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.moderation.unbanUser( + userId, + options: {'shadow': true, 'type': _channelType, 'id': _channelId}, + ), + ); + }, + ); +} 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 d2bf0835c3..7e730abcc5 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -588,279 +588,6 @@ void main() { ), ).called(1); }); - - test('`.mute`', () async { - when( - () => client.muteChannel( - channelCid, - expiration: any(named: 'expiration'), - ), - ).thenAnswer((_) async => EmptyResponse()); - - final res = await channel.mute(); - - expect(res, isNotNull); - - verify( - () => client.muteChannel( - channelCid, - expiration: any(named: 'expiration'), - ), - ).called(1); - }); - - test('`.mute with expiration`', () async { - const expiration = Duration(seconds: 3); - - when( - () => client.muteChannel( - channelCid, - expiration: expiration, - ), - ).thenAnswer((_) async => EmptyResponse()); - - when(() => client.unmuteChannel(channelCid)).thenAnswer((_) async => EmptyResponse()); - - final res = await channel.mute(expiration: expiration); - - expect(res, isNotNull); - - verify( - () => client.muteChannel( - channelCid, - expiration: expiration, - ), - ).called(1); - - // wait for expiration - await Future.delayed(expiration); - verify(() => client.unmuteChannel(channelCid)).called(1); - }); - - test('`.unmute`', () async { - when( - () => client.unmuteChannel(channelCid), - ).thenAnswer((_) async => EmptyResponse()); - - final res = await channel.unmute(); - - expect(res, isNotNull); - - verify( - () => client.unmuteChannel(channelCid), - ).called(1); - }); - - test('`.enableSlowMode`', () async { - const cooldown = 10; - - final channelModel = ChannelModel( - cid: channelCid, - cooldown: cooldown, - ); - - when( - () => client.enableSlowdown( - channelId, - channelType, - cooldown, - ), - ).thenAnswer((_) async => PartialUpdateChannelResponse()..channel = channelModel); - - final res = await channel.enableSlowMode(cooldownInterval: 10); - - expect(res, isNotNull); - - verify( - () => client.enableSlowdown( - channelId, - channelType, - cooldown, - ), - ).called(1); - }); - - test('`.disableSlowMode`', () async { - final channelModel = ChannelModel( - cid: channelCid, - ); - - when( - () => client.disableSlowdown( - channelId, - channelType, - ), - ).thenAnswer((_) async => PartialUpdateChannelResponse()..channel = channelModel); - - final res = await channel.disableSlowMode(); - - expect(res, isNotNull); - - verify(() => client.disableSlowdown(channelId, channelType)).called(1); - }); - - test('`.banUser`', () async { - const userId = 'test-user-id'; - const options = {'key': 'value'}; - - when( - () => client.banUser( - userId, - {'type': channelType, 'id': channelId, ...options}, - ), - ).thenAnswer((_) async => EmptyResponse()); - - final res = await channel.banMember(userId, options); - - expect(res, isNotNull); - - verify( - () => client.banUser( - userId, - {'type': channelType, 'id': channelId, ...options}, - ), - ).called(1); - }); - - test('`.unbanUser`', () async { - const userId = 'test-user-id'; - - when(() => client.unbanUser(userId, any())).thenAnswer((_) async => EmptyResponse()); - - final res = await channel.unbanMember(userId); - - expect(res, isNotNull); - - verify(() => client.unbanUser(userId, any())).called(1); - }); - - test('`.shadowBan`', () async { - const userId = 'test-user-id'; - const options = {'key': 'value'}; - - when( - () => client.shadowBan( - userId, - {'type': channelType, 'id': channelId, ...options}, - ), - ).thenAnswer((_) async => EmptyResponse()); - - final res = await channel.shadowBan(userId, options); - - expect(res, isNotNull); - - verify( - () => client.shadowBan( - userId, - {'type': channelType, 'id': channelId, ...options}, - ), - ).called(1); - }); - - test('`.removeShadowBan`', () async { - const userId = 'test-user-id'; - - when(() => client.removeShadowBan(userId, any())).thenAnswer((_) async => EmptyResponse()); - - final res = await channel.removeShadowBan(userId); - - expect(res, isNotNull); - - verify(() => client.removeShadowBan(userId, any())).called(1); - }); - - test('`.hide`', () async { - const clearHistory = true; - - when( - () => client.hideChannel( - channelId, - channelType, - clearHistory: clearHistory, - ), - ).thenAnswer((_) async => EmptyResponse()); - - final res = await channel.hide(clearHistory: clearHistory); - - expect(res, isNotNull); - - verify( - () => client.hideChannel( - channelId, - channelType, - clearHistory: clearHistory, - ), - ).called(1); - }); - - test('`.show`', () async { - when(() => client.showChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); - - final res = await channel.show(); - - expect(res, isNotNull); - - verify(() => client.showChannel(channelId, channelType)).called(1); - }); - - // testing archiving - test('`.archive`', () async { - when(() => client.archiveChannel(channelId: channelId, channelType: channelType)).thenAnswer( - (_) async => FakePartialUpdateMemberResponse(), - ); - - final res = await channel.archive(); - - expect(res, isNotNull); - - verify(() => client.archiveChannel(channelId: channelId, channelType: channelType)).called(1); - }); - - test('`.unarchive`', () async { - when(() => client.unarchiveChannel(channelId: channelId, channelType: channelType)).thenAnswer( - (_) async => FakePartialUpdateMemberResponse(), - ); - - final res = await channel.unarchive(); - - expect(res, isNotNull); - - verify(() => client.unarchiveChannel(channelId: channelId, channelType: channelType)).called(1); - }); - - // testing pinning - test('`.pin`', () async { - when( - () => client.pinChannel(channelId: channelId, channelType: channelType), - ).thenAnswer((_) async => FakePartialUpdateMemberResponse()); - - final res = await channel.pin(); - - expect(res, isNotNull); - - verify(() => client.pinChannel(channelId: channelId, channelType: channelType)).called(1); - }); - - test('`.unpin`', () async { - when( - () => client.unpinChannel(channelId: channelId, channelType: channelType), - ).thenAnswer((_) async => FakePartialUpdateMemberResponse()); - - final res = await channel.unpin(); - - expect(res, isNotNull); - - verify(() => client.unpinChannel(channelId: channelId, channelType: channelType)).called(1); - }); - - test('`.on`', () async { - const eventType = 'test.event'; - final event = Event(type: eventType, cid: channelCid); - - Future.microtask(() => client.addEvent(event)); - - return expectLater(channel.on(eventType), emitsInOrder([event])); - }); }); group('Channel State Validation and Cooldown', () { diff --git a/packages/stream_chat_test/lib/src/helpers/test_data.dart b/packages/stream_chat_test/lib/src/helpers/test_data.dart index aac8340d72..723ca39424 100644 --- a/packages/stream_chat_test/lib/src/helpers/test_data.dart +++ b/packages/stream_chat_test/lib/src/helpers/test_data.dart @@ -526,6 +526,15 @@ PartialUpdateChannelResponse createDefaultPartialUpdateChannelResponse({ ..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({ From 5124e28329d65659be4d82c959ac99eb6b724d23 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 14:18:01 +0200 Subject: [PATCH 041/103] test(llc): migrate channel query tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../client/channel/channel_query_test.dart | 393 ++++++++++++++++++ .../test/src/client/channel/channel_test.dart | 310 -------------- 2 files changed, 393 insertions(+), 310 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_query_test.dart diff --git a/packages/stream_chat/test/src/client/channel/channel_query_test.dart b/packages/stream_chat/test/src/client/channel/channel_query_test.dart new file mode 100644 index 0000000000..91af0652a5 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_query_test.dart @@ -0,0 +1,393 @@ +import 'package:stream_chat/stream_chat.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 — 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, + ), + ); +} + +void main() { + group('`.query`', () { + 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 tester.channel.query(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + ), + ); + }, + ); + + 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(errorCode: ChatErrorCode.inputError), + ); + + try { + await tester.channel.query(); + } catch (e) { + expect(e, isA()); + } + + tester.verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + ), + ); + }, + ); + + 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, + ); + + 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'), + ], + ); + + const pagination = PaginationParams(idAround: 'target-message-id'); + + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + messagesPagination: pagination, + ), + result: newState, + ); + + final res = await tester.channel.query(messagesPagination: pagination); + + 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, + ), + ); + }, + ); + + 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, + ); + + 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'), + ], + ); + + final pagination = PaginationParams(createdAtAround: targetDate); + + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + messagesPagination: pagination, + ), + result: newState, + ); + + final res = await tester.channel.query(messagesPagination: pagination); + + 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, + ), + ); + }, + ); + + 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), + ), + ], + ), + body: (tester) async { + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + ), + result: createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ), + ); + + registerFallbackValue([]); + tester.mockApi( + (api) => api.channel.markChannelsDelivered(any()), + result: createDefaultEmptyResponse(), + ); + + // 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)); + + // 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'); + }, + ); + + channelTest( + 'should NOT submit for delivery when querying with pagination (older messages)', + channelType: _channelType, + channelId: _channelId, + // Seeded exactly like the positive test above, so the absence of a + // receipt can 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), + ), + ], + ), + body: (tester) async { + registerFallbackValue([]); + tester.mockApi( + (api) => api.channel.markChannelsDelivered(any()), + result: createDefaultEmptyResponse(), + ); + + const pagination = PaginationParams(limit: 20, lessThan: 'some-message-id'); + + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + messagesPagination: pagination, + ), + result: createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ), + ); + + // Query with pagination params (fetching older messages) + await tester.channel.query(messagesPagination: pagination); + + // 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)); + + // Verify no delivery receipt was sent + tester.verifyNeverCalled( + (api) => api.channel.markChannelsDelivered(any()), + ); + }, + ); + }); + + channelTest( + '`.queryMembers`', + channelType: _channelType, + channelId: _channelId, + build: _buildInitializedChannel, + body: (tester) async { + final filter = Filter.in_('cid', const [_channelCid]); + + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); + + 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, + ); + + final res = await tester.channel.queryMembers(filter: filter); + + expect(res, isNotNull); + expect(res.members.length, members.length); + + tester.verifyApi( + (api) => api.general.queryMembers( + _channelType, + channelId: _channelId, + filter: filter, + members: const [], + ), + ); + }, + ); + + channelTest( + '`.queryBannedUsers`', + channelType: _channelType, + channelId: _channelId, + build: _buildInitializedChannel, + body: (tester) async { + final filter = Filter.equal('channel_cid', _channelCid); + + final bans = List.generate( + 3, + (index) => BannedUser( + user: User(id: 'test-user-id-$index'), + bannedBy: User(id: 'test-user-id-${index + 1}'), + ), + ); + + tester.mockApi( + (api) => api.moderation.queryBannedUsers(filter: filter), + result: QueryBannedUsersResponse()..bans = bans, + ); + + final res = await tester.channel.queryBannedUsers(); + + expect(res, isNotNull); + expect(res.bans.length, bans.length); + + tester.verifyApi( + (api) => api.moderation.queryBannedUsers(filter: filter), + ); + }, + ); +} 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 7e730abcc5..a4be46c6e3 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -206,7 +206,6 @@ void main() { late final client = MockStreamChatClient(); const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; - const channelCid = '$channelType:$channelId'; late Channel channel; setUpAll(() { @@ -279,315 +278,6 @@ void main() { expect(e, isA()); } }); - - group('`.query`', () { - test('should work fine', () async { - final channelState = _generateChannelState(channelId, channelType); - - when( - () => client.queryChannel( - channelType, - channelId: channelId, - channelData: any(named: 'channelData'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).thenAnswer((_) async => channelState); - - final res = await channel.query(); - - expect(res, isNotNull); - - verify( - () => client.queryChannel( - channelType, - channelId: channelId, - channelData: any(named: 'channelData'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).called(1); - }); - - test('should rethrow if `client.queryChannel` throws', () async { - when( - () => client.queryChannel( - channelType, - channelId: channelId, - channelData: any(named: 'channelData'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); - - try { - await channel.query(); - } catch (e) { - expect(e, isA()); - } - - verify( - () => client.queryChannel( - channelType, - channelId: channelId, - channelData: any(named: 'channelData'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).called(1); - }); - - test('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'), - ]; - - 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'), - ], - ); - - 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); - - const pagination = PaginationParams(idAround: 'target-message-id'); - - final res = await channel.query(messagesPagination: pagination); - - 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'), - ]; - - 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'), - ], - ); - - 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); - - final pagination = PaginationParams(createdAtAround: targetDate); - - final res = await channel.query(messagesPagination: pagination); - - 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); - }); - - 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'), - ), - ).thenAnswer((_) async => channelState); - - // Query without pagination params (fetching latest messages) - await channel.query(); - - // Verify submitForDelivery was called - verify( - () => client.channelDeliveryReporter.submitForDelivery([channel]), - ).called(1); - }, - ); - - 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'), - ), - ).thenAnswer((_) async => channelState); - - // Query with pagination params (fetching older messages) - await channel.query( - messagesPagination: const PaginationParams( - limit: 20, - lessThan: 'some-message-id', - ), - ); - - // Verify submitForDelivery was NOT called - verifyNever( - () => client.channelDeliveryReporter.submitForDelivery([channel]), - ); - }, - ); - }); - - test('`.queryMembers`', () async { - final filter = Filter.in_('cid', const [channelCid]); - - final members = List.generate( - 3, - (index) => Member(userId: 'test-user-id-$index'), - ); - - when( - () => client.queryMembers( - channelType, - channelId: channelId, - filter: filter, - members: any(named: 'members'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - ), - ).thenAnswer((_) async => QueryMembersResponse()..members = members); - - final res = await channel.queryMembers(filter: filter); - - expect(res, isNotNull); - expect(res.members.length, members.length); - - verify( - () => client.queryMembers( - channelType, - channelId: channelId, - filter: filter, - members: any(named: 'members'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - ), - ).called(1); - }); - - test('`.queryBannedUsers`', () async { - final filter = Filter.equal('channel_cid', channelCid); - - final bans = List.generate( - 3, - (index) => BannedUser( - user: User(id: 'test-user-id-$index'), - bannedBy: User(id: 'test-user-id-${index + 1}'), - ), - ); - - when( - () => client.queryBannedUsers( - filter: filter, - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - ), - ).thenAnswer((_) async => QueryBannedUsersResponse()..bans = bans); - - final res = await channel.queryBannedUsers(); - - expect(res, isNotNull); - expect(res.bans.length, bans.length); - - verify( - () => client.queryBannedUsers( - filter: filter, - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - ), - ).called(1); - }); }); group('Channel State Validation and Cooldown', () { From 2490ee3882c433df77eeebfe1da5a88ce099fe7c Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 14:33:19 +0200 Subject: [PATCH 042/103] test(llc): migrate channel read receipt tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_read_receipts_test.dart | 275 ++++++++++++++++ .../test/src/client/channel/channel_test.dart | 301 ------------------ .../lib/src/helpers/test_data.dart | 8 + 3 files changed, 283 insertions(+), 301 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_read_receipts_test.dart diff --git a/packages/stream_chat/test/src/client/channel/channel_read_receipts_test.dart b/packages/stream_chat/test/src/client/channel/channel_read_receipts_test.dart new file mode 100644 index 0000000000..b72572dda5 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_read_receipts_test.dart @@ -0,0 +1,275 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel({ + List ownCapabilities = const [], +}) { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + ownCapabilities: ownCapabilities, + ), + ); +} + +void main() { + group('Read Receipts', () { + channelTest( + ".markRead should throw if we don't have the capability", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + // no readEvents capability + modifyResponse: _seedChannel(ownCapabilities: []), + ), + body: (tester) async { + await expectLater( + tester.channel.markRead(messageId: 'message-id-123'), + throwsA(isA()), + ); + }, + ); + + channelTest( + '.markRead should succeed if we have the capability', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.readEvents], + ), + ), + body: (tester) async { + tester.mockApi( + (api) => api.channel.markRead( + _channelId, + _channelType, + messageId: 'message-id-123', + ), + result: createDefaultEmptyResponse(), + ); + + await expectLater( + tester.channel.markRead(messageId: 'message-id-123'), + completes, + ); + + tester.verifyApi( + (api) => api.channel.markRead( + _channelId, + _channelType, + messageId: 'message-id-123', + ), + ); + }, + ); + + channelTest( + ".markUnread should throw if we don't have the capability", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + // no readEvents capability + modifyResponse: _seedChannel(ownCapabilities: []), + ), + body: (tester) async { + await expectLater( + tester.channel.markUnread('message-id-123'), + throwsA(isA()), + ); + }, + ); + + channelTest( + '.markUnread should succeed if we have the capability', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.readEvents], + ), + ), + body: (tester) async { + tester.mockApi( + (api) => api.channel.markUnread( + _channelId, + _channelType, + 'message-id-123', + ), + result: createDefaultEmptyResponse(), + ); + + await expectLater( + tester.channel.markUnread('message-id-123'), + completes, + ); + + tester.verifyApi( + (api) => api.channel.markUnread( + _channelId, + _channelType, + 'message-id-123', + ), + ); + }, + ); + + channelTest( + ".markUnreadByTimestamp should throw if we don't have the capability", + 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( + tester.channel.markUnreadByTimestamp(timestamp), + throwsA(isA()), + ); + }, + ); + + channelTest( + '.markUnreadByTimestamp should succeed if we have the capability', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.readEvents], + ), + ), + body: (tester) async { + final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); + + tester.mockApi( + (api) => api.channel.markUnreadByTimestamp( + _channelId, + _channelType, + timestamp, + ), + result: createDefaultEmptyResponse(), + ); + + await expectLater( + tester.channel.markUnreadByTimestamp(timestamp), + completes, + ); + + tester.verifyApi( + (api) => api.channel.markUnreadByTimestamp( + _channelId, + _channelType, + timestamp, + ), + ); + }, + ); + + channelTest( + ".markThreadRead should throw if we don't have the capability", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + // no readEvents capability + modifyResponse: _seedChannel(ownCapabilities: []), + ), + body: (tester) async { + await expectLater( + tester.channel.markThreadRead('thread-id-123'), + throwsA(isA()), + ); + }, + ); + + channelTest( + '.markThreadRead should succeed if we have the capability', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.readEvents], + ), + ), + body: (tester) async { + tester.mockApi( + (api) => api.channel.markThreadRead( + _channelId, + _channelType, + 'thread-id-123', + ), + result: createDefaultEmptyResponse(), + ); + + await expectLater( + tester.channel.markThreadRead('thread-id-123'), + completes, + ); + + tester.verifyApi( + (api) => api.channel.markThreadRead( + _channelId, + _channelType, + 'thread-id-123', + ), + ); + }, + ); + + channelTest( + ".markThreadUnread should throw if we don't have the capability", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + // no readEvents capability + modifyResponse: _seedChannel(ownCapabilities: []), + ), + body: (tester) async { + await expectLater( + tester.channel.markThreadUnread('thread-id-123'), + throwsA(isA()), + ); + }, + ); + + channelTest( + '.markThreadUnread should succeed if we have the capability', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.readEvents], + ), + ), + body: (tester) async { + tester.mockApi( + (api) => api.channel.markThreadUnread( + _channelId, + _channelType, + 'thread-id-123', + ), + result: createDefaultEmptyResponse(), + ); + + await expectLater( + tester.channel.markThreadUnread('thread-id-123'), + completes, + ); + + tester.verifyApi( + (api) => api.channel.markThreadUnread( + _channelId, + _channelType, + 'thread-id-123', + ), + ); + }, + ); + }); +} 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 a4be46c6e3..dc1996c4ee 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -1391,307 +1391,6 @@ void main() { }); }); - group('Read Receipts', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); - - setUpAll(() { - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - 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 - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - }); - - test( - ".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); - - await expectLater( - channel.markRead(messageId: 'message-id-123'), - throwsA(isA()), - ); - }, - ); - - test( - '.markRead should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.readEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - when( - () => client.markChannelRead( - channelId, - channelType, - messageId: 'message-id-123', - ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater( - channel.markRead(messageId: 'message-id-123'), - completes, - ); - - verify( - () => client.markChannelRead( - channelId, - channelType, - messageId: 'message-id-123', - ), - ).called(1); - }, - ); - - test( - ".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); - - await expectLater( - channel.markUnread('message-id-123'), - throwsA(isA()), - ); - }, - ); - - test( - '.markUnread should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.readEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - when( - () => client.markChannelUnread( - channelId, - channelType, - 'message-id-123', - ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater( - channel.markUnread('message-id-123'), - completes, - ); - - verify( - () => client.markChannelUnread( - channelId, - channelType, - 'message-id-123', - ), - ).called(1); - }, - ); - - test( - ".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); - - final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); - - await expectLater( - channel.markUnreadByTimestamp(timestamp), - throwsA(isA()), - ); - }, - ); - - test( - '.markUnreadByTimestamp should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.readEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); - - when( - () => client.markChannelUnreadByTimestamp( - channelId, - channelType, - timestamp, - ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater( - channel.markUnreadByTimestamp(timestamp), - completes, - ); - - verify( - () => client.markChannelUnreadByTimestamp( - channelId, - channelType, - timestamp, - ), - ).called(1); - }, - ); - - test( - ".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); - - await expectLater( - channel.markThreadRead('thread-id-123'), - throwsA(isA()), - ); - }, - ); - - test( - '.markThreadRead should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.readEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - when( - () => client.markThreadRead( - channelId, - channelType, - 'thread-id-123', - ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater( - channel.markThreadRead('thread-id-123'), - completes, - ); - - verify( - () => client.markThreadRead( - channelId, - channelType, - 'thread-id-123', - ), - ).called(1); - }, - ); - - test( - ".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); - - await expectLater( - channel.markThreadUnread('thread-id-123'), - throwsA(isA()), - ); - }, - ); - - test( - '.markThreadUnread should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.readEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - when( - () => client.markThreadUnread( - channelId, - channelType, - 'thread-id-123', - ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater( - channel.markThreadUnread('thread-id-123'), - completes, - ); - - verify( - () => client.markThreadUnread( - channelId, - channelType, - 'thread-id-123', - ), - ).called(1); - }, - ); - }); - group('Retry functionality with parameter preservation', () { late final client = MockStreamChatClient(); const channelId = 'test-channel-id'; diff --git a/packages/stream_chat_test/lib/src/helpers/test_data.dart b/packages/stream_chat_test/lib/src/helpers/test_data.dart index 723ca39424..6f53eb9846 100644 --- a/packages/stream_chat_test/lib/src/helpers/test_data.dart +++ b/packages/stream_chat_test/lib/src/helpers/test_data.dart @@ -129,6 +129,8 @@ Event createDefaultEvent({ OwnUser? me, int? totalUnreadCount, int? unreadChannels, + int? channelMemberCount, + int? channelMessageCount, int? watcherCount, bool? hardDelete, bool? deletedForMe, @@ -159,6 +161,8 @@ Event createDefaultEvent({ me: me, totalUnreadCount: totalUnreadCount, unreadChannels: unreadChannels, + channelMemberCount: channelMemberCount, + channelMessageCount: channelMessageCount, watcherCount: watcherCount, hardDelete: hardDelete, deletedForMe: deletedForMe, @@ -208,9 +212,11 @@ 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, @@ -220,9 +226,11 @@ ChannelModel createDefaultChannelModel({ cid: cid, createdBy: createdBy, memberCount: memberCount, + cooldown: cooldown, members: members, ownCapabilities: ownCapabilities, config: config, + filterTags: filterTags, createdAt: createdAt ?? testCreatedAt, updatedAt: updatedAt ?? testUpdatedAt, lastMessageAt: lastMessageAt, From 07d605fc913d158a92522f803b55c0c63c8929d5 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 14:34:52 +0200 Subject: [PATCH 043/103] test(llc): migrate channel typing indicator tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../test/src/client/channel/channel_test.dart | 328 ------------------ .../channel_typing_indicator_test.dart | 300 ++++++++++++++++ 2 files changed, 300 insertions(+), 328 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_typing_indicator_test.dart 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 dc1996c4ee..972a205820 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -1063,334 +1063,6 @@ void main() { }); }); - 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()); - - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - 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 - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - }); - - test( - ".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); - - final typingEvent = Event(type: EventType.typingStart); - - await expectLater(channel.keyStroke(), completes); - - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingEvent)), - ), - ); - }, - ); - - test( - '.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, - ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final typingEvent = Event(type: EventType.typingStart); - - await expectLater(channel.keyStroke(), completes); - - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingEvent)), - ), - ); - }, - ); - - test( - ".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, - ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - 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); - - verify( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(startTypingEvent)), - ), - ).called(1); - - verify( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(stopTypingEvent)), - ), - ).called(1); - }, - ); - - test( - ".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); - - final typingStartEvent = Event(type: EventType.typingStart); - - await expectLater(channel.startTyping(), completes); - - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStartEvent)), - ), - ); - }, - ); - - test( - '.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, - ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final typingStartEvent = Event(type: EventType.typingStart); - - await expectLater(channel.startTyping(), completes); - - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStartEvent)), - ), - ); - }, - ); - - 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)), - ), - ).called(1); - }); - - test(".stopTyping 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); - - final typingStopEvent = Event(type: EventType.typingStop); - - await expectLater(channel.stopTyping(), completes); - - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStopEvent)), - ), - ); - }); - - 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), - ), - ); - - client.state.updateUser(updatedUser); - addTearDown(() => client.state.updateUser(currentUser)); - - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final typingStopEvent = Event(type: EventType.typingStop); - - await expectLater(channel.stopTyping(), completes); - - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStopEvent)), - ), - ); - }, - ); - - 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)), - ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater(channel.stopTyping(), completes); - - verify( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStopEvent)), - ), - ).called(1); - }); - }); - group('Retry functionality with parameter preservation', () { late final client = MockStreamChatClient(); const channelId = 'test-channel-id'; diff --git a/packages/stream_chat/test/src/client/channel/channel_typing_indicator_test.dart b/packages/stream_chat/test/src/client/channel/channel_typing_indicator_test.dart new file mode 100644 index 0000000000..8bf8c7ea4f --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_typing_indicator_test.dart @@ -0,0 +1,300 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel({ + List ownCapabilities = const [], +}) { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + ownCapabilities: ownCapabilities, + ), + ); +} + +// 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), + ), + ), + ), + ); +} + +void main() { + group('Typing Indicator', () { + channelTest( + ".keystore 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 typingEvent = Event(type: EventType.typingStart); + + await expectLater(tester.channel.keyStroke(), completes); + + tester.verifyNeverCalled( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingEvent)), + ), + ); + }, + ); + + channelTest( + '.keystore should return when user privacy settings is disabled', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.typingEvents], + ), + ), + body: (tester) async { + await _disableTypingIndicators(tester); + + final typingEvent = Event(type: EventType.typingStart); + + await expectLater(tester.channel.keyStroke(), completes); + + tester.verifyNeverCalled( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingEvent)), + ), + ); + }, + ); + + channelTest( + ".keystore should send 'typingStart' event if there is not already a typingEvent or the difference between " + 'the two is > 3 seconds', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.typingEvents], + ), + ), + body: (tester) async { + final startTypingEvent = Event(type: EventType.typingStart); + final stopTypingEvent = Event(type: EventType.typingStop); + + tester + ..mockApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(startTypingEvent)), + ), + result: createDefaultEmptyResponse(), + ) + ..mockApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(stopTypingEvent)), + ), + result: createDefaultEmptyResponse(), + ); + + await expectLater(tester.channel.keyStroke(), completes); + + tester + ..verifyApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(startTypingEvent)), + ), + ) + ..verifyApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(stopTypingEvent)), + ), + ); + }, + ); + + channelTest( + ".startTyping 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 typingStartEvent = Event(type: EventType.typingStart); + + await expectLater(tester.channel.startTyping(), completes); + + tester.verifyNeverCalled( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingStartEvent)), + ), + ); + }, + ); + + channelTest( + '.startTyping should return when user privacy settings is disabled', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.typingEvents], + ), + ), + body: (tester) async { + await _disableTypingIndicators(tester); + + final typingStartEvent = Event(type: EventType.typingStart); + + await expectLater(tester.channel.startTyping(), completes); + + tester.verifyNeverCalled( + (api) => api.channel.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], + ), + ), + body: (tester) async { + final typingStartEvent = Event(type: EventType.typingStart); + + tester.mockApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingStartEvent)), + ), + result: createDefaultEmptyResponse(), + ); + + await expectLater(tester.channel.startTyping(), completes); + + tester.verifyApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingStartEvent)), + ), + ); + }, + ); + + 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); + + await expectLater(tester.channel.stopTyping(), completes); + + tester.verifyNeverCalled( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingStopEvent)), + ), + ); + }, + ); + + channelTest( + '.stopTyping should return when user privacy settings is disabled', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.typingEvents], + ), + ), + body: (tester) async { + await _disableTypingIndicators(tester); + + final typingStopEvent = Event(type: EventType.typingStop); + + await expectLater(tester.channel.stopTyping(), completes); + + tester.verifyNeverCalled( + (api) => api.channel.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], + ), + ), + body: (tester) async { + final typingStopEvent = Event(type: EventType.typingStop); + + tester.mockApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingStopEvent)), + ), + result: createDefaultEmptyResponse(), + ); + + await expectLater(tester.channel.stopTyping(), completes); + + tester.verifyApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingStopEvent)), + ), + ); + }, + ); + }); +} From 4969d1627444fecc7cab0f341021afb0c7d2c6d0 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 14:36:20 +0200 Subject: [PATCH 044/103] test(llc): migrate channel filter tags tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_filter_tags_test.dart | 56 ++++++++++++++++ .../test/src/client/channel/channel_test.dart | 65 ------------------- 2 files changed, 56 insertions(+), 65 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_filter_tags_test.dart diff --git a/packages/stream_chat/test/src/client/channel/channel_filter_tags_test.dart b/packages/stream_chat/test/src/client/channel/channel_filter_tags_test.dart new file mode 100644 index 0000000000..548208391b --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_filter_tags_test.dart @@ -0,0 +1,56 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel({ + List? filterTags, +}) { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + filterTags: filterTags, + ), + ); +} + +void main() { + group('Channel filterTags', () { + 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'])); + }, + ); + + 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 channelModel = tester.channelState!.channelState.channel!; + final updatedChannel = channelModel.copyWith( + filterTags: ['tag3', 'tag4', 'tag5'], + ); + + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith(channel: updatedChannel), + ); + + expect(tester.channel.filterTags, equals(['tag3', 'tag4', 'tag5'])); + }, + ); + }); +} 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 972a205820..83a916570a 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -998,71 +998,6 @@ void main() { }); }); - group('Channel filterTags', () { - late final client = MockStreamChatClient(); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - setUpAll(() { - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - 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 - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - }); - - 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); - - expect(testChannel.filterTags, equals(['tag1', 'tag2'])); - }); - - test('should update filterTags when channel state is updated', () { - final channelModel = ChannelModel( - id: channelId, - type: channelType, - filterTags: ['tag1', 'tag2'], - ); - - final channelState = ChannelState(channel: channelModel); - final testChannel = Channel.fromState(client, channelState); - addTearDown(testChannel.dispose); - - expect(testChannel.filterTags, equals(['tag1', 'tag2'])); - - final updatedChannel = channelModel.copyWith( - filterTags: ['tag3', 'tag4', 'tag5'], - ); - - testChannel.state?.updateChannelState( - testChannel.state!.channelState.copyWith(channel: updatedChannel), - ); - - expect(testChannel.filterTags, equals(['tag3', 'tag4', 'tag5'])); - }); - }); - group('Retry functionality with parameter preservation', () { late final client = MockStreamChatClient(); const channelId = 'test-channel-id'; From a81fc62eae09cd54109fcbd7264c005e95c5cbd6 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 14:37:51 +0200 Subject: [PATCH 045/103] test(llc): migrate channel state validation and count event tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_count_events_test.dart | 346 +++++++++ .../channel_state_validation_test.dart | 363 +++++++++ .../test/src/client/channel/channel_test.dart | 718 ------------------ 3 files changed, 709 insertions(+), 718 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_count_events_test.dart create mode 100644 packages/stream_chat/test/src/client/channel/channel_state_validation_test.dart diff --git a/packages/stream_chat/test/src/client/channel/channel_count_events_test.dart b/packages/stream_chat/test/src/client/channel/channel_count_events_test.dart new file mode 100644 index 0000000000..1d9df151df --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_count_events_test.dart @@ -0,0 +1,346 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +// A bare channel state (no config, no capabilities). +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); +} + +void main() { + group('Channel message count events', () { + channelTest( + 'should update channel messageCount when event contains channelMessageCount', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + // Verify initial state - no messageCount + expect(tester.channel.messageCount, isNull); + + // Create event with channelMessageCount + final messageCountEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageNew, + channelMessageCount: 42, + ); + + // Dispatch event and wait for it to be processed + await tester.emitEvent(messageCountEvent); + + // Verify channel messageCount was updated + expect(tester.channel.messageCount, equals(42)); + }, + ); + + channelTest( + 'should update channel messageCount from message.new and message.deleted events', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + // Test with message.new event - count increases + final messageNewEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageNew, + message: createDefaultMessage( + id: 'new-message-1', + text: 'Hello world!', + user: User(id: 'user-1'), + ), + channelMessageCount: 1, + ); + + await tester.emitEvent(messageNewEvent); + expect(tester.channel.messageCount, equals(1)); + + // Test with another message.new event - count increases + final messageNewEvent2 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageNew, + message: createDefaultMessage( + id: 'new-message-2', + text: 'Second message', + user: User(id: 'user-2'), + ), + channelMessageCount: 2, + ); + + await tester.emitEvent(messageNewEvent2); + expect(tester.channel.messageCount, equals(2)); + + // Test with message.deleted event - count decreases + final messageDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageDeleted, + message: createDefaultMessage( + id: 'new-message-1', + text: 'Hello world!', + user: User(id: 'user-1'), + ), + channelMessageCount: 1, + ); + + await tester.emitEvent(messageDeletedEvent); + expect(tester.channel.messageCount, equals(1)); + }, + ); + + channelTest( + 'should preserve other channel properties when updating messageCount', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + // Set initial channel state with some properties + final initialChannel = tester.channelState?.channelState.channel?.copyWith( + extraData: {'name': 'Test Channel'}, + memberCount: 5, + frozen: true, + ); + + if (initialChannel != null) { + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith(channel: initialChannel), + ); + } + + // Verify initial state + 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 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageNew, + channelMessageCount: 100, + ); + + await tester.emitEvent(messageCountEvent); + + // Verify messageCount was updated while preserving other properties + expect(tester.channel.messageCount, equals(100)); + expect(tester.channel.name, 'Test Channel'); + expect(tester.channel.memberCount, equals(5)); + expect(tester.channel.frozen, equals(true)); + }, + ); + + channelTest( + 'should provide messageCountStream for reactive updates', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final emitted = []; + 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 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageNew, + message: createDefaultMessage( + id: 'msg-$index', + text: 'Message $count', + user: User(id: 'user-1'), + ), + channelMessageCount: count, + ); + + await tester.emitEvent(event); + } + + // The repeated count should not be emitted twice. + expect(emitted, equals([null, 1, 5, 10])); + }, + ); + }); + + group('Channel member count events', () { + channelTest( + 'should update channel memberCount when event contains channelMemberCount', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + // Verify initial state - default memberCount + expect(tester.channel.memberCount, equals(0)); + + // Create event with channelMemberCount + final memberCountEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberAdded, + member: createDefaultMember(user: User(id: 'user-1')), + channelMemberCount: 42, + ); + + // Dispatch event and wait for it to be processed + await tester.emitEvent(memberCountEvent); + + // Verify channel memberCount was updated + expect(tester.channel.memberCount, equals(42)); + }, + ); + + channelTest( + 'should update channel memberCount from member.added and member.removed events', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + // Test with member.added event - count increases + final memberAddedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberAdded, + member: createDefaultMember(user: User(id: 'user-1')), + channelMemberCount: 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 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberAdded, + member: createDefaultMember(user: User(id: 'user-2')), + channelMemberCount: 2, + ); + + await tester.emitEvent(memberAddedEvent2); + expect(tester.channel.memberCount, equals(2)); + expect( + tester.channelState?.channelState.members?.map((it) => it.userId), + equals(['user-1', 'user-2']), + ); + + // Test with member.removed event - count decreases + final memberRemovedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberRemoved, + user: User(id: 'user-1'), + channelMemberCount: 1, + ); + + await tester.emitEvent(memberRemovedEvent); + expect(tester.channel.memberCount, equals(1)); + expect(tester.channelState?.channelState.members?.map((it) => it.userId), equals(['user-2'])); + }, + ); + + channelTest( + 'should preserve other channel properties when updating memberCount', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + // Set initial channel state with some properties + final initialChannel = tester.channelState?.channelState.channel?.copyWith( + extraData: {'name': 'Test Channel'}, + messageCount: 7, + frozen: true, + ); + + if (initialChannel != null) { + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith(channel: initialChannel), + ); + } + + // Verify initial state + 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 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberAdded, + member: createDefaultMember(user: User(id: 'user-1')), + channelMemberCount: 100, + ); + + await tester.emitEvent(memberCountEvent); + + // Verify memberCount was updated while preserving other properties + expect(tester.channel.memberCount, equals(100)); + expect(tester.channel.name, 'Test Channel'); + expect(tester.channel.messageCount, equals(7)); + expect(tester.channel.frozen, equals(true)); + }, + ); + + channelTest( + 'should not update memberCount when the event omits channelMemberCount', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + // Seed a known member count. + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberAdded, + member: createDefaultMember(user: User(id: 'user-1')), + channelMemberCount: 5, + ), + ); + + expect(tester.channel.memberCount, equals(5)); + + // An event without the field should leave the count untouched. + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberAdded, + member: createDefaultMember(user: User(id: 'user-2')), + ), + ); + + expect(tester.channel.memberCount, equals(5)); + }, + ); + + channelTest( + 'should provide memberCountStream for reactive updates', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final emitted = []; + 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 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberAdded, + member: createDefaultMember(user: User(id: 'user-$index')), + channelMemberCount: count, + ); + + await tester.emitEvent(event); + } + + // The repeated count should not be emitted twice. + expect(emitted, equals([0, 1, 5, 10])); + }, + ); + }); +} diff --git a/packages/stream_chat/test/src/client/channel/channel_state_validation_test.dart b/packages/stream_chat/test/src/client/channel/channel_state_validation_test.dart new file mode 100644 index 0000000000..394ecfffcd --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_state_validation_test.dart @@ -0,0 +1,363 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +const _cooldownDuration = 30; // seconds + +// A bare channel state (no config, no capabilities). +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); +} + +// 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], + ), + ); +} + +void main() { + group('Channel State Validation and Cooldown', () { + group('Non-initialized channel state validation', () { + channelTest( + 'should throw StateError when accessing cooldown on non-initialized channel', + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final channel = tester.channel; + expect(() => channel.cooldown, throwsA(isA())); + }, + ); + + channelTest( + 'should throw StateError when accessing getRemainingCooldown on non-initialized channel', + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final channel = tester.channel; + expect(channel.getRemainingCooldown, throwsA(isA())); + }, + ); + + channelTest( + 'should throw StateError when accessing cooldownStream on non-initialized channel', + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final channel = tester.channel; + expect(() => channel.cooldownStream, throwsA(isA())); + }, + ); + }); + + group('Initialized channel cooldown functionality', () { + channelTest( + 'should return default cooldown value of 0 for initialized channel', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) => expect(tester.channel.cooldown, equals(0)), + ); + + 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(tester.client, stateWithCooldown); + addTearDown(testChannel.dispose); + + expect(testChannel.cooldown, equals(30)); + }, + ); + + 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)); + }, + ); + + 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', () { + channelTest( + 'should return positive cooldown after current user sends a thread reply', + 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: tester.currentUser!.id), + ); + tester.channelState!.updateThreadInfo('parent-msg-1', [threadReply]); + + expect(tester.channel.getRemainingCooldown(), greaterThan(0)); + }, + ); + + channelTest( + 'should return 0 cooldown when thread reply was sent outside the cooldown window', + 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), + ), + user: User(id: tester.currentUser!.id), + ); + tester.channelState!.updateThreadInfo('parent-msg-1', [oldReply]); + + expect(tester.channel.getRemainingCooldown(), equals(0)); + }, + ); + + channelTest( + 'should not trigger cooldown for a thread reply from another user', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelWithCooldown()), + body: (tester) async { + final otherUserReply = Message( + id: 'thread-reply-other', + parentId: 'parent-msg-1', + showInChannel: false, + createdAt: DateTime.timestamp(), + user: User(id: 'other-user-id'), + ); + tester.channelState!.updateThreadInfo('parent-msg-1', [otherUserReply]); + + expect(tester.channel.getRemainingCooldown(), equals(0)); + }, + ); + + channelTest( + 'should clear cooldown when the most-recent own message is hard-deleted', + 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: tester.currentUser!.id), + ); + tester.channelState!.updateMessage(ownMessage); + expect(tester.channel.getRemainingCooldown(), greaterThan(0)); + + tester.channelState!.deleteMessage(ownMessage, hardDelete: true); + expect(tester.channel.getRemainingCooldown(), equals(0)); + }, + ); + + channelTest( + 'currentUserLastMessageAtStream emits a new timestamp when own message is added', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelWithCooldown()), + body: (tester) async { + final emissions = []; + 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; + + tester.channelState!.updateMessage( + Message( + id: 'msg-1', + createdAt: DateTime.timestamp(), + user: User(id: tester.currentUser!.id), + ), + ); + await Future.delayed(Duration.zero); + + expect(emissions.last, isNotNull); + expect(emissions.last, isNot(equals(seededLast))); + }, + ); + + channelTest( + 'getRemainingCooldown uses the explicit [lastMessageAt] override', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelWithCooldown()), + body: (tester) async { + // No messages in state, so the default path returns 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(tester.channel.getRemainingCooldown(lastMessageAt: recent), greaterThan(0)); + + // Override pointing outside the window → 0. + final old = DateTime.timestamp().subtract( + const Duration(seconds: _cooldownDuration + 5), + ); + expect(tester.channel.getRemainingCooldown(lastMessageAt: old), equals(0)); + }, + ); + + channelTest( + 'currentUserLastMessageAt picks the latest across channel messages and threads', + 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. + tester.channelState!.updateMessage( + Message( + id: 'msg-1', + createdAt: older, + user: User(id: tester.currentUser!.id), + ), + ); + // Newer reply in a thread. + tester.channelState!.updateThreadInfo('parent-msg-1', [ + Message( + id: 'thread-reply-1', + parentId: 'parent-msg-1', + showInChannel: false, + createdAt: newer, + user: User(id: tester.currentUser!.id), + ), + ]); + + // Should pick the newer thread reply, not the older channel message. + final result = tester.channel.currentUserLastMessageAt; + expect(result, isNotNull); + expect(result!.isAtSameMomentAs(newer), isTrue); + }, + ); + }); + + group('Disposed channel state validation', () { + 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)); + + // Dispose the channel + channel.dispose(); + + // Now accessing cooldown should throw + expect(() => channel.cooldown, throwsA(isA())); + }, + ); + + 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)); + + // Dispose the channel + channel.dispose(); + + // Now accessing getRemainingCooldown should throw + expect(channel.getRemainingCooldown, throwsA(isA())); + }, + ); + + 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 + await expectLater(channel.cooldownStream.take(1), emits(0)); + + // Dispose the channel + channel.dispose(); + + // Now accessing cooldownStream should throw + expect(() => channel.cooldownStream, throwsA(isA())); + }, + ); + + 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 = createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); + final raceChannel = Channel.fromState(tester.client, channelState); + + // Verify it works initially + expect(raceChannel.cooldown, equals(0)); + + // Simulate quick disposal (like what happens with rapid navigation) + raceChannel.dispose(); + + // This should throw StateError instead of crashing with null check operator + expect(() => raceChannel.cooldown, throwsA(isA())); + + expect(raceChannel.getRemainingCooldown, throwsA(isA())); + }, + ); + }); + }); +} 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 83a916570a..94da407f07 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -280,724 +280,6 @@ void main() { }); }); - group('Channel State Validation and Cooldown', () { - late final client = MockStreamChatClient(); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - setUpAll(() { - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - 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 - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - - // mock channel delivery reporter - when( - () => client.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); - }); - - group('Non-initialized channel state validation', () { - test( - 'should throw StateError when accessing cooldown on non-initialized channel', - () { - final channel = Channel(client, channelType, channelId); - expect(() => channel.cooldown, throwsA(isA())); - }, - ); - - test( - 'should throw StateError when accessing getRemainingCooldown on non-initialized channel', - () { - final channel = Channel(client, channelType, channelId); - expect(channel.getRemainingCooldown, throwsA(isA())); - }, - ); - - test( - 'should throw StateError when accessing cooldownStream on non-initialized channel', - () { - final channel = Channel(client, channelType, channelId); - 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( - 'should return default cooldown value of 0 for initialized channel', - () => expect(channel.cooldown, equals(0)), - ); - - test('should return custom cooldown value when set in channel model', () { - final channelWithCooldown = ChannelModel( - id: channelId, - type: channelType, - cooldown: 30, - ); - - final stateWithCooldown = ChannelState(channel: channelWithCooldown); - final testChannel = Channel.fromState(client, stateWithCooldown); - addTearDown(testChannel.dispose); - - expect(testChannel.cooldown, equals(30)); - }); - - test('should return 0 remaining cooldown when no cooldown is set', () { - expect(channel.getRemainingCooldown(), equals(0)); - }); - - test('should return cooldown stream with default value', () { - expectLater(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; - } - - test( - 'should return positive cooldown after current user sends a thread reply', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - - // 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), - ); - ch.state!.updateThreadInfo('parent-msg-1', [threadReply]); - - expect(ch.getRemainingCooldown(), greaterThan(0)); - }, - ); - - test( - 'should return 0 cooldown when thread reply was sent outside the cooldown window', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - - // 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), - ), - user: User(id: currentUserId), - ); - ch.state!.updateThreadInfo('parent-msg-1', [oldReply]); - - expect(ch.getRemainingCooldown(), equals(0)); - }, - ); - - test( - 'should not trigger cooldown for a thread reply from another user', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - - final otherUserReply = Message( - id: 'thread-reply-other', - parentId: 'parent-msg-1', - showInChannel: false, - createdAt: DateTime.timestamp(), - user: User(id: 'other-user-id'), - ); - ch.state!.updateThreadInfo('parent-msg-1', [otherUserReply]); - - expect(ch.getRemainingCooldown(), equals(0)); - }, - ); - - test( - 'should clear cooldown when the most-recent own message is hard-deleted', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - - final ownMessage = Message( - id: 'msg-1', - createdAt: DateTime.timestamp(), - user: User(id: currentUserId), - ); - ch.state!.updateMessage(ownMessage); - expect(ch.getRemainingCooldown(), greaterThan(0)); - - ch.state!.deleteMessage(ownMessage, hardDelete: true); - expect(ch.getRemainingCooldown(), equals(0)); - }, - ); - - test( - 'currentUserLastMessageAtStream emits a new timestamp when own message is added', - () async { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - - final emissions = []; - final sub = ch.currentUserLastMessageAtStream.listen(emissions.add); - addTearDown(sub.cancel); - - // Let the seed emission settle. - await Future.delayed(Duration.zero); - final seededLast = emissions.last; - - ch.state!.updateMessage( - Message( - id: 'msg-1', - createdAt: DateTime.timestamp(), - user: User(id: currentUserId), - ), - ); - await Future.delayed(Duration.zero); - - expect(emissions.last, isNotNull); - expect(emissions.last, isNot(equals(seededLast))); - }, - ); - - test( - 'getRemainingCooldown uses the explicit [lastMessageAt] override', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - - // No messages in state, so the default path returns 0. - expect(ch.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)); - - // Override pointing outside the window → 0. - final old = DateTime.timestamp().subtract( - const Duration(seconds: cooldownDuration + 5), - ); - expect(ch.getRemainingCooldown(lastMessageAt: old), equals(0)); - }, - ); - - test( - 'currentUserLastMessageAt picks the latest across channel messages and threads', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - - 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( - Message( - id: 'msg-1', - createdAt: older, - user: User(id: currentUserId), - ), - ); - // Newer reply in a thread. - ch.state!.updateThreadInfo('parent-msg-1', [ - Message( - id: 'thread-reply-1', - parentId: 'parent-msg-1', - showInChannel: false, - createdAt: newer, - user: User(id: currentUserId), - ), - ]); - - // Should pick the newer thread reply, not the older channel message. - final result = ch.currentUserLastMessageAt; - expect(result, isNotNull); - expect(result!.isAtSameMomentAs(newer), isTrue); - }, - ); - }); - - group('Disposed channel state validation', () { - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - test( - 'should throw StateError when accessing cooldown after disposal', - () { - // First verify it works when initialized - expect(channel.cooldown, equals(0)); - - // Dispose the channel - channel.dispose(); - - // Now accessing cooldown should throw - expect(() => channel.cooldown, throwsA(isA())); - }, - ); - - test( - 'should throw StateError when accessing getRemainingCooldown after disposal', - () { - // First verify it works when initialized - expect(channel.getRemainingCooldown(), equals(0)); - - // Dispose the channel - channel.dispose(); - - // Now accessing getRemainingCooldown should throw - expect(channel.getRemainingCooldown, throwsA(isA())); - }, - ); - - test( - 'should throw StateError when accessing cooldownStream after disposal', - () { - // First verify it works when initialized - expectLater(channel.cooldownStream.take(1), emits(0)); - - // Dispose the channel - channel.dispose(); - - // Now accessing cooldownStream should throw - expect(() => channel.cooldownStream, throwsA(isA())); - }, - ); - - test( - 'should handle race condition scenario - initialization then quick disposal', - () { - // This test simulates the race condition that was causing the production crash - final channelState = _generateChannelState(channelId, channelType); - final raceChannel = Channel.fromState(client, channelState); - - // Verify it works initially - expect(raceChannel.cooldown, equals(0)); - - // Simulate quick disposal (like what happens with rapid navigation) - raceChannel.dispose(); - - // This should throw StateError instead of crashing with null check operator - expect(() => raceChannel.cooldown, throwsA(isA())); - - expect(raceChannel.getRemainingCooldown, throwsA(isA())); - }, - ); - }); - - 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( - 'should update channel messageCount when event contains channelMessageCount', - () async { - // Verify initial state - no messageCount - expect(channel.messageCount, isNull); - - // Create event with channelMessageCount - final messageCountEvent = Event( - cid: channel.cid, - type: EventType.messageNew, - channelMessageCount: 42, - ); - - // Dispatch event - client.addEvent(messageCountEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify channel messageCount was updated - expect(channel.messageCount, equals(42)); - }, - ); - - test( - 'should update channel messageCount from message.new and message.deleted events', - () async { - // Test with message.new event - count increases - final messageNewEvent = Event( - cid: channel.cid, - type: EventType.messageNew, - message: Message( - id: 'new-message-1', - text: 'Hello world!', - user: User(id: 'user-1'), - ), - channelMessageCount: 1, - ); - - client.addEvent(messageNewEvent); - await Future.delayed(Duration.zero); - expect(channel.messageCount, equals(1)); - - // Test with another message.new event - count increases - final messageNewEvent2 = Event( - cid: channel.cid, - type: EventType.messageNew, - message: Message( - id: 'new-message-2', - text: 'Second message', - user: User(id: 'user-2'), - ), - channelMessageCount: 2, - ); - - client.addEvent(messageNewEvent2); - await Future.delayed(Duration.zero); - expect(channel.messageCount, equals(2)); - - // Test with message.deleted event - count decreases - final messageDeletedEvent = Event( - cid: channel.cid, - type: EventType.messageDeleted, - message: Message( - id: 'new-message-1', - text: 'Hello world!', - user: User(id: 'user-1'), - ), - channelMessageCount: 1, - ); - - client.addEvent(messageDeletedEvent); - await Future.delayed(Duration.zero); - expect(channel.messageCount, equals(1)); - }, - ); - - test( - 'should preserve other channel properties when updating messageCount', - () async { - // Set initial channel state with some properties - final initialChannel = channel.state?.channelState.channel?.copyWith( - extraData: {'name': 'Test Channel'}, - memberCount: 5, - frozen: true, - ); - - if (initialChannel != null) { - channel.state?.updateChannelState( - channel.state!.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); - - // Update messageCount via event - final messageCountEvent = Event( - cid: channel.cid, - type: EventType.messageNew, - channelMessageCount: 100, - ); - - client.addEvent(messageCountEvent); - await Future.delayed(Duration.zero); - - // 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)); - }, - ); - - test( - 'should provide messageCountStream for reactive updates', - () async { - final emitted = []; - final subscription = 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, - type: EventType.messageNew, - message: Message( - id: 'msg-$index', - text: 'Message $count', - user: User(id: 'user-1'), - ), - channelMessageCount: count, - ); - - client.addEvent(event); - await Future.delayed(Duration.zero); - } - - // The repeated count should not be emitted twice. - expect(emitted, equals([null, 1, 5, 10])); - }, - ); - }); - - 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( - 'should update channel memberCount when event contains channelMemberCount', - () async { - // Verify initial state - default memberCount - expect(channel.memberCount, equals(0)); - - // Create event with channelMemberCount - final memberCountEvent = Event( - cid: channel.cid, - type: EventType.memberAdded, - member: Member( - userId: 'user-1', - user: User(id: 'user-1'), - ), - channelMemberCount: 42, - ); - - // Dispatch event - client.addEvent(memberCountEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify channel memberCount was updated - expect(channel.memberCount, equals(42)); - }, - ); - - test( - 'should update channel memberCount from member.added and member.removed events', - () async { - // Test with member.added event - count increases - final memberAddedEvent = Event( - cid: channel.cid, - type: EventType.memberAdded, - member: Member( - userId: 'user-1', - 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'])); - - // Test with another member.added event - count increases - final memberAddedEvent2 = Event( - cid: channel.cid, - type: EventType.memberAdded, - member: Member( - userId: 'user-2', - user: User(id: 'user-2'), - ), - channelMemberCount: 2, - ); - - client.addEvent(memberAddedEvent2); - await Future.delayed(Duration.zero); - expect(channel.memberCount, equals(2)); - expect( - channel.state?.channelState.members?.map((it) => it.userId), - equals(['user-1', 'user-2']), - ); - - // Test with member.removed event - count decreases - final memberRemovedEvent = Event( - cid: 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'])); - }, - ); - - test( - 'should preserve other channel properties when updating memberCount', - () async { - // Set initial channel state with some properties - final initialChannel = channel.state?.channelState.channel?.copyWith( - extraData: {'name': 'Test Channel'}, - messageCount: 7, - frozen: true, - ); - - if (initialChannel != null) { - channel.state?.updateChannelState( - channel.state!.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)); - - // Update memberCount via event - final memberCountEvent = Event( - cid: channel.cid, - type: EventType.memberAdded, - member: Member( - userId: 'user-1', - user: User(id: 'user-1'), - ), - channelMemberCount: 100, - ); - - client.addEvent(memberCountEvent); - await Future.delayed(Duration.zero); - - // 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)); - }, - ); - - test( - 'should not update memberCount when the event omits channelMemberCount', - () async { - // Seed a known member count. - client.addEvent( - Event( - cid: channel.cid, - type: EventType.memberAdded, - member: Member( - userId: 'user-1', - user: User(id: 'user-1'), - ), - channelMemberCount: 5, - ), - ); - - await Future.delayed(Duration.zero); - expect(channel.memberCount, equals(5)); - - // An event without the field should leave the count untouched. - client.addEvent( - Event( - cid: channel.cid, - type: EventType.memberAdded, - member: Member( - userId: 'user-2', - user: User(id: 'user-2'), - ), - ), - ); - - await Future.delayed(Duration.zero); - expect(channel.memberCount, equals(5)); - }, - ); - - test( - 'should provide memberCountStream for reactive updates', - () async { - final emitted = []; - final subscription = 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, - type: EventType.memberAdded, - member: Member( - userId: 'user-$index', - user: User(id: 'user-$index'), - ), - channelMemberCount: count, - ); - - client.addEvent(event); - await Future.delayed(Duration.zero); - } - - // The repeated count should not be emitted twice. - expect(emitted, equals([0, 1, 5, 10])); - }, - ); - }); - }); - group('Retry functionality with parameter preservation', () { late final client = MockStreamChatClient(); const channelId = 'test-channel-id'; From 823e14644b9cea0a4f87849912f14699c5cb1f12 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 14:39:20 +0200 Subject: [PATCH 046/103] test(llc): migrate channel setter tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../client/channel/channel_setters_test.dart | 170 ++++++++++++++++ .../test/src/client/channel/channel_test.dart | 190 ------------------ 2 files changed, 170 insertions(+), 190 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_setters_test.dart diff --git a/packages/stream_chat/test/src/client/channel/channel_setters_test.dart b/packages/stream_chat/test/src/client/channel/channel_setters_test.dart new file mode 100644 index 0000000000..cd5c2b0968 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_setters_test.dart @@ -0,0 +1,170 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), +); + +void main() { + 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'); + }, + ); + + channelTest( + 'should be able to get and set `image`', + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final channel = tester.channel; + + expect(channel.extraData.isEmpty, isTrue); + + const imageUrl = 'https://getstream.io/some-image'; + channel.image = imageUrl; + + expect(channel.image, imageUrl); + expect(channel.extraData['image'], imageUrl); + + const newImage = 'https://getstream.io/new-image'; + final newChannelInstance = Channel(tester.client, _channelType, _channelId, image: newImage); + + expect(newChannelInstance.image, newImage); + expect(newChannelInstance.extraData['image'], newImage); + }, + ); + + channelTest( + 'should be able to get and set `name`', + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final channel = tester.channel; + + expect(channel.extraData.isEmpty, isTrue); + + const name = 'Channel name'; + channel.name = name; + + expect(channel.name, name); + expect(channel.extraData['name'], name); + + const newName = 'New channel name'; + final newChannelInstance = Channel(tester.client, _channelType, _channelId, name: newName); + + expect(newChannelInstance.name, newName); + expect(newChannelInstance.extraData['name'], newName); + }, + ); + + 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(errorCode: ChatErrorCode.inputError), + ); + + // 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()), + ); + + await expectLater( + channel.watch(), + throwsA(isA()), + ); + 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'); + }, + ); + }); + + group('Initialized Channel', () { + 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()); + } + }, + ); + }); +} 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 94da407f07..a065c856ba 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -39,118 +39,6 @@ void main() { return logger; } - group('Non-Initialized Channel', () { - late final client = MockStreamChatClient(); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUpAll(() { - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - // client logger - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - }); - - setUp(() { - channel = Channel(client, channelType, channelId); - }); - - tearDown(() { - channel.dispose(); - }); - - test('should be able to set `extraData`', () { - 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'); - }); - - test('should be able to get and set `image`', () { - expect(channel.extraData.isEmpty, isTrue); - - const imageUrl = 'https://getstream.io/some-image'; - channel.image = imageUrl; - - expect(channel.image, imageUrl); - expect(channel.extraData['image'], imageUrl); - - const newImage = 'https://getstream.io/new-image'; - final newChannelInstance = Channel(client, channelType, channelId, image: newImage); - - expect(newChannelInstance.image, newImage); - expect(newChannelInstance.extraData['image'], newImage); - }); - - test('should be able to get and set `name`', () { - expect(channel.extraData.isEmpty, isTrue); - - const name = 'Channel name'; - channel.name = name; - - expect(channel.name, name); - expect(channel.extraData['name'], name); - - const newName = 'New channel name'; - final newChannelInstance = Channel(client, channelType, channelId, name: newName); - - expect(newChannelInstance.name, newName); - expect(newChannelInstance.extraData['name'], newName); - }); - - 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'), - ), - ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); - - // 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()), - ); - - await expectLater( - channel.watch(), - throwsA(isA()), - ); - 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'); - }); - }); - group('Initialized Channel with Persistence', () { late final client = MockStreamChatClientWithPersistence(); const channelId = 'test-channel-id'; @@ -202,84 +90,6 @@ void main() { }); }); - group('Initialized Channel', () { - late final client = MockStreamChatClient(); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUpAll(() { - // Fallback values - registerFallbackValue(FakeMessage()); - registerFallbackValue(FakeAttachmentFile()); - registerFallbackValue(FakeEvent()); - - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - - // client logger - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - - // mock channel delivery reporter - when( - () => client.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); - }); - - // Setting up a initialized channel - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ownCapabilities: [ChannelCapability.readEvents], - ); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - clearInteractions(client); - }); - - test('should throw if trying to set `extraData`', () { - try { - channel.extraData = {'name': 'test-channel-name'}; - } catch (e) { - expect(e, isA()); - } - }); - - test('should throw if trying to set `image`', () { - try { - channel.image = 'https://stream.io/some-image'; - } catch (e) { - expect(e, isA()); - } - }); - - test('should throw if trying to set `name`', () { - try { - channel.name = 'New name'; - } catch (e) { - expect(e, isA()); - } - }); - }); - group('Retry functionality with parameter preservation', () { late final client = MockStreamChatClient(); const channelId = 'test-channel-id'; From c90dc668315326e30dd9b180318dfc614eb823db Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 14:40:47 +0200 Subject: [PATCH 047/103] test(llc): migrate channel persistence lifecycle test to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_persistence_test.dart | 65 +++++++++++++++++++ .../test/src/client/channel/channel_test.dart | 51 --------------- 2 files changed, 65 insertions(+), 51 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_persistence_test.dart diff --git a/packages/stream_chat/test/src/client/channel/channel_persistence_test.dart b/packages/stream_chat/test/src/client/channel/channel_persistence_test.dart new file mode 100644 index 0000000000..c510220efd --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_persistence_test.dart @@ -0,0 +1,65 @@ +import 'package:stream_chat/stream_chat.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, the way the monolith +// group's setUp did, so the state attaches while a persistence client is set. +Channel _buildInitializedChannel(StreamChatClient client) { + return Channel.fromState( + client, + createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ), + ); +} + +// Stubs every persistence call the monolith group stubbed +// (`getChannelThreads`, `getChannelStateByCid`, `updateMessages`) plus the +// calls 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; +} + +void main() { + group('Initialized Channel with Persistence', () { + final persistenceClient = _createPersistenceClient(); + 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); + }, + ); + }); +} 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 a065c856ba..5913ac0efb 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -39,57 +39,6 @@ void main() { return logger; } - group('Initialized Channel with Persistence', () { - late final client = MockStreamChatClientWithPersistence(); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - const channelCid = '$channelType:$channelId'; - late Channel channel; - - setUpAll(() { - // Fallback values - registerFallbackValue(FakeMessage()); - registerFallbackValue([]); - registerFallbackValue(FakeAttachmentFile()); - - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - 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()); - - // client logger - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - }); - - // Setting up a initialized channel - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - }); - group('Retry functionality with parameter preservation', () { late final client = MockStreamChatClient(); const channelId = 'test-channel-id'; From 1011b7e33ae4a88801ed103a05ffecb982d815af Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 14:52:59 +0200 Subject: [PATCH 048/103] test(llc): migrate retry tests and retire channel_test monolith Co-Authored-By: Claude Fable 5 --- .../client/channel/channel_retry_test.dart | 354 +++++++++++++++++ .../test/src/client/channel/channel_test.dart | 367 ------------------ 2 files changed, 354 insertions(+), 367 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_retry_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_test.dart diff --git a/packages/stream_chat/test/src/client/channel/channel_retry_test.dart b/packages/stream_chat/test/src/client/channel/channel_retry_test.dart new file mode 100644 index 0000000000..0e86cac4fc --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_retry_test.dart @@ -0,0 +1,354 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); +} + +void main() { + group('Retry functionality with parameter preservation', () { + group('retryMessage method', () { + 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, + ), + ); + + tester.mockApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); + + final result = await tester.channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); + + tester.verifyApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + ); + }, + ); + + 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, + ), + ); + + tester.mockApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + ), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); + + final result = await tester.channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); + + tester.verifyApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + ), + ); + }, + ); + + 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, + ), + ); + + tester.mockApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipEnrichUrl: true, + ), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); + + final result = await tester.channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); + + tester.verifyApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipEnrichUrl: true, + ), + ); + }, + ); + + 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, + ), + ); + + tester.mockApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + ), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); + + final result = await tester.channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); + + tester.verifyApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + ), + ); + }, + ); + + 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, + ), + ); + + tester.mockApi( + (api) => api.message.updateMessage( + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + result: createDefaultUpdateMessageResponse(message: message.copyWith(state: MessageState.updated)), + ); + + final result = await tester.channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); + + tester.verifyApi( + (api) => api.message.updateMessage( + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + ); + }, + ); + + 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, + ), + ); + + tester.mockApi( + (api) => api.message.updateMessage( + any(that: isSameMessageAs(message)), + ), + result: createDefaultUpdateMessageResponse(message: message.copyWith(state: MessageState.updated)), + ); + + final result = await tester.channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); + + tester.verifyApi( + (api) => api.message.updateMessage( + any(that: isSameMessageAs(message)), + ), + ); + }, + ); + + 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, + ); + + tester.mockApi( + (api) => api.message.deleteMessage(message.id, hard: true), + result: createDefaultEmptyResponse(), + ); + + final result = await tester.channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); + + tester.verifyApi( + (api) => api.message.deleteMessage(message.id, hard: true), + ); + }, + ); + + 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, + ); + + tester.mockApi( + (api) => api.message.deleteMessage(message.id, hard: false), + result: createDefaultEmptyResponse(), + ); + + final result = await tester.channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); + + tester.verifyApi( + (api) => api.message.deleteMessage(message.id, hard: false), + ); + }, + ); + + 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, + ); + + tester.mockApi( + (api) => api.message.deleteMessage(message.id, deleteForMe: true), + result: createDefaultEmptyResponse(), + ); + + final result = await tester.channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); + + tester.verifyApi( + (api) => api.message.deleteMessage(message.id, deleteForMe: true), + ); + }, + ); + + 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(() => tester.channel.retryMessage(message), throwsA(isA())); + }, + ); + }); + }); +} diff --git a/packages/stream_chat/test/src/client/channel/channel_test.dart b/packages/stream_chat/test/src/client/channel/channel_test.dart deleted file mode 100644 index 5913ac0efb..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ /dev/null @@ -1,367 +0,0 @@ -// 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 '../../fakes.dart'; -import '../../matchers.dart'; -import '../../mocks.dart'; - -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, - ); - final state = ChannelState(channel: channel); - return state; - } - - Logger _createLogger(String name) { - final logger = Logger.detached(name)..level = Level.ALL; - logger.onRecord.listen(print); - return logger; - } - - 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()); - - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, error) { - return error is StreamChatNetworkError && error.isRetriable; - }, - ); - 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, - ), - ); - - 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, - ), - ); - - final sendMessageResponse = SendMessageResponse()..message = message.copyWith(state: MessageState.sent); - - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - skipPush: 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, - ), - ).called(1); - }); - - 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, - ), - ); - - final sendMessageResponse = SendMessageResponse()..message = message.copyWith(state: MessageState.sent); - - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - 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, - skipEnrichUrl: true, - ), - ).called(1); - }); - - 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 sendMessageResponse = SendMessageResponse()..message = message.copyWith(state: MessageState.sent); - - when( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).thenAnswer((_) async => sendMessageResponse); - - final result = await channel.retryMessage(message); - - expect(result, isNotNull); - expect(result, isA()); - - verify( - () => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - ), - ).called(1); - }); - - 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, - ), - ); - - final updateMessageResponse = UpdateMessageResponse()..message = message.copyWith(state: MessageState.updated); - - when( - () => client.updateMessage( - any(that: isSameMessageAs(message)), - skipPush: true, - skipEnrichUrl: true, - ), - ).thenAnswer((_) async => updateMessageResponse); - - final result = await channel.retryMessage(message); - - expect(result, isNotNull); - expect(result, isA()); - - verify( - () => client.updateMessage( - any(that: isSameMessageAs(message)), - skipPush: true, - skipEnrichUrl: true, - ), - ).called(1); - }); - - 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, - ), - ); - - final updateMessageResponse = UpdateMessageResponse()..message = message.copyWith(state: MessageState.updated); - - when( - () => client.updateMessage( - any(that: isSameMessageAs(message)), - ), - ).thenAnswer((_) async => updateMessageResponse); - - final result = await channel.retryMessage(message); - - expect(result, isNotNull); - expect(result, isA()); - - verify( - () => client.updateMessage( - any(that: isSameMessageAs(message)), - ), - ).called(1); - }); - - test('should call deleteMessage with preserved hard parameter', () async { - final message = Message( - id: 'test-message-id', - createdAt: DateTime.now(), - state: MessageState.hardDeletingFailed, - ); - - when( - () => client.deleteMessage( - message.id, - hard: true, - ), - ).thenAnswer((_) async => EmptyResponse()); - - final result = await channel.retryMessage(message); - - expect(result, isNotNull); - expect(result, isA()); - - verify( - () => client.deleteMessage( - message.id, - hard: true, - ), - ).called(1); - }); - - test('should call deleteMessage with preserved false hard parameter', () async { - final message = Message( - id: 'test-message-id', - createdAt: DateTime.now(), - state: MessageState.softDeletingFailed, - ); - - when( - () => client.deleteMessage( - message.id, - ), - ).thenAnswer((_) async => EmptyResponse()); - - final result = await channel.retryMessage(message); - - expect(result, isNotNull); - expect(result, isA()); - - verify( - () => client.deleteMessage( - message.id, - ), - ).called(1); - }); - - test('should call deleteMessageForMe for deletingForMeFailed state', () async { - final message = Message( - id: 'test-message-id', - createdAt: DateTime.now(), - state: MessageState.deletingForMeFailed, - ); - - when(() => client.deleteMessageForMe(message.id)).thenAnswer((_) async => EmptyResponse()); - - final result = await channel.retryMessage(message); - - expect(result, isNotNull); - expect(result, isA()); - - verify(() => client.deleteMessageForMe(message.id)).called(1); - }); - - test('should throw AssertionError when message state is not failed', () async { - final message = Message( - id: 'test-message-id', - state: MessageState.sent, - ); - - expect(() => channel.retryMessage(message), throwsA(isA())); - }); - }); - }); -} From 84906cec55eae6529f78fc98a5fc689193fc8ee8 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 16:14:12 +0200 Subject: [PATCH 049/103] test(llc): migrate channel read helper tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel/channel_read_helper_test.dart | 470 +++++++----------- 1 file changed, 191 insertions(+), 279 deletions(-) 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 ecda0d62d5..7573d488f3 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,93 +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(() { - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - 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 - when(() => client.logger).thenReturn(_createLogger('mock-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'); - - final nonExistentRead = channel.state!.userReadOf(userId: 'user-3'); - expect(nonExistentRead, isNull); - }); - - test('userReadOf should return null when userId is null', () { - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + // 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 read = channel.state!.userReadOf(userId: null); - expect(read, isNull); - }); + // ============================================================ + // FEATURE: User Reads + // ============================================================ - test( - 'userReadStreamOf should emit read updates for specific user', - () async { - final now = DateTime.now(); - final user1 = User(id: 'user-1', name: 'User 1'); + group('Channel Read Helper - User Reads', () { + 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 channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + 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); + }, + ); - final readStream = channel.state!.userReadStreamOf(userId: 'user-1'); + channelTest( + 'userReadStreamOf - should emit read updates for specific user', + setUp: (tester) => tester.watch(), + body: (tester) async { + final readStream = tester.channelState!.userReadStreamOf(userId: 'user-1'); - expectLater( + final readEmitted = expectLater( readStream, emitsInOrder([ isNull, // initial state @@ -96,224 +75,157 @@ 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, - ); + // ============================================================ + // FEATURE: Message Reads + // ============================================================ - 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)), + group('Channel Read Helper - Message Reads', () { + 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'))); - }); + ), + 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'))); + }, + ); - 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'); + channelTest( + 'readsOfStream - should emit read updates for a message', + setUp: (tester) => tester.watch(), + body: (tester) async { + final readsStream = tester.channelState!.readsOfStream(message: message); - final message = Message( - id: 'msg-1', - text: 'Test message', - user: sender, - createdAt: now, - ); + final readsEmitted = expectLater( + readsStream, + emitsInOrder([ + isEmpty, // initial state + hasLength(1), // after adding read + ]), + ); - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + // Update with read + tester.channelState!.updateChannelState( + ChannelState( + read: [ + createDefaultRead(user: user1, lastRead: messageSentAt.add(const Duration(seconds: 1))), + ], + ), + ); - final deliveriesStream = channel.state!.deliveriesOfStream(message: message); + await readsEmitted; + }, + ); + }); - expectLater( - deliveriesStream, - emitsInOrder([ - isEmpty, // initial state - hasLength(1), // after adding delivery - ]), - ); + // ============================================================ + // FEATURE: Message Deliveries + // ============================================================ - // Update with delivery - channel.state!.updateChannelState( - ChannelState( - channel: channelState.channel, + group('Channel Read Helper - Message Deliveries', () { + 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 + ]), + ); -Logger _createLogger(String name) { - final logger = Logger.detached(name)..level = Level.ALL; - logger.onRecord.listen(print); - return logger; -} + // Update with delivery + tester.channelState!.updateChannelState( + ChannelState( + read: [ + createDefaultRead( + user: user1, + lastRead: distantPast, + lastDeliveredAt: messageSentAt.add(const Duration(seconds: 1)), + ), + ], + ), + ); -// endregion + await deliveriesEmitted; + }, + ); + }); +} From d5c59363ad4f9da54f13c0b07d38be60a4dc2f19 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 16:14:12 +0200 Subject: [PATCH 050/103] test(llc): migrate channel capability check tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../channel_capability_check_test.dart | 238 +++++++----------- 1 file changed, 97 insertions(+), 141 deletions(-) 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 cf02992e4e..693f184b1d 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,60 +1,39 @@ // 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(() { - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - 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 - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - }); + // ============================================================ + // FEATURE: Capability Getters + // ============================================================ - /// Parameterized test for channel capability extension properties + group('Channel Capability Check - Capability Getters', () { + /// 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 - should return false when capability is absent', + setUp: (tester) => tester.watch(), + body: (tester) async { + expect(getterMethod(tester.channel), false); + }, + ); + + channelTest( + 'can$capabilityName - should return 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 @@ -310,101 +289,78 @@ void main() { (channel) => channel.canNotifyGroup, ); - test('returns correct values with multiple capabilities', () { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ - ChannelCapability.sendMessage, - ChannelCapability.sendReply, - ChannelCapability.deleteOwnMessage, - ], - ); - - 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); - }); - - 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( + 'should return 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); + }, + ); }); -} - -// 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); -} -Logger _createLogger(String name) { - final logger = Logger.detached(name)..level = Level.ALL; - logger.onRecord.listen(print); - return logger; + // ============================================================ + // FEATURE: Local Unread Count + // ============================================================ + + group('Channel Capability Check - Local Unread Count', () { + channelTest( + 'usesLocalUnreadCount - should be false when disabled and read receipts are unavailable', + setUp: (tester) => tester.watch(), + body: (tester) async { + expect(tester.channel.usesLocalUnreadCount, false); + }, + ); + + channelTest( + 'usesLocalUnreadCount - should be 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); + }, + ); + + channelTest( + 'usesLocalUnreadCount - should be 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); + }, + ); + + channelTest( + 'usesLocalUnreadCount - should be true when enabled and read receipts are unavailable', + isLocalUnreadCountEnabled: true, + setUp: (tester) => tester.watch(), + body: (tester) async { + expect(tester.channel.usesLocalUnreadCount, true); + }, + ); + }); } - -// endregion From cf35999d8a1f351b006d45b31af98f2ff6448a06 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 16:27:20 +0200 Subject: [PATCH 051/103] test(llc): make satellite tests independent of shared test helpers Co-Authored-By: Claude Fable 5 --- .../channel_delivery_reporter_test.dart | 55 +++++++++++++- .../test/src/client/retry_queue_test.dart | 26 ++++++- .../src/core/util/message_rules_test.dart | 73 +++++++++++++++++-- 3 files changed, 142 insertions(+), 12 deletions(-) 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 7f0a529f3e..8fd518eedc 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,9 +1,9 @@ +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'; import '../utils.dart'; void main() { @@ -516,8 +516,8 @@ Logger _createLogger(String name) { } StreamChatClient _createMockClient() { - final client = MockStreamChatClient(); - final clientState = FakeClientState( + final client = _MockStreamChatClient(); + final clientState = _FakeClientState( currentUser: OwnUser(id: 'current-user-id'), ); @@ -624,3 +624,50 @@ Read _createCurrentUserRead( } // endregion + +// region Test Doubles + +class _MockStreamChatClient extends Mock implements StreamChatClient { + @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, + }); + + final OwnUser? _currentUser; + + @override + OwnUser? get currentUser => _currentUser; + + @override + Map get channels => _channels; + final _channels = {}; + + @override + void addChannels(Map channelMap) { + _channels.addAll(channelMap); + } +} + +// endregion 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 59e2e51f47..cbf036e21e 100644 --- a/packages/stream_chat/test/src/client/retry_queue_test.dart +++ b/packages/stream_chat/test/src/client/retry_queue_test.dart @@ -3,11 +3,31 @@ import 'package:stream_chat/src/client/retry_queue.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; -import '../mocks.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; +} + +class _MockLogger extends Mock implements Logger { + @override + Level get level => Level.ALL; +} void main() { - late final channel = MockRetryQueueChannel(); - late final logger = MockLogger(); + late final channel = _MockChannel(); + late final logger = _MockLogger(); late RetryQueue retryQueue; setUpAll(() { 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 115f35ec21..4fc0627a4a 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; @@ -698,8 +697,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.detachedLogger(any())).thenAnswer((invocation) { @@ -757,3 +756,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 From 5d7950eeaafbe284d125ba4d78b92f704fa29003 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 19:42:32 +0200 Subject: [PATCH 052/103] test(llc): migrate client state guard tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../src/client/client_state_guards_test.dart | 143 ++++++++++++++++++ .../test/src/client/client_test.dart | 118 --------------- 2 files changed, 143 insertions(+), 118 deletions(-) create mode 100644 packages/stream_chat/test/src/client/client_state_guards_test.dart diff --git a/packages/stream_chat/test/src/client/client_state_guards_test.dart b/packages/stream_chat/test/src/client/client_state_guards_test.dart new file mode 100644 index 0000000000..55109d5103 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_state_guards_test.dart @@ -0,0 +1,143 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +void main() { + group('ClientState mutation guards', () { + // The old suite exercised these guards on a client that was never + // connected; skipping the connect phase preserves that setup. + 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}); + + 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); + }, + ); + + chatClientTest( + '`state.users` returns an unmodifiable view', + connect: (_) {}, + body: (tester) async { + tester.clientState.updateUser(User(id: 'u1')); + + expect(tester.clientState.users.containsKey('u1'), isTrue); + expect(() => tester.clientState.users.remove('u1'), throwsUnsupportedError); + expect(() => tester.clientState.users.clear(), throwsUnsupportedError); + }, + ); + + chatClientTest( + '`state.activeLiveLocations` returns an unmodifiable view', + connect: (_) {}, + body: (tester) async { + expect(() => tester.clientState.activeLiveLocations.clear(), throwsUnsupportedError); + }, + ); + + 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}); + + 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); + + tester.clientState.removeChannel('messaging:c1'); + await Future.delayed(Duration.zero); + + expect(received, hasLength(1)); + expect(received.single, isEmpty); + + await sub.cancel(); + }, + ); + + 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); + }, + ); + + chatClientTest( + '`channelsStream` emits unmodifiable maps', + connect: (_) {}, + body: (tester) async { + final received = >[]; + final sub = tester.clientState.channelsStream.listen(received.add); + + final channel = Channel.fromState( + tester.client, + createDefaultChannelState(channel: createDefaultChannelModel(cid: 'messaging:c1')), + ); + tester.clientState.addChannels({'messaging:c1': channel}); + await Future.delayed(Duration.zero); + + // 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); + } + + await sub.cancel(); + }, + ); + + chatClientTest( + '`usersStream` emits unmodifiable maps', + connect: (_) {}, + body: (tester) async { + final received = >[]; + final sub = tester.clientState.usersStream.listen(received.add); + + tester.clientState.updateUser(User(id: 'u1')); + await Future.delayed(Duration.zero); + + expect(received, hasLength(greaterThanOrEqualTo(2))); + for (final emitted in received) { + expect(emitted.clear, throwsUnsupportedError); + } + + await sub.cancel(); + }, + ); + + chatClientTest( + '`activeLiveLocationsStream` emits unmodifiable lists', + connect: (_) {}, + body: (tester) async { + final received = >[]; + final sub = tester.clientState.activeLiveLocationsStream.listen(received.add); + + tester.clientState.activeLiveLocations = const []; + await Future.delayed(Duration.zero); + + expect(received, isNotEmpty); + for (final emitted in received) { + expect(emitted.clear, throwsUnsupportedError); + } + + await sub.cancel(); + }, + ); + }); +} diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index d5dc7a52f6..9ec5b90e11 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -5728,122 +5728,4 @@ void main() { ); }); }); - - group('ClientState mutation guards', () { - const apiKey = 'test-api-key'; - late final api = FakeChatApi(); - late StreamChatClient client; - - setUp(() { - final ws = FakeWebSocket(); - client = StreamChatClient(apiKey, ws: ws, chatApi: api); - }); - - tearDown(() { - client.dispose(); - }); - - 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(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); - }); - - test('`state.users` returns an unmodifiable view', () { - client.state.updateUser(User(id: 'u1')); - - expect(client.state.users.containsKey('u1'), isTrue); - expect(() => client.state.users.remove('u1'), throwsUnsupportedError); - expect(() => client.state.users.clear(), throwsUnsupportedError); - }); - - test('`state.activeLiveLocations` returns an unmodifiable view', () { - expect(() => client.state.activeLiveLocations.clear(), throwsUnsupportedError); - }); - - 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}); - - 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); - - client.state.removeChannel('messaging:c1'); - await Future.delayed(Duration.zero); - - expect(received, hasLength(1)); - expect(received.single, isEmpty); - - await sub.cancel(); - }); - - 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); - }); - - test('`channelsStream` emits unmodifiable maps', () async { - final received = >[]; - final sub = client.state.channelsStream.listen(received.add); - - final channel = Channel.fromState( - client, - ChannelState(channel: ChannelModel(cid: 'messaging:c1')), - ); - client.state.addChannels({'messaging:c1': channel}); - await Future.delayed(Duration.zero); - - // 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); - } - - await sub.cancel(); - }); - - test('`usersStream` emits unmodifiable maps', () async { - final received = >[]; - final sub = client.state.usersStream.listen(received.add); - - client.state.updateUser(User(id: 'u1')); - await Future.delayed(Duration.zero); - - expect(received, hasLength(greaterThanOrEqualTo(2))); - for (final emitted in received) { - expect(emitted.clear, throwsUnsupportedError); - } - - await sub.cancel(); - }); - - test('`activeLiveLocationsStream` emits unmodifiable lists', () async { - final received = >[]; - final sub = client.state.activeLiveLocationsStream.listen(received.add); - - client.state.activeLiveLocations = const []; - await Future.delayed(Duration.zero); - - expect(received, isNotEmpty); - for (final emitted in received) { - expect(emitted.clear, throwsUnsupportedError); - } - - await sub.cancel(); - }); - }); } From 8080a2f4dcc7baebc823b3d64f30adbf20ba6843 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 19:44:01 +0200 Subject: [PATCH 053/103] test(llc): migrate client WS event tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../test/src/client/client_events_test.dart | 138 +++++++++++++++ .../test/src/client/client_test.dart | 158 ------------------ 2 files changed, 138 insertions(+), 158 deletions(-) create mode 100644 packages/stream_chat/test/src/client/client_events_test.dart diff --git a/packages/stream_chat/test/src/client/client_events_test.dart b/packages/stream_chat/test/src/client/client_events_test.dart new file mode 100644 index 0000000000..26c868d847 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_events_test.dart @@ -0,0 +1,138 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +void main() { + group('WS events', () { + group('User messages deleted event', () { + chatClientTest( + 'should broadcast global user.messages.deleted event to all channels', + body: (tester) async { + // Add messages from the user to be deleted + final bannedUser = User(id: 'banned-user', name: 'Banned User'); + final message1 = Message( + id: 'msg-1', + text: 'Message in channel 1', + user: bannedUser, + ); + final message2 = Message( + id: 'msg-2', + text: 'Message in channel 2', + user: bannedUser, + ); + + // Setup: Create multiple channels with state + final channelState1 = createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'messaging:channel-1'), + messages: [message1], + ); + final channelState2 = createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'messaging:channel-2'), + messages: [message2], + ); + + final channel1 = Channel.fromState(tester.client, channelState1); + final channel2 = Channel.fromState(tester.client, channelState2); + + // Register channels in client state + tester.clientState.addChannels({ + 'messaging:channel-1': channel1, + 'messaging:channel-2': channel2, + }); + + // Verify initial state + expect(channel1.state?.messages.length, equals(1)); + expect(channel2.state?.messages.length, equals(1)); + + // 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, + ); + + await tester.emitEvent(event); + + // Verify messages are soft deleted in all channels + final channel1Message = channel1.state?.messages.first; + expect(channel1Message?.type, equals(MessageType.deleted)); + expect(channel1Message?.state.isDeleted, isTrue); + + final channel2Message = channel2.state?.messages.first; + expect(channel2Message?.type, equals(MessageType.deleted)); + expect(channel2Message?.state.isDeleted, isTrue); + }, + ); + + chatClientTest( + 'should broadcast global hard delete to all channels', + 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'); + + final message1 = Message( + id: 'msg-1', + text: 'Message in channel 1', + user: bannedUser, + ); + final message2 = Message( + id: 'msg-2', + text: 'Message in channel 2', + user: bannedUser, + ); + final message3 = Message( + id: 'msg-3', + text: 'Safe message', + user: otherUser, + ); + + // Setup: Create multiple channels with state + final channelState1 = createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'messaging:channel-1'), + messages: [message1, message3], + ); + final channelState2 = createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'messaging:channel-2'), + messages: [message2], + ); + + final channel1 = Channel.fromState(tester.client, channelState1); + final channel2 = Channel.fromState(tester.client, channelState2); + + // Register channels in client state + tester.clientState.addChannels({ + 'messaging:channel-1': channel1, + 'messaging:channel-2': channel2, + }); + + // Verify initial state + expect(channel1.state?.messages.length, equals(2)); + expect(channel2.state?.messages.length, equals(1)); + + // 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, + ); + + await tester.emitEvent(event); + + // Verify banned user's messages are removed from all channels + expect(channel1.state?.messages.length, equals(1)); + expect( + channel1.state?.messages.any((m) => m.user?.id == 'banned-user'), + isFalse, + ); + expect(channel2.state?.messages.length, equals(0)); + + // Verify other user's message is unaffected + final safeMessage = channel1.state?.messages.firstWhere((m) => m.id == 'msg-3'); + expect(safeMessage?.user?.id, equals('other-user')); + }, + ); + }); + }); +} diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 9ec5b90e11..1d16ea2944 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -5570,162 +5570,4 @@ void main() { expect(client.wsConnectionStatus, ConnectionStatus.disconnected); }); }); - - 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 = Token.development(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( - 'should broadcast global user.messages.deleted event to all channels', - () async { - // Add messages from the user to be deleted - final bannedUser = User(id: 'banned-user', name: 'Banned User'); - final message1 = Message( - id: 'msg-1', - text: 'Message in channel 1', - user: bannedUser, - ); - final message2 = Message( - id: 'msg-2', - text: 'Message in channel 2', - user: bannedUser, - ); - - // Setup: Create multiple channels with state - final channelState1 = ChannelState( - channel: ChannelModel(id: 'channel-1', type: 'messaging'), - messages: [message1], - ); - final channelState2 = ChannelState( - channel: ChannelModel(id: 'channel-2', type: 'messaging'), - messages: [message2], - ); - - final channel1 = Channel.fromState(client, channelState1); - final channel2 = Channel.fromState(client, channelState2); - - // Register channels in client state - client.state.addChannels({ - 'messaging:channel-1': channel1, - 'messaging:channel-2': channel2, - }); - - // Verify initial state - 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( - type: EventType.userMessagesDeleted, - user: bannedUser, - hardDelete: false, - ); - - client.handleEvent(event); - - // Wait for the events to be processed - await Future.delayed(Duration.zero); - - // Verify messages are soft deleted in all channels - final channel1Message = channel1.state?.messages.first; - expect(channel1Message?.type, equals(MessageType.deleted)); - expect(channel1Message?.state.isDeleted, isTrue); - - final channel2Message = channel2.state?.messages.first; - expect(channel2Message?.type, equals(MessageType.deleted)); - expect(channel2Message?.state.isDeleted, isTrue); - }, - ); - - test( - 'should broadcast global hard delete to all channels', - () 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'); - - final message1 = Message( - id: 'msg-1', - text: 'Message in channel 1', - user: bannedUser, - ); - final message2 = Message( - id: 'msg-2', - text: 'Message in channel 2', - user: bannedUser, - ); - final message3 = Message( - id: 'msg-3', - text: 'Safe message', - user: otherUser, - ); - - // Setup: Create multiple channels with state - final channelState1 = ChannelState( - channel: ChannelModel(id: 'channel-1', type: 'messaging'), - messages: [message1, message3], - ); - final channelState2 = ChannelState( - channel: ChannelModel(id: 'channel-2', type: 'messaging'), - messages: [message2], - ); - - final channel1 = Channel.fromState(client, channelState1); - final channel2 = Channel.fromState(client, channelState2); - - // Register channels in client state - client.state.addChannels({ - 'messaging:channel-1': channel1, - 'messaging:channel-2': channel2, - }); - - // Verify initial state - 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( - type: EventType.userMessagesDeleted, - user: bannedUser, - hardDelete: true, - ); - - client.handleEvent(event); - - // Wait for the events to be processed - await Future.delayed(Duration.zero); - - // Verify banned user's messages are removed from all channels - expect(channel1.state?.messages.length, equals(1)); - expect( - channel1.state?.messages.any((m) => m.user?.id == 'banned-user'), - isFalse, - ); - expect(channel2.state?.messages.length, equals(0)); - - // Verify other user's message is unaffected - final safeMessage = channel1.state?.messages.firstWhere((m) => m.id == 'msg-3'); - expect(safeMessage?.user?.id, equals('other-user')); - }, - ); - }); - }); } From 4a073551918092f0e7c5ddbe9dbced7326ad76cc Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 19:47:03 +0200 Subject: [PATCH 054/103] test(llc): carve out reconnection tests into a self-contained legacy file Co-Authored-By: Claude Fable 5 --- .../src/client/client_reconnection_test.dart | 510 ++++++++++++++++++ .../test/src/client/client_test.dart | 301 ----------- 2 files changed, 510 insertions(+), 301 deletions(-) create mode 100644 packages/stream_chat/test/src/client/client_reconnection_test.dart diff --git a/packages/stream_chat/test/src/client/client_reconnection_test.dart b/packages/stream_chat/test/src/client/client_reconnection_test.dart new file mode 100644 index 0000000000..cc5e4ee79f --- /dev/null +++ b/packages/stream_chat/test/src/client/client_reconnection_test.dart @@ -0,0 +1,510 @@ +// 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:rxdart/rxdart.dart'; +import 'package:stream_chat/src/ws/websocket.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +void main() { + group('Fake web-socket connection functions', () { + const apiKey = 'test-api-key'; + late final api = FakeChatApi(); + + late StreamChatClient client; + + setUpAll(() { + // fallback values + registerFallbackValue(_FakeUser()); + }); + + setUp(() { + final ws = _FakeWebSocket(); + client = StreamChatClient(apiKey, ws: ws, chatApi: api); + }); + + tearDown(() { + client.dispose(); + }); + + test('`.connectAnonymousUser` should work fine', () async { + expectLater( + // skipping first seed status -> ConnectionStatus.disconnected + client.wsConnectionStatusStream.skip(1), + emitsInOrder([ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + final res = await client.connectAnonymousUser(); + expect(res, isNotNull); + }); + }); + + group('Fake web-socket connection functions failure', () { + const apiKey = 'test-api-key'; + late final api = FakeChatApi(); + + late StreamChatClient client; + + setUpAll(() { + // fallback values + registerFallbackValue(_FakeUser()); + }); + + setUp(() { + final ws = _FakeWebSocketWithConnectionError(); + client = StreamChatClient(apiKey, chatApi: api, ws: ws); + }); + + tearDown(() { + client.dispose(); + }); + + test( + '`.connectAnonymousUser` should throw if `ws.connect` fails', + () async { + try { + await client.connectAnonymousUser(); + } catch (e) { + expect(e, isA()); + } + }, + ); + }); + + group('Connect user calls with `connectWebSocket`: false', () { + const apiKey = 'test-api-key'; + late final api = FakeChatApi(); + + late StreamChatClient client; + + setUpAll(() { + // fallback values + registerFallbackValue(_FakeUser()); + }); + + setUp(() { + client = StreamChatClient(apiKey, chatApi: api); + }); + + tearDown(() { + client.dispose(); + }); + + test( + '`.connectAnonymousUser` should succeed without connecting', + () async { + final res = await client.connectAnonymousUser( + connectWebSocket: false, + ); + + expect(res, isNotNull); + expect(client.wsConnectionStatus, ConnectionStatus.disconnected); + }, + ); + }); + + group('recoverStateOnReconnect', () { + const apiKey = 'test-api-key'; + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + late FakeChatApi api; + late _FakeWebSocket ws; + late StreamChatClient client; + + setUpAll(() { + registerFallbackValue(const PaginationParams()); + registerFallbackValue(Filter.equal('cid', '')); + }); + + setUp(() { + api = FakeChatApi(); + 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 + // async error inside the connection-status listener. + 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 = []); + }); + + tearDown(() async { + await client.dispose(); + }); + + // 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; + await delay(100); + ws.connectionStatus = ConnectionStatus.connected; + await delay(300); + } + + test('should re-query active channels on reconnect when enabled (default)', () async { + // Setup: connect with default flag, register two channels. + client = StreamChatClient(apiKey, chatApi: api, ws: ws); + await client.connectUser(user, token); + await delay(300); + + final channel1 = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); + final channel2 = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c2'))); + client.state.addChannels({'messaging:c1': channel1, 'messaging:c2': channel2}); + + // Drop interactions from the initial connect's (empty-channel) recovery + // so we only count the reconnect call. + clearInteractions(api.channel); + + await simulateReconnect(); + + verify( + () => api.channel.queryChannels( + filter: Filter.in_('cid', const ['messaging:c1', 'messaging:c2']), + 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: const PaginationParams(limit: 30), + ), + ).called(1); + }); + + test('should skip the re-query on reconnect when disabled', () async { + client = StreamChatClient(apiKey, chatApi: api, ws: ws, recoverStateOnReconnect: false); + await client.connectUser(user, token); + await delay(300); + + final channel = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); + client.state.addChannels({'messaging:c1': channel}); + clearInteractions(api.channel); + + await simulateReconnect(); + + verifyNever( + () => 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('should still emit `connectionRecovered` when disabled', () async { + client = StreamChatClient(apiKey, chatApi: api, ws: ws, recoverStateOnReconnect: false); + await client.connectUser(user, token); + await delay(300); + + final channel = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); + client.state.addChannels({'messaging:c1': channel}); + + // Subscribe AFTER the initial connect so the captured event is the + // one fired by the manual reconnect. + final recoveredEvents = []; + final sub = client.on(EventType.connectionRecovered).listen(recoveredEvents.add); + + await simulateReconnect(); + await sub.cancel(); + + expect(recoveredEvents, hasLength(1)); + }); + + // 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. + test('should not surface an error when the re-query fails', () async { + 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'), + ), + ).thenThrow(const StreamChatError('You cannot use queryChannels without an active connection.')); + + client = StreamChatClient(apiKey, chatApi: api, ws: ws); + await client.connectUser(user, token); + await delay(300); + + final channel = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); + client.state.addChannels({'messaging:c1': channel}); + + // Subscribe AFTER the initial connect so the captured event is the + // one fired by the manual reconnect. + final recoveredEvents = []; + final sub = client.on(EventType.connectionRecovered).listen(recoveredEvents.add); + + await simulateReconnect(); + await sub.cancel(); + + expect(recoveredEvents, hasLength(1)); + }); + + test('should skip the re-query when no active channels are tracked', () async { + client = StreamChatClient(apiKey, chatApi: api, ws: ws); + await client.connectUser(user, token); + await delay(300); + + // No channels added — the cids.isNotEmpty guard should short-circuit. + clearInteractions(api.channel); + + await simulateReconnect(); + + verifyNever( + () => 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('should respect runtime toggling via the setter', () async { + client = StreamChatClient(apiKey, chatApi: api, ws: ws); + await client.connectUser(user, token); + await delay(300); + + final channel = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); + client.state.addChannels({'messaging:c1': channel}); + clearInteractions(api.channel); + + // Disable mid-flight → no re-query on reconnect. + client.recoverStateOnReconnect = false; + await simulateReconnect(); + verifyNever( + () => 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'), + ), + ); + + // Re-enable → re-query on the next reconnect. + client.recoverStateOnReconnect = true; + await simulateReconnect(); + 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); + }); + }); + + group('dispose during reconnect recovery', () { + const apiKey = 'test-api-key'; + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + late FakeChatApi api; + late _FakeWebSocket ws; + late StreamChatClient client; + var disposed = false; + + setUpAll(() { + registerFallbackValue(const PaginationParams()); + registerFallbackValue(Filter.equal('cid', '')); + }); + + setUp(() { + api = FakeChatApi(); + ws = _FakeWebSocket(); + disposed = false; + }); + + // The test disposes the client itself; avoid disposing it a second time. + tearDown(() async { + 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. + test('disposing mid-recovery does not surface a late recovery event', () async { + // Keep the recovery's channel query pending so the client is still + // mid-recovery at the moment it is disposed. + final pendingQuery = Completer(); + 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((_) => pendingQuery.future); + + client = StreamChatClient(apiKey, chatApi: api, ws: ws); + await client.connectUser(user, token); + await delay(300); + + // Track a channel so reconnecting triggers channel recovery, which then + // blocks on the pending query above. + final channel = Channel.fromState( + client, + ChannelState(channel: ChannelModel(cid: 'messaging:c1')), + ); + client.state.addChannels({'messaging:c1': channel}); + + // Drop then restore the connection to start a reconnect recovery. + ws.connectionStatus = ConnectionStatus.disconnected; + await delay(100); + ws.connectionStatus = ConnectionStatus.connected; + await delay(100); + + // Dispose while the recovery is still in flight. + await client.dispose(); + disposed = true; + + // Let the now-orphaned recovery finish. Its trailing work must be + // discarded silently instead of thrown as an unhandled async error. + pendingQuery.complete(QueryChannelsResponse()..channels = []); + await delay(300); + + expect(client.wsConnectionStatus, ConnectionStatus.disconnected); + }); + }); +} + +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 = StreamWebSocketError('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/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 1d16ea2944..89e29bb2e1 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -5269,305 +5269,4 @@ void main() { }); }); }); - - group('recoverStateOnReconnect', () { - const apiKey = 'test-api-key'; - final user = User(id: 'test-user-id'); - final token = Token.development(user.id).rawValue; - - late FakeChatApi api; - late FakeWebSocket ws; - late StreamChatClient client; - - setUpAll(() { - registerFallbackValue(const PaginationParams()); - registerFallbackValue(Filter.equal('cid', '')); - }); - - setUp(() { - api = FakeChatApi(); - 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 - // async error inside the connection-status listener. - 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 = []); - }); - - tearDown(() async { - await client.dispose(); - }); - - // 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; - await delay(100); - ws.connectionStatus = ConnectionStatus.connected; - await delay(300); - } - - test('should re-query active channels on reconnect when enabled (default)', () async { - // Setup: connect with default flag, register two channels. - client = StreamChatClient(apiKey, chatApi: api, ws: ws); - await client.connectUser(user, token); - await delay(300); - - final channel1 = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); - final channel2 = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c2'))); - client.state.addChannels({'messaging:c1': channel1, 'messaging:c2': channel2}); - - // Drop interactions from the initial connect's (empty-channel) recovery - // so we only count the reconnect call. - clearInteractions(api.channel); - - await simulateReconnect(); - - verify( - () => api.channel.queryChannels( - filter: Filter.in_('cid', const ['messaging:c1', 'messaging:c2']), - 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: const PaginationParams(limit: 30), - ), - ).called(1); - }); - - test('should skip the re-query on reconnect when disabled', () async { - client = StreamChatClient(apiKey, chatApi: api, ws: ws, recoverStateOnReconnect: false); - await client.connectUser(user, token); - await delay(300); - - final channel = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); - client.state.addChannels({'messaging:c1': channel}); - clearInteractions(api.channel); - - await simulateReconnect(); - - verifyNever( - () => 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('should still emit `connectionRecovered` when disabled', () async { - client = StreamChatClient(apiKey, chatApi: api, ws: ws, recoverStateOnReconnect: false); - await client.connectUser(user, token); - await delay(300); - - final channel = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); - client.state.addChannels({'messaging:c1': channel}); - - // Subscribe AFTER the initial connect so the captured event is the - // one fired by the manual reconnect. - final recoveredEvents = []; - final sub = client.on(EventType.connectionRecovered).listen(recoveredEvents.add); - - await simulateReconnect(); - await sub.cancel(); - - expect(recoveredEvents, hasLength(1)); - }); - - // 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. - test('should not surface an error when the re-query fails', () async { - 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'), - ), - ).thenThrow(const StreamChatError('You cannot use queryChannels without an active connection.')); - - client = StreamChatClient(apiKey, chatApi: api, ws: ws); - await client.connectUser(user, token); - await delay(300); - - final channel = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); - client.state.addChannels({'messaging:c1': channel}); - - // Subscribe AFTER the initial connect so the captured event is the - // one fired by the manual reconnect. - final recoveredEvents = []; - final sub = client.on(EventType.connectionRecovered).listen(recoveredEvents.add); - - await simulateReconnect(); - await sub.cancel(); - - expect(recoveredEvents, hasLength(1)); - }); - - test('should skip the re-query when no active channels are tracked', () async { - client = StreamChatClient(apiKey, chatApi: api, ws: ws); - await client.connectUser(user, token); - await delay(300); - - // No channels added — the cids.isNotEmpty guard should short-circuit. - clearInteractions(api.channel); - - await simulateReconnect(); - - verifyNever( - () => 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('should respect runtime toggling via the setter', () async { - client = StreamChatClient(apiKey, chatApi: api, ws: ws); - await client.connectUser(user, token); - await delay(300); - - final channel = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); - client.state.addChannels({'messaging:c1': channel}); - clearInteractions(api.channel); - - // Disable mid-flight → no re-query on reconnect. - client.recoverStateOnReconnect = false; - await simulateReconnect(); - verifyNever( - () => 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'), - ), - ); - - // Re-enable → re-query on the next reconnect. - client.recoverStateOnReconnect = true; - await simulateReconnect(); - 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); - }); - }); - - group('dispose during reconnect recovery', () { - const apiKey = 'test-api-key'; - final user = User(id: 'test-user-id'); - final token = Token.development(user.id).rawValue; - - late FakeChatApi api; - late FakeWebSocket ws; - late StreamChatClient client; - var disposed = false; - - setUpAll(() { - registerFallbackValue(const PaginationParams()); - registerFallbackValue(Filter.equal('cid', '')); - }); - - setUp(() { - api = FakeChatApi(); - ws = FakeWebSocket(); - disposed = false; - }); - - // The test disposes the client itself; avoid disposing it a second time. - tearDown(() async { - 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. - test('disposing mid-recovery does not surface a late recovery event', () async { - // Keep the recovery's channel query pending so the client is still - // mid-recovery at the moment it is disposed. - final pendingQuery = Completer(); - 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((_) => pendingQuery.future); - - client = StreamChatClient(apiKey, chatApi: api, ws: ws); - await client.connectUser(user, token); - await delay(300); - - // Track a channel so reconnecting triggers channel recovery, which then - // blocks on the pending query above. - final channel = Channel.fromState( - client, - ChannelState(channel: ChannelModel(cid: 'messaging:c1')), - ); - client.state.addChannels({'messaging:c1': channel}); - - // Drop then restore the connection to start a reconnect recovery. - ws.connectionStatus = ConnectionStatus.disconnected; - await delay(100); - ws.connectionStatus = ConnectionStatus.connected; - await delay(100); - - // Dispose while the recovery is still in flight. - await client.dispose(); - disposed = true; - - // Let the now-orphaned recovery finish. Its trailing work must be - // discarded silently instead of thrown as an unhandled async error. - pendingQuery.complete(QueryChannelsResponse()..channels = []); - await delay(300); - - expect(client.wsConnectionStatus, ConnectionStatus.disconnected); - }); - }); } From 508735ad1c7defa8b892be23695b2fc00c79d87a Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 19:58:10 +0200 Subject: [PATCH 055/103] test(llc): migrate client poll API tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../src/client/client_polls_api_test.dart | 377 ++++++++++++++++++ .../test/src/client/client_test.dart | 330 --------------- .../lib/src/helpers/api_mocker_mixin.dart | 31 +- .../lib/src/helpers/test_data.dart | 121 +++++- .../lib/stream_chat_test.dart | 2 +- 5 files changed, 526 insertions(+), 335 deletions(-) create mode 100644 packages/stream_chat/test/src/client/client_polls_api_test.dart diff --git a/packages/stream_chat/test/src/client/client_polls_api_test.dart b/packages/stream_chat/test/src/client/client_polls_api_test.dart new file mode 100644 index 0000000000..ca32387b74 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_polls_api_test.dart @@ -0,0 +1,377 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +void main() { + chatClientTest( + '`.createPoll`', + body: (tester) async { + final poll = createDefaultPoll(); + + tester.mockApi( + (api) => api.polls.createPoll(poll), + result: CreatePollResponse()..poll = poll, + ); + + final res = await tester.client.createPoll(poll); + expect(res, isNotNull); + expect(res.poll, poll); + + tester + ..verifyApi((api) => api.polls.createPoll(poll)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.getPoll`', + body: (tester) async { + const pollId = 'test-poll-id'; + final poll = createDefaultPoll(id: pollId); + + tester.mockApi( + (api) => api.polls.getPoll(pollId), + result: GetPollResponse()..poll = poll, + ); + + final res = await tester.client.getPoll(pollId); + expect(res, isNotNull); + expect(res.poll, poll); + + tester + ..verifyApi((api) => api.polls.getPoll(pollId)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.updatePoll`', + body: (tester) async { + final poll = createDefaultPoll(id: 'test-poll-id'); + + tester.mockApi( + (api) => api.polls.updatePoll(poll), + result: createDefaultUpdatePollResponse(poll: poll), + ); + + final res = await tester.client.updatePoll(poll); + expect(res, isNotNull); + expect(res.poll, poll); + + tester + ..verifyApi((api) => api.polls.updatePoll(poll)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.partialUpdatePoll`', + body: (tester) async { + const pollId = 'test-poll-id'; + final set = {'name': 'What is your favorite color?'}; + final unset = []; + + final poll = createDefaultPoll(id: pollId, name: set['name']!); + + tester.mockApi( + (api) => api.polls.partialUpdatePoll(pollId, set: set, unset: unset), + result: createDefaultUpdatePollResponse(poll: poll), + ); + + 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']); + + tester + ..verifyApi((api) => api.polls.partialUpdatePoll(pollId, set: set, unset: unset)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.deletePoll`', + body: (tester) async { + const pollId = 'test-poll-id'; + + tester.mockApi( + (api) => api.polls.deletePoll(pollId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.deletePoll(pollId); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.polls.deletePoll(pollId)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.closePoll`', + body: (tester) async { + const pollId = 'test-poll-id'; + + tester.mockApi( + (api) => api.polls.partialUpdatePoll(pollId, set: {'is_closed': true}), + result: createDefaultUpdatePollResponse(), + ); + + final res = await tester.client.closePoll(pollId); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.polls.partialUpdatePoll(pollId, set: {'is_closed': true})) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.createPollOption`', + body: (tester) async { + const pollId = 'test-poll-id'; + final option = createDefaultPollOption(); + + tester.mockApi( + (api) => api.polls.createPollOption(pollId, option), + result: CreatePollOptionResponse()..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); + }, + ); + + chatClientTest( + '`.getPollOption`', + body: (tester) async { + const pollId = 'test-poll-id'; + const optionId = 'test-option-id'; + final option = createDefaultPollOption(id: optionId); + + tester.mockApi( + (api) => api.polls.getPollOption(pollId, optionId), + result: GetPollOptionResponse()..pollOption = option, + ); + + final res = await tester.client.getPollOption(pollId, optionId); + expect(res, isNotNull); + expect(res.pollOption, option); + + tester + ..verifyApi((api) => api.polls.getPollOption(pollId, optionId)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.updatePollOption`', + body: (tester) async { + const pollId = 'test-poll-id'; + final option = createDefaultPollOption(id: 'test-option-id'); + + tester.mockApi( + (api) => api.polls.updatePollOption(pollId, option), + result: UpdatePollOptionResponse()..pollOption = option, + ); + + final res = await tester.client.updatePollOption(pollId, option); + expect(res, isNotNull); + expect(res.pollOption, option); + + tester + ..verifyApi((api) => api.polls.updatePollOption(pollId, option)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.deletePollOption`', + body: (tester) async { + const pollId = 'test-poll-id'; + const optionId = 'test-option-id'; + + tester.mockApi( + (api) => api.polls.deletePollOption(pollId, optionId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.deletePollOption(pollId, optionId); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.polls.deletePollOption(pollId, optionId)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + 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', + ); + + tester.mockApi( + (api) => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteOption(optionId))), + result: createDefaultCastPollVoteResponse(vote: vote), + ); + + final res = await tester.client.castPollVote(messageId, pollId, optionId: optionId); + expect(res, isNotNull); + expect(res.vote, vote); + + tester + ..verifyApi((api) => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteOption(optionId)))) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + 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', + ); + + tester.mockApi( + (api) => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteAnswer(answerText))), + result: createDefaultCastPollVoteResponse(vote: vote), + ); + + final res = await tester.client.addPollAnswer(messageId, pollId, answerText: answerText); + expect(res, isNotNull); + expect(res.vote, vote); + + tester + ..verifyApi((api) => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteAnswer(answerText)))) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.removePollVote`', + body: (tester) async { + const messageId = 'test-message-id'; + const pollId = 'test-poll-id'; + const voteId = 'test-vote-id'; + + tester.mockApi( + (api) => api.polls.removePollVote(messageId, pollId, voteId), + result: RemovePollVoteResponse(), + ); + + final res = await tester.client.removePollVote(messageId, pollId, voteId); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.polls.removePollVote(messageId, pollId, voteId)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.queryPolls`', + body: (tester) async { + final filter = Filter.in_('id', const ['test-poll-id']); + final sort = [const SortOption.desc('created_at')]; + const pagination = PaginationParams(limit: 20); + + final polls = List.generate( + pagination.limit, + (index) => createDefaultPoll(id: 'test-poll-id-$index'), + ); + + tester.mockApi( + (api) => api.polls.queryPolls( + filter: filter, + sort: sort, + pagination: pagination, + ), + result: QueryPollsResponse()..polls = polls, + ); + + final res = await tester.client.queryPolls( + filter: filter, + sort: sort, + pagination: pagination, + ); + expect(res, isNotNull); + expect(res.polls.length, polls.length); + + tester + ..verifyApi( + (api) => api.polls.queryPolls( + filter: filter, + sort: sort, + pagination: pagination, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.queryPollVotes`', + body: (tester) async { + const pollId = 'test-poll-id'; + final filter = Filter.in_('id', const ['test-vote-id']); + final sort = [const SortOption.desc('created_at')]; + const pagination = PaginationParams(limit: 20); + + final votes = List.generate( + pagination.limit, + (index) => createDefaultPollVote(id: 'test-vote-id-$index', answerText: 'Red'), + ); + + tester.mockApi( + (api) => api.polls.queryPollVotes( + pollId, + filter: filter, + sort: sort, + pagination: pagination, + ), + result: QueryPollVotesResponse()..votes = votes, + ); + + final res = await tester.client.queryPollVotes( + pollId, + filter: filter, + sort: sort, + pagination: pagination, + ); + expect(res, isNotNull); + expect(res.votes.length, votes.length); + + tester + ..verifyApi( + (api) => api.polls.queryPollVotes( + pollId, + filter: filter, + sort: sort, + pagination: pagination, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); +} diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 89e29bb2e1..2f3b3d7f7f 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -3176,336 +3176,6 @@ void main() { verifyNoMoreInteractions(api.channel); }); - test('`.createPoll`', () async { - final poll = Poll( - name: 'What is your favorite color?', - options: const [ - PollOption(text: 'Red'), - PollOption(text: 'Blue'), - ], - ); - - when(() => api.polls.createPoll(poll)).thenAnswer( - (_) async => CreatePollResponse()..poll = poll, - ); - - final res = await client.createPoll(poll); - expect(res, isNotNull); - expect(res.poll, poll); - - verify(() => api.polls.createPoll(poll)).called(1); - verifyNoMoreInteractions(api.polls); - }); - - 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'), - ], - ); - - when(() => api.polls.getPoll(pollId)).thenAnswer( - (_) async => GetPollResponse()..poll = poll, - ); - - final res = await client.getPoll(pollId); - expect(res, isNotNull); - expect(res.poll, poll); - - verify(() => api.polls.getPoll(pollId)).called(1); - verifyNoMoreInteractions(api.polls); - }); - - test('`.updatePoll`', () async { - final poll = Poll( - id: 'test-poll-id', - name: 'What is your favorite color?', - options: const [ - PollOption(text: 'Red'), - PollOption(text: 'Blue'), - ], - ); - - when(() => api.polls.updatePoll(poll)).thenAnswer( - (_) async => UpdatePollResponse()..poll = poll, - ); - - final res = await client.updatePoll(poll); - expect(res, isNotNull); - expect(res.poll, poll); - - verify(() => api.polls.updatePoll(poll)).called(1); - verifyNoMoreInteractions(api.polls); - }); - - 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'), - ], - ); - - when( - () => api.polls.partialUpdatePoll(pollId, set: set, unset: unset), - ).thenAnswer((_) async => UpdatePollResponse()..poll = poll); - - final res = await client.partialUpdatePoll(pollId, set: set, unset: unset); - expect(res, isNotNull); - expect(res.poll.id, pollId); - expect(res.poll.name, set['name']); - - verify(() => api.polls.partialUpdatePoll(pollId, set: set, unset: unset)).called(1); - verifyNoMoreInteractions(api.polls); - }); - - test('`.deletePoll`', () async { - const pollId = 'test-poll-id'; - - when(() => api.polls.deletePoll(pollId)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.deletePoll(pollId); - expect(res, isNotNull); - - verify(() => api.polls.deletePoll(pollId)).called(1); - verifyNoMoreInteractions(api.polls); - }); - - test('`.closePoll`', () async { - const pollId = 'test-poll-id'; - - when( - () => api.polls.partialUpdatePoll(pollId, set: {'is_closed': true}), - ).thenAnswer((_) async => UpdatePollResponse()); - - final res = await client.closePoll(pollId); - expect(res, isNotNull); - - verify(() => api.polls.partialUpdatePoll(pollId, set: {'is_closed': true})).called(1); - verifyNoMoreInteractions(api.polls); - }); - - test('`.createPollOption`', () async { - const pollId = 'test-poll-id'; - const option = PollOption(text: 'Red'); - - when( - () => api.polls.createPollOption(pollId, option), - ).thenAnswer((_) async => CreatePollOptionResponse()..pollOption = option); - - final res = await client.createPollOption(pollId, option); - expect(res, isNotNull); - expect(res.pollOption, option); - - verify(() => api.polls.createPollOption(pollId, option)).called(1); - verifyNoMoreInteractions(api.polls); - }); - - test('`.getPollOption`', () async { - const pollId = 'test-poll-id'; - const optionId = 'test-option-id'; - const option = PollOption(id: optionId, text: 'Red'); - - when( - () => api.polls.getPollOption(pollId, optionId), - ).thenAnswer((_) async => GetPollOptionResponse()..pollOption = option); - - final res = await client.getPollOption(pollId, optionId); - expect(res, isNotNull); - expect(res.pollOption, option); - - verify(() => api.polls.getPollOption(pollId, optionId)).called(1); - verifyNoMoreInteractions(api.polls); - }); - - test('`.updatePollOption`', () async { - const pollId = 'test-poll-id'; - const option = PollOption(id: 'test-option-id', text: 'Red'); - - when( - () => api.polls.updatePollOption(pollId, option), - ).thenAnswer((_) async => UpdatePollOptionResponse()..pollOption = option); - - final res = await client.updatePollOption(pollId, option); - expect(res, isNotNull); - expect(res.pollOption, option); - - verify(() => api.polls.updatePollOption(pollId, option)).called(1); - verifyNoMoreInteractions(api.polls); - }); - - test('`.deletePollOption`', () async { - const pollId = 'test-poll-id'; - const optionId = 'test-option-id'; - - when(() => api.polls.deletePollOption(pollId, optionId)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.deletePollOption(pollId, optionId); - expect(res, isNotNull); - - verify(() => api.polls.deletePollOption(pollId, optionId)).called(1); - verifyNoMoreInteractions(api.polls); - }); - - test('`.castPollVote`', () async { - const messageId = 'test-message-id'; - const pollId = 'test-poll-id'; - const optionId = 'test-option-id'; - final vote = PollVote(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', - ); - - when( - () => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteOption(optionId))), - ).thenAnswer((_) async => CastPollVoteResponse()..vote = vote); - - final res = await client.castPollVote(messageId, pollId, optionId: optionId); - expect(res, isNotNull); - expect(res.vote, vote); - - verify(() => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteOption(optionId)))).called(1); - verifyNoMoreInteractions(api.polls); - }); - - test('`.addPollAnswer`', () async { - const messageId = 'test-message-id'; - const pollId = 'test-poll-id'; - const answerText = 'Red'; - final vote = PollVote(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); - - final res = await 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); - }); - - test('`.removePollVote`', () 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()); - - final res = await client.removePollVote(messageId, pollId, voteId); - expect(res, isNotNull); - - verify(() => api.polls.removePollVote(messageId, pollId, voteId)).called(1); - verifyNoMoreInteractions(api.polls); - }); - - test('`.queryPolls`', () async { - final filter = Filter.in_('id', const ['test-poll-id']); - final sort = [const SortOption.desc('created_at')]; - 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'), - ], - ), - ); - - when( - () => api.polls.queryPolls( - filter: filter, - sort: sort, - pagination: pagination, - ), - ).thenAnswer( - (_) async => QueryPollsResponse()..polls = polls, - ); - - final res = await client.queryPolls( - filter: filter, - sort: sort, - pagination: pagination, - ); - expect(res, isNotNull); - expect(res.polls.length, polls.length); - - verify( - () => api.polls.queryPolls( - filter: filter, - sort: sort, - pagination: pagination, - ), - ).called(1); - verifyNoMoreInteractions(api.polls); - }); - - test('`.queryPollVotes`', () async { - const pollId = 'test-poll-id'; - final filter = Filter.in_('id', const ['test-vote-id']); - final sort = [const SortOption.desc('created_at')]; - const pagination = PaginationParams(limit: 20); - - final votes = List.generate( - pagination.limit, - (index) => PollVote(id: 'test-vote-id-$index', answerText: 'Red'), - ); - - when( - () => api.polls.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - ), - ).thenAnswer( - (_) async => QueryPollVotesResponse()..votes = votes, - ); - - final res = await client.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - ); - expect(res, isNotNull); - expect(res.votes.length, votes.length); - - verify( - () => api.polls.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - ), - ).called(1); - verifyNoMoreInteractions(api.polls); - }); - test('`.updateUser`', () async { final user = User( id: 'test-user-id', 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 index c4aec912c5..bb91c3447e 100644 --- a/packages/stream_chat_test/lib/src/helpers/api_mocker_mixin.dart +++ b/packages/stream_chat_test/lib/src/helpers/api_mocker_mixin.dart @@ -32,11 +32,18 @@ mixin ApiMockerMixin { 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 => result); + 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]. @@ -44,12 +51,21 @@ mixin ApiMockerMixin { /// The returned future completes with the error — matching the real API /// layer, which always fails asynchronously. Defaults to a /// [StreamChatNetworkError] with [ChatErrorCode.internalSystemError]. + /// + /// 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 ?? StreamChatNetworkError(ChatErrorCode.internalSystemError); - return when(() => apiCall(chatApi)).thenAnswer((_) => Future.error(failure)); + 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 @@ -109,4 +125,15 @@ mixin ApiMockerMixin { 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/lib/src/helpers/test_data.dart b/packages/stream_chat_test/lib/src/helpers/test_data.dart index 6f53eb9846..5a87dcd217 100644 --- a/packages/stream_chat_test/lib/src/helpers/test_data.dart +++ b/packages/stream_chat_test/lib/src/helpers/test_data.dart @@ -125,6 +125,7 @@ Event createDefaultEvent({ ChannelModel? channel, Draft? draft, MessageReminder? reminder, + PushPreference? pushPreference, ChannelPushPreference? channelPushPreference, OwnUser? me, int? totalUnreadCount, @@ -157,6 +158,7 @@ Event createDefaultEvent({ channel: channel, draft: draft, reminder: reminder, + pushPreference: pushPreference, channelPushPreference: channelPushPreference, me: me, totalUnreadCount: totalUnreadCount, @@ -380,6 +382,76 @@ MessageReminder createDefaultMessageReminder({ ); } +/// 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, @@ -516,13 +588,25 @@ TranslateMessageResponse createDefaultTranslateMessageResponse({ ..message = message ?? createDefaultMessage(); } -/// Creates a [QueryChannelsResponse] wrapping [channels]. +/// 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; + ..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]. @@ -566,6 +650,39 @@ GetAppSettingsResponse createDefaultGetAppSettingsResponse({ ..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 [EmptyResponse]. EmptyResponse createDefaultEmptyResponse() => EmptyResponse()..duration = '10ms'; diff --git a/packages/stream_chat_test/lib/stream_chat_test.dart b/packages/stream_chat_test/lib/stream_chat_test.dart index 4f33be9398..39528a9acb 100644 --- a/packages/stream_chat_test/lib/stream_chat_test.dart +++ b/packages/stream_chat_test/lib/stream_chat_test.dart @@ -9,7 +9,7 @@ export 'package:mocktail/mocktail.dart'; // NOTE(re-evaluate): re-exported so tests can name `Token` / `TokenProvider` // without importing `package:stream_chat/src/...` themselves. Re-evaluate once // stream_chat exposes these from its public barrel or a testing entrypoint. -export 'package:stream_chat/src/core/http/token.dart' show Token; +export 'package:stream_chat/src/core/http/token.dart' show AuthType, Token; export 'package:stream_chat/src/core/http/token_manager.dart' show TokenProvider; export 'package:test/test.dart'; From 237645d064b908f356cf7ba644b68ab422d32c95 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 19:59:19 +0200 Subject: [PATCH 056/103] test(llc): migrate client channel API tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../src/client/client_channel_api_test.dart | 864 ++++++++++++++++++ .../test/src/client/client_test.dart | 842 ----------------- 2 files changed, 864 insertions(+), 842 deletions(-) create mode 100644 packages/stream_chat/test/src/client/client_channel_api_test.dart diff --git a/packages/stream_chat/test/src/client/client_channel_api_test.dart b/packages/stream_chat/test/src/client/client_channel_api_test.dart new file mode 100644 index 0000000000..9d7be3a076 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_channel_api_test.dart @@ -0,0 +1,864 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; +const _channelData = {'name': 'test-channel-name'}; + +void main() { + group('`.channel`', () { + chatClientTest( + 'should return back a new channel instance', + body: (tester) { + final channel = tester.client.channel( + _channelType, + id: _channelId, + extraData: _channelData, + ); + + expect(channel, isNotNull); + expect(channel.type, _channelType); + expect(channel.id, _channelId); + expect(channel.cid, _channelCid); + expect(channel.extraData, _channelData); + }, + ); + + chatClientTest( + 'should return back in memory channel instance if available', + body: (tester) async { + final channel = tester.client.channel( + _channelType, + id: _channelId, + extraData: _channelData, + ); + + final channelState = createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); + + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + state: true, + watch: true, + presence: false, + ), + result: channelState, + ); + + final channelsEmission = expectLater( + tester.clientState.channelsStream.skip(1), + emitsInOrder([ + {_channelCid: isCorrectChannelFor(channelState)}, + ]), + ); + + await channel.watch(); + await channelsEmission; + + final newChannel = tester.client.channel(_channelType, id: _channelId); + expect(newChannel, channel); + + tester.verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + state: true, + watch: true, + presence: false, + ), + ); + }, + ); + }); + + chatClientTest( + '`.createChannel`', + body: (tester) async { + final channelState = createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid, extraData: _channelData), + ); + + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + state: false, + ), + result: channelState, + ); + + final res = await tester.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, _channelCid); + expect(channel.extraData, _channelData); + + tester + ..verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + state: false, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.watchChannel`', + body: (tester) async { + final channelState = createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid, extraData: _channelData), + ); + + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + watch: true, + ), + result: channelState, + ); + + final res = await tester.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, _channelCid); + expect(channel.extraData, _channelData); + + tester + ..verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + watch: true, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.queryChannel`', + body: (tester) async { + final channelState = createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid, extraData: _channelData), + ); + + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + ), + result: channelState, + ); + + final res = await tester.client.queryChannel( + _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, _channelCid); + expect(channel.extraData, _channelData); + + tester + ..verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.queryMembers`', + body: (tester) async { + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); + + tester.mockApi( + (api) => api.general.queryMembers(_channelType), + result: QueryMembersResponse()..members = members, + ); + + final res = await tester.client.queryMembers(_channelType); + expect(res, isNotNull); + expect(res.members.length, members.length); + + tester + ..verifyApi((api) => api.general.queryMembers(_channelType)) + ..verifyApi((api) => api.general.getAppSettings()) + ..verifyNoMoreApiInteractions((api) => api.general); + }, + ); + + chatClientTest( + '`.hideChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.hideChannel(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.hideChannel(_channelId, _channelType); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.channel.hideChannel(_channelId, _channelType)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.showChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.showChannel(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.showChannel(_channelId, _channelType); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.channel.showChannel(_channelId, _channelType)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.deleteChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.deleteChannel(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.deleteChannel(_channelId, _channelType); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.channel.deleteChannel(_channelId, _channelType)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.truncateChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.truncateChannel(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.truncateChannel(_channelId, _channelType); + + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.channel.truncateChannel(_channelId, _channelType), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.muteChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.moderation.muteChannel(_channelCid), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.muteChannel(_channelCid); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.muteChannel(_channelCid)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + chatClientTest( + '`.unmuteChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.moderation.unmuteChannel(_channelCid), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.unmuteChannel(_channelCid); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.unmuteChannel(_channelCid)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + 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), + ), + ); + + final res = await tester.client.partialMemberUpdate( + channelId: _channelId, + channelType: _channelType, + set: set, + unset: unset, + ); + + 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); + }, + ); + + 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), + ), + ); + + final res = await tester.client.partialMemberUpdate( + channelId: _channelId, + channelType: _channelType, + set: set, + unset: unset, + ); + + 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); + }, + ); + + 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 tester.client.pinChannel( + channelId: _channelId, + channelType: _channelType, + ); + + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: const MemberUpdatePayload(pinned: true).toJson(), + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + 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)), + ), + ); + + final res = await tester.client.unpinChannel( + channelId: _channelId, + channelType: _channelType, + ); + + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + unset: [MemberUpdateType.pinned.name], + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + 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)), + ), + ); + + final res = await tester.client.archiveChannel( + channelId: _channelId, + channelType: _channelType, + ); + + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: const MemberUpdatePayload(archived: true).toJson(), + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + 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 res = await tester.client.unarchiveChannel( + channelId: _channelId, + channelType: _channelType, + ); + + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + unset: [MemberUpdateType.archived.name], + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.acceptChannelInvite`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.acceptChannelInvite(_channelId, _channelType), + result: AcceptInviteResponse()..channel = createDefaultChannelModel(cid: _channelCid), + ); + + final res = await tester.client.acceptChannelInvite(_channelId, _channelType); + expect(res, isNotNull); + expect(res.channel.cid, _channelCid); + + tester + ..verifyApi((api) => api.channel.acceptChannelInvite(_channelId, _channelType)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.rejectChannelInvite`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.rejectChannelInvite(_channelId, _channelType), + result: RejectInviteResponse()..channel = createDefaultChannelModel(cid: _channelCid), + ); + + final res = await tester.client.rejectChannelInvite(_channelId, _channelType); + expect(res, isNotNull); + expect(res.channel.cid, _channelCid); + + tester + ..verifyApi((api) => api.channel.rejectChannelInvite(_channelId, _channelType)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.addChannelMembers`', + 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); + + tester.mockApi( + (api) => api.channel.addMembers(_channelId, _channelType, memberIds), + result: createDefaultAddMembersResponse( + channel: createDefaultChannelModel(cid: _channelCid), + members: members, + ), + ); + + final res = await tester.client.addChannelMembers( + _channelId, + _channelType, + memberIds, + ); + + 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); + }, + ); + + 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 hideHistoryBefore = DateTime.parse('2024-01-01T00:00:00Z'); + + tester.mockApi( + (api) => api.channel.addMembers( + _channelId, + _channelType, + memberIds, + hideHistoryBefore: hideHistoryBefore, + ), + result: createDefaultAddMembersResponse( + channel: createDefaultChannelModel(cid: _channelCid), + members: members, + ), + ); + + 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); + + tester + ..verifyApi( + (api) => api.channel.addMembers( + _channelId, + _channelType, + memberIds, + hideHistoryBefore: hideHistoryBefore, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.removeChannelMembers`', + 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); + + tester.mockApi( + (api) => api.channel.removeMembers(_channelId, _channelType, memberIds), + result: RemoveMembersResponse() + ..channel = createDefaultChannelModel(cid: _channelCid) + ..members = members, + ); + + final res = await tester.client.removeChannelMembers( + _channelId, + _channelType, + memberIds, + ); + + 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); + }, + ); + + chatClientTest( + '`.inviteChannelMembers`', + 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); + + tester.mockApi( + (api) => api.channel.inviteChannelMembers(_channelId, _channelType, memberIds), + result: InviteMembersResponse() + ..channel = createDefaultChannelModel(cid: _channelCid) + ..members = members, + ); + + final res = await tester.client.inviteChannelMembers( + _channelId, + _channelType, + memberIds, + ); + + expect(res, isNotNull); + expect(res.channel.cid, _channelCid); + expect(res.members.length, memberIds.length); + + tester + ..verifyApi((api) => api.channel.inviteChannelMembers(_channelId, _channelType, memberIds)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.stopChannelWatching`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.stopWatching(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.stopChannelWatching(_channelId, _channelType); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.channel.stopWatching(_channelId, _channelType)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.sendAction`', + body: (tester) async { + const messageId = 'test-message-id'; + const formData = {'key': 'value'}; + + tester.mockApi( + (api) => api.message.sendAction(_channelId, _channelType, messageId, formData), + result: createDefaultSendActionResponse(), + ); + + final res = await tester.client.sendAction( + _channelId, + _channelType, + messageId, + formData, + ); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.message.sendAction(_channelId, _channelType, messageId, formData)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.markChannelRead`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.markRead(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.markChannelRead(_channelId, _channelType); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.channel.markRead(_channelId, _channelType)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.markChannelUnread`', + body: (tester) async { + const messageId = 'test-message-id'; + + tester.mockApi( + (api) => api.channel.markUnread(_channelId, _channelType, messageId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.markChannelUnread( + _channelId, + _channelType, + messageId, + ); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.channel.markUnread(_channelId, _channelType, messageId)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.markChannelUnreadByTimestamp`', + body: (tester) async { + final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); + + tester.mockApi( + (api) => api.channel.markUnreadByTimestamp( + _channelId, + _channelType, + timestamp, + ), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.markChannelUnreadByTimestamp( + _channelId, + _channelType, + timestamp, + ); + + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.channel.markUnreadByTimestamp( + _channelId, + _channelType, + timestamp, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); +} diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 2f3b3d7f7f..951836ff2f 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -2334,848 +2334,6 @@ void main() { expect(token.authType, AuthType.jwt); }); - 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), - ); - - 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); - }); - - test('`.queryChannel`', () 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.queryChannel( - 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('`.queryMembers`', () async { - const channelType = 'test-channel-type'; - - final members = List.generate( - 3, - (index) => Member(userId: 'test-user-id-$index'), - ); - - when(() => api.general.queryMembers(channelType)).thenAnswer( - (_) async => QueryMembersResponse()..members = members, - ); - - 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); - }); - - test('`.deleteChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - - when(() => api.channel.deleteChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.deleteChannel(channelId, channelType); - - expect(res, isNotNull); - - verify(() => api.channel.deleteChannel(channelId, channelType)).called(1); - verifyNoMoreInteractions(api.channel); - }); - - test('`.truncateChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - - when(() => api.channel.truncateChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.truncateChannel(channelId, channelType); - - expect(res, isNotNull); - - verify( - () => api.channel.truncateChannel(channelId, channelType), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); - - test('`.muteChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const channelCid = '$channelType:$channelId'; - - when(() => api.moderation.muteChannel(channelCid)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.muteChannel(channelCid); - - expect(res, isNotNull); - - verify(() => api.moderation.muteChannel(channelCid)).called(1); - verifyNoMoreInteractions(api.moderation); - }); - - test('`.unmuteChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const channelCid = '$channelType:$channelId'; - - when(() => api.moderation.unmuteChannel(channelCid)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.unmuteChannel(channelCid); - - expect(res, isNotNull); - - verify(() => api.moderation.unmuteChannel(channelCid)).called(1); - verifyNoMoreInteractions(api.moderation); - }); - - 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']; - - when( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: set, - unset: unset, - ), - ).thenAnswer( - (_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: otherUserId), - ), - ); - - final res = await client.partialMemberUpdate( - channelId: channelId, - channelType: channelType, - set: set, - unset: unset, - ); - - expect(res, isNotNull); - expect(res.channelMember.userId, otherUserId); - - verify( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: set, - unset: unset, - ), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); - - test('`.partialMemberUpdate with current user`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const set = {'pinned': true}; - const unset = ['pinned']; - - when( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: set, - unset: unset, - ), - ).thenAnswer( - (_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: userId), - ), - ); - - final res = await client.partialMemberUpdate( - channelId: channelId, - channelType: channelType, - set: set, - unset: unset, - ); - - 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); - }); - - test('`.pinChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - - when( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: const MemberUpdatePayload(pinned: true).toJson(), - ), - ).thenAnswer( - (_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: userId, pinnedAt: DateTime.now()), - ), - ); - - final res = await client.pinChannel( - channelId: channelId, - channelType: channelType, - ); - - expect(res, isNotNull); - - verify( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: const MemberUpdatePayload(pinned: true).toJson(), - ), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); - - test('`.unpinChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - - when( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - unset: [MemberUpdateType.pinned.name], - ), - ).thenAnswer( - (_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: userId, pinnedAt: DateTime.now()), - ), - ); - - final res = await client.unpinChannel( - channelId: channelId, - channelType: channelType, - ); - - expect(res, isNotNull); - - verify( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - unset: [MemberUpdateType.pinned.name], - ), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); - - test('`.archiveChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - - 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 client.archiveChannel( - channelId: channelId, - channelType: channelType, - ); - - expect(res, isNotNull); - - verify( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: const MemberUpdatePayload(archived: true).toJson(), - ), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); - - test('`.unarchiveChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - - when( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - unset: [MemberUpdateType.archived.name], - ), - ).thenAnswer( - (_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: userId, pinnedAt: DateTime.now()), - ), - ); - - final res = await client.unarchiveChannel( - channelId: channelId, - channelType: channelType, - ); - - expect(res, isNotNull); - - verify( - () => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - unset: [MemberUpdateType.archived.name], - ), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); - - test('`.acceptChannelInvite`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const channelCid = '$channelType:$channelId'; - - when( - () => api.channel.acceptChannelInvite(channelId, channelType), - ).thenAnswer((_) async => AcceptInviteResponse()..channel = ChannelModel(cid: channelCid)); - - final res = await client.acceptChannelInvite(channelId, channelType); - expect(res, isNotNull); - expect(res.channel.cid, channelCid); - - verify(() => api.channel.acceptChannelInvite(channelId, channelType)).called(1); - verifyNoMoreInteractions(api.channel); - }); - - test('`.rejectChannelInvite`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const channelCid = '$channelType:$channelId'; - - when( - () => api.channel.rejectChannelInvite(channelId, channelType), - ).thenAnswer((_) async => RejectInviteResponse()..channel = ChannelModel(cid: channelCid)); - - final res = await client.rejectChannelInvite(channelId, channelType); - expect(res, isNotNull); - expect(res.channel.cid, channelCid); - - verify(() => api.channel.rejectChannelInvite(channelId, channelType)).called(1); - verifyNoMoreInteractions(api.channel); - }); - - test('`.addChannelMembers`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const channelCid = '$channelType:$channelId'; - - final members = List.generate( - 3, - (index) => Member(userId: 'test-user-id-$index'), - ); - - final memberIds = members.map((e) => e.userId!).toList(growable: false); - - when(() => api.channel.addMembers(channelId, channelType, memberIds)).thenAnswer( - (_) async => AddMembersResponse() - ..channel = ChannelModel(cid: channelCid) - ..members = members, - ); - - final res = await client.addChannelMembers( - channelId, - channelType, - memberIds, - ); - - expect(res, isNotNull); - expect(res.channel.cid, channelCid); - expect(res.members.length, memberIds.length); - - verify( - () => api.channel.addMembers(channelId, channelType, memberIds), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); - - test('`.addChannelMembers` with hideHistoryBefore', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const channelCid = '$channelType:$channelId'; - - final members = List.generate( - 3, - (index) => Member(userId: 'test-user-id-$index'), - ); - - final memberIds = members.map((e) => e.userId!).toList(growable: false); - final hideHistoryBefore = DateTime.parse('2024-01-01T00:00:00Z'); - - when( - () => api.channel.addMembers( - channelId, - channelType, - memberIds, - hideHistoryBefore: hideHistoryBefore, - ), - ).thenAnswer( - (_) async => AddMembersResponse() - ..channel = ChannelModel(cid: channelCid) - ..members = members, - ); - - final res = await client.addChannelMembers( - channelId, - channelType, - memberIds, - hideHistoryBefore: hideHistoryBefore, - ); - - expect(res, isNotNull); - expect(res.channel.cid, channelCid); - expect(res.members.length, memberIds.length); - - verify( - () => api.channel.addMembers( - 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'; - - final members = List.generate( - 3, - (index) => Member(userId: 'test-user-id-$index'), - ); - - final memberIds = members.map((e) => e.userId!).toList(growable: false); - - when(() => api.channel.removeMembers(channelId, channelType, memberIds)).thenAnswer( - (_) async => RemoveMembersResponse() - ..channel = ChannelModel(cid: channelCid) - ..members = members, - ); - - final res = await client.removeChannelMembers( - channelId, - channelType, - memberIds, - ); - - expect(res, isNotNull); - expect(res.channel.cid, channelCid); - expect(res.members.length, memberIds.length); - - verify( - () => api.channel.removeMembers(channelId, channelType, memberIds), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); - - test('`.inviteChannelMembers`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const channelCid = '$channelType:$channelId'; - - final members = List.generate( - 3, - (index) => Member(userId: 'test-user-id-$index'), - ); - - final memberIds = members.map((e) => e.userId!).toList(growable: false); - - when(() => api.channel.inviteChannelMembers(channelId, channelType, memberIds)).thenAnswer( - (_) async => InviteMembersResponse() - ..channel = ChannelModel(cid: channelCid) - ..members = members, - ); - - final res = await client.inviteChannelMembers( - channelId, - channelType, - memberIds, - ); - - expect(res, isNotNull); - expect(res.channel.cid, channelCid); - expect(res.members.length, memberIds.length); - - verify(() => api.channel.inviteChannelMembers(channelId, channelType, memberIds)).called(1); - verifyNoMoreInteractions(api.channel); - }); - - test('`.stopChannelWatching`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - - when(() => api.channel.stopWatching(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.stopChannelWatching(channelId, channelType); - expect(res, isNotNull); - - verify(() => api.channel.stopWatching(channelId, channelType)).called(1); - verifyNoMoreInteractions(api.channel); - }); - - test('`.sendAction`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const messageId = 'test-message-id'; - const formData = {'key': 'value'}; - - when( - () => api.message.sendAction(channelId, channelType, messageId, formData), - ).thenAnswer((_) async => SendActionResponse()); - - final res = await client.sendAction( - channelId, - channelType, - messageId, - formData, - ); - - expect(res, isNotNull); - - verify(() => api.message.sendAction(channelId, channelType, messageId, formData)).called(1); - verifyNoMoreInteractions(api.message); - }); - - test('`.markChannelRead`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - - when(() => api.channel.markRead(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.markChannelRead(channelId, channelType); - - expect(res, isNotNull); - - verify(() => api.channel.markRead(channelId, channelType)).called(1); - verifyNoMoreInteractions(api.channel); - }); - - test('`.markChannelUnread`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const messageId = 'test-message-id'; - - when(() => api.channel.markUnread(channelId, channelType, messageId)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.markChannelUnread( - channelId, - channelType, - messageId, - ); - - expect(res, isNotNull); - - verify(() => api.channel.markUnread(channelId, channelType, messageId)).called(1); - verifyNoMoreInteractions(api.channel); - }); - - test('`.markChannelUnreadByTimestamp`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); - - when( - () => api.channel.markUnreadByTimestamp( - channelId, - channelType, - timestamp, - ), - ).thenAnswer((_) async => EmptyResponse()); - - final res = await client.markChannelUnreadByTimestamp( - channelId, - channelType, - timestamp, - ); - - expect(res, isNotNull); - - verify( - () => api.channel.markUnreadByTimestamp( - channelId, - channelType, - timestamp, - ), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); - test('`.updateUser`', () async { final user = User( id: 'test-user-id', From 55e8c0805e659433e1b03aa5b28dc6d84c1ee280 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 20:00:21 +0200 Subject: [PATCH 057/103] test(llc): migrate client API delegation tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../test/src/client/client_api_test.dart | 890 ++++++++++++++++++ .../test/src/client/client_test.dart | 774 --------------- 2 files changed, 890 insertions(+), 774 deletions(-) create mode 100644 packages/stream_chat/test/src/client/client_api_test.dart diff --git a/packages/stream_chat/test/src/client/client_api_test.dart b/packages/stream_chat/test/src/client/client_api_test.dart new file mode 100644 index 0000000000..701557b1ca --- /dev/null +++ b/packages/stream_chat/test/src/client/client_api_test.dart @@ -0,0 +1,890 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +void main() { + chatClientTest( + '`.queryUsers`', + body: (tester) async { + final users = List.generate( + 3, + (index) => User(id: 'test-user-id-$index'), + ); + + tester.mockApi( + (api) => api.user.queryUsers(presence: true), + result: QueryUsersResponse()..users = users, + ); + + 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 tester.client.queryUsers(); + expect(res, isNotNull); + expect(res.users.length, users.length); + + await usersEmitted; + + 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 = Filter.equal('channel_cid', cid); + + tester.mockApi( + (api) => api.moderation.queryBannedUsers(filter: filter), + result: QueryBannedUsersResponse()..bans = bans, + ); + + final res = await tester.client.queryBannedUsers(filter: filter); + expect(res, isNotNull); + expect(res.bans.length, bans.length); + + tester + ..verifyApi((api) => api.moderation.queryBannedUsers(filter: filter)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + chatClientTest( + '`.search`', + body: (tester) async { + const cid = 'test-type:test-id'; + final filter = Filter.in_('cid', const [cid]); + + final messages = List.generate( + 3, + (index) => createDefaultGetMessageResponse( + channel: ChannelModel(cid: cid), + message: Message(id: 'test-message-id-$index'), + ), + ); + + tester.mockApi( + (api) => api.general.searchMessages(filter), + result: createDefaultSearchMessagesResponse(results: messages), + ); + + final res = await tester.client.search(filter); + expect(res, isNotNull); + expect(res.results.length, messages.length); + + tester + ..verifyApi((api) => api.general.searchMessages(filter)) + ..verifyApi((api) => api.general.getAppSettings()) + ..verifyNoMoreApiInteractions((api) => api.general); + }, + ); + + 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'; + + tester.mockApi( + (api) => api.fileUploader.sendFile(file, channelId, channelType), + result: SendFileResponse()..file = fileUrl, + ); + + final res = await tester.client.sendFile(file, channelId, channelType); + expect(res, isNotNull); + expect(res.file, fileUrl); + + tester + ..verifyApi((api) => api.fileUploader.sendFile(file, channelId, channelType)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); + + 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'; + + tester.mockApi( + (api) => api.fileUploader.sendImage(image, channelId, channelType), + result: SendImageResponse()..file = fileUrl, + ); + + final res = await tester.client.sendImage(image, channelId, channelType); + expect(res, isNotNull); + expect(res.file, fileUrl); + + tester + ..verifyApi((api) => api.fileUploader.sendImage(image, channelId, channelType)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); + + chatClientTest( + '`.deleteFile`', + body: (tester) async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const fileUrl = 'test-file-url'; + + tester.mockApi( + (api) => api.fileUploader.deleteFile(fileUrl, channelId, channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.deleteFile(fileUrl, channelId, channelType); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.fileUploader.deleteFile(fileUrl, channelId, channelType)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); + + chatClientTest( + '`.deleteImage`', + body: (tester) async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const imageUrl = 'test-image-url'; + + tester.mockApi( + (api) => api.fileUploader.deleteImage(imageUrl, channelId, channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.deleteImage(imageUrl, channelId, channelType); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.fileUploader.deleteImage(imageUrl, channelId, channelType)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); + + chatClientTest( + '`.uploadImage`', + body: (tester) async { + final image = AttachmentFile(size: 33, path: 'test-image-path'); + const fileUrl = 'test-image-url'; + + tester.mockApi( + (api) => api.fileUploader.uploadImage(image), + result: UploadImageResponse()..file = fileUrl, + ); + + final res = await tester.client.uploadImage(image); + expect(res, isNotNull); + expect(res.file, fileUrl); + + tester + ..verifyApi((api) => api.fileUploader.uploadImage(image)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); + + chatClientTest( + '`.uploadFile`', + body: (tester) async { + final file = AttachmentFile(size: 33, path: 'test-file-path'); + const fileUrl = 'test-file-url'; + + tester.mockApi( + (api) => api.fileUploader.uploadFile(file), + result: UploadFileResponse()..file = fileUrl, + ); + + final res = await tester.client.uploadFile(file); + expect(res, isNotNull); + expect(res.file, fileUrl); + + tester + ..verifyApi((api) => api.fileUploader.uploadFile(file)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); + + chatClientTest( + '`.removeImage`', + body: (tester) async { + const imageUrl = 'test-image-url'; + + tester.mockApi( + (api) => api.fileUploader.removeImage(imageUrl), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.removeImage(imageUrl); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.fileUploader.removeImage(imageUrl)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); + + chatClientTest( + '`.removeFile`', + body: (tester) async { + const fileUrl = 'test-file-url'; + + tester.mockApi( + (api) => api.fileUploader.removeFile(fileUrl), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.removeFile(fileUrl); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.fileUploader.removeFile(fileUrl)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); + + 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 tester.client.updateChannel(channelId, channelType, data); + expect(res, isNotNull); + expect(res.channel.cid, '$channelType:$channelId'); + expect(res.channel.extraData['name'], 'test-channel'); + + tester + ..verifyApi((api) => api.channel.updateChannel(channelId, channelType, data)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + 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 tester.client.updateChannelPartial( + channelId, + channelType, + set: set, + unset: unset, + ); + expect(res, isNotNull); + expect(res.channel.cid, '$channelType:$channelId'); + expect(res.channel.extraData, set); + + tester + ..verifyApi((api) => api.channel.updateChannelPartial(channelId, channelType, set: set, unset: unset)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.addDevice should work`', + body: (tester) async { + const id = 'test-device-id'; + const provider = PushProvider.firebase; + + tester.mockApi( + (api) => api.device.addDevice(id, provider), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.addDevice(id, provider); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.device.addDevice(id, provider)) + ..verifyNoMoreApiInteractions((api) => api.device); + }, + ); + + 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(), + ); + + final res = await tester.client.addDevice( + id, + provider, + pushProviderName: pushProviderName, + ); + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.device.addDevice( + id, + provider, + pushProviderName: pushProviderName, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.device); + }, + ); + + chatClientTest( + '`.getDevices`', + body: (tester) async { + final devices = List.generate( + 3, + (index) => Device( + id: 'test-device-id-$index', + pushProvider: PushProvider.firebase.name, + ), + ); + + tester.mockApi( + (api) => api.device.getDevices(), + result: ListDevicesResponse()..devices = devices, + ); + + final res = await tester.client.getDevices(); + expect(res, isNotNull); + expect(res.devices.length, devices.length); + + tester + ..verifyApi((api) => api.device.getDevices()) + ..verifyNoMoreApiInteractions((api) => api.device); + }, + ); + + chatClientTest( + '`.removeDevice`', + body: (tester) async { + const deviceId = 'test-device-id'; + + tester.mockApi( + (api) => api.device.removeDevice(deviceId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.removeDevice(deviceId); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.device.removeDevice(deviceId)) + ..verifyNoMoreApiInteractions((api) => api.device); + }, + ); + + 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 preferences = [pushPreferenceInput, channelPreferenceInput]; + + 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( + 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, + ), + ]), + ); + + final res = await tester.client.setPushPreferences(preferences); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.device.setPushPreferences(preferences)) + ..verifyNoMoreApiInteractions((api) => api.device); + }, + ); + + chatClientTest( + 'should handle push_preference.updated event', + body: (tester) async { + final pushPreference = PushPreference( + chatLevel: ChatLevel.mentions, + callLevel: CallLevel.all, + disabledUntil: DateTime.utc(2021, 3), + ); + + final event = createDefaultEvent( + type: EventType.pushPreferenceUpdated, + pushPreference: pushPreference, + ); + + // Initially null + expect(tester.currentUser?.pushPreferences, isNull); + + // 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); + }, + ); + + 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 [], + ); + + final res = await tester.client.listUserGroups( + limit: limit, + idGt: idGt, + createdAtGt: createdAtGt, + teamId: teamId, + ); + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.userGroups.listUserGroups( + limit: limit, + idGt: idGt, + createdAtGt: createdAtGt, + teamId: teamId, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.userGroups); + }, + ); + + 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 [], + ); + + final res = await tester.client.searchUserGroups( + query, + limit: limit, + nameGt: nameGt, + idGt: idGt, + teamId: teamId, + ); + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.userGroups.searchUserGroups( + query, + limit: limit, + nameGt: nameGt, + idGt: idGt, + teamId: teamId, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.userGroups); + }, + ); + + 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), + ), + ); + + final res = await tester.client.getUserGroup(id, teamId: teamId); + expect(res, isNotNull); + expect(res.userGroup.id, id); + + tester + ..verifyApi((api) => api.userGroups.getUserGroup(id, teamId: teamId)) + ..verifyNoMoreApiInteractions((api) => api.userGroups); + }, + ); + + 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, + description: description, + teamId: teamId, + memberIds: memberIds, + ), + result: CreateUserGroupResponse() + ..userGroup = UserGroup( + id: id, + name: name, + description: description, + teamId: teamId, + createdAt: DateTime.utc(2024, 1, 1), + updatedAt: DateTime.utc(2024, 1, 2), + ), + ); + + final res = await tester.client.createUserGroup( + name, + id: id, + description: description, + teamId: teamId, + memberIds: memberIds, + ); + 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); + }, + ); + + 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, + ), + result: UpdateUserGroupResponse() + ..userGroup = UserGroup( + id: id, + name: name, + description: description, + teamId: teamId, + createdAt: DateTime.utc(2024, 1, 1), + updatedAt: DateTime.utc(2024, 1, 2), + ), + ); + + final res = await tester.client.updateUserGroup( + id, + name: name, + description: description, + teamId: teamId, + ); + 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); + }, + ); + + chatClientTest( + '`.deleteUserGroup`', + body: (tester) async { + const id = 'test-group-id'; + const teamId = 'test-team-id'; + + tester.mockApi( + (api) => api.userGroups.deleteUserGroup(id, teamId: teamId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.deleteUserGroup(id, teamId: teamId); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.userGroups.deleteUserGroup(id, teamId: teamId)) + ..verifyNoMoreApiInteractions((api) => api.userGroups); + }, + ); + + 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, + ), + result: AddUserGroupMembersResponse() + ..userGroup = UserGroup( + id: id, + name: 'test-group-name', + createdAt: DateTime.utc(2024, 1, 1), + updatedAt: DateTime.utc(2024, 1, 2), + ), + ); + + final res = await tester.client.addUserGroupMembers( + id, + memberIds, + asAdmin: asAdmin, + teamId: teamId, + ); + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.userGroups.addUserGroupMembers( + id, + memberIds, + asAdmin: asAdmin, + teamId: teamId, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.userGroups); + }, + ); + + 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, + ), + result: RemoveUserGroupMembersResponse() + ..userGroup = UserGroup( + id: id, + name: 'test-group-name', + createdAt: DateTime.utc(2024, 1, 1), + updatedAt: DateTime.utc(2024, 1, 2), + ), + ); + + final res = await tester.client.removeUserGroupMembers( + id, + memberIds, + teamId: teamId, + ); + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.userGroups.removeUserGroupMembers( + id, + memberIds, + teamId: teamId, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.userGroups); + }, + ); + + 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 [], + ); + + final res = await tester.client.searchRoles( + query, + limit: limit, + nameGt: nameGt, + roleType: roleType, + includeGlobalRoles: includeGlobalRoles, + ); + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.roles.searchRoles( + query, + limit: limit, + nameGt: nameGt, + roleType: roleType, + includeGlobalRoles: includeGlobalRoles, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.roles); + }, + ); + + chatClientTest( + '`.devToken`', + body: (tester) async { + const userId = 'test-user-id'; + + final token = tester.client.devToken(userId); + + expect(token, isNotNull); + expect(token.userId, userId); + expect(token.authType, AuthType.jwt); + }, + ); +} diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 951836ff2f..742b1a4d3a 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -1560,780 +1560,6 @@ void main() { ); }); - test('`.queryUsers`', () 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); - - expectLater( - // skipping initial seed event -> {} users - client.state.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); - - 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); - }); - - 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}'), - ), - ); - - const cid = 'message:nice-channel'; - final filter = Filter.equal('channel_cid', cid); - - when( - () => api.moderation.queryBannedUsers( - filter: filter, - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - ), - ).thenAnswer((_) async => QueryBannedUsersResponse()..bans = bans); - - final res = await 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); - }); - - test('`.search`', () async { - const cid = 'test-type:test-id'; - final filter = Filter.in_('cid', const [cid]); - - final messages = List.generate( - 3, - (index) => GetMessageResponse() - ..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); - - final res = await 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); - }); - - test('`.sendFile`', () 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'; - - when( - () => api.fileUploader.sendFile(file, channelId, channelType), - ).thenAnswer((_) async => SendFileResponse()..file = fileUrl); - - final res = await client.sendFile(file, channelId, channelType); - expect(res, isNotNull); - expect(res.file, fileUrl); - - verify(() => api.fileUploader.sendFile(file, channelId, channelType)).called(1); - verifyNoMoreInteractions(api.fileUploader); - }); - - test('`.sendImage`', () 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'; - - when( - () => api.fileUploader.sendImage(image, channelId, channelType), - ).thenAnswer((_) async => SendImageResponse()..file = fileUrl); - - final res = await client.sendImage(image, channelId, channelType); - expect(res, isNotNull); - expect(res.file, fileUrl); - - verify(() => api.fileUploader.sendImage(image, channelId, channelType)).called(1); - verifyNoMoreInteractions(api.fileUploader); - }); - - test('`.deleteFile`', () 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()); - - final res = await client.deleteFile(fileUrl, channelId, channelType); - expect(res, isNotNull); - - verify(() => api.fileUploader.deleteFile(fileUrl, channelId, channelType)).called(1); - verifyNoMoreInteractions(api.fileUploader); - }); - - test('`.deleteImage`', () 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()); - - final res = await client.deleteImage(imageUrl, channelId, channelType); - expect(res, isNotNull); - - verify( - () => api.fileUploader.deleteImage(imageUrl, channelId, channelType), - ).called(1); - verifyNoMoreInteractions(api.fileUploader); - }); - - test('`.uploadImage`', () 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); - - final res = await client.uploadImage(image); - expect(res, isNotNull); - expect(res.file, fileUrl); - - verify(() => api.fileUploader.uploadImage(image)).called(1); - verifyNoMoreInteractions(api.fileUploader); - }); - - test('`.uploadFile`', () 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); - - final res = await client.uploadFile(file); - expect(res, isNotNull); - expect(res.file, fileUrl); - - verify(() => api.fileUploader.uploadFile(file)).called(1); - verifyNoMoreInteractions(api.fileUploader); - }); - - test('`.removeImage`', () async { - const imageUrl = 'test-image-url'; - - when(() => api.fileUploader.removeImage(imageUrl)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.removeImage(imageUrl); - expect(res, isNotNull); - - verify(() => api.fileUploader.removeImage(imageUrl)).called(1); - verifyNoMoreInteractions(api.fileUploader); - }); - - test('`.removeFile`', () async { - const fileUrl = 'test-file-url'; - - when(() => api.fileUploader.removeFile(fileUrl)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.removeFile(fileUrl); - expect(res, isNotNull); - - verify(() => api.fileUploader.removeFile(fileUrl)).called(1); - verifyNoMoreInteractions(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}, - ), - ); - - final res = await 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); - }); - - 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}, - ), - ); - - 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); - - verify(() => api.channel.updateChannelPartial(channelId, channelType, set: set, unset: unset)).called(1); - verifyNoMoreInteractions(api.channel); - }); - - test('`.addDevice should work`', () async { - const id = 'test-device-id'; - const provider = PushProvider.firebase; - - when(() => api.device.addDevice(id, provider)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.addDevice(id, provider); - expect(res, isNotNull); - - verify(() => api.device.addDevice(id, provider)).called(1); - verifyNoMoreInteractions(api.device); - }); - - test('`.addDevice should work with pushProviderName`', () async { - const id = 'test-device-id'; - const provider = PushProvider.firebase; - const pushProviderName = 'my-custom-config'; - - when( - () => api.device.addDevice( - id, - provider, - pushProviderName: pushProviderName, - ), - ).thenAnswer((_) async => EmptyResponse()); - - final res = await client.addDevice( - id, - provider, - pushProviderName: pushProviderName, - ); - expect(res, isNotNull); - - verify( - () => api.device.addDevice( - id, - provider, - pushProviderName: pushProviderName, - ), - ).called(1); - verifyNoMoreInteractions(api.device); - }); - - test('`.getDevices`', () 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); - - final res = await client.getDevices(); - expect(res, isNotNull); - expect(res.devices.length, devices.length); - - verify(() => api.device.getDevices()).called(1); - verifyNoMoreInteractions(api.device); - }); - - test('`.removeDevice`', () async { - const deviceId = 'test-device-id'; - - when(() => api.device.removeDevice(deviceId)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.removeDevice(deviceId); - expect(res, isNotNull); - - verify(() => api.device.removeDevice(deviceId)).called(1); - verifyNoMoreInteractions(api.device); - }); - - test('`.setPushPreferences`', () async { - const pushPreferenceInput = PushPreferenceInput( - chatLevel: ChatLevel.mentions, - ); - - const channelCid = 'messaging:123'; - const channelPreferenceInput = PushPreferenceInput.channel( - channelCid: channelCid, - chatLevel: ChatLevel.mentions, - ); - - 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, - ), - }, - }, - ); - - 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, - ); - - // Initially null - expect(client.state.currentUser?.pushPreferences, isNull); - - // Trigger the event - client.handleEvent(event); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - // 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); - }); - - test('`.listUserGroups`', () async { - const limit = 10; - const idGt = 'cursor-group-id'; - final createdAtGt = DateTime.utc(2024, 6, 15, 12); - const teamId = 'test-team-id'; - - when( - () => api.userGroups.listUserGroups( - limit: limit, - idGt: idGt, - createdAtGt: createdAtGt, - teamId: teamId, - ), - ).thenAnswer((_) async => ListUserGroupsResponse()..userGroups = const []); - - final res = await client.listUserGroups( - limit: limit, - idGt: idGt, - createdAtGt: createdAtGt, - teamId: teamId, - ); - expect(res, isNotNull); - - verify( - () => api.userGroups.listUserGroups( - limit: limit, - idGt: idGt, - createdAtGt: createdAtGt, - teamId: teamId, - ), - ).called(1); - verifyNoMoreInteractions(api.userGroups); - }); - - test('`.searchUserGroups`', () async { - const query = 'eng'; - const limit = 10; - const nameGt = 'engineering'; - const idGt = 'cursor-group-id'; - const teamId = 'test-team-id'; - - 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); - - verify( - () => api.userGroups.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'; - - 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), - ), - ); - - final res = await client.getUserGroup(id, teamId: teamId); - expect(res, isNotNull); - expect(res.userGroup.id, id); - - verify(() => api.userGroups.getUserGroup(id, teamId: teamId)).called(1); - verifyNoMoreInteractions(api.userGroups); - }); - - test('`.createUserGroup`', () async { - const name = 'Engineering'; - const id = 'eng'; - const description = 'Engineering team'; - const teamId = 'test-team-id'; - const memberIds = ['user-1', 'user-2']; - - when( - () => api.userGroups.createUserGroup( - name, - id: id, - description: description, - teamId: teamId, - memberIds: memberIds, - ), - ).thenAnswer( - (_) async => CreateUserGroupResponse() - ..userGroup = UserGroup( - id: id, - name: name, - description: description, - teamId: teamId, - createdAt: DateTime.utc(2024, 1, 1), - updatedAt: DateTime.utc(2024, 1, 2), - ), - ); - - final res = await client.createUserGroup( - name, - id: id, - description: description, - teamId: teamId, - memberIds: memberIds, - ); - expect(res, isNotNull); - expect(res.userGroup.id, id); - - verify( - () => api.userGroups.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'; - - when( - () => api.userGroups.updateUserGroup( - id, - name: name, - description: description, - teamId: teamId, - ), - ).thenAnswer( - (_) async => UpdateUserGroupResponse() - ..userGroup = UserGroup( - id: 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); - - verify( - () => api.userGroups.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'; - - when(() => api.userGroups.deleteUserGroup(id, teamId: teamId)).thenAnswer( - (_) async => EmptyResponse(), - ); - - final res = await client.deleteUserGroup(id, teamId: teamId); - expect(res, isNotNull); - - verify(() => api.userGroups.deleteUserGroup(id, teamId: teamId)).called(1); - verifyNoMoreInteractions(api.userGroups); - }); - - test('`.addUserGroupMembers`', () async { - const id = 'test-group-id'; - const memberIds = ['user-1', 'user-2']; - const asAdmin = true; - const teamId = 'test-team-id'; - - 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), - ), - ); - - final res = await client.addUserGroupMembers( - id, - memberIds, - asAdmin: asAdmin, - teamId: teamId, - ); - expect(res, isNotNull); - - verify( - () => api.userGroups.addUserGroupMembers( - id, - memberIds, - asAdmin: asAdmin, - teamId: teamId, - ), - ).called(1); - verifyNoMoreInteractions(api.userGroups); - }); - - test('`.removeUserGroupMembers`', () async { - const id = 'test-group-id'; - const memberIds = ['user-1', 'user-2']; - const teamId = 'test-team-id'; - - 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), - ), - ); - - final res = await client.removeUserGroupMembers( - id, - memberIds, - teamId: teamId, - ); - expect(res, isNotNull); - - verify( - () => api.userGroups.removeUserGroupMembers( - id, - memberIds, - teamId: teamId, - ), - ).called(1); - verifyNoMoreInteractions(api.userGroups); - }); - - test('`.searchRoles`', () async { - const query = 'adm'; - const limit = 10; - const nameGt = 'admin'; - const roleType = RoleType.user; - const includeGlobalRoles = true; - - 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); - - verify( - () => api.roles.searchRoles( - query, - limit: limit, - nameGt: nameGt, - roleType: roleType, - includeGlobalRoles: includeGlobalRoles, - ), - ).called(1); - verifyNoMoreInteractions(api.roles); - }); - - test('`.devToken`', () async { - const userId = 'test-user-id'; - - final token = client.devToken(userId); - - expect(token, isNotNull); - expect(token.userId, userId); - expect(token.authType, AuthType.jwt); - }); - test('`.updateUser`', () async { final user = User( id: 'test-user-id', From ddf440f5c77873a6500e4493ada33b96f1e4b059 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 20:01:21 +0200 Subject: [PATCH 058/103] test(llc): migrate client queryChannels tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../client/client_query_channels_test.dart | 290 +++++++++++++++++ .../test/src/client/client_test.dart | 293 ------------------ .../lib/src/helpers/test_data.dart | 49 +++ 3 files changed, 339 insertions(+), 293 deletions(-) create mode 100644 packages/stream_chat/test/src/client/client_query_channels_test.dart diff --git a/packages/stream_chat/test/src/client/client_query_channels_test.dart b/packages/stream_chat/test/src/client/client_query_channels_test.dart new file mode 100644 index 0000000000..ac4fe1db95 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_query_channels_test.dart @@ -0,0 +1,290 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +void main() { + group('`.queryChannels`', () { + 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'), + ), + ); + + 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), + ); + + 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'), + ), + ); + }, + ); + + chatClientTest( + '''should rethrow if `.queryChannelsOnline` throws and persistence channels are empty''', + body: (tester) async { + tester.mockApiFailure( + (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'), + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError), + ); + + await expectLater( + tester.client.queryChannels(), + emitsError(isA()), + ); + + 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'), + ), + ); + }, + ); + + chatClientTest( + 'should coalesce concurrent identical calls into a single HTTP request', + 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 + // cache and each fired its own queryChannels HTTP request. + // + // With the fix, the cache slot is reserved synchronously after the + // hash check, so concurrent callers find the in-flight future and + // share its result. + final channelStates = List.generate( + 3, + (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. + 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), + delay: const Duration(milliseconds: 100), + ); + + // Fire 5 identical calls back-to-back in the same tick. + final results = await Future.wait( + List.generate(5, (_) => tester.client.queryChannels().toList()), + ); + + // All callers should receive the same channels. + for (final emitted in results) { + expect(emitted, hasLength(1)); + expect(emitted.single, channelStates.map(isCorrectChannelFor)); + } + + // But only ONE HTTP request should have been issued. + 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'), + ), + ); + }, + ); + + chatClientTest( + 'should fire a fresh request once the cached future has settled', + 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) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$index:test-id-$index'), + ), + ); + + 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), + ); + + await tester.client.queryChannels().toList(); + await tester.client.queryChannels().toList(); + + tester.verifyApiCalled( + (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'), + ), + times: 2, + ); + }, + ); + + chatClientTest( + 'concurrent calls with different filters do not share the cache', + 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. + 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(), + delay: const Duration(milliseconds: 100), + ); + + await Future.wait([ + tester.client.queryChannels(filter: Filter.in_('cid', const ['a'])).toList(), + tester.client.queryChannels(filter: Filter.in_('cid', const ['b'])).toList(), + ]); + + tester.verifyApiCalled( + (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'), + ), + times: 2, + ); + }, + ); + + chatClientTest( + 'concurrent calls share the same error when the request fails', + 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. + tester.mockApiFailure( + (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'), + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError), + delay: const Duration(milliseconds: 100), + ); + + final errors = await Future.wait( + List.generate(5, (_) async { + try { + await tester.client.queryChannels().toList(); + return null; + } catch (e) { + return e; + } + }), + ); + + // Every caller surfaces the same error type. + expect(errors, hasLength(5)); + for (final error in errors) { + expect(error, isA()); + } + + // But only ONE HTTP request was made — the rest piggybacked. + 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'), + ), + ); + }, + ); + }); +} diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 742b1a4d3a..5a149d1b23 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -1267,299 +1267,6 @@ void main() { }); }); - 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'), - ), - ); - - 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, - ); - - 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); - }); - - test( - '''should rethrow if `.queryChannelsOnline` throws and persistence channels are empty''', - () async { - 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'), - ), - ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); - - expectLater( - client.queryChannels(), - emitsError(isA()), - ); - - // 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); - }, - ); - - test( - 'should coalesce concurrent identical calls into a single HTTP request', - () 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 - // cache and each fired its own queryChannels HTTP request. - // - // With the fix, the cache slot is reserved synchronously after the - // hash check, so concurrent callers find the in-flight future and - // share its result. - final channelStates = List.generate( - 3, - (index) => ChannelState( - channel: ChannelModel(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( - 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 { - await delay(100); - return QueryChannelsResponse()..channels = channelStates; - }); - - // Fire 5 identical calls back-to-back in the same tick. - final results = await Future.wait( - List.generate(5, (_) => client.queryChannels().toList()), - ); - - // All callers should receive the same channels. - for (final emitted in results) { - expect(emitted, hasLength(1)); - expect(emitted.single, channelStates.map(isCorrectChannelFor)); - } - - // But only ONE HTTP request should have been issued. - 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); - }, - ); - - test( - 'should fire a fresh request once the cached future has settled', - () 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'), - ), - ); - - 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, - ); - - await client.queryChannels().toList(); - await client.queryChannels().toList(); - - 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(2); - }, - ); - - test( - 'concurrent calls with different filters do not share the cache', - () 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( - 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 { - await delay(100); - return QueryChannelsResponse()..channels = []; - }); - - await Future.wait([ - client.queryChannels(filter: Filter.in_('cid', const ['a'])).toList(), - client.queryChannels(filter: Filter.in_('cid', const ['b'])).toList(), - ]); - - 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(2); - }, - ); - - test( - 'concurrent calls share the same error when the request fails', - () 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( - 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 { - await delay(100); - throw StreamChatNetworkError(ChatErrorCode.inputError); - }); - - final errors = await Future.wait( - List.generate(5, (_) async { - try { - await client.queryChannels().toList(); - return null; - } catch (e) { - return e; - } - }), - ); - - // Every caller surfaces the same error type. - expect(errors, hasLength(5)); - for (final error in errors) { - expect(error, isA()); - } - - // But only ONE HTTP request was made — the rest piggybacked. - 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); - }, - ); - }); - test('`.updateUser`', () async { final user = User( id: 'test-user-id', diff --git a/packages/stream_chat_test/lib/src/helpers/test_data.dart b/packages/stream_chat_test/lib/src/helpers/test_data.dart index 5a87dcd217..082c9145da 100644 --- a/packages/stream_chat_test/lib/src/helpers/test_data.dart +++ b/packages/stream_chat_test/lib/src/helpers/test_data.dart @@ -683,6 +683,55 @@ CastPollVoteResponse createDefaultCastPollVoteResponse({ ..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'; From 1ca84d27c9acae8c7bab5658a1b0437c73f6a10f Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 20:02:20 +0200 Subject: [PATCH 059/103] test(llc): migrate client sync tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../test/src/client/client_sync_test.dart | 53 +++++++++++++++++++ .../test/src/client/client_test.dart | 38 ------------- 2 files changed, 53 insertions(+), 38 deletions(-) create mode 100644 packages/stream_chat/test/src/client/client_sync_test.dart diff --git a/packages/stream_chat/test/src/client/client_sync_test.dart b/packages/stream_chat/test/src/client/client_sync_test.dart new file mode 100644 index 0000000000..7968879e32 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_sync_test.dart @@ -0,0 +1,53 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +void main() { + 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); + + 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 tester.client.sync(cids: cids, lastSyncAt: lastSyncAt); + + tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); + }, + ); + + chatClientTest( + 'should return if `cids` is not available', + body: (tester) async { + expect(tester.client.sync, returnsNormally); + tester.verifyNeverCalled((api) => api.general.sync(any(), any())); + }, + ); + + chatClientTest( + 'should return if `lastSyncAt` is not available', + body: (tester) async { + expect(() => tester.client.sync(cids: ['test-cid-1']), returnsNormally); + tester.verifyNeverCalled((api) => api.general.sync(any(), any())); + }, + ); + }); +} diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 5a149d1b23..b6cc31191e 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -1229,44 +1229,6 @@ void main() { 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'), - ), - ], - ); - - await client.sync(cids: cids, lastSyncAt: lastSyncAt); - - verify(() => api.general.sync(cids, lastSyncAt)).called(1); - }); - - test('should return if `cids` is not available', () async { - expect(client.sync, returnsNormally); - verifyNever(() => 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())); - }); - }); - test('`.updateUser`', () async { final user = User( id: 'test-user-id', From 1e1b1e7188a5e3ce2b0a2043c6026214e52638d9 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 20:03:59 +0200 Subject: [PATCH 060/103] test(llc): migrate client live location tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../src/client/client_live_location_test.dart | 279 ++++++++++++++++++ .../test/src/client/client_test.dart | 271 ----------------- 2 files changed, 279 insertions(+), 271 deletions(-) create mode 100644 packages/stream_chat/test/src/client/client_live_location_test.dart diff --git a/packages/stream_chat/test/src/client/client_live_location_test.dart b/packages/stream_chat/test/src/client/client_live_location_test.dart new file mode 100644 index 0000000000..467698a613 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_live_location_test.dart @@ -0,0 +1,279 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +void main() { + 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)), + ); + + final event = createDefaultEvent( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: location, + ), + ); + + // Initially empty + expect(tester.clientState.activeLiveLocations, isEmpty); + + // Trigger the event + await tester.emitEvent(event); + + // Should add location to active live locations + final activeLiveLocations = tester.clientState.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations.first.messageId, equals('message-123')); + }, + ); + + 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)), + ); + + // Set initial location + tester.clientState.activeLiveLocations = [initialLocation]; + + 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)), + ); + + final event = createDefaultEvent( + type: EventType.locationUpdated, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: updatedLocation, + ), + ); + + // Trigger the event + await tester.emitEvent(event); + + // 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: tester.currentUser!.id, + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + endAt: DateTime.timestamp().add(const Duration(hours: 1)), + ); + + // Set initial location + tester.clientState.activeLiveLocations = [location]; + expect(tester.clientState.activeLiveLocations, hasLength(1)); + + final expiredLocation = location.copyWith( + endAt: DateTime.timestamp().subtract(const Duration(hours: 1)), + ); + + final event = createDefaultEvent( + type: EventType.locationExpired, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: expiredLocation, + ), + ); + + // Trigger the event + await tester.emitEvent(event); + + // Should remove the location + expect(tester.clientState.activeLiveLocations, isEmpty); + }, + ); + + 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); + + // 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)); + }, + ); + + 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)), + ); + + final event = createDefaultEvent( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: location, + ), + ); + + // Trigger the event + await tester.emitEvent(event); + + // Should not add location from other user + expect(tester.clientState.activeLiveLocations, isEmpty); + }, + ); + + 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 + ); + + final event = createDefaultEvent( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: staticLocation, + ), + ); + + // Trigger the event + await tester.emitEvent(event); + + // Should not add static location + expect(tester.clientState.activeLiveLocations, isEmpty); + }, + ); + + 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 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)), + ); + + final event1 = createDefaultEvent( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: location1, + ), + ); + + final event2 = createDefaultEvent( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-456', + sharedLocation: location2, + ), + ); + + // Trigger first event + await tester.emitEvent(event1); + + 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); + + final activeLiveLocations2 = tester.clientState.activeLiveLocations; + expect(activeLiveLocations2, hasLength(1)); + expect(activeLiveLocations2.first.messageId, equals('message-456')); + }, + ); + }); +} diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index b6cc31191e..65d6bac3bc 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -1857,277 +1857,6 @@ void main() { verifyNoMoreInteractions(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, - 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, - ), - ); - - // Initially empty - expect(client.state.activeLiveLocations, isEmpty); - - // Trigger the event - client.handleEvent(event); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - // Should add location to active live locations - final activeLiveLocations = client.state.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations.first.messageId, equals('message-123')); - }); - - test('should handle location.updated event', () async { - final initialLocation = 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)), - ); - - // 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)), - ); - - final event = Event( - type: EventType.locationUpdated, - cid: 'test-channel:123', - message: Message( - id: 'message-123', - sharedLocation: updatedLocation, - ), - ); - - // Trigger the event - client.handleEvent(event); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - // 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)); - }); - - 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)), - ); - - // Set initial location - client.state.activeLiveLocations = [location]; - expect(client.state.activeLiveLocations, hasLength(1)); - - final expiredLocation = location.copyWith( - endAt: DateTime.now().subtract(const Duration(hours: 1)), - ); - - final event = Event( - type: EventType.locationExpired, - cid: 'test-channel:123', - message: Message( - id: 'message-123', - sharedLocation: expiredLocation, - ), - ); - - // Trigger the event - client.handleEvent(event); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - // Should remove the location - expect(client.state.activeLiveLocations, isEmpty); - }); - - 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); - - // Setting an active location schedules a one-shot expiry timer. - client.state.activeLiveLocations = [ - 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(milliseconds: 800)), - ), - ]; - expect(client.state.activeLiveLocations, hasLength(1)); - - // Before endAt nothing is emitted and the location stays active. - await delay(200); - expect(expiredEvents, isEmpty); - expect(client.state.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); - - // The timer is one-shot: no further events are emitted. - await delay(300); - expect(expiredEvents, hasLength(1)); - }); - - 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)), - ); - - final event = Event( - type: EventType.locationShared, - cid: 'test-channel:123', - message: Message( - id: 'message-123', - sharedLocation: location, - ), - ); - - // Trigger the event - client.handleEvent(event); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - // Should not add location from other user - expect(client.state.activeLiveLocations, isEmpty); - }); - - 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 = Event( - type: EventType.locationShared, - cid: 'test-channel:123', - message: Message( - id: 'message-123', - sharedLocation: staticLocation, - ), - ); - - // Trigger the event - client.handleEvent(event); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - // Should not add static location - expect(client.state.activeLiveLocations, isEmpty); - }); - - 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, - ), - ); - - final event2 = Event( - type: EventType.locationShared, - cid: 'test-channel:123', - message: Message( - id: 'message-456', - sharedLocation: location2, - ), - ); - - // Trigger first event - client.handleEvent(event1); - await Future.delayed(Duration.zero); - - final activeLiveLocations = client.state.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations.first.messageId, equals('message-123')); - - // Trigger second event - should merge/update - client.handleEvent(event2); - await Future.delayed(Duration.zero); - - final activeLiveLocations2 = client.state.activeLiveLocations; - expect(activeLiveLocations2, hasLength(1)); - expect(activeLiveLocations2.first.messageId, equals('message-456')); - }); - }); - test('`.markAllRead`', () async { when(() => api.channel.markAllRead()).thenAnswer((_) async => EmptyResponse()); From adf559e51db938956d790d3a25d79e6d51e92b5f Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 20:04:58 +0200 Subject: [PATCH 061/103] test(llc): migrate client user API and block user tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../src/client/client_block_user_test.dart | 181 +++++ .../test/src/client/client_test.dart | 628 ------------------ .../test/src/client/client_user_api_test.dart | 572 ++++++++++++++++ 3 files changed, 753 insertions(+), 628 deletions(-) create mode 100644 packages/stream_chat/test/src/client/client_block_user_test.dart create mode 100644 packages/stream_chat/test/src/client/client_user_api_test.dart diff --git a/packages/stream_chat/test/src/client/client_block_user_test.dart b/packages/stream_chat/test/src/client/client_block_user_test.dart new file mode 100644 index 0000000000..f0d3960ea3 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_block_user_test.dart @@ -0,0 +1,181 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +void main() { + group('Block user state management', () { + 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(tester.currentUser?.blockedUserIds, isEmpty); + + tester.mockApi( + (api) => api.user.blockUser(userId), + result: createDefaultUserBlockResponse( + blockedUserId: userId, + blockedByUserId: testUser.id, + ), + ); + + await tester.client.blockUser(userId); + + // 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); + }, + ); + + chatClientTest( + 'blockUser should not duplicate existing blocked user IDs', + body: (tester) async { + const userId = 'blocked-user-id'; + tester.clientState.blockedUserIds = const [userId]; + + // Verify the user is already in the blocked list + expect(tester.currentUser?.blockedUserIds, contains(userId)); + + tester.mockApi( + (api) => api.user.blockUser(userId), + result: createDefaultUserBlockResponse( + blockedUserId: userId, + blockedByUserId: tester.currentUser!.id, + ), + ); + + await tester.client.blockUser(userId); + + // Verify - should still have only one entry + expect(tester.currentUser?.blockedUserIds, contains(userId)); + expect(tester.currentUser?.blockedUserIds.length, 1); + tester + ..verifyApi((api) => api.user.blockUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + 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( + tester.currentUser?.blockedUserIds, + containsAll([blockedUserId, otherBlockedId]), + ); + + tester.mockApi( + (api) => api.user.unblockUser(blockedUserId), + result: createDefaultEmptyResponse(), + ); + + await tester.client.unblockUser(blockedUserId); + + // Verify - blockedUserId should be removed + expect( + tester.currentUser?.blockedUserIds, + contains(otherBlockedId), + ); + + expect( + tester.currentUser?.blockedUserIds, + isNot(contains(blockedUserId)), + ); + + tester + ..verifyApi((api) => api.user.unblockUser(blockedUserId)) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + chatClientTest( + 'unblockUser should be resilient if user ID not in blocked list', + body: (tester) async { + const nonBlockedUserId = 'not-in-list'; + const otherBlockedId = 'other-blocked-id'; + tester.clientState.blockedUserIds = const [otherBlockedId]; + + // Verify initial state + expect( + tester.currentUser?.blockedUserIds, + contains(otherBlockedId), + ); + + expect( + tester.currentUser?.blockedUserIds, + isNot(contains(nonBlockedUserId)), + ); + + tester.mockApi( + (api) => api.user.unblockUser(nonBlockedUserId), + result: createDefaultEmptyResponse(), + ); + + await tester.client.unblockUser(nonBlockedUserId); + + // Verify - should remain unchanged + expect(tester.currentUser?.blockedUserIds, contains(otherBlockedId)); + expect(tester.currentUser?.blockedUserIds, isNot(contains(nonBlockedUserId))); + tester + ..verifyApi((api) => api.user.unblockUser(nonBlockedUserId)) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + chatClientTest( + 'queryBlockedUsers should update client state with blockedUserIds', + body: (tester) async { + const blockedId1 = 'blocked-1'; + const blockedId2 = 'blocked-2'; + + // Verify initial state + 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 + tester.mockApi( + (api) => api.user.queryBlockedUsers(), + result: createDefaultBlockedUsersResponse( + blocks: [ + UserBlock( + user: user, + userId: user.id, + blockedUser: blockedUser1, + blockedUserId: blockedId1, + ), + UserBlock( + user: user, + userId: user.id, + blockedUser: blockedUser2, + blockedUserId: blockedId2, + ), + ], + ), + ); + + await tester.client.queryBlockedUsers(); + + // Verify - should now include both blocked IDs + expect( + tester.currentUser?.blockedUserIds, + containsAll([blockedId1, blockedId2]), + ); + + tester + ..verifyApi((api) => api.user.queryBlockedUsers()) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + }); +} diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 65d6bac3bc..5f6e72390e 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -1229,634 +1229,6 @@ void main() { await client.dispose(); }); - test('`.updateUser`', () async { - final user = User( - id: 'test-user-id', - extraData: const {'name': 'test-user'}, - ); - - when(() => api.user.updateUsers([user])).thenAnswer((_) async => UpdateUsersResponse()..users = {user.id: user}); - - final res = await client.updateUser(user); - - expect(res, isNotNull); - expect(res.users, {user.id: user}); - - verify(() => api.user.updateUsers([user])).called(1); - verifyNoMoreInteractions(api.user); - }); - - test('`.partialUpdateUser`', () async { - const userId = 'test-user-id'; - - final set = {'color': 'yellow'}; - final unset = []; - - final partialUpdateRequest = PartialUpdateUserRequest( - id: userId, - set: set, - unset: unset, - ); - - final updatedUser = User( - id: userId, - extraData: {'color': set['color']}, - ); - - when(() => api.user.partialUpdateUsers([partialUpdateRequest])).thenAnswer( - (_) async => UpdateUsersResponse() - ..users = { - updatedUser.id: updatedUser, - }, - ); - - final res = await client.partialUpdateUser( - userId, - set: set, - unset: unset, - ); - - expect(res, isNotNull); - expect(res.users, {updatedUser.id: updatedUser}); - - verify( - () => api.user.partialUpdateUsers([partialUpdateRequest]), - ).called(1); - verifyNoMoreInteractions(api.user); - }); - - test('`.banUser`', () async { - const userId = 'test-user-id'; - - when( - () => api.moderation.banUser(userId, options: any(named: 'options')), - ).thenAnswer((_) async => EmptyResponse()); - - final res = await client.banUser(userId); - - expect(res, isNotNull); - - verify( - () => api.moderation.banUser(userId, options: any(named: 'options')), - ).called(1); - verifyNoMoreInteractions(api.moderation); - }); - - test('`.unbanUser`', () async { - const userId = 'test-user-id'; - - when( - () => api.moderation.unbanUser(userId, options: any(named: 'options')), - ).thenAnswer((_) async => EmptyResponse()); - - final res = await client.unbanUser(userId); - - expect(res, isNotNull); - - verify( - () => api.moderation.unbanUser(userId, options: any(named: 'options')), - ).called(1); - verifyNoMoreInteractions(api.moderation); - }); - - test('`.blockUser`', () async { - const userId = 'test-user-id'; - - 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', - }), - ); - - final res = await client.blockUser(userId); - - expect(res, isNotNull); - - verify( - () => api.user.blockUser(userId), - ).called(1); - verifyNoMoreInteractions(api.user); - }); - - test('`.unblockUser`', () async { - const userId = 'test-user-id'; - - when(() => api.user.unblockUser(userId)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.unblockUser(userId); - - expect(res, isNotNull); - - verify( - () => api.user.unblockUser(userId), - ).called(1); - verifyNoMoreInteractions(api.user); - }); - - test('`.queryBlockedUsers`', () 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]), - ], - ); - - final res = await client.queryBlockedUsers(); - expect(res, isNotNull); - expect(res.blocks.length, 2); - - verify(() => api.user.queryBlockedUsers()).called(1); - verifyNoMoreInteractions(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'; - - // Verify initial state - expect(client.state.currentUser?.blockedUserIds, isEmpty); - - when(() => api.user.blockUser(userId)).thenAnswer( - (_) async => UserBlockResponse() - ..blockedUserId = userId - ..blockedByUserId = testUser.id - ..createdAt = DateTime.now(), - ); - - await 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); - }); - - test( - 'blockUser should not duplicate existing blocked user IDs', - () async { - const userId = 'blocked-user-id'; - client.state.blockedUserIds = const [userId]; - - // Verify the user is already in the blocked list - expect(client.state.currentUser?.blockedUserIds, contains(userId)); - - when(() => api.user.blockUser(userId)).thenAnswer( - (_) async => UserBlockResponse() - ..blockedUserId = userId - ..blockedByUserId = client.state.currentUser!.id - ..createdAt = DateTime.now(), - ); - - await 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); - }, - ); - - test('unblockUser should remove user from blockedUserIds', () async { - const blockedUserId = 'blocked-user-id'; - const otherBlockedId = 'other-blocked-id'; - client.state.blockedUserIds = const [blockedUserId, otherBlockedId]; - - // Verify initial state includes both blocked IDs - expect( - client.state.currentUser?.blockedUserIds, - containsAll([blockedUserId, otherBlockedId]), - ); - - when(() => api.user.unblockUser(blockedUserId)).thenAnswer( - (_) async => EmptyResponse(), - ); - - await client.unblockUser(blockedUserId); - - // Verify - blockedUserId should be removed - expect( - client.state.currentUser?.blockedUserIds, - contains(otherBlockedId), - ); - - expect( - client.state.currentUser?.blockedUserIds, - isNot(contains(blockedUserId)), - ); - - verify(() => api.user.unblockUser(blockedUserId)).called(1); - verifyNoMoreInteractions(api.user); - }); - - test( - 'unblockUser should be resilient if user ID not in blocked list', - () async { - const nonBlockedUserId = 'not-in-list'; - const otherBlockedId = 'other-blocked-id'; - client.state.blockedUserIds = const [otherBlockedId]; - - // Verify initial state - expect( - client.state.currentUser?.blockedUserIds, - contains(otherBlockedId), - ); - - expect( - client.state.currentUser?.blockedUserIds, - isNot(contains(nonBlockedUserId)), - ); - - when(() => api.user.unblockUser(nonBlockedUserId)).thenAnswer( - (_) async => EmptyResponse(), - ); - - await 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); - }, - ); - - test( - 'queryBlockedUsers should update client state with blockedUserIds', - () async { - const blockedId1 = 'blocked-1'; - const blockedId2 = 'blocked-2'; - - // Verify initial state - expect(client.state.currentUser?.blockedUserIds, isEmpty); - - // Create mock users - 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 = [ - UserBlock( - user: user, - userId: user.id, - blockedUser: blockedUser1, - blockedUserId: blockedId1, - ), - UserBlock( - user: user, - userId: user.id, - blockedUser: blockedUser2, - blockedUserId: blockedId2, - ), - ], - ); - - await client.queryBlockedUsers(); - - // Verify - should now include both blocked IDs - expect( - client.state.currentUser?.blockedUserIds, - containsAll([blockedId1, blockedId2]), - ); - - verify(() => api.user.queryBlockedUsers()).called(1); - verifyNoMoreInteractions(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', - ), - ], - ); - - final res = await client.getUnreadCount(); - - expect(res, isNotNull); - expect(res.totalUnreadCount, 42); - expect(res.totalUnreadThreadsCount, 8); - - verify(() => api.user.getUnreadCount()).called(1); - verifyNoMoreInteractions(api.user); - }); - - test( - '`.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 = [ - 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', - ), - ], - ); - - 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 - - verify(() => api.user.getUnreadCount()).called(1); - verifyNoMoreInteractions(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); - - 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); - - verify(() => api.moderation.muteUser(userId)).called(1); - verifyNoMoreInteractions(api.moderation); - }); - - test('`.unmuteUser`', () async { - const userId = 'test-user-id'; - - when(() => api.moderation.unmuteUser(userId)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.unmuteUser(userId); - - expect(res, isNotNull); - - verify(() => api.moderation.unmuteUser(userId)).called(1); - verifyNoMoreInteractions(api.moderation); - }); - - test('`.flagMessage`', () async { - const messageId = 'test-message-id'; - - when(() => api.moderation.flagMessage(messageId)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.flagMessage(messageId); - - expect(res, isNotNull); - - verify(() => api.moderation.flagMessage(messageId)).called(1); - verifyNoMoreInteractions(api.moderation); - }); - - test('`.unflagMessage`', () async { - const messageId = 'test-message-id'; - - when(() => api.moderation.unflagMessage(messageId)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.unflagMessage(messageId); - - expect(res, isNotNull); - - verify(() => api.moderation.unflagMessage(messageId)).called(1); - verifyNoMoreInteractions(api.moderation); - }); - - test('`.flagUser`', () async { - const userId = 'test-message-id'; - - when(() => api.moderation.flagUser(userId)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.flagUser(userId); - - expect(res, isNotNull); - - verify(() => api.moderation.flagUser(userId)).called(1); - verifyNoMoreInteractions(api.moderation); - }); - - test('`.unflagUser`', () async { - const userId = 'test-message-id'; - - when(() => api.moderation.unflagUser(userId)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.unflagUser(userId); - - expect(res, isNotNull); - - verify(() => api.moderation.unflagUser(userId)).called(1); - verifyNoMoreInteractions(api.moderation); - }); - - 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)), - ), - ]; - - when(() => api.user.getActiveLiveLocations()).thenAnswer( - (_) async => - GetActiveLiveLocationsResponse() // - ..activeLiveLocations = locations, - ); - - // Initial state should be empty - expect(client.state.activeLiveLocations, isEmpty); - - final res = await client.getActiveLiveLocations(); - - expect(res, isNotNull); - expect(res.activeLiveLocations, hasLength(2)); - expect(res.activeLiveLocations, equals(locations)); - expect(client.state.activeLiveLocations, equals(locations)); - - verify(() => api.user.getActiveLiveLocations()).called(1); - verifyNoMoreInteractions(api.user); - }); - - test('`.updateLiveLocation`', () async { - const messageId = 'test-message-id'; - const createdByDeviceId = 'test-device-id'; - final endAt = DateTime.timestamp().add(const Duration(hours: 1)); - const location = LocationCoordinates( - latitude: 40.7128, - longitude: -74.0060, - ); - - final expectedLocation = Location( - latitude: location.latitude, - longitude: location.longitude, - createdByDeviceId: createdByDeviceId, - endAt: endAt, - ); - - when( - () => api.user.updateLiveLocation( - messageId: messageId, - createdByDeviceId: createdByDeviceId, - location: location, - endAt: endAt, - ), - ).thenAnswer((_) async => expectedLocation); - - final res = await client.updateLiveLocation( - messageId: messageId, - createdByDeviceId: createdByDeviceId, - location: location, - endAt: endAt, - ); - - expect(res, isNotNull); - expect(res, equals(expectedLocation)); - - verify( - () => api.user.updateLiveLocation( - messageId: messageId, - createdByDeviceId: createdByDeviceId, - location: location, - endAt: endAt, - ), - ).called(1); - verifyNoMoreInteractions(api.user); - }); - - test('`.stopLiveLocation`', () async { - const messageId = 'test-message-id'; - const createdByDeviceId = 'test-device-id'; - - final expectedLocation = Location( - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: createdByDeviceId, - endAt: DateTime.now(), // Should be expired - ); - - when( - () => api.user.updateLiveLocation( - messageId: messageId, - createdByDeviceId: createdByDeviceId, - endAt: any(named: 'endAt'), - ), - ).thenAnswer((_) async => expectedLocation); - - final res = await client.stopLiveLocation( - messageId: messageId, - createdByDeviceId: createdByDeviceId, - ); - - expect(res, isNotNull); - expect(res, equals(expectedLocation)); - - verify( - () => api.user.updateLiveLocation( - messageId: messageId, - createdByDeviceId: createdByDeviceId, - endAt: any(named: 'endAt'), - ), - ).called(1); - verifyNoMoreInteractions(api.user); - }); - test('`.markAllRead`', () async { when(() => api.channel.markAllRead()).thenAnswer((_) async => EmptyResponse()); diff --git a/packages/stream_chat/test/src/client/client_user_api_test.dart b/packages/stream_chat/test/src/client/client_user_api_test.dart new file mode 100644 index 0000000000..c2f41e851a --- /dev/null +++ b/packages/stream_chat/test/src/client/client_user_api_test.dart @@ -0,0 +1,572 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +void main() { + chatClientTest( + '`.updateUser`', + body: (tester) async { + final user = User( + id: 'test-user-id', + extraData: const {'name': 'test-user'}, + ); + + tester.mockApi( + (api) => api.user.updateUsers([user]), + result: createDefaultUpdateUsersResponse(users: {user.id: user}), + ); + + final res = await tester.client.updateUser(user); + + expect(res, isNotNull); + expect(res.users, {user.id: user}); + + tester + ..verifyApi((api) => api.user.updateUsers([user])) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + chatClientTest( + '`.partialUpdateUser`', + body: (tester) async { + const userId = 'test-user-id'; + + final set = {'color': 'yellow'}; + final unset = []; + + final partialUpdateRequest = PartialUpdateUserRequest( + id: userId, + set: set, + unset: unset, + ); + + final updatedUser = User( + id: userId, + extraData: {'color': set['color']}, + ); + + tester.mockApi( + (api) => api.user.partialUpdateUsers([partialUpdateRequest]), + result: createDefaultUpdateUsersResponse(users: {updatedUser.id: updatedUser}), + ); + + final res = await tester.client.partialUpdateUser( + userId, + set: set, + unset: unset, + ); + + expect(res, isNotNull); + expect(res.users, {updatedUser.id: updatedUser}); + + tester + ..verifyApi((api) => api.user.partialUpdateUsers([partialUpdateRequest])) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + chatClientTest( + '`.banUser`', + body: (tester) async { + const userId = 'test-user-id'; + + tester.mockApi( + (api) => api.moderation.banUser(userId, options: any(named: 'options')), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.banUser(userId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.banUser(userId, options: any(named: 'options'))) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + chatClientTest( + '`.unbanUser`', + body: (tester) async { + const userId = 'test-user-id'; + + tester.mockApi( + (api) => api.moderation.unbanUser(userId, options: any(named: 'options')), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.unbanUser(userId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.unbanUser(userId, options: any(named: 'options'))) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + 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 tester.client.blockUser(userId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.user.blockUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + chatClientTest( + '`.unblockUser`', + body: (tester) async { + const userId = 'test-user-id'; + + tester.mockApi( + (api) => api.user.unblockUser(userId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.unblockUser(userId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.user.unblockUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + chatClientTest( + '`.queryBlockedUsers`', + body: (tester) async { + final users = List.generate( + 3, + (index) => User(id: 'test-user-id-$index'), + ); + + 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 tester.client.queryBlockedUsers(); + expect(res, isNotNull); + expect(res.blocks.length, 2); + + tester + ..verifyApi((api) => api.user.queryBlockedUsers()) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + 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 tester.client.getUnreadCount(); + + expect(res, isNotNull); + expect(res.totalUnreadCount, 42); + expect(res.totalUnreadThreadsCount, 8); + + tester + ..verifyApi((api) => api.user.getUnreadCount()) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + chatClientTest( + '`.getUnreadCount` should also update user unread count as a side effect', + 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.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', + ), + ], + ), + ); + + tester.client.getUnreadCount().ignore(); + + // Wait for the local side effect event to be processed + await Future.delayed(Duration.zero); + + expect(tester.currentUser?.totalUnreadCount, 25); + expect(tester.currentUser?.unreadChannels, 2); // channels.length + expect(tester.currentUser?.unreadThreads, 2); // threads.length + + tester + ..verifyApi((api) => api.user.getUnreadCount()) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + chatClientTest( + '`.shadowBan`', + body: (tester) async { + const userId = 'test-user-id'; + + tester.mockApi( + (api) => api.moderation.banUser(userId, options: {'shadow': true}), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.shadowBan(userId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.banUser(userId, options: {'shadow': true})) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + chatClientTest( + '`.removeShadowBan`', + body: (tester) async { + const userId = 'test-user-id'; + + tester.mockApi( + (api) => api.moderation.unbanUser(userId, options: {'shadow': true}), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.removeShadowBan(userId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.unbanUser(userId, options: {'shadow': true})) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + chatClientTest( + '`.muteUser`', + body: (tester) async { + const userId = 'test-user-id'; + + tester.mockApi( + (api) => api.moderation.muteUser(userId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.muteUser(userId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.muteUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + chatClientTest( + '`.unmuteUser`', + body: (tester) async { + const userId = 'test-user-id'; + + tester.mockApi( + (api) => api.moderation.unmuteUser(userId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.unmuteUser(userId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.unmuteUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + chatClientTest( + '`.flagMessage`', + body: (tester) async { + const messageId = 'test-message-id'; + + tester.mockApi( + (api) => api.moderation.flagMessage(messageId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.flagMessage(messageId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.flagMessage(messageId)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + chatClientTest( + '`.unflagMessage`', + body: (tester) async { + const messageId = 'test-message-id'; + + tester.mockApi( + (api) => api.moderation.unflagMessage(messageId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.unflagMessage(messageId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.unflagMessage(messageId)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + chatClientTest( + '`.flagUser`', + body: (tester) async { + const userId = 'test-message-id'; + + tester.mockApi( + (api) => api.moderation.flagUser(userId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.flagUser(userId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.flagUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + chatClientTest( + '`.unflagUser`', + body: (tester) async { + const userId = 'test-message-id'; + + tester.mockApi( + (api) => api.moderation.unflagUser(userId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.unflagUser(userId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.unflagUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + 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)), + ), + ]; + + tester.mockApi( + (api) => api.user.getActiveLiveLocations(), + result: GetActiveLiveLocationsResponse()..activeLiveLocations = locations, + ); + + // Initial state should be empty + expect(tester.clientState.activeLiveLocations, isEmpty); + + final res = await tester.client.getActiveLiveLocations(); + + expect(res, isNotNull); + expect(res.activeLiveLocations, hasLength(2)); + expect(res.activeLiveLocations, equals(locations)); + expect(tester.clientState.activeLiveLocations, equals(locations)); + + tester + ..verifyApi((api) => api.user.getActiveLiveLocations()) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + 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 = LocationCoordinates( + latitude: 40.7128, + longitude: -74.0060, + ); + + final expectedLocation = Location( + latitude: location.latitude, + longitude: location.longitude, + createdByDeviceId: createdByDeviceId, + endAt: endAt, + ); + + tester.mockApi( + (api) => api.user.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + location: location, + endAt: endAt, + ), + result: expectedLocation, + ); + + final res = await tester.client.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + location: location, + endAt: endAt, + ); + + 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); + }, + ); + + chatClientTest( + '`.stopLiveLocation`', + body: (tester) async { + const messageId = 'test-message-id'; + const createdByDeviceId = 'test-device-id'; + + final expectedLocation = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: createdByDeviceId, + endAt: DateTime.timestamp(), // Should be expired + ); + + tester.mockApi( + (api) => api.user.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + endAt: any(named: 'endAt'), + ), + result: expectedLocation, + ); + + final res = await tester.client.stopLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + ); + + 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); + }, + ); +} From 1789b9e40f888808e0e0bea617c96fa1532b5274 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 10 Sep 2026 20:05:49 +0200 Subject: [PATCH 062/103] test(llc): migrate client with-persistence tests to stream_chat_test Co-Authored-By: Claude Fable 5 --- .../test/src/client/client_test.dart | 706 ----------------- .../client/client_with_persistence_test.dart | 725 ++++++++++++++++++ 2 files changed, 725 insertions(+), 706 deletions(-) create mode 100644 packages/stream_chat/test/src/client/client_with_persistence_test.dart diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 5f6e72390e..81b00c13f4 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -483,712 +483,6 @@ 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 = Token.development(user.id).rawValue; - - late StreamChatClient client; - - setUpAll(() { - // fallback values - registerFallbackValue(FakeEvent()); - registerFallbackValue(const PaginationParams()); - registerFallbackValue(FakeChannelState()); - registerFallbackValue(const Filter.empty()); - }); - - 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); - }); - - tearDown(() async { - await client.dispose(); - }); - - group('`.sync`', () { - test( - '''should update persistence connectionInfo and lastSync when sync succeeds''', - () 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 = [ - 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'), - ), - ], - ); - - when(() => persistence.updateConnectionInfo(any())).thenAnswer((_) => Future.value()); - when(() => persistence.updateLastSyncAt(any())).thenAnswer((_) => Future.value()); - - await 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); - }, - ); - - test( - 'should work fine if persistence contains sync params', - () 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(persistence.getChannelCids).thenAnswer((_) async => cids); - when(persistence.getLastSyncAt).thenAnswer((_) async => lastSyncAt); - - 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', text: 'Hey!'), - ), - ], - ); - - when(() => persistence.updateConnectionInfo(any())).thenAnswer((_) => Future.value()); - when(() => persistence.updateLastSyncAt(any())).thenAnswer((_) => Future.value()); - - await client.sync(); - - verify(() => persistence.updateConnectionInfo(any())).called(1); - verify(() => persistence.updateLastSyncAt(any())).called(1); - verify(() => api.general.sync(cids, lastSyncAt)).called(1); - verify(persistence.getChannelCids).called(1); - verify(persistence.getLastSyncAt).called(1); - }, - ); - }); - - group('`.queryChannels`', () { - test( - 'should emit channels twice if persistence contains some channels', - () async { - final persistentChannelStates = List.generate( - 3, - (index) => ChannelState( - channel: ChannelModel(cid: 'test-type-$index:test-id-$index'), - ), - ); - - when( - () => persistence.queryChannelStates( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - ), - ).thenAnswer((_) async => QueryChannelsResponse()..channels = persistentChannelStates); - - final channelStates = List.generate( - 3, - (index) => ChannelState( - channel: ChannelModel(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, - ); - - when(() => persistence.getChannelThreads(any())).thenAnswer( - (_) async => >{ - for (final channelState in channelStates) - channelState.channel!.cid: [Message(id: 'test-message-id', text: 'Test message')], - }, - ); - - when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); - when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); - when( - () => persistence.saveChannelQueries( - cids: any(named: 'cids'), - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - resolvedFilter: any(named: 'resolvedFilter'), - resolvedSort: any(named: 'resolvedSort'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - clearQueryCache: any(named: 'clearQueryCache'), - ), - ).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); - clearInteractions(persistence); - - await expectLater( - client.queryChannels(), - emitsInOrder([ - // emits persistent channels first - persistentChannelStates.map(isCorrectChannelFor), - // makes api call and emits network fetched channels - channelStates.map(isCorrectChannelFor), - ]), - ); - - // Wait safely past the 1s debounce on persistence writes - // (updateChannelState, updateChannelThreads) so all trailing - // invocations have fired before we verify counts. - await delay(1500); - - verify( - () => persistence.queryChannelStates( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - ), - ).called(1); - - 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); - - verify(() => persistence.getChannelThreads(any())).called(channelStates.length); - verify(() => persistence.updateChannelState(any())).called(channelStates.length); - verify(() => persistence.updateChannelThreads(any(), any())).called(channelStates.length); - verify( - () => persistence.saveChannelQueries( - cids: any(named: 'cids'), - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - resolvedFilter: any(named: 'resolvedFilter'), - resolvedSort: any(named: 'resolvedSort'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - clearQueryCache: any(named: 'clearQueryCache'), - ), - ).called(1); - }, - ); - - test( - '''should never rethrow network call if persistence already emitted some channels''', - () async { - final persistentChannelStates = List.generate( - 3, - (index) => ChannelState( - channel: ChannelModel(cid: 'test-type-$index:test-id-$index'), - ), - ); - - when( - () => persistence.queryChannelStates( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - ), - ).thenAnswer((_) async => QueryChannelsResponse()..channels = persistentChannelStates); - - 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'), - ), - ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); - - when(() => persistence.getChannelThreads(any())).thenAnswer( - (_) async => >{ - for (final channelState in persistentChannelStates) - channelState.channel!.cid: [Message(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); - clearInteractions(persistence); - - await expectLater( - client.queryChannels(), - emitsInOrder([ - // emits persistent channels - persistentChannelStates.map(isCorrectChannelFor), - ]), - ); - - // Wait safely past the 1s debounce on persistence writes - // (updateChannelState, updateChannelThreads) so all trailing - // invocations have fired before we verify counts. - await delay(1500); - - verify( - () => persistence.queryChannelStates( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - ), - ).called(1); - - 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); - - verify(() => persistence.getChannelThreads(any())).called(persistentChannelStates.length); - verify(() => persistence.updateChannelState(any())).called(persistentChannelStates.length); - verify(() => persistence.updateChannelThreads(any(), any())).called(persistentChannelStates.length); - }, - ); - - test( - 'queryChannelsOnline with inline filter persists via saveChannelQueries', - () async { - final filter = Filter.in_('members', const ['test-user-id']); - - final channelStates = List.generate( - 3, - (i) => ChannelState(channel: ChannelModel(cid: 'test-type-$i:test-id-$i')), - ); - - when( - () => api.channel.queryChannels( - filter: filter, - 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, - ); - - when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); - when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); - when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); - when( - () => persistence.saveChannelQueries( - cids: any(named: 'cids'), - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - resolvedFilter: any(named: 'resolvedFilter'), - resolvedSort: any(named: 'resolvedSort'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - clearQueryCache: any(named: 'clearQueryCache'), - ), - ).thenAnswer((_) => Future.value()); - - await delay(1100); - clearInteractions(persistence); - - await client.queryChannelsOnline(filter: filter); - - // The standard path passes filter (the inline filter) and a null - // predefinedFilter. resolvedFilter / resolvedSort stay null — - // they're only meaningful for the predefined-filter path. - verify( - () => persistence.saveChannelQueries( - cids: channelStates.map((s) => s.channel!.cid).toList(), - filter: filter, - sort: null, - predefinedFilter: null, - resolvedFilter: null, - resolvedSort: null, - filterValues: null, - sortValues: null, - clearQueryCache: true, - ), - ).called(1); - }, - ); - - test( - 'queryChannelsOnline with predefined filter persists via saveChannelQueries with resolved sort', - () 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')), - ); - - when( - () => api.channel.queryChannels( - predefinedFilter: filterName, - filterValues: filterValues, - sortValues: sortValues, - 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 - ..predefinedFilter = const PredefinedFilter( - name: filterName, - filter: Filter.empty(), - sort: [SortOption.desc('last_message_at')], - ), - ); - - when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); - when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); - when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); - when( - () => persistence.saveChannelQueries( - cids: any(named: 'cids'), - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - resolvedFilter: any(named: 'resolvedFilter'), - resolvedSort: any(named: 'resolvedSort'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - clearQueryCache: any(named: 'clearQueryCache'), - ), - ).thenAnswer((_) => Future.value()); - - await delay(1100); - clearInteractions(persistence); - - await client.queryChannelsOnline( - predefinedFilter: filterName, - filterValues: filterValues, - sortValues: sortValues, - ); - - verify( - () => persistence.saveChannelQueries( - cids: channelStates.map((s) => s.channel!.cid).toList(), - filter: null, - sort: null, - predefinedFilter: filterName, - resolvedFilter: const Filter.empty(), - resolvedSort: const [SortOption.desc('last_message_at')], - filterValues: filterValues, - sortValues: sortValues, - clearQueryCache: true, - ), - ).called(1); - }, - ); - - test( - 'queryChannelsOffline with predefined filter reads via queryChannelStates', - () 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')), - ); - - when( - () => persistence.queryChannelStates( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: filterName, - filterValues: filterValues, - sortValues: sortValues, - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - ), - ).thenAnswer((_) async => QueryChannelsResponse()..channels = channelStates); - - when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); - when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); - when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); - - await delay(1100); - clearInteractions(persistence); - - final channels = await client.queryChannelsOffline( - predefinedFilter: filterName, - filterValues: filterValues, - sortValues: sortValues, - ); - - expect(channels, hasLength(channelStates.length)); - - verify( - () => persistence.queryChannelStates( - filter: null, - sort: null, - predefinedFilter: filterName, - filterValues: filterValues, - sortValues: sortValues, - messageLimit: 25, - paginationParams: const PaginationParams(), - ), - ).called(1); - }, - ); - - test( - 'queryChannelsWithResult yields QueryChannelsResult with predefinedFilter=null for inline filter', - () async { - final channelStates = List.generate( - 2, - (i) => ChannelState(channel: ChannelModel(cid: 'test-type-$i:test-id-$i')), - ); - - when( - () => persistence.queryChannelStates( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - ), - ).thenAnswer((_) async => QueryChannelsResponse()..channels = const []); - - 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, - ); - - when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); - when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); - when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); - when( - () => persistence.saveChannelQueries( - cids: any(named: 'cids'), - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - resolvedFilter: any(named: 'resolvedFilter'), - resolvedSort: any(named: 'resolvedSort'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - clearQueryCache: any(named: 'clearQueryCache'), - ), - ).thenAnswer((_) => Future.value()); - - await delay(1100); - clearInteractions(persistence); - - final results = await client.queryChannelsWithResult().toList(); - - // Persistence returned empty, so only the online emission is yielded. - expect(results, hasLength(1)); - expect(results.single.channels, hasLength(channelStates.length)); - expect(results.single.predefinedFilter, isNull); - }, - ); - - test( - 'queryChannelsWithResult yields QueryChannelsResult with predefinedFilter populated for predefined query', - () 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')), - ); - - const resolvedSort = [SortOption.desc('last_message_at')]; - const resolvedFilter = Filter.empty(); - const expectedPredefinedFilter = PredefinedFilter( - name: filterName, - filter: resolvedFilter, - sort: resolvedSort, - ); - - when( - () => persistence.queryChannelStates( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - ), - ).thenAnswer((_) async => QueryChannelsResponse()..channels = const []); - - when( - () => api.channel.queryChannels( - predefinedFilter: filterName, - filterValues: filterValues, - sortValues: sortValues, - 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 - ..predefinedFilter = expectedPredefinedFilter, - ); - - when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); - when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); - when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); - when( - () => persistence.saveChannelQueries( - cids: any(named: 'cids'), - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - resolvedFilter: any(named: 'resolvedFilter'), - resolvedSort: any(named: 'resolvedSort'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - clearQueryCache: any(named: 'clearQueryCache'), - ), - ).thenAnswer((_) => Future.value()); - - await delay(1100); - clearInteractions(persistence); - - final results = await client - .queryChannelsWithResult( - predefinedFilter: filterName, - filterValues: filterValues, - sortValues: sortValues, - ) - .toList(); - - // Persistence returned empty, so only the online emission is yielded. - expect(results, hasLength(1)); - expect(results.single.channels, hasLength(channelStates.length)); - expect(results.single.predefinedFilter, isNotNull); - expect(results.single.predefinedFilter!.name, equals(filterName)); - expect(results.single.predefinedFilter!.sort, equals(resolvedSort)); - }, - ); - }); - - test('`.disconnectUser` should reset state and user', () async { - expect(client.state.currentUser, isNotNull); - expect(client.wsConnectionStatus, ConnectionStatus.connected); - - expectLater( - // skipping initial connected value - client.wsConnectionStatusStream.skip(1), - emits(ConnectionStatus.disconnected), - ); - - await client.disconnectUser(flushChatPersistence: true); - - expect(client.state.currentUser, isNull); - expect(client.wsConnectionStatus, ConnectionStatus.disconnected); - }); - }); - group('Client with connected user without persistence', () { const apiKey = 'test-api-key'; const userId = 'test-user-id'; diff --git a/packages/stream_chat/test/src/client/client_with_persistence_test.dart b/packages/stream_chat/test/src/client/client_with_persistence_test.dart new file mode 100644 index 0000000000..26213173cd --- /dev/null +++ b/packages/stream_chat/test/src/client/client_with_persistence_test.dart @@ -0,0 +1,725 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +// Runs [body] as a [chatClientTest] whose client is backed by a fresh +// [MockPersistenceClient], mirroring the monolith group's setUp: 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); + + tester.mockSuccessfulAuth(tester.user.id); + await tester.client.connectUser(tester.user, createTestToken(tester.user.id).rawValue); + + expect(tester.client.persistenceEnabled, isTrue); + expect(tester.connectionStatus, ConnectionStatus.connected); + }, + body: (tester) => body(tester, persistence), + ); +} + +void main() { + group('Client with connected user with persistence', () { + group('`.sync`', () { + _clientWithPersistenceTest( + '''should update persistence connectionInfo and lastSync when sync succeeds''', + 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.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(tester.user), + ), + Event( + isLocal: false, + type: EventType.messageDeleted, + message: Message(id: 'test-message-id'), + ), + ], + ), + ); + + when(() => persistence.updateConnectionInfo(any())).thenAnswer((_) => Future.value()); + when(() => persistence.updateLastSyncAt(any())).thenAnswer((_) => Future.value()); + + await tester.client.sync(cids: cids, lastSyncAt: lastSyncAt); + + verify(() => persistence.updateConnectionInfo(any())).called(1); + verify(() => persistence.updateLastSyncAt(any())).called(1); + tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); + }, + ); + + _clientWithPersistenceTest( + 'should work fine if persistence contains sync params', + 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.utc(2021, 3); + + when(persistence.getChannelCids).thenAnswer((_) async => cids); + when(persistence.getLastSyncAt).thenAnswer((_) async => lastSyncAt); + + 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', text: 'Hey!'), + ), + ], + ), + ); + + when(() => persistence.updateConnectionInfo(any())).thenAnswer((_) => Future.value()); + when(() => persistence.updateLastSyncAt(any())).thenAnswer((_) => Future.value()); + + await tester.client.sync(); + + verify(() => persistence.updateConnectionInfo(any())).called(1); + verify(() => persistence.updateLastSyncAt(any())).called(1); + tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); + verify(persistence.getChannelCids).called(1); + verify(persistence.getLastSyncAt).called(1); + }, + ); + }); + + group('`.queryChannels`', () { + _clientWithPersistenceTest( + 'should emit channels twice if persistence contains some channels', + body: (tester, persistence) async { + final persistentChannelStates = List.generate( + 3, + (index) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$index:test-id-$index'), + ), + ); + + when( + () => persistence.queryChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) async => createDefaultQueryChannelsResponse(channels: persistentChannelStates)); + + final channelStates = List.generate( + 3, + (index) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$index:test-id-$index'), + ), + ); + + 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), + ); + + when(() => persistence.getChannelThreads(any())).thenAnswer( + (_) async => >{ + for (final channelState in channelStates) + channelState.channel!.cid: [createDefaultMessage(id: 'test-message-id', text: 'Test message')], + }, + ); + + when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); + when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); + when( + () => persistence.saveChannelQueries( + cids: any(named: 'cids'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + resolvedFilter: any(named: 'resolvedFilter'), + resolvedSort: any(named: 'resolvedSort'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + clearQueryCache: any(named: 'clearQueryCache'), + ), + ).thenAnswer((_) => Future.value()); + + // 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( + tester.client.queryChannels(), + emitsInOrder([ + // emits persistent channels first + persistentChannelStates.map(isCorrectChannelFor), + // makes api call and emits network fetched channels + channelStates.map(isCorrectChannelFor), + ]), + ); + + // Wait safely past the 1s debounce on persistence writes + // (updateChannelState, updateChannelThreads) so all trailing + // invocations have fired before we verify counts. + await Future.delayed(const Duration(milliseconds: 1500)); + + verify( + () => persistence.queryChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).called(1); + + 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'), + ), + ); + + verify(() => persistence.getChannelThreads(any())).called(channelStates.length); + verify(() => persistence.updateChannelState(any())).called(channelStates.length); + verify(() => persistence.updateChannelThreads(any(), any())).called(channelStates.length); + verify( + () => persistence.saveChannelQueries( + cids: any(named: 'cids'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + resolvedFilter: any(named: 'resolvedFilter'), + resolvedSort: any(named: 'resolvedSort'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + clearQueryCache: any(named: 'clearQueryCache'), + ), + ).called(1); + }, + ); + + _clientWithPersistenceTest( + '''should never rethrow network call if persistence already emitted some channels''', + body: (tester, persistence) async { + final persistentChannelStates = List.generate( + 3, + (index) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$index:test-id-$index'), + ), + ); + + when( + () => persistence.queryChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) async => createDefaultQueryChannelsResponse(channels: persistentChannelStates)); + + tester.mockApiFailure( + (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'), + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError), + ); + + when(() => persistence.getChannelThreads(any())).thenAnswer( + (_) async => >{ + for (final channelState in persistentChannelStates) + channelState.channel!.cid: [createDefaultMessage(id: 'test-message-id', text: 'Test message')], + }, + ); + + when(() => persistence.updateChannelState(any())).thenAnswer((_) async => {}); + when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async => {}); + + // 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( + tester.client.queryChannels(), + emitsInOrder([ + // emits persistent channels + persistentChannelStates.map(isCorrectChannelFor), + ]), + ); + + // Wait safely past the 1s debounce on persistence writes + // (updateChannelState, updateChannelThreads) so all trailing + // invocations have fired before we verify counts. + await Future.delayed(const Duration(milliseconds: 1500)); + + verify( + () => persistence.queryChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).called(1); + + 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'), + ), + ); + + verify(() => persistence.getChannelThreads(any())).called(persistentChannelStates.length); + verify(() => persistence.updateChannelState(any())).called(persistentChannelStates.length); + verify(() => persistence.updateChannelThreads(any(), any())).called(persistentChannelStates.length); + }, + ); + + _clientWithPersistenceTest( + 'queryChannelsOnline with inline filter persists via saveChannelQueries', + body: (tester, persistence) async { + final filter = Filter.in_('members', const ['test-user-id']); + + final channelStates = List.generate( + 3, + (i) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$i:test-id-$i'), + ), + ); + + tester.mockApi( + (api) => api.channel.queryChannels( + filter: filter, + 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), + ); + + when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); + when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); + when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); + when( + () => persistence.saveChannelQueries( + cids: any(named: 'cids'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + resolvedFilter: any(named: 'resolvedFilter'), + resolvedSort: any(named: 'resolvedSort'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + clearQueryCache: any(named: 'clearQueryCache'), + ), + ).thenAnswer((_) => Future.value()); + + await Future.delayed(const Duration(milliseconds: 1100)); + clearInteractions(persistence); + + await tester.client.queryChannelsOnline(filter: filter); + + // The standard path passes filter (the inline filter) and a null + // predefinedFilter. resolvedFilter / resolvedSort stay null — + // they're only meaningful for the predefined-filter path. + verify( + () => persistence.saveChannelQueries( + cids: channelStates.map((s) => s.channel!.cid).toList(), + filter: filter, + sort: null, + predefinedFilter: null, + resolvedFilter: null, + resolvedSort: null, + filterValues: null, + sortValues: null, + clearQueryCache: true, + ), + ).called(1); + }, + ); + + _clientWithPersistenceTest( + 'queryChannelsOnline with predefined filter persists via saveChannelQueries with resolved sort', + 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) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$i:test-id-$i'), + ), + ); + + tester.mockApi( + (api) => api.channel.queryChannels( + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + 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, + predefinedFilter: const PredefinedFilter( + name: filterName, + filter: Filter.empty(), + sort: [SortOption.desc('last_message_at')], + ), + ), + ); + + when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); + when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); + when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); + when( + () => persistence.saveChannelQueries( + cids: any(named: 'cids'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + resolvedFilter: any(named: 'resolvedFilter'), + resolvedSort: any(named: 'resolvedSort'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + clearQueryCache: any(named: 'clearQueryCache'), + ), + ).thenAnswer((_) => Future.value()); + + await Future.delayed(const Duration(milliseconds: 1100)); + clearInteractions(persistence); + + await tester.client.queryChannelsOnline( + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + ); + + verify( + () => persistence.saveChannelQueries( + cids: channelStates.map((s) => s.channel!.cid).toList(), + filter: null, + sort: null, + predefinedFilter: filterName, + resolvedFilter: const Filter.empty(), + resolvedSort: const [SortOption.desc('last_message_at')], + filterValues: filterValues, + sortValues: sortValues, + clearQueryCache: true, + ), + ).called(1); + }, + ); + + _clientWithPersistenceTest( + 'queryChannelsOffline with predefined filter reads via queryChannelStates', + 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) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$i:test-id-$i'), + ), + ); + + when( + () => persistence.queryChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).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 Future.delayed(const Duration(milliseconds: 1100)); + clearInteractions(persistence); + + final channels = await tester.client.queryChannelsOffline( + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + ); + + expect(channels, hasLength(channelStates.length)); + + verify( + () => persistence.queryChannelStates( + filter: null, + sort: null, + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + messageLimit: 25, + paginationParams: const PaginationParams(), + ), + ).called(1); + }, + ); + + _clientWithPersistenceTest( + 'queryChannelsWithResult yields QueryChannelsResult with predefinedFilter=null for inline filter', + body: (tester, persistence) async { + final channelStates = List.generate( + 2, + (i) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$i:test-id-$i'), + ), + ); + + when( + () => persistence.queryChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) async => createDefaultQueryChannelsResponse()); + + 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), + ); + + when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); + when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); + when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); + when( + () => persistence.saveChannelQueries( + cids: any(named: 'cids'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + resolvedFilter: any(named: 'resolvedFilter'), + resolvedSort: any(named: 'resolvedSort'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + clearQueryCache: any(named: 'clearQueryCache'), + ), + ).thenAnswer((_) => Future.value()); + + await Future.delayed(const Duration(milliseconds: 1100)); + clearInteractions(persistence); + + final results = await tester.client.queryChannelsWithResult().toList(); + + // Persistence returned empty, so only the online emission is yielded. + expect(results, hasLength(1)); + expect(results.single.channels, hasLength(channelStates.length)); + expect(results.single.predefinedFilter, isNull); + }, + ); + + _clientWithPersistenceTest( + 'queryChannelsWithResult yields QueryChannelsResult with predefinedFilter populated for predefined query', + 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) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$i:test-id-$i'), + ), + ); + + const resolvedSort = [SortOption.desc('last_message_at')]; + const resolvedFilter = Filter.empty(); + const expectedPredefinedFilter = PredefinedFilter( + name: filterName, + filter: resolvedFilter, + sort: resolvedSort, + ); + + when( + () => persistence.queryChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) async => createDefaultQueryChannelsResponse()); + + tester.mockApi( + (api) => api.channel.queryChannels( + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + 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, + predefinedFilter: expectedPredefinedFilter, + ), + ); + + when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); + when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); + when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); + when( + () => persistence.saveChannelQueries( + cids: any(named: 'cids'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + resolvedFilter: any(named: 'resolvedFilter'), + resolvedSort: any(named: 'resolvedSort'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + clearQueryCache: any(named: 'clearQueryCache'), + ), + ).thenAnswer((_) => Future.value()); + + await Future.delayed(const Duration(milliseconds: 1100)); + clearInteractions(persistence); + + final results = await tester.client + .queryChannelsWithResult( + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + ) + .toList(); + + // Persistence returned empty, so only the online emission is yielded. + expect(results, hasLength(1)); + expect(results.single.channels, hasLength(channelStates.length)); + expect(results.single.predefinedFilter, isNotNull); + expect(results.single.predefinedFilter!.name, equals(filterName)); + expect(results.single.predefinedFilter!.sort, equals(resolvedSort)); + }, + ); + }); + + _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 + tester.client.wsConnectionStatusStream.skip(1), + emits(ConnectionStatus.disconnected), + ); + + await tester.client.disconnectUser(flushChatPersistence: true); + + expect(tester.clientState.currentUser, isNull); + expect(tester.connectionStatus, ConnectionStatus.disconnected); + }, + ); + }); +} From e4efeac98a2c5f2e6c8caf80ea0bd86c90168e03 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 09:08:54 +0200 Subject: [PATCH 063/103] test(llc): add migrated client connection, pin message and persistence connection tests Co-Authored-By: Claude Fable 5 --- .../test/src/client/client_api_test.dart | 567 ++++++++++++++++++ .../src/client/client_connection_test.dart | 320 ++++++++++ .../client_persistence_connection_test.dart | 310 ++++++++++ .../src/client/client_pin_message_test.dart | 153 +++++ 4 files changed, 1350 insertions(+) create mode 100644 packages/stream_chat/test/src/client/client_connection_test.dart create mode 100644 packages/stream_chat/test/src/client/client_persistence_connection_test.dart create mode 100644 packages/stream_chat/test/src/client/client_pin_message_test.dart diff --git a/packages/stream_chat/test/src/client/client_api_test.dart b/packages/stream_chat/test/src/client/client_api_test.dart index 701557b1ca..3feef0034c 100644 --- a/packages/stream_chat/test/src/client/client_api_test.dart +++ b/packages/stream_chat/test/src/client/client_api_test.dart @@ -887,4 +887,571 @@ void main() { expect(token.authType, AuthType.jwt); }, ); + + chatClientTest( + '`.markAllRead`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.markAllRead(), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.markAllRead(); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.channel.markAllRead()) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + 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', + ), + ]; + + tester.mockApi( + (api) => api.channel.markChannelsDelivered(deliveries), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.markChannelsDelivered(deliveries); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.channel.markChannelsDelivered(deliveries)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.sendEvent`', + body: (tester) async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + final event = Event(type: EventType.any); + + tester.mockApi( + (api) => api.channel.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(event)), + ), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.sendEvent(channelId, channelType, event); + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.channel.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(event)), + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.sendReaction`', + body: (tester) 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, + ); + + tester.mockApi( + (api) => api.message.sendReaction(messageId, reaction), + result: createDefaultSendReactionResponse( + message: Message(id: messageId), + reaction: reaction, + ), + ); + + 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); + }, + ); + + chatClientTest( + '`.deleteReaction`', + body: (tester) async { + const messageId = 'test-message-id'; + const reactionType = 'like'; + + tester.mockApi( + (api) => api.message.deleteReaction(messageId, reactionType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.deleteReaction(messageId, reactionType); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.message.deleteReaction(messageId, reactionType)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.sendMessage`', + body: (tester) async { + final message = Message(id: 'test-message-id'); + 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), + ); + + 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); + }, + ); + + chatClientTest( + '`.createDraft`', + body: (tester) 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.createDraft( + channelId, + channelType, + any(that: isSameDraftMessageAs(message)), + ), + result: createDefaultCreateDraftResponse( + draft: createDefaultDraft( + channelCid: '$channelType:$channelId', + message: message, + ), + ), + ); + + final res = await tester.client.createDraft( + message, + channelId, + channelType, + ); + + 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); + }, + ); + + chatClientTest( + '`.deleteDraft`', + body: (tester) async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + tester.mockApi( + (api) => api.message.deleteDraft(channelId, channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.deleteDraft(channelId, channelType); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.message.deleteDraft(channelId, channelType)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.getDraft`', + body: (tester) async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final message = DraftMessage(id: 'test-message-id', text: 'Hello!'); + + tester.mockApi( + (api) => api.message.getDraft(channelId, channelType), + result: createDefaultGetDraftResponse( + draft: createDefaultDraft( + channelCid: '$channelType:$channelId', + message: message, + ), + ), + ); + + final res = await tester.client.getDraft(channelId, channelType); + + expect(res, isNotNull); + expect(res.draft.message, isSameDraftMessageAs(message)); + + tester + ..verifyApi((api) => api.message.getDraft(channelId, channelType)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.queryDrafts`', + body: (tester) async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final filter = Filter.equal('channel_cid', '$channelType:$channelId'); + final sort = [const SortOption.desc('created_at')]; + const pagination = PaginationParams(limit: 20); + + final drafts = [ + createDefaultDraft( + channelCid: '$channelType:$channelId', + message: DraftMessage(id: 'test-message-id', text: 'Hello!'), + ), + ]; + + 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, + ); + + 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); + }, + ); + + chatClientTest( + '`.getReplies`', + body: (tester) async { + const parentId = 'test-parent-id'; + + final messages = List.generate( + 3, + (index) => Message(id: 'test-message-id-$index'), + ); + + tester.mockApi( + (api) => api.message.getReplies(parentId), + result: createDefaultQueryRepliesResponse(messages: messages), + ); + + final res = await tester.client.getReplies(parentId); + expect(res, isNotNull); + expect(res.messages.length, messages.length); + + tester + ..verifyApi((api) => api.message.getReplies(parentId)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.getReactions`', + body: (tester) async { + const messageId = 'test-parent-id'; + + 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), + ); + + 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); + + tester + ..verifyApi((api) => api.message.getReactions(messageId)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.queryReactions`', + body: (tester) async { + const messageId = 'test-message-id'; + + 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), + ); + + 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); + + tester + ..verifyApi((api) => api.message.queryReactions(messageId)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.updateMessage`', + body: (tester) async { + final message = Message(id: 'test-message-id', text: 'Hello!'); + + tester.mockApi( + (api) => api.message.updateMessage(any(that: isSameMessageAs(message))), + result: createDefaultUpdateMessageResponse(message: message), + ); + + final res = await tester.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); + }, + ); + + chatClientTest( + '`.deleteMessage`', + body: (tester) async { + const messageId = 'test-message-id'; + + tester.mockApi( + (api) => api.message.deleteMessage(messageId, hard: false), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.deleteMessage(messageId); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.message.deleteMessage(messageId, hard: false)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.deleteMessageForMe`', + body: (tester) async { + const messageId = 'test-message-id'; + + tester.mockApi( + (api) => api.message.deleteMessage(messageId, deleteForMe: true), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.deleteMessageForMe(messageId); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.message.deleteMessage(messageId, deleteForMe: true)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.getMessage`', + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + + tester.mockApi( + (api) => api.message.getMessage(messageId), + result: createDefaultGetMessageResponse(message: message), + ); + + final res = await tester.client.getMessage(messageId); + expect(res, isNotNull); + expect(res.message.id, messageId); + + tester + ..verifyApi((api) => api.message.getMessage(messageId)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.getMessagesById`', + body: (tester) 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(); + + tester.mockApi( + (api) => api.message.getMessagesById(channelId, channelType, messageIds), + result: createDefaultGetMessagesByIdResponse(messages: messages), + ); + + final res = await tester.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); + }, + ); + + chatClientTest( + '`.translateMessage`', + body: (tester) 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), + ); + + final res = await tester.client.translateMessage(messageId, language); + + expect(res, isNotNull); + expect(res.message.i18n, translatedMessage.i18n); + + tester + ..verifyApi((api) => api.message.translateMessage(messageId, language)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.partialUpdateMessage`', + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + final updateMessageResponse = createDefaultUpdateMessageResponse( + message: message.copyWith(text: set['text'], pinExpires: null), + ); + + tester.mockApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + ), + result: updateMessageResponse, + ); + + final res = await tester.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); + + tester + ..verifyApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); } diff --git a/packages/stream_chat/test/src/client/client_connection_test.dart b/packages/stream_chat/test/src/client/client_connection_test.dart new file mode 100644 index 0000000000..d4482ee9c9 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_connection_test.dart @@ -0,0 +1,320 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +// NOTE(migration): the monolith's `.connectAnonymousUser` tests are not +// migrated — the anonymous token's user id cannot pass the fake server's +// connect-URI validation without the TokenManager seam. They stay in +// client_test.dart until the seam lands. +void main() { + group('Fake web-socket connection functions', () { + chatClientTest( + '`.connectUser` should work fine', + connect: (tester) => tester.mockSuccessfulAuth(tester.user.id), + body: (tester) async { + final user = tester.user; + final token = createTestToken(user.id).rawValue; + + final statusEmitted = expectLater( + // skipping first seed status -> ConnectionStatus.disconnected + tester.client.wsConnectionStatusStream.skip(1), + emitsInOrder([ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + final res = await tester.client.connectUser(user, token); + expect(res, isNotNull); + expect(res, isSameUserAs(user)); + + await statusEmitted; + }, + ); + + chatClientTest( + '`.connectUserWithProvider` should work fine', + connect: (tester) => tester.mockSuccessfulAuth(tester.user.id), + body: (tester) async { + final user = tester.user; + Future tokenProvider(String userId) async { + expect(userId, user.id); + return createTestToken(userId).rawValue; + } + + final statusEmitted = expectLater( + // skipping first seed status -> ConnectionStatus.disconnected + tester.client.wsConnectionStatusStream.skip(1), + emitsInOrder([ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + final res = await tester.client.connectUserWithProvider(user, tokenProvider); + expect(res, isNotNull); + expect(res, isSameUserAs(user)); + + await statusEmitted; + }, + ); + + group('`.connectGuestUser`', () { + chatClientTest( + 'should work fine', + connect: (tester) => tester.mockSuccessfulAuth(tester.user.id), + 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), + ); + + final statusEmitted = expectLater( + // skipping first seed status -> ConnectionStatus.disconnected + tester.client.wsConnectionStatusStream.skip(1), + emitsInOrder([ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + final res = await tester.client.connectGuestUser(user); + expect(res, isNotNull); + expect(res, isSameUserAs(user)); + + tester.verifyApi( + (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), + ); + + 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(errorCode: ChatErrorCode.inputError), + ); + + 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; + }, + ); + }); + + group('`.openConnection`', () { + chatClientTest( + 'should throw if state does not contain user', + connect: (_) {}, + body: (tester) async { + expect(tester.currentUser, isNull); + await expectLater( + tester.client.openConnection(), + throwsA(isA()), + ); + }, + ); + + chatClientTest( + 'should throw if connection is already available', + connect: (tester) => tester.mockSuccessfulAuth(tester.user.id), + body: (tester) async { + expect(tester.currentUser, isNull); + + // NOTE(migration): the monolith establishes the connection through + // `.connectAnonymousUser`; the harness connects the regular user + // instead (see the anonymous carve-out note at the top). + final token = createTestToken(tester.user.id).rawValue; + await tester.client.connectUser(tester.user, token); + + await expectLater( + tester.client.openConnection(), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Connection already available for'), + ), + ), + ); + }, + ); + + chatClientTest( + 'should open connection for closed connection', + connect: (tester) => tester.mockSuccessfulAuth(tester.user.id), + 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, + ]), + ); + + // NOTE(migration): the monolith establishes the connection through + // `.connectAnonymousUser`; the harness connects the regular user + // instead (see the anonymous carve-out note at the top). + final token = createTestToken(tester.user.id).rawValue; + await tester.client.connectUser(tester.user, token); + + tester.client.closeConnection(); + + await tester.client.openConnection(); + + await statusEmitted; + }, + ); + }); + }); + + group('Fake web-socket connection functions failure', () { + 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; + + await expectLater( + tester.client.connectUser(user, token), + throwsA(isA()), + ); + }, + ); + + chatClientTest( + '`.connectUserWithProvider` should throw if `ws.connect` fails', + connect: (tester) => tester.mockConnectionError(), + body: (tester) async { + final user = tester.user; + Future tokenProvider(String userId) async { + expect(userId, user.id); + return createTestToken(userId).rawValue; + } + + await expectLater( + tester.client.connectUserWithProvider(user, tokenProvider), + throwsA(isA()), + ); + }, + ); + + 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; + + tester.mockApi( + (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), + result: createDefaultConnectGuestUserResponse(user: user, accessToken: token), + ); + + await expectLater( + tester.client.connectGuestUser(user), + throwsA(isA()), + ); + + tester.verifyApi( + (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), + ); + }, + ); + }); + + group('Connect user calls with `connectWebSocket`: false', () { + chatClientTest( + '`.connectUser` should succeed without connecting', + connect: (_) {}, + body: (tester) async { + final user = tester.user; + final token = createTestToken(user.id).rawValue; + + final res = await tester.client.connectUser( + user, + token, + connectWebSocket: false, + ); + expect(res, isSameUserAs(user)); + expect(tester.connectionStatus, ConnectionStatus.disconnected); + }, + ); + + chatClientTest( + '`.connectUserWithProvider` should succeed without connecting', + connect: (_) {}, + body: (tester) async { + final user = tester.user; + Future tokenProvider(String userId) async { + expect(userId, user.id); + return createTestToken(userId).rawValue; + } + + final res = await tester.client.connectUserWithProvider( + user, + tokenProvider, + connectWebSocket: false, + ); + expect(res, isSameUserAs(user)); + expect(tester.connectionStatus, ConnectionStatus.disconnected); + }, + ); + + chatClientTest( + '`.connectGuestUser` should succeed without connecting', + connect: (_) {}, + 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), + ); + + final res = await tester.client.connectGuestUser( + user, + connectWebSocket: false, + ); + + expect(res, isSameUserAs(user)); + expect(tester.connectionStatus, ConnectionStatus.disconnected); + tester.verifyApi( + (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), + ); + }, + ); + }); +} diff --git a/packages/stream_chat/test/src/client/client_persistence_connection_test.dart b/packages/stream_chat/test/src/client/client_persistence_connection_test.dart new file mode 100644 index 0000000000..bc99ba9386 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_persistence_connection_test.dart @@ -0,0 +1,310 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +// Runs [body] as a [chatClientTest] whose client is backed by a fresh +// [MockPersistenceClient] and whose WebSocket transport fails to connect, +// mirroring the monolith group's setUp: 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), + ); +} + +// Runs [body] as a [chatClientTest] that never opens the socket, mirroring +// the monolith group's setUp/tearDown: persistence starts disabled and the +// persistence client is detached (and asserted disabled) 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); + }, + ); +} + +void main() { + group('Fake web-socket connection function with failure and persistence', () { + _failedConnectionWithPersistenceTest( + '`.connectUser` should connect successfully if persistence contains event', + body: (tester, persistence) async { + final user = tester.user; + + final event = Event( + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ); + when(persistence.getConnectionInfo).thenAnswer((_) async => event); + + final res = await tester.client.connectUser(user, createTestToken(user.id).rawValue); + expect(res, isNotNull); + expect(res, isSameUserAs(user)); + + verify(persistence.getConnectionInfo).called(1); + verifyNoMoreInteractions(persistence); + }, + ); + + _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 createTestToken(userId).rawValue; + } + + final event = Event( + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ); + when(persistence.getConnectionInfo).thenAnswer((_) async => event); + + final res = await tester.client.connectUserWithProvider(user, tokenProvider); + expect(res, isNotNull); + expect(res, isSameUserAs(user)); + + verify(persistence.getConnectionInfo).called(1); + verifyNoMoreInteractions(persistence); + }, + ); + + _failedConnectionWithPersistenceTest( + '`.connectGuestUser` should connect successfully if persistence contains event', + body: (tester, persistence) async { + final user = tester.user; + + final event = Event( + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ); + when(persistence.getConnectionInfo).thenAnswer((_) async => event); + + tester.mockApi( + (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), + result: createDefaultConnectGuestUserResponse(user: user), + ); + + final res = await tester.client.connectGuestUser(user); + expect(res, isNotNull); + expect(res, isSameUserAs(user)); + + verify(persistence.getConnectionInfo).called(1); + verifyNoMoreInteractions(persistence); + tester + ..verifyApi((api) => api.guest.getGuestUser(any(that: isSameUserAs(user)))) + ..verifyNoMoreApiInteractions((api) => api.guest); + }, + ); + }); + + group('PersistenceConnectionTests', () { + _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); + }, + ); + + _persistenceConnectionTest( + '''multiple call to openPersistenceConnection does not throws an error if already connected to the same user''', + 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); + }, + ); + + _persistenceConnectionTest( + '''openPersistenceConnection throws an error if client is already connected to a different user''', + 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.copyWith(id: 'new-id')), + throwsA(const TypeMatcher()), + ); + }, + ); + + _persistenceConnectionTest( + '''openPersistenceConnection throws an error if chatPersistenceClient is not set''', + body: (tester) async { + await expectLater( + tester.client.openPersistenceConnection(tester.user), + throwsA(const TypeMatcher()), + ); + }, + ); + + _persistenceConnectionTest( + 'closePersistenceConnection disconnects the client', + body: (tester) async { + tester.client.chatPersistenceClient = MockPersistenceClient(); + await tester.client.openPersistenceConnection(tester.user); + expect(tester.client.persistenceEnabled, isTrue); + + await tester.client.closePersistenceConnection(); + expect(tester.client.persistenceEnabled, isFalse); + }, + ); + + _persistenceConnectionTest( + '''closePersistenceConnection compeletes normally if chatPersistenceClient is not connected''', + body: (tester) async { + tester.client.chatPersistenceClient = MockPersistenceClient(); + expect(tester.client.chatPersistenceClient!.isConnected, isFalse); + + await expectLater(tester.client.closePersistenceConnection(), completes); + }, + ); + + _persistenceConnectionTest( + '''closePersistenceConnection completes normally if chatPersistenceClient is not set''', + body: (tester) async { + expect(tester.client.persistenceEnabled, isFalse); + await expectLater(tester.client.closePersistenceConnection(), completes); + }, + ); + + _persistenceConnectionTest( + '''connectUser completes normally if the persistence connection is already connected to the same user''', + body: (tester) async { + tester.client.chatPersistenceClient = MockPersistenceClient(); + await tester.client.openPersistenceConnection(tester.user); + expect(tester.client.persistenceEnabled, isTrue); + + await expectLater( + tester.client.connectUser( + tester.user, + createTestToken(tester.user.id).rawValue, + connectWebSocket: false, + ), + completes, + ); + }, + ); + + _persistenceConnectionTest( + '''connectUser should throw if the persistence connection if already connected to a different user''', + body: (tester) async { + tester.client.chatPersistenceClient = MockPersistenceClient(); + await tester.client.openPersistenceConnection(tester.user.copyWith(id: 'new-id')); + expect(tester.client.persistenceEnabled, isTrue); + + await expectLater( + tester.client.connectUser( + tester.user, + createTestToken(tester.user.id).rawValue, + connectWebSocket: false, + ), + throwsA(const TypeMatcher()), + ); + }, + ); + + group('Sync Method Tests', () { + setUpAll(() { + // fallback values + registerFallbackValue([]); + registerFallbackValue(DateTime(0)); + }); + + _persistenceConnectionTest( + 'should retrieve data from persistence client and sync successfully', + body: (tester) async { + final cids = ['channel1', 'channel2']; + final lastSyncAt = DateTime.utc(2021, 3); + final fakeClient = FakePersistenceClient( + channelCids: cids, + lastSyncAt: lastSyncAt, + ); + + tester.client.chatPersistenceClient = fakeClient; + tester.mockApi( + (api) => api.general.sync(cids, lastSyncAt), + result: createDefaultSyncResponse(), + ); + + await tester.client.sync(); + + tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); + + final newLastSyncAt = await fakeClient.getLastSyncAt(); + expect(newLastSyncAt?.isAfter(lastSyncAt), isTrue); + }, + ); + + _persistenceConnectionTest( + 'should set lastSyncAt on first sync when null', + body: (tester) async { + final fakeClient = FakePersistenceClient( + channelCids: ['channel1'], + lastSyncAt: null, + ); + + tester.client.chatPersistenceClient = fakeClient; + + await tester.client.sync(); + + expectLater(fakeClient.getLastSyncAt(), completion(isNotNull)); + tester.verifyNeverCalled((api) => api.general.sync(any(), any())); + }, + ); + + _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, + ); + + tester.client.chatPersistenceClient = fakeClient; + tester.mockApiFailure( + (api) => api.general.sync(cids, lastSyncAt), + error: StreamChatNetworkError.raw( + code: 4, + statusCode: 400, + message: 'Too many events', + ), + ); + + await tester.client.sync(); + + expect(await fakeClient.getChannelCids(), isEmpty); // Should be flushed + + tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); + }, + ); + }); + }); +} diff --git a/packages/stream_chat/test/src/client/client_pin_message_test.dart b/packages/stream_chat/test/src/client/client_pin_message_test.dart new file mode 100644 index 0000000000..2ffdeda300 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_pin_message_test.dart @@ -0,0 +1,153 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +void main() { + group('`.pinMessage`', () { + 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 tester.client.pinMessage(messageId); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNull); + + tester + ..verifyApi( + (api) => api.message.partialUpdateMessage( + messageId, + set: {'pinned': true, 'pin_expires': null}, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + 'should work fine if passed timeoutOrExpirationDate as num(seconds)', + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + const timeoutOrExpirationDate = 300; // 300 seconds + + // 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'), + ), + result: createDefaultUpdateMessageResponse( + message: message.copyWith( + pinned: true, + pinExpires: DateTime.utc(2021, 3).add( + const Duration(seconds: timeoutOrExpirationDate), + ), + state: MessageState.sent, + ), + ), + ); + + final res = await tester.client.pinMessage( + messageId, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNotNull); + + tester + ..verifyApi( + (api) => api.message.partialUpdateMessage( + messageId, + set: any(named: 'set'), + ), + ) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + 'should work fine if passed timeoutOrExpirationDate as DateTime', + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + final timeoutOrExpirationDate = DateTime.utc(2021, 3).add(const Duration(days: 3)); // 3 days + + tester.mockApi( + (api) => api.message.partialUpdateMessage( + messageId, + set: { + 'pinned': true, + 'pin_expires': timeoutOrExpirationDate.toUtc().toIso8601String(), + }, + ), + result: createDefaultUpdateMessageResponse( + message: message.copyWith( + pinned: true, + pinExpires: timeoutOrExpirationDate, + state: MessageState.sent, + ), + ), + ); + + final res = await tester.client.pinMessage( + messageId, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNotNull); + expect(res.message.pinExpires, timeoutOrExpirationDate.toUtc()); + + tester + ..verifyApi( + (api) => api.message.partialUpdateMessage( + messageId, + set: { + 'pinned': true, + 'pin_expires': timeoutOrExpirationDate.toUtc().toIso8601String(), + }, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + 'should throw if invalid timeoutOrExpirationDate is passed', + body: (tester) async { + const messageId = 'test-message-id'; + const timeoutOrExpirationDate = 'invalid-value'; + + try { + await tester.client.pinMessage( + messageId, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + } catch (e) { + expect(e, isA()); + } + }, + ); + }); +} From 15c4450d1d0b1afe822285d3137e608fb638857c Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 09:16:32 +0200 Subject: [PATCH 064/103] test(llc): carve out anonymous persistence connect test into client_reconnection_test Co-Authored-By: Claude Opus 5 --- .../src/client/client_reconnection_test.dart | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/stream_chat/test/src/client/client_reconnection_test.dart b/packages/stream_chat/test/src/client/client_reconnection_test.dart index cc5e4ee79f..225e61aea1 100644 --- a/packages/stream_chat/test/src/client/client_reconnection_test.dart +++ b/packages/stream_chat/test/src/client/client_reconnection_test.dart @@ -115,6 +115,49 @@ void main() { ); }); + group('Fake web-socket connection function with failure and persistence', () { + const apiKey = 'test-api-key'; + late final api = FakeChatApi(); + late final persistence = MockPersistenceClient(); + + late StreamChatClient client; + + setUpAll(() { + // fallback values + registerFallbackValue(_FakeUser()); + }); + + setUp(() { + final ws = _FakeWebSocketWithConnectionError(); + client = StreamChatClient(apiKey, chatApi: api, ws: ws)..chatPersistenceClient = persistence; + }); + + tearDown(() { + client.dispose(); + }); + + test( + '''`.connectAnonymousUser` should connect successfully if persistence contains event''', + () async { + final user = User(id: 'test-user-id'); + + when(persistence.getConnectionInfo).thenAnswer( + (invocation) async => Event( + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ), + ); + + final res = await client.connectAnonymousUser(); + expect(res, isNotNull); + + verify(persistence.getConnectionInfo).called(1); + verifyNoMoreInteractions(persistence); + }, + ); + }); + group('recoverStateOnReconnect', () { const apiKey = 'test-api-key'; final user = User(id: 'test-user-id'); From 31271bcd43ff3e2ccb3dc50d32814567eb18a9f3 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 09:40:50 +0200 Subject: [PATCH 065/103] test(llc): remove migrated persistence connection tests from client_test Co-Authored-By: Claude Opus 5 --- .../test/src/client/client_test.dart | 305 ------------------ 1 file changed, 305 deletions(-) diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 81b00c13f4..1a1a5147af 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -9,7 +9,6 @@ import 'package:test/test.dart'; import '../fakes.dart'; import '../matchers.dart'; -import '../mocks.dart'; import '../utils.dart'; void main() { @@ -363,126 +362,6 @@ void main() { ); }); - group('Fake web-socket connection function with failure and persistence', () { - const apiKey = 'test-api-key'; - late final api = FakeChatApi(); - late final persistence = MockPersistenceClient(); - - late StreamChatClient client; - - setUpAll(() { - // fallback values - registerFallbackValue(FakeUser()); - }); - - setUp(() { - final ws = FakeWebSocketWithConnectionError(); - client = StreamChatClient(apiKey, chatApi: api, ws: ws)..chatPersistenceClient = persistence; - }); - - tearDown(() { - client.dispose(); - }); - - test( - '''`.connectUser` should connect successfully if persistence contains event''', - () async { - final user = User(id: 'test-user-id'); - final token = Token.development(user.id).rawValue; - - final event = Event( - type: EventType.healthCheck, - connectionId: 'test-connection-id', - me: OwnUser.fromUser(user), - ); - when(persistence.getConnectionInfo).thenAnswer((_) async => event); - - final res = await client.connectUser(user, token); - expect(res, isNotNull); - expect(res, isSameUserAs(user)); - - verify(persistence.getConnectionInfo).called(1); - verifyNoMoreInteractions(persistence); - }, - ); - - test( - '''`.connectUserWithProvider` should connect successfully if persistence contains event''', - () async { - final user = User(id: 'test-user-id'); - Future tokenProvider(String userId) async { - expect(userId, user.id); - return Token.development(userId).rawValue; - } - - final event = Event( - type: EventType.healthCheck, - connectionId: 'test-connection-id', - me: OwnUser.fromUser(user), - ); - when(persistence.getConnectionInfo).thenAnswer((_) async => event); - - final res = await client.connectUserWithProvider(user, tokenProvider); - expect(res, isNotNull); - expect(res, isSameUserAs(user)); - - verify(persistence.getConnectionInfo).called(1); - verifyNoMoreInteractions(persistence); - }, - ); - - test( - '''`.connectGuestUser` should connect successfully if persistence contains event''', - () async { - final user = User(id: 'test-user-id'); - final token = Token.development(user.id).rawValue; - - final event = Event( - type: EventType.healthCheck, - connectionId: 'test-connection-id', - me: OwnUser.fromUser(user), - ); - when(persistence.getConnectionInfo).thenAnswer((_) async => event); - - when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))).thenAnswer( - (_) async => ConnectGuestUserResponse() - ..user = user - ..accessToken = token, - ); - - final res = await 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); - }, - ); - - test( - '''`.connectAnonymousUser` should connect successfully if persistence contains event''', - () async { - final user = User(id: 'test-user-id'); - - when(persistence.getConnectionInfo).thenAnswer( - (invocation) async => Event( - type: EventType.healthCheck, - connectionId: 'test-connection-id', - me: OwnUser.fromUser(user), - ), - ); - - final res = await client.connectAnonymousUser(); - expect(res, isNotNull); - - verify(persistence.getConnectionInfo).called(1); - verifyNoMoreInteractions(persistence); - }, - ); - }); - group('Client with connected user without persistence', () { const apiKey = 'test-api-key'; const userId = 'test-user-id'; @@ -1203,188 +1082,4 @@ void main() { }, ); }); - - group('PersistenceConnectionTests', () { - const apiKey = 'test-api-key'; - late final api = FakeChatApi(); - - final user = User(id: 'test-user-id'); - final token = Token.development(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(); - }); - - test('openPersistenceConnection connects the client to the user', () async { - client.chatPersistenceClient = MockPersistenceClient(); - await client.openPersistenceConnection(user); - expect(client.persistenceEnabled, isTrue); - }); - - test( - '''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); - }, - ); - - test( - '''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); - - await expectLater( - client.openPersistenceConnection(user.copyWith(id: 'new-id')), - throwsA(const TypeMatcher()), - ); - }, - ); - - test( - '''openPersistenceConnection throws an error if chatPersistenceClient is not set''', - () async { - await expectLater( - client.openPersistenceConnection(user), - throwsA(const TypeMatcher()), - ); - }, - ); - - test('closePersistenceConnection disconnects the client', () async { - client.chatPersistenceClient = MockPersistenceClient(); - await client.openPersistenceConnection(user); - expect(client.persistenceEnabled, isTrue); - - await client.closePersistenceConnection(); - expect(client.persistenceEnabled, isFalse); - }); - - test( - '''closePersistenceConnection compeletes normally if chatPersistenceClient is not connected''', - () async { - client.chatPersistenceClient = MockPersistenceClient(); - expect(client.chatPersistenceClient!.isConnected, isFalse); - - await expectLater(client.closePersistenceConnection(), completes); - }, - ); - - test( - '''closePersistenceConnection completes normally if chatPersistenceClient is not set''', - () async { - expect(client.persistenceEnabled, isFalse); - await expectLater(client.closePersistenceConnection(), completes); - }, - ); - - test( - '''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); - - await expectLater( - client.connectUser(user, token, connectWebSocket: false), - completes, - ); - }, - ); - - test( - '''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); - - await expectLater( - client.connectUser(user, token, connectWebSocket: false), - throwsA(const TypeMatcher()), - ); - }, - ); - - group('Sync Method Tests', () { - test( - 'should retrieve data from persistence client and sync successfully', - () async { - final cids = ['channel1', 'channel2']; - final lastSyncAt = DateTime.now().subtract(const Duration(hours: 1)); - final fakeClient = FakePersistenceClient( - channelCids: cids, - lastSyncAt: lastSyncAt, - ); - - client.chatPersistenceClient = fakeClient; - when(() => api.general.sync(cids, lastSyncAt)).thenAnswer( - (_) async => SyncResponse()..events = [], - ); - - await client.sync(); - - verify(() => api.general.sync(cids, lastSyncAt)).called(1); - - 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, - ); - - client.chatPersistenceClient = fakeClient; - - await client.sync(); - - expectLater(fakeClient.getLastSyncAt(), completion(isNotNull)); - verifyNever(() => 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; - when(() => api.general.sync(cids, lastSyncAt)).thenThrow( - StreamChatNetworkError.raw( - code: 4, - statusCode: 400, - message: 'Too many events', - ), - ); - - await client.sync(); - - expect(await fakeClient.getChannelCids(), isEmpty); // Should be flushed - - verify(() => api.general.sync(cids, lastSyncAt)).called(1); - }); - }); - }); } From 48a99e432a417759df81c598852efd3862ee0742 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 09:48:17 +0200 Subject: [PATCH 066/103] test(llc): migrate client unpinMessage, enrichUrl and currentUser unread count tests Co-Authored-By: Claude Opus 5 --- .../test/src/client/client_api_test.dart | 32 +++++++++++++++++ .../src/client/client_pin_message_test.dart | 35 +++++++++++++++++++ .../src/client/client_state_guards_test.dart | 23 ++++++++++++ 3 files changed, 90 insertions(+) diff --git a/packages/stream_chat/test/src/client/client_api_test.dart b/packages/stream_chat/test/src/client/client_api_test.dart index 3feef0034c..d410a3d3b9 100644 --- a/packages/stream_chat/test/src/client/client_api_test.dart +++ b/packages/stream_chat/test/src/client/client_api_test.dart @@ -1454,4 +1454,36 @@ void main() { ..verifyNoMoreApiInteractions((api) => api.message); }, ); + + 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 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', + ); + + tester + ..verifyApi((api) => api.general.enrichUrl(url)) + ..verifyApi((api) => api.general.getAppSettings()) + ..verifyNoMoreApiInteractions((api) => api.general); + }, + ); } diff --git a/packages/stream_chat/test/src/client/client_pin_message_test.dart b/packages/stream_chat/test/src/client/client_pin_message_test.dart index 2ffdeda300..310f9c4c6d 100644 --- a/packages/stream_chat/test/src/client/client_pin_message_test.dart +++ b/packages/stream_chat/test/src/client/client_pin_message_test.dart @@ -150,4 +150,39 @@ void main() { }, ); }); + + chatClientTest( + '`.unpinMessage`', + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message(id: messageId, pinned: true); + + tester.mockApi( + (api) => api.message.partialUpdateMessage( + messageId, + set: {'pinned': false}, + ), + result: createDefaultUpdateMessageResponse( + message: message.copyWith( + pinned: false, + state: MessageState.sent, + ), + ), + ); + + final res = await tester.client.unpinMessage(messageId); + + expect(res, isNotNull); + expect(res.message.pinned, isFalse); + + tester + ..verifyApi( + (api) => api.message.partialUpdateMessage( + messageId, + set: {'pinned': false}, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); } diff --git a/packages/stream_chat/test/src/client/client_state_guards_test.dart b/packages/stream_chat/test/src/client/client_state_guards_test.dart index 55109d5103..df8a1671e9 100644 --- a/packages/stream_chat/test/src/client/client_state_guards_test.dart +++ b/packages/stream_chat/test/src/client/client_state_guards_test.dart @@ -140,4 +140,27 @@ void main() { }, ); }); + + // 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', + body: (tester) async { + final state = tester.clientState; + final initialUser = state.currentUser!; + + expect(state.totalUnreadCount, 0); + expect(state.unreadChannels, 0); + + final updateUser = initialUser.copyWith( + totalUnreadCount: 33, + unreadChannels: 33, + ); + state.currentUser = updateUser; + + expect(state.currentUser, updateUser); + expect(state.totalUnreadCount, 33); + expect(state.unreadChannels, 33); + }, + ); } From 43b3381f6b928cb7e3967279242920397f077220 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 09:53:52 +0200 Subject: [PATCH 067/103] test(llc): remove migrated pin message and trailing tests from client_test Co-Authored-By: Claude Opus 5 --- .../test/src/client/client_test.dart | 221 ------------------ 1 file changed, 221 deletions(-) diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 1a1a5147af..a12c606850 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -860,226 +860,5 @@ void main() { ).called(1); verifyNoMoreInteractions(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'), - ), - ).thenAnswer( - (_) async => UpdateMessageResponse() - ..message = message.copyWith( - pinned: true, - pinExpires: null, - state: MessageState.sent, - ), - ); - - final res = await client.pinMessage(messageId); - - 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); - }); - - test( - 'should work fine if passed timeoutOrExpirationDate as num(seconds)', - () async { - const messageId = 'test-message-id'; - final message = Message(id: messageId); - const timeoutOrExpirationDate = 300; // 300 seconds - - when( - () => api.message.partialUpdateMessage( - message.id, - set: any(named: 'set'), - unset: any(named: 'unset'), - ), - ).thenAnswer( - (_) async => UpdateMessageResponse() - ..message = message.copyWith( - pinned: true, - pinExpires: DateTime.now().add( - const Duration(seconds: timeoutOrExpirationDate), - ), - state: MessageState.sent, - ), - ); - - final res = await client.pinMessage( - messageId, - timeoutOrExpirationDate: timeoutOrExpirationDate, - ); - - expect(res, isNotNull); - 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); - }, - ); - - test( - 'should work fine if passed timeoutOrExpirationDate as DateTime', - () async { - const messageId = 'test-message-id'; - final message = Message(id: messageId); - final timeoutOrExpirationDate = DateTime.now().add(const Duration(days: 3)); // 3 days - - when( - () => api.message.partialUpdateMessage( - messageId, - set: any(named: 'set'), - unset: any(named: 'unset'), - ), - ).thenAnswer( - (_) async => UpdateMessageResponse() - ..message = message.copyWith( - pinned: true, - pinExpires: timeoutOrExpirationDate, - state: MessageState.sent, - ), - ); - - final res = await client.pinMessage( - messageId, - timeoutOrExpirationDate: timeoutOrExpirationDate, - ); - - expect(res, isNotNull); - expect(res.message.pinned, isTrue); - 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); - }, - ); - - test( - 'should throw if invalid timeoutOrExpirationDate is passed', - () async { - const messageId = 'test-message-id'; - const timeoutOrExpirationDate = 'invalid-value'; - - try { - await client.pinMessage( - messageId, - timeoutOrExpirationDate: timeoutOrExpirationDate, - ); - } catch (e) { - expect(e, isA()); - } - }, - ); - }); - - test('`.unpinMessage`', () 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, - ), - ); - - final res = await client.unpinMessage(messageId); - - expect(res, isNotNull); - expect(res.message.pinned, isFalse); - - verify( - () => api.message.partialUpdateMessage( - messageId, - set: {'pinned': false}, - ), - ).called(1); - verifyNoMoreInteractions(api.message); - }); - - test('`.enrichUrl`', () async { - const url = 'https://www.techyourchance.com/finite-state-machine-with-unit-tests-real-world-example'; - - when(() => api.general.enrichUrl(url)).thenAnswer( - (_) async => OGAttachmentResponse() - ..type = 'image' - ..ogScrapeUrl = url - ..authorName = 'TechYourChance' - ..title = 'Finite State Machine with Unit Tests: Real World Example', - ); - - final res = await 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', - ); - - verify(() => api.general.enrichUrl(url)).called(1); - verify(() => api.general.getAppSettings()).called(1); - verifyNoMoreInteractions(api.general); - }); - - test( - '''setting the `currentUser` should also compute and update the unreadCounts''', - () { - final state = client.state; - final initialUser = OwnUser.fromUser(user); - - expect(state.currentUser, initialUser); - expect(state.totalUnreadCount, 0); - expect(state.unreadChannels, 0); - - final updateUser = initialUser.copyWith( - totalUnreadCount: 33, - unreadChannels: 33, - ); - state.currentUser = updateUser; - - expect(state.currentUser, updateUser); - expect(state.totalUnreadCount, 33); - expect(state.unreadChannels, 33); - }, - ); }); } From ff2604f62f2a8991e4a563156886c6c26cc60ca8 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 09:54:50 +0200 Subject: [PATCH 068/103] test(llc): remove migrated client API delegation tests from client_test Co-Authored-By: Claude Opus 5 --- .../test/src/client/client_test.dart | 459 ------------------ 1 file changed, 459 deletions(-) diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index a12c606850..0a15fca3b6 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -401,464 +401,5 @@ void main() { tearDown(() async { await client.dispose(); }); - - test('`.markAllRead`', () async { - when(() => api.channel.markAllRead()).thenAnswer((_) async => EmptyResponse()); - - final res = await client.markAllRead(); - expect(res, isNotNull); - - verify(() => api.channel.markAllRead()).called(1); - verifyNoMoreInteractions(api.channel); - }); - - 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', - ), - ]; - - when(() => api.channel.markChannelsDelivered(deliveries)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.markChannelsDelivered(deliveries); - expect(res, isNotNull); - - verify(() => api.channel.markChannelsDelivered(deliveries)).called(1); - verifyNoMoreInteractions(api.channel); - }); - - test('`.sendEvent`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - final event = Event(type: EventType.any); - - when( - () => api.channel.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(event)), - ), - ).thenAnswer((_) async => EmptyResponse()); - - final res = await client.sendEvent(channelId, channelType, event); - expect(res, isNotNull); - - verify( - () => api.channel.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(event)), - ), - ).called(1); - verifyNoMoreInteractions(api.channel); - }); - - 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, - ); - - when(() => api.message.sendReaction(messageId, reaction)).thenAnswer( - (_) async => SendReactionResponse() - ..message = Message(id: messageId) - ..reaction = reaction, - ); - - 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); - }); - - test('`.deleteReaction`', () async { - const messageId = 'test-message-id'; - const reactionType = 'like'; - - when(() => api.message.deleteReaction(messageId, reactionType)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.deleteReaction(messageId, reactionType); - expect(res, isNotNull); - - verify( - () => api.message.deleteReaction(messageId, reactionType), - ).called(1); - verifyNoMoreInteractions(api.message); - }); - - test('`.sendMessage`', () async { - final message = Message(id: 'test-message-id'); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - when( - () => api.message.sendMessage(channelId, channelType, any(that: isSameMessageAs(message))), - ).thenAnswer((_) async => SendMessageResponse()..message = message); - - final res = await client.sendMessage(message, channelId, channelType); - expect(res, isNotNull); - expect(res.message, isSameMessageAs(message)); - - verify( - () => api.message.sendMessage( - channelId, - channelType, - any(that: isSameMessageAs(message)), - ), - ).called(1); - verifyNoMoreInteractions(api.message); - }); - - test('`.createDraft`', () async { - final message = DraftMessage(id: 'test-message-id', text: 'Hello!'); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - 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 client.createDraft( - message, - channelId, - channelType, - ); - - expect(res, isNotNull); - expect(res.draft.message, isSameDraftMessageAs(message)); - - verify( - () => api.message.createDraft( - channelId, - channelType, - any(that: isSameDraftMessageAs(message)), - ), - ).called(1); - - verifyNoMoreInteractions(api.message); - }); - - test('`.deleteDraft`', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - when(() => api.message.deleteDraft(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.deleteDraft(channelId, channelType); - expect(res, isNotNull); - - verify(() => api.message.deleteDraft(channelId, channelType)); - verifyNoMoreInteractions(api.message); - }); - - test('`.getDraft`', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - 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, - ), - ); - - final res = await client.getDraft(channelId, channelType); - - expect(res, isNotNull); - expect(res.draft.message, isSameDraftMessageAs(message)); - - verify(() => api.message.getDraft(channelId, channelType)); - verifyNoMoreInteractions(api.message); - }); - - test('`.queryDrafts`', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - final filter = Filter.equal('channel_cid', '$channelType:$channelId'); - final sort = [const SortOption.desc('created_at')]; - const pagination = PaginationParams(limit: 20); - - final drafts = [ - Draft( - channelCid: '$channelType:$channelId', - createdAt: DateTime.now(), - message: DraftMessage(id: 'test-message-id', text: 'Hello!'), - ), - ]; - - when( - () => api.message.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); - - verify( - () => api.message.queryDrafts( - filter: filter, - sort: sort, - pagination: pagination, - ), - ).called(1); - verifyNoMoreInteractions(api.message); - }); - - test('`.getReplies`', () async { - const parentId = 'test-parent-id'; - - final messages = List.generate( - 3, - (index) => Message(id: 'test-message-id-$index'), - ); - - when(() => api.message.getReplies(parentId)).thenAnswer((_) async => QueryRepliesResponse()..messages = messages); - - final res = await client.getReplies(parentId); - expect(res, isNotNull); - expect(res.messages.length, messages.length); - - verify(() => api.message.getReplies(parentId)).called(1); - verifyNoMoreInteractions(api.message); - }); - - test('`.getReactions`', () async { - const messageId = 'test-parent-id'; - - final reactions = List.generate( - 3, - (index) => Reaction( - type: 'test-reactions-type-$index', - messageId: messageId, - ), - ); - - when( - () => api.message.getReactions(messageId), - ).thenAnswer((_) async => QueryReactionsResponse()..reactions = reactions); - - final res = await client.getReactions(messageId); - expect(res, isNotNull); - expect(res.reactions.length, reactions.length); - expect(res.reactions.every((it) => it.messageId == messageId), isTrue); - - verify(() => api.message.getReactions(messageId)).called(1); - verifyNoMoreInteractions(api.message); - }); - - test('`.queryReactions`', () async { - const messageId = 'test-message-id'; - - final reactions = List.generate( - 3, - (index) => Reaction( - type: 'test-reactions-type-$index', - messageId: messageId, - ), - ); - - when( - () => api.message.queryReactions(messageId), - ).thenAnswer( - (_) async => QueryReactionsResponse() - ..reactions = reactions - ..next = null, - ); - - final res = await client.queryReactions(messageId); - expect(res, isNotNull); - expect(res.reactions.length, reactions.length); - expect(res.reactions.every((it) => it.messageId == messageId), isTrue); - - verify(() => api.message.queryReactions(messageId)).called(1); - verifyNoMoreInteractions(api.message); - }); - - test('`.updateMessage`', () async { - final message = Message(id: 'test-message-id', text: 'Hello!'); - - when( - () => api.message.updateMessage(any(that: isSameMessageAs(message))), - ).thenAnswer((_) async => UpdateMessageResponse()..message = message); - - final res = await client.updateMessage(message); - expect(res, isNotNull); - expect(res.message, isSameMessageAs(message)); - - verify( - () => api.message.updateMessage(any(that: isSameMessageAs(message))), - ).called(1); - verifyNoMoreInteractions(api.message); - }); - - test('`.deleteMessage`', () async { - const messageId = 'test-message-id'; - - when(() => api.message.deleteMessage(messageId, hard: false)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.deleteMessage(messageId); - expect(res, isNotNull); - - verify(() => api.message.deleteMessage(messageId, hard: false)).called(1); - verifyNoMoreInteractions(api.message); - }); - - test('`.deleteMessageForMe`', () async { - const messageId = 'test-message-id'; - - when(() => api.message.deleteMessage(messageId, deleteForMe: true)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.deleteMessageForMe(messageId); - expect(res, isNotNull); - - verify(() => api.message.deleteMessage(messageId, deleteForMe: true)).called(1); - verifyNoMoreInteractions(api.message); - }); - - test('`.getMessage`', () async { - const messageId = 'test-message-id'; - final message = Message(id: messageId); - - when(() => api.message.getMessage(messageId)).thenAnswer((_) async => GetMessageResponse()..message = message); - - final res = await client.getMessage(messageId); - expect(res, isNotNull); - expect(res.message.id, messageId); - - verify(() => api.message.getMessage(messageId)).called(1); - verifyNoMoreInteractions(api.message); - }); - - 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(); - - when( - () => api.message.getMessagesById(channelId, channelType, messageIds), - ).thenAnswer((_) async => GetMessagesByIdResponse()..messages = messages); - - final res = await client.getMessagesById( - channelId, - channelType, - messageIds, - ); - expect(res, isNotNull); - expect(res.messages.length, messageIds.length); - - verify( - () => api.message.getMessagesById(channelId, channelType, messageIds), - ).called(1); - verifyNoMoreInteractions(api.message); - }); - - test('`.translateMessage`', () async { - const messageId = 'test-message-id'; - const language = 'hi'; // Hindi - const translatedMessageText = 'नमस्ते'; - final translatedMessage = Message( - i18n: const { - language: translatedMessageText, - }, - ); - - when(() => api.message.translateMessage(messageId, language)).thenAnswer( - (_) async => TranslateMessageResponse()..message = translatedMessage, - ); - - final res = await client.translateMessage(messageId, language); - - expect(res, isNotNull); - expect(res.message.i18n, translatedMessage.i18n); - - verify(() => api.message.translateMessage(messageId, language)).called(1); - verifyNoMoreInteractions(api.message); - }); - - test('`.partialUpdateMessage`', () async { - const messageId = 'test-message-id'; - final message = Message(id: messageId); - - const set = {'text': 'Update Message text'}; - const unset = ['pinExpires']; - - final updateMessageResponse = UpdateMessageResponse() - ..message = message.copyWith(text: set['text'], pinExpires: null); - - when( - () => api.message.partialUpdateMessage( - message.id, - 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); - - verify( - () => api.message.partialUpdateMessage( - message.id, - set: set, - unset: unset, - ), - ).called(1); - verifyNoMoreInteractions(api.message); - }); }); } From 6474b74c00ec9c18456a049a4fe9e2f6d5ff3cac Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 09:56:53 +0200 Subject: [PATCH 069/103] test(llc): remove migrated connection tests from client_test Co-Authored-By: Claude Opus 5 --- .../test/src/client/client_test.dart | 353 ------------------ 1 file changed, 353 deletions(-) diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 0a15fca3b6..eb9207e21f 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -1,367 +1,14 @@ // ignore_for_file: avoid_redundant_argument_values, lines_longer_than_80_chars, deprecated_member_use_from_same_package -import 'dart:async'; - import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/src/core/http/token.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; import '../fakes.dart'; -import '../matchers.dart'; import '../utils.dart'; void main() { - group('Fake web-socket connection functions', () { - const apiKey = 'test-api-key'; - late final api = FakeChatApi(); - - late StreamChatClient client; - - setUpAll(() { - // fallback values - registerFallbackValue(FakeUser()); - }); - - setUp(() { - final ws = FakeWebSocket(); - client = StreamChatClient(apiKey, ws: ws, chatApi: api); - }); - - tearDown(() { - client.dispose(); - }); - - test('`.connectUser` should work fine', () async { - final user = User(id: 'test-user-id'); - final token = Token.development(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 Token.development(userId).rawValue; - } - - expectLater( - // skipping first seed status -> ConnectionStatus.disconnected - client.wsConnectionStatusStream.skip(1), - emitsInOrder([ - ConnectionStatus.connecting, - ConnectionStatus.connected, - ]), - ); - - final res = await client.connectUserWithProvider(user, tokenProvider); - expect(res, isNotNull); - expect(res, isSameUserAs(user)); - }); - - group('`.connectGuestUser`', () { - test('should work fine', () async { - final user = User(id: 'test-user-id'); - final token = Token.development(user.id).rawValue; - - when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))).thenAnswer( - (_) async => ConnectGuestUserResponse() - ..user = user - ..accessToken = token, - ); - - expectLater( - // skipping first seed status -> ConnectionStatus.disconnected - client.wsConnectionStatusStream.skip(1), - emitsInOrder([ - ConnectionStatus.connecting, - ConnectionStatus.connected, - ]), - ); - - final res = await client.connectGuestUser(user); - expect(res, isNotNull); - expect(res, isSameUserAs(user)); - - verify( - () => api.guest.getGuestUser(any(that: isSameUserAs(user))), - ).called(1); - }); - - test('should throw if `.getGuestUser` fails', () async { - final user = User(id: 'test-user-id'); - - when( - () => api.guest.getGuestUser(any(that: isSameUserAs(user))), - ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); - - expectLater( - client.wsConnectionStatusStream, - emitsInOrder([ - // only emits the seed -> disconnected status - // as the call never reaches `ws.connect` - ConnectionStatus.disconnected, - ]), - ); - - try { - await client.connectGuestUser(user); - } catch (e) { - expect(e, isA()); - } - - verify( - () => api.guest.getGuestUser(any(that: isSameUserAs(user))), - ).called(1); - }); - }); - - test('`.connectAnonymousUser` should work fine', () async { - expectLater( - // skipping first seed status -> ConnectionStatus.disconnected - client.wsConnectionStatusStream.skip(1), - emitsInOrder([ - ConnectionStatus.connecting, - ConnectionStatus.connected, - ]), - ); - - final res = await client.connectAnonymousUser(); - expect(res, isNotNull); - }); - - 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()); - } - }); - - 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); - - await client.openConnection(); - } catch (e) { - expect(e, isA()); - final err = e as StreamChatError; - expect( - err.message.contains('Connection already available for'), - isTrue, - ); - } - }); - - 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 client.connectAnonymousUser(); - // waiting 300ms for `wsConnectionStatusStream` to emit - await delay(300); - - client.closeConnection(); - - await client.openConnection(); - }); - }); - }); - - group('Fake web-socket connection functions failure', () { - const apiKey = 'test-api-key'; - late final api = FakeChatApi(); - - late StreamChatClient client; - - setUpAll(() { - // fallback values - registerFallbackValue(FakeUser()); - }); - - setUp(() { - final ws = FakeWebSocketWithConnectionError(); - client = StreamChatClient(apiKey, chatApi: api, ws: ws); - }); - - tearDown(() { - client.dispose(); - }); - - test('`.connectUser` should throw if `ws.connect` fails', () async { - final user = User(id: 'test-user-id'); - final token = Token.development(user.id).rawValue; - - try { - await client.connectUser(user, token); - } catch (e) { - expect(e, isA()); - } - }); - - test( - '`.connectUserWithProvider` should throw if `ws.connect` fails', - () async { - final user = User(id: 'test-user-id'); - Future tokenProvider(String userId) async { - expect(userId, user.id); - return Token.development(userId).rawValue; - } - - try { - await client.connectUserWithProvider(user, tokenProvider); - } catch (e) { - expect(e, isA()); - } - }, - ); - - test('`.connectGuestUser` should throw if `ws.connect` fails', () async { - final user = User(id: 'test-user-id'); - final token = Token.development(user.id).rawValue; - - when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))).thenAnswer( - (_) async => ConnectGuestUserResponse() - ..user = user - ..accessToken = token, - ); - - try { - await client.connectGuestUser(user); - } catch (e) { - expect(e, isA()); - } - verify( - () => api.guest.getGuestUser(any(that: isSameUserAs(user))), - ).called(1); - }); - - test( - '`.connectAnonymousUser` should throw if `ws.connect` fails', - () async { - try { - await client.connectAnonymousUser(); - } catch (e) { - expect(e, isA()); - } - }, - ); - }); - - group('Connect user calls with `connectWebSocket`: false', () { - const apiKey = 'test-api-key'; - late final api = FakeChatApi(); - - late StreamChatClient client; - - setUpAll(() { - // fallback values - registerFallbackValue(FakeUser()); - }); - - setUp(() { - client = StreamChatClient(apiKey, chatApi: api); - }); - - tearDown(() { - client.dispose(); - }); - - test('`.connectUser` should succeed without connecting', () async { - final user = User(id: 'test-user-id'); - final token = Token.development(user.id).rawValue; - - final res = await client.connectUser( - user, - token, - connectWebSocket: false, - ); - expect(res, isSameUserAs(user)); - expect(client.wsConnectionStatus, ConnectionStatus.disconnected); - }); - - test( - '`.connectUserWithProvider` should succeed without connecting', - () async { - final user = User(id: 'test-user-id'); - Future tokenProvider(String userId) async { - expect(userId, user.id); - return Token.development(userId).rawValue; - } - - final res = await client.connectUserWithProvider( - user, - tokenProvider, - connectWebSocket: false, - ); - expect(res, isSameUserAs(user)); - expect(client.wsConnectionStatus, ConnectionStatus.disconnected); - }, - ); - - test('`.connectGuestUser` should succeed without connecting', () async { - final user = User(id: 'test-user-id'); - final token = Token.development(user.id).rawValue; - - when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))).thenAnswer( - (_) async => ConnectGuestUserResponse() - ..user = user - ..accessToken = token, - ); - - final res = await client.connectGuestUser( - user, - connectWebSocket: false, - ); - - expect(res, isSameUserAs(user)); - expect(client.wsConnectionStatus, ConnectionStatus.disconnected); - verify( - () => api.guest.getGuestUser(any(that: isSameUserAs(user))), - ).called(1); - }); - - test( - '`.connectAnonymousUser` should succeed without connecting', - () async { - final res = await client.connectAnonymousUser( - connectWebSocket: false, - ); - - expect(res, isNotNull); - expect(client.wsConnectionStatus, ConnectionStatus.disconnected); - }, - ); - }); - group('Client with connected user without persistence', () { const apiKey = 'test-api-key'; const userId = 'test-user-id'; From 69caf3886672f848f4ad01742bc895770bfe9052 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 09:57:36 +0200 Subject: [PATCH 070/103] test(llc): retire client_test monolith Co-Authored-By: Claude Opus 5 --- .../test/src/client/client_test.dart | 52 ------------------- 1 file changed, 52 deletions(-) delete mode 100644 packages/stream_chat/test/src/client/client_test.dart diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart deleted file mode 100644 index eb9207e21f..0000000000 --- a/packages/stream_chat/test/src/client/client_test.dart +++ /dev/null @@ -1,52 +0,0 @@ -// ignore_for_file: avoid_redundant_argument_values, lines_longer_than_80_chars, deprecated_member_use_from_same_package - -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat/src/core/http/token.dart'; -import 'package:stream_chat/stream_chat.dart'; -import 'package:test/test.dart'; - -import '../fakes.dart'; -import '../utils.dart'; - -void main() { - 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 = Token.development(user.id).rawValue; - - late StreamChatClient client; - - setUpAll(() { - // fallback values - registerFallbackValue(FakeEvent()); - registerFallbackValue(FakeMessage()); - registerFallbackValue(FakeDraftMessage()); - registerFallbackValue(FakePollVote()); - registerFallbackValue(const PaginationParams()); - }); - - setUp(() async { - // Clear any accumulated interactions from a previous test so that - // verifyNoMoreInteractions on api.general stays accurate. - clearInteractions(api.general); - - 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(); - }); - }); -} From 22e98369943a75c6958d773855e34bbd9b1fdf90 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 10:01:41 +0200 Subject: [PATCH 071/103] test(llc): retire test/src/matchers.dart in favour of the stream_chat_test matchers Co-Authored-By: Claude Opus 5 --- .../api/attachment_file_uploader_test.dart | 4 +- packages/stream_chat/test/src/matchers.dart | 239 ------------------ 2 files changed, 1 insertion(+), 242 deletions(-) delete mode 100644 packages/stream_chat/test/src/matchers.dart 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..ef37cd866b 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,11 +1,9 @@ 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'; import '../../utils.dart'; diff --git a/packages/stream_chat/test/src/matchers.dart b/packages/stream_chat/test/src/matchers.dart deleted file mode 100644 index 8a82ea566b..0000000000 --- a/packages/stream_chat/test/src/matchers.dart +++ /dev/null @@ -1,239 +0,0 @@ -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:test/test.dart'; - -Matcher isSameMultipartFileAs(MultipartFile targetFile) => _IsSameMultipartFileAs(targetFile: targetFile); - -class _IsSameMultipartFileAs extends Matcher { - const _IsSameMultipartFileAs({required this.targetFile}); - - final MultipartFile targetFile; - - @override - Description describe(Description description) => description.add('is same multipartFile as $targetFile'); - - @override - bool matches(covariant MultipartFile file, Map matchState) => file.length == targetFile.length; -} - -Matcher isSameEventAs(Event targetEvent) => _IsSameEventAs(targetEvent: targetEvent); - -class _IsSameEventAs extends Matcher { - const _IsSameEventAs({required this.targetEvent}); - - final Event targetEvent; - - @override - Description describe(Description description) => description.add('is same event as $targetEvent'); - - @override - bool matches(covariant Event event, Map matchState) => event.type == targetEvent.type; -} - -Matcher isSameMessageAs( - Message targetMessage, { - bool matchText = false, - bool matchReactions = false, - bool matchMessageState = false, - bool matchAttachments = false, - bool matchAttachmentsUploadState = false, - bool matchParentId = false, -}) => _IsSameMessageAs( - targetMessage: targetMessage, - matchText: matchText, - matchReactions: matchReactions, - matchMessageState: matchMessageState, - matchAttachments: matchAttachments, - matchAttachmentsUploadState: matchAttachmentsUploadState, - matchParentId: matchParentId, -); - -class _IsSameMessageAs extends Matcher { - const _IsSameMessageAs({ - required this.targetMessage, - this.matchText = false, - this.matchReactions = false, - this.matchMessageState = false, - this.matchAttachments = false, - this.matchAttachmentsUploadState = false, - this.matchParentId = false, - }); - - final Message targetMessage; - final bool matchText; - final bool matchReactions; - final bool matchMessageState; - final bool matchAttachments; - final bool matchAttachmentsUploadState; - final bool matchParentId; - - @override - Description describe(Description description) => description.add('is same message as $targetMessage'); - - @override - bool matches(covariant Message message, Map matchState) { - var matches = message.id == targetMessage.id; - if (matchText) { - matches &= message.text == targetMessage.text; - } - if (matchMessageState) { - matches &= message.state == targetMessage.state; - } - if (matchReactions) { - matches &= const ListEquality().equals( - message.ownReactions?.map((it) => '${it.type}-${it.messageId}').toList(), - targetMessage.ownReactions?.map((it) => '${it.type}-${it.messageId}').toList(), - ); - matches &= const ListEquality().equals( - message.latestReactions?.map((it) => '${it.type}-${it.messageId}').toList(), - targetMessage.latestReactions?.map((it) => '${it.type}-${it.messageId}').toList(), - ); - } - if (matchAttachments) { - bool matchAttachments() { - final attachments = message.attachments; - final targetAttachments = targetMessage.attachments; - if (identical(attachments, targetAttachments)) return true; - final length = attachments.length; - if (length != targetAttachments.length) return false; - for (var i = 0; i < length; i++) { - if (!isSameAttachmentAs( - attachments[i], - matchUploadState: matchAttachmentsUploadState, - ).matches(targetAttachments[i], matchState)) { - return false; - } - } - return true; - } - - matches &= matchAttachments(); - } - if (matchParentId) { - matches &= message.parentId == targetMessage.parentId; - } - return matches; - } -} - -Matcher isSameDraftMessageAs( - DraftMessage targetMessage, { - bool matchText = false, - bool matchAttachments = false, - bool matchParentId = false, -}) => _IsSameDraftMessageAs( - targetMessage: targetMessage, - matchText: matchText, - matchAttachments: matchAttachments, - matchParentId: matchParentId, -); - -class _IsSameDraftMessageAs extends Matcher { - const _IsSameDraftMessageAs({ - required this.targetMessage, - this.matchText = false, - this.matchAttachments = false, - this.matchParentId = false, - }); - - final DraftMessage targetMessage; - final bool matchText; - final bool matchAttachments; - final bool matchParentId; - - @override - Description describe(Description description) => description.add('is same draft message as $targetMessage'); - - @override - bool matches(covariant DraftMessage message, Map matchState) { - var matches = message.id == targetMessage.id; - if (matchText) matches &= message.text == targetMessage.text; - if (matchParentId) matches &= message.parentId == targetMessage.parentId; - if (matchAttachments) { - bool matchAttachments() { - final attachments = message.attachments; - final targetAttachments = targetMessage.attachments; - if (identical(attachments, targetAttachments)) return true; - final length = attachments.length; - if (length != targetAttachments.length) return false; - for (var i = 0; i < length; i++) { - if (!isSameAttachmentAs( - attachments[i], - ).matches(targetAttachments[i], matchState)) { - return false; - } - } - return true; - } - - matches &= matchAttachments(); - } - - return matches; - } -} - -Matcher isSameAttachmentAs( - Attachment targetAttachment, { - bool matchUploadState = false, -}) => _IsSameAttachmentAs( - targetAttachment: targetAttachment, - matchUploadState: matchUploadState, -); - -class _IsSameAttachmentAs extends Matcher { - const _IsSameAttachmentAs({ - required this.targetAttachment, - this.matchUploadState = false, - }); - - final Attachment targetAttachment; - final bool matchUploadState; - - @override - Description describe(Description description) => description.add('is same attachment as $targetAttachment'); - - @override - bool matches(covariant Attachment attachment, Map matchState) { - var matches = attachment.id == targetAttachment.id; - if (matchUploadState) { - matches &= attachment.uploadState == targetAttachment.uploadState; - } - return matches; - } -} - -Matcher isSameUserAs(User targetUser) => _IsSameUserAs(targetUser: targetUser); - -class _IsSameUserAs extends Matcher { - const _IsSameUserAs({required this.targetUser}); - - final User targetUser; - - @override - Description describe(Description description) => description.add('is same user as $targetUser'); - - @override - bool matches(covariant User user, Map matchState) => user.id == targetUser.id; -} - -Matcher isCorrectChannelFor(ChannelState channelState) => _IsCorrectChannelFor(channelState: channelState); - -class _IsCorrectChannelFor extends Matcher { - const _IsCorrectChannelFor({required this.channelState}); - - final ChannelState channelState; - - @override - Description describe(Description description) => description.add('is correct channel for $channelState'); - - @override - bool matches(covariant Channel channel, Map matchState) => channel.cid == channelState.channel?.cid; -} From 13b12cb1861b7157f5c33b8e74732ff98d89db61 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 10:02:57 +0200 Subject: [PATCH 072/103] test(llc): retire test/src/fakes.dart by inlining its two surviving doubles Co-Authored-By: Claude Opus 5 --- .../api/attachment_file_uploader_test.dart | 5 +- packages/stream_chat/test/src/fakes.dart | 317 ------------------ .../test/src/ws/websocket_test.dart | 27 +- 3 files changed, 28 insertions(+), 321 deletions(-) delete mode 100644 packages/stream_chat/test/src/fakes.dart 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 ef37cd866b..03bc16ea78 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 @@ -3,7 +3,6 @@ import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; import 'package:stream_chat/src/core/models/attachment_file.dart'; import 'package:stream_chat_test/stream_chat_test.dart'; -import '../../fakes.dart'; import '../../mocks.dart'; import '../../utils.dart'; @@ -13,7 +12,7 @@ void main() { setUp(() { fileUploader = StreamAttachmentFileUploader(client); - registerFallbackValue(FakeMultiPartFile()); + registerFallbackValue(_FakeMultipartFile()); }); Response successResponse(String path, {Object? data}) => Response( @@ -262,3 +261,5 @@ void main() { verifyNoMoreInteractions(client); }); } + +class _FakeMultipartFile extends Fake implements MultipartFile {} diff --git a/packages/stream_chat/test/src/fakes.dart b/packages/stream_chat/test/src/fakes.dart deleted file mode 100644 index 3a919eb879..0000000000 --- a/packages/stream_chat/test/src/fakes.dart +++ /dev/null @@ -1,317 +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/core/http/token.dart'; -import 'package:stream_chat/src/core/http/token_manager.dart'; -import 'package:stream_chat/src/ws/websocket.dart'; -import 'package:stream_chat/stream_chat.dart'; - -import 'mocks.dart'; - -class FakeTokenManager extends Fake implements TokenManager { - final token = Token.development('test-user-id'); - - @override - bool get isStatic => true; - - @override - String? get userId => token.userId; - - @override - Future loadToken({bool refresh = false}) async => token; - - @override - Future setTokenOrProvider( - String userId, { - Token? token, - TokenProvider? provider, - }) async => this.token; - - @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; - - @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 = []; - } - - @override - Future getLastSyncAt() async => _lastSyncAt; - - @override - Future updateLastSyncAt(DateTime lastSyncAt) async { - _lastSyncAt = lastSyncAt; - } - - @override - Future> getChannelCids() async => _channelCids; -} - -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 = StreamWebSocketError('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/ws/websocket_test.dart b/packages/stream_chat/test/src/ws/websocket_test.dart index 29f4d246f3..b2757d192c 100644 --- a/packages/stream_chat/test/src/ws/websocket_test.dart +++ b/packages/stream_chat/test/src/ws/websocket_test.dart @@ -2,13 +2,13 @@ import 'dart:async'; import 'dart:convert'; import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/http/token.dart'; import 'package:stream_chat/src/core/http/token_manager.dart'; import 'package:stream_chat/src/ws/websocket.dart'; 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'; void main() { @@ -18,7 +18,7 @@ void main() { late WebSocket webSocket; setUp(() { - tokenManager = FakeTokenManager(); + tokenManager = _FakeTokenManager(); webSocketChannel = MockWebSocketChannel(); WebSocketChannel channelProvider( @@ -541,3 +541,26 @@ void main() { addTearDown(timer.cancel); }); } + +class _FakeTokenManager extends Fake implements TokenManager { + final token = Token.development('test-user-id'); + + @override + bool get isStatic => true; + + @override + String? get userId => token.userId; + + @override + Future loadToken({bool refresh = false}) async => token; + + @override + Future setTokenOrProvider( + String userId, { + Token? token, + TokenProvider? provider, + }) async => this.token; + + @override + void reset() {} +} From d048805e07a3a408a7e5f3174f42ad340f3efef2 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 10:03:53 +0200 Subject: [PATCH 073/103] test(llc): shrink test/src/mocks.dart to the doubles below the algebra seam Co-Authored-By: Claude Opus 5 --- packages/stream_chat/test/src/mocks.dart | 150 ----------------------- 1 file changed, 150 deletions(-) diff --git a/packages/stream_chat/test/src/mocks.dart b/packages/stream_chat/test/src/mocks.dart index e248766ac3..e7b5559f1f 100644 --- a/packages/stream_chat/test/src/mocks.dart +++ b/packages/stream_chat/test/src/mocks.dart @@ -1,29 +1,10 @@ import 'package:dio/dio.dart'; import 'package:logging/logging.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/http/token_manager.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:web_socket_channel/web_socket_channel.dart'; class MockWebSocketChannel extends Mock implements WebSocketChannel {} @@ -53,135 +34,4 @@ 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 MockStreamChatClient extends Mock implements StreamChatClient { - @override - bool get persistenceEnabled => false; - - // 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; - - ChannelDeliveryReporter? _deliveryReporter; - - @override - 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 { - ChatPersistenceClient? _persistenceClient; - - @override - ChatPersistenceClient get chatPersistenceClient => _persistenceClient ??= MockPersistenceClient(); - - @override - bool get persistenceEnabled => true; -} - -class MockChannelConfig extends Mock implements ChannelConfig {} - -class MockRetryQueueChannel extends Mock implements Channel { - final channelId = 'test-channel-id'; - final channelType = 'test-channel-type'; - - @override - String? get id => channelId; - - @override - String get type => channelType; - - @override - String? get cid => '$channelType:$channelId'; - - StreamChatClient? _client; - - @override - StreamChatClient get client => _client ??= MockStreamChatClient(); - - @override - Stream on([ - String? eventType, - String? eventType2, - String? eventType3, - String? eventType4, - ]) { - return client.on(eventType, eventType2, eventType3, eventType4).where((e) => e.cid == cid); - } -} - -class MockWebSocket extends Mock implements WebSocket {} - -class MockChannelDeliveryReporter extends Mock implements ChannelDeliveryReporter {} From 667492821ac4edb895dae33ebd6365a7a5640d9b Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 10:07:13 +0200 Subject: [PATCH 074/103] docs(test): record the migration-era stream_chat_test helper additions Co-Authored-By: Claude Opus 5 --- packages/stream_chat_test/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/stream_chat_test/CHANGELOG.md b/packages/stream_chat_test/CHANGELOG.md index b5b019a364..7ff2375b0a 100644 --- a/packages/stream_chat_test/CHANGELOG.md +++ b/packages/stream_chat_test/CHANGELOG.md @@ -5,3 +5,5 @@ - Added `createDefaultChannelConfig`, `createDefaultErrorResponse` and `createDefaultNetworkError` fixtures. - Added `MockPersistenceClient` and `FakePersistenceClient` doubles for the persistence seam. - Emitted events are now serialized with server fidelity, so message and reaction payloads keep their server-assigned fields (sender, timestamps, reactions). +- Added `createDefaultXxx` fixtures and response factories for users, polls, drafts, reminders, members, reactions, replies, sync and unread counts. +- Added `mockApiFailureOnce`, `verifyNoMoreApiInteractions` and a `delay` option on the API mockers. From 21f8c9c75b35e663a37e96c48db3ae4fcabe1de0 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 10:56:55 +0200 Subject: [PATCH 075/103] test(llc): build retry queue test doubles per test so assertions stop depending on order Co-Authored-By: Claude Opus 5 --- .../test/src/client/retry_queue_test.dart | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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 cbf036e21e..1712ce034f 100644 --- a/packages/stream_chat/test/src/client/retry_queue_test.dart +++ b/packages/stream_chat/test/src/client/retry_queue_test.dart @@ -26,20 +26,23 @@ class _MockLogger extends Mock implements Logger { } void main() { - late final channel = _MockChannel(); - late final logger = _MockLogger(); + // Built per test: the logger assertions below count interactions, so sharing + // the doubles would make them depend on the order the tests run in. + late _MockChannel channel; + late _MockLogger logger; late RetryQueue retryQueue; - setUpAll(() { + setUp(() { + channel = _MockChannel(); + logger = _MockLogger(); + final retryPolicy = RetryPolicy( shouldRetry: (_, __, error) { return error is StreamChatNetworkError && error.isRetriable; }, ); when(() => channel.client.retryPolicy).thenReturn(retryPolicy); - }); - setUp(() { retryQueue = RetryQueue(channel: channel, logger: logger); }); From 993f657dfdb46d06c9f262e38fdcc5df23c3b7b1 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 10:57:50 +0200 Subject: [PATCH 076/103] test(llc): scope the local mocks import so it cannot clash with the stream_chat_test doubles Co-Authored-By: Claude Opus 5 --- .../test/src/core/api/attachment_file_uploader_test.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 03bc16ea78..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 @@ -3,7 +3,10 @@ import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; import 'package:stream_chat/src/core/models/attachment_file.dart'; import 'package:stream_chat_test/stream_chat_test.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() { From eb7f0d7c88acbad46f3365a6e4b74c57cf03af6f Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 11:00:47 +0200 Subject: [PATCH 077/103] test(stream_chat_test): pin the server-fidelity fields each serverXxxJson restores Co-Authored-By: Claude Opus 5 --- .../test/server_json_test.dart | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 packages/stream_chat_test/test/server_json_test.dart 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..08758a233c --- /dev/null +++ b/packages/stream_chat_test/test/server_json_test.dart @@ -0,0 +1,267 @@ +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 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), +); From 97583344cf784caef42d01787ecc082470499888 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 11:02:17 +0200 Subject: [PATCH 078/103] test(llc): cover the channel config getter on initialized and non-initialized channels Co-Authored-By: Claude Opus 5 --- .../channel_state_validation_test.dart | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/stream_chat/test/src/client/channel/channel_state_validation_test.dart b/packages/stream_chat/test/src/client/channel/channel_state_validation_test.dart index 394ecfffcd..aa75cb07c2 100644 --- a/packages/stream_chat/test/src/client/channel/channel_state_validation_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_state_validation_test.dart @@ -39,6 +39,16 @@ void main() { }, ); + 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())); + }, + ); + channelTest( 'should throw StateError when accessing getRemainingCooldown on non-initialized channel', channelType: _channelType, @@ -69,6 +79,24 @@ void main() { body: (tester) => expect(tester.channel.cooldown, equals(0)), ); + 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); + }, + ); + channelTest( 'should return custom cooldown value when set in channel model', channelType: _channelType, From 60c0cb6046c3457f6c5918a2336c1a8eb459115a Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 11:15:38 +0200 Subject: [PATCH 079/103] test(llc): restore the event payload pinning the old channel on and sendEvent tests had Co-Authored-By: Claude Opus 5 --- .../client/channel/channel_display_api_test.dart | 10 +++++++++- .../channel/channel_misc_operations_test.dart | 15 +++++---------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/stream_chat/test/src/client/channel/channel_display_api_test.dart b/packages/stream_chat/test/src/client/channel/channel_display_api_test.dart index 5c33053a33..e491139ab6 100644 --- a/packages/stream_chat/test/src/client/channel/channel_display_api_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_display_api_test.dart @@ -197,7 +197,15 @@ void main() { final eventReceived = expectLater( tester.channel.on(eventType), - emitsInOrder([isSameEventAs(event)]), + // The old suite asserted on the event instance itself, which held + // because the event never left the process. Here it round-trips + // through the real wire decode, so pin every field it carries. + emitsInOrder([ + isA() + .having((it) => it.type, 'type', event.type) + .having((it) => it.cid, 'cid', event.cid) + .having((it) => it.createdAt, 'createdAt', event.createdAt), + ]), ); await tester.emitEvent(event); diff --git a/packages/stream_chat/test/src/client/channel/channel_misc_operations_test.dart b/packages/stream_chat/test/src/client/channel/channel_misc_operations_test.dart index 5ec8843ade..b324fea916 100644 --- a/packages/stream_chat/test/src/client/channel/channel_misc_operations_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_misc_operations_test.dart @@ -101,12 +101,11 @@ void main() { body: (tester) async { final event = Event(type: 'event.local'); + // 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, - any(that: isSameEventAs(event)), - ), + (api) => api.channel.sendEvent(_channelId, _channelType, event), result: createDefaultEmptyResponse(), ); @@ -115,11 +114,7 @@ void main() { expect(res, isNotNull); tester.verifyApi( - (api) => api.channel.sendEvent( - _channelId, - _channelType, - any(that: isSameEventAs(event)), - ), + (api) => api.channel.sendEvent(_channelId, _channelType, event), ); }, ); From 92e71d3d065946dee0b9a23f25176decaba67f05 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 11:16:53 +0200 Subject: [PATCH 080/103] test(llc): assert the connected user against the harness user, not against state Co-Authored-By: Claude Opus 5 --- .../test/src/client/client_state_guards_test.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/stream_chat/test/src/client/client_state_guards_test.dart b/packages/stream_chat/test/src/client/client_state_guards_test.dart index df8a1671e9..9cffc1c6ca 100644 --- a/packages/stream_chat/test/src/client/client_state_guards_test.dart +++ b/packages/stream_chat/test/src/client/client_state_guards_test.dart @@ -147,8 +147,11 @@ void main() { 'setting the `currentUser` should also compute and update the unreadCounts', body: (tester) async { final state = tester.clientState; - final initialUser = state.currentUser!; + // 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); expect(state.unreadChannels, 0); From 51bd3edb3edb3ba375587e3ac1358ae93531ba26 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 11:17:40 +0200 Subject: [PATCH 081/103] docs(llc): point the connection test carve-out note at client_reconnection_test Co-Authored-By: Claude Opus 5 --- .../test/src/client/client_connection_test.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/stream_chat/test/src/client/client_connection_test.dart b/packages/stream_chat/test/src/client/client_connection_test.dart index d4482ee9c9..2b578dd2e2 100644 --- a/packages/stream_chat/test/src/client/client_connection_test.dart +++ b/packages/stream_chat/test/src/client/client_connection_test.dart @@ -1,10 +1,11 @@ import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_test/stream_chat_test.dart'; -// NOTE(migration): the monolith's `.connectAnonymousUser` tests are not -// migrated — the anonymous token's user id cannot pass the fake server's -// connect-URI validation without the TokenManager seam. They stay in -// client_test.dart until the seam lands. +// NOTE(migration): the `.connectAnonymousUser` tests are not here — the +// anonymous token's user id cannot pass the fake server's connect-URI +// validation without the TokenManager seam. They live in +// client_reconnection_test.dart, on a hand-rolled fake socket, until the seam +// lands. void main() { group('Fake web-socket connection functions', () { chatClientTest( From 546a0ff9242a65a8782ff6aa2915efc4399b9eb6 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 11:22:07 +0200 Subject: [PATCH 082/103] test(llc): cover the retry queue giving up when the policy declines the error Co-Authored-By: Claude Opus 5 --- .../test/src/client/retry_queue_test.dart | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) 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 1712ce034f..e93c11de8d 100644 --- a/packages/stream_chat/test/src/client/retry_queue_test.dart +++ b/packages/stream_chat/test/src/client/retry_queue_test.dart @@ -80,6 +80,46 @@ void main() { verify(() => logger.info('Adding 1 messages to the queue')).called(1); }); + // The queue consults the retry policy through `retryIf` whenever an + // attempt fails with a `StreamChatError`; 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: a network error that carries response data. + final error = StreamChatNetworkError( + ChatErrorCode.internalSystemError, + statusCode: 500, + data: ErrorResponse() + ..code = ChatErrorCode.internalSystemError.code + ..message = ChatErrorCode.internalSystemError.message + ..statusCode = 500, + ); + 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', From e1d10dff4a8aa78e024fb90e06b543c22e809a2a Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 13:23:48 +0200 Subject: [PATCH 083/103] test(stream_chat_test): let skip carry a reason now that the feeds package widened it Co-Authored-By: Claude Opus 5 --- .../stream_chat_test/lib/src/testers/base_tester.dart | 8 +++----- .../stream_chat_test/lib/src/testers/channel_tester.dart | 2 +- .../lib/src/testers/chat_client_tester.dart | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/stream_chat_test/lib/src/testers/base_tester.dart b/packages/stream_chat_test/lib/src/testers/base_tester.dart index 983c31c76e..941bac4cfd 100644 --- a/packages/stream_chat_test/lib/src/testers/base_tester.dart +++ b/packages/stream_chat_test/lib/src/testers/base_tester.dart @@ -160,7 +160,8 @@ Future createTester>({ /// [tokenProvider] when given (which takes precedence over [token]), and /// asserts the connection; [connect] replaces it entirely. [logLevel] and /// [isLocalUnreadCountEnabled] are forwarded to the client constructor; -/// [skip], [tags] and [timeout] to `test`. +/// [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>( @@ -178,10 +179,7 @@ void testWithTester>( required FutureOr Function(T tester) body, FutureOr Function(T tester)? verify, FutureOr Function(T tester)? tearDown, - // NOTE(parity): typed `bool` to match stream_feeds_test, which prevents - // passing the skip *reason* STYLE_GUIDE.md requires (`package:test` accepts - // a String for exactly that). Widen together with the feeds package. - bool skip = false, + String? skip, Iterable tags = const [], test.Timeout? timeout, }) { diff --git a/packages/stream_chat_test/lib/src/testers/channel_tester.dart b/packages/stream_chat_test/lib/src/testers/channel_tester.dart index a1d4be3c2c..4d60aeeb79 100644 --- a/packages/stream_chat_test/lib/src/testers/channel_tester.dart +++ b/packages/stream_chat_test/lib/src/testers/channel_tester.dart @@ -54,7 +54,7 @@ void channelTest( required FutureOr Function(ChannelTester tester) body, FutureOr Function(ChannelTester tester)? verify, FutureOr Function(ChannelTester tester)? tearDown, - bool skip = false, + String? skip, Iterable tags = const ['channel'], test.Timeout? timeout, }) { 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 index fd6c5f6e69..114b02876b 100644 --- a/packages/stream_chat_test/lib/src/testers/chat_client_tester.dart +++ b/packages/stream_chat_test/lib/src/testers/chat_client_tester.dart @@ -40,7 +40,7 @@ void chatClientTest( required FutureOr Function(ChatClientTester tester) body, FutureOr Function(ChatClientTester tester)? verify, FutureOr Function(ChatClientTester tester)? tearDown, - bool skip = false, + String? skip, Iterable tags = const ['chat-client'], test.Timeout? timeout, }) { From ee46a2c2995a456e3c175d8c4d0ba0a9f8804dd3 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 13:31:42 +0200 Subject: [PATCH 084/103] fix(stream_chat_test): restore moderation, mentioned groups and channel role in emitted messages Co-Authored-By: Claude Opus 5 --- .../lib/src/helpers/server_json.dart | 5 ++++ .../test/server_json_test.dart | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/packages/stream_chat_test/lib/src/helpers/server_json.dart b/packages/stream_chat_test/lib/src/helpers/server_json.dart index 6634647f6f..d1a83f706d 100644 --- a/packages/stream_chat_test/lib/src/helpers/server_json.dart +++ b/packages/stream_chat_test/lib/src/helpers/server_json.dart @@ -71,6 +71,11 @@ Map serverMessageJson(Message message) { 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, diff --git a/packages/stream_chat_test/test/server_json_test.dart b/packages/stream_chat_test/test/server_json_test.dart index 08758a233c..5e362de82a 100644 --- a/packages/stream_chat_test/test/server_json_test.dart +++ b/packages/stream_chat_test/test/server_json_test.dart @@ -60,6 +60,32 @@ void main() { 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(), From ca96e3d7de63ae66ee2bae0aadca5fc0c4d57149 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 13:33:38 +0200 Subject: [PATCH 085/103] docs(stream_chat_test): correct the guarded-zone and fallback-registration claims Co-Authored-By: Claude Opus 5 --- packages/stream_chat_test/README.md | 10 +++++----- packages/stream_chat_test/lib/src/helpers/mocks.dart | 4 +++- .../stream_chat_test/lib/src/testers/base_tester.dart | 8 ++++---- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/stream_chat_test/README.md b/packages/stream_chat_test/README.md index 12f0b569b1..0b3f899b7e 100644 --- a/packages/stream_chat_test/README.md +++ b/packages/stream_chat_test/README.md @@ -50,9 +50,11 @@ 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 fail the -test). The default connect phase mocks successful authentication, connects the -client as `luke_skywalker` with a development token, and asserts the connection. +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: (_) {}`. @@ -128,8 +130,6 @@ 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). -- `skip:` is typed `bool`, so the skip *reason* required by `STYLE_GUIDE.md` cannot - be provided (`package:test` accepts a String for this). ## Token handling — known limitation diff --git a/packages/stream_chat_test/lib/src/helpers/mocks.dart b/packages/stream_chat_test/lib/src/helpers/mocks.dart index 0c119f2ac3..242b1f72a5 100644 --- a/packages/stream_chat_test/lib/src/helpers/mocks.dart +++ b/packages/stream_chat_test/lib/src/helpers/mocks.dart @@ -244,7 +244,9 @@ 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. /// -/// Idempotent; called automatically by the tester lifecycle. +/// 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()); diff --git a/packages/stream_chat_test/lib/src/testers/base_tester.dart b/packages/stream_chat_test/lib/src/testers/base_tester.dart index 941bac4cfd..d602548305 100644 --- a/packages/stream_chat_test/lib/src/testers/base_tester.dart +++ b/packages/stream_chat_test/lib/src/testers/base_tester.dart @@ -294,11 +294,11 @@ FutureOr Function(BaseTester) _defaultConnect({ }; } -// Runs the test body in a guarded zone to catch all errors. +// Runs the test body in a guarded zone. // -// This ensures that errors from event handlers, timers, and unawaited -// futures are properly caught and reported, not just errors in the -// main async chain. +// 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 From 5582631cdd7c94d91f883e6b1a1d65e112c27192 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 13:35:00 +0200 Subject: [PATCH 086/103] fix(stream_chat_test): give the default channel model a deterministic config Co-Authored-By: Claude Opus 5 --- packages/stream_chat_test/lib/src/helpers/test_data.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/stream_chat_test/lib/src/helpers/test_data.dart b/packages/stream_chat_test/lib/src/helpers/test_data.dart index 082c9145da..78eff1f137 100644 --- a/packages/stream_chat_test/lib/src/helpers/test_data.dart +++ b/packages/stream_chat_test/lib/src/helpers/test_data.dart @@ -209,7 +209,10 @@ Message createDefaultMessage({ /// Creates a [ChannelModel] with sensible defaults. /// /// Timestamps are always passed explicitly because the constructor defaults -/// them to `DateTime.now()`, which would break deterministic matching. +/// 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, @@ -231,7 +234,7 @@ ChannelModel createDefaultChannelModel({ cooldown: cooldown, members: members, ownCapabilities: ownCapabilities, - config: config, + config: config ?? createDefaultChannelConfig(), filterTags: filterTags, createdAt: createdAt ?? testCreatedAt, updatedAt: updatedAt ?? testUpdatedAt, From e4a8799a248e96bdb2d133509977f4cec70e7eda Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 13:35:20 +0200 Subject: [PATCH 087/103] docs(stream_chat_test): match the repo changelog format and cover the whole 0.1.0 surface Co-Authored-By: Claude Opus 5 --- packages/stream_chat_test/CHANGELOG.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/stream_chat_test/CHANGELOG.md b/packages/stream_chat_test/CHANGELOG.md index 7ff2375b0a..6c44d0fabe 100644 --- a/packages/stream_chat_test/CHANGELOG.md +++ b/packages/stream_chat_test/CHANGELOG.md @@ -1,9 +1,10 @@ -# 0.1.0 +## 0.1.0 -- Initial release of internal BDD-style test helpers for the `stream_chat` package. +✅ 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 `createDefaultChannelConfig`, `createDefaultErrorResponse` and `createDefaultNetworkError` fixtures. +- 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. -- Emitted events are now serialized with server fidelity, so message and reaction payloads keep their server-assigned fields (sender, timestamps, reactions). -- Added `createDefaultXxx` fixtures and response factories for users, polls, drafts, reminders, members, reactions, replies, sync and unread counts. -- Added `mockApiFailureOnce`, `verifyNoMoreApiInteractions` and a `delay` option on the API mockers. +- 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). From dd924f4a221114df49861fbac03c1e80b4844319 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 13:36:25 +0200 Subject: [PATCH 088/103] test(llc): assert no delivery is reported for a channel without a cid Co-Authored-By: Claude Opus 5 --- .../test/src/client/channel_delivery_reporter_test.dart | 3 +++ 1 file changed, 3 insertions(+) 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 8fd518eedc..64ef01adca 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 @@ -321,6 +321,9 @@ void main() { final channel = Channel(client, 'test-type', null); await reporter.reconcileDelivery([channel]); + await delay(150); + + expect(capturedDeliveries, isEmpty); }); }); From 4059d546edcc24bcd7dde0414e633f154dea828a Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 13:55:01 +0200 Subject: [PATCH 089/103] fix(stream_chat_test): echo the configured user as the connected user instead of a default Co-Authored-By: Claude Opus 5 --- .../test/src/client/client_connection_test.dart | 10 +++++----- .../client/client_with_persistence_test.dart | 2 +- .../lib/src/testers/base_tester.dart | 10 ++++++---- .../lib/src/testers/websocket_tester.dart | 17 +++++++++++++---- .../test/chat_client_tester_test.dart | 14 ++++++++++++-- 5 files changed, 37 insertions(+), 16 deletions(-) diff --git a/packages/stream_chat/test/src/client/client_connection_test.dart b/packages/stream_chat/test/src/client/client_connection_test.dart index 2b578dd2e2..fbd7fd48e2 100644 --- a/packages/stream_chat/test/src/client/client_connection_test.dart +++ b/packages/stream_chat/test/src/client/client_connection_test.dart @@ -10,7 +10,7 @@ void main() { group('Fake web-socket connection functions', () { chatClientTest( '`.connectUser` should work fine', - connect: (tester) => tester.mockSuccessfulAuth(tester.user.id), + connect: (tester) => tester.mockSuccessfulAuth(), body: (tester) async { final user = tester.user; final token = createTestToken(user.id).rawValue; @@ -34,7 +34,7 @@ void main() { chatClientTest( '`.connectUserWithProvider` should work fine', - connect: (tester) => tester.mockSuccessfulAuth(tester.user.id), + connect: (tester) => tester.mockSuccessfulAuth(), body: (tester) async { final user = tester.user; Future tokenProvider(String userId) async { @@ -62,7 +62,7 @@ void main() { group('`.connectGuestUser`', () { chatClientTest( 'should work fine', - connect: (tester) => tester.mockSuccessfulAuth(tester.user.id), + connect: (tester) => tester.mockSuccessfulAuth(), body: (tester) async { final user = tester.user; final token = createTestToken(user.id).rawValue; @@ -142,7 +142,7 @@ void main() { chatClientTest( 'should throw if connection is already available', - connect: (tester) => tester.mockSuccessfulAuth(tester.user.id), + connect: (tester) => tester.mockSuccessfulAuth(), body: (tester) async { expect(tester.currentUser, isNull); @@ -167,7 +167,7 @@ void main() { chatClientTest( 'should open connection for closed connection', - connect: (tester) => tester.mockSuccessfulAuth(tester.user.id), + connect: (tester) => tester.mockSuccessfulAuth(), body: (tester) async { final statusEmitted = expectLater( tester.client.wsConnectionStatusStream.skip(1), diff --git a/packages/stream_chat/test/src/client/client_with_persistence_test.dart b/packages/stream_chat/test/src/client/client_with_persistence_test.dart index 26213173cd..acffae2427 100644 --- a/packages/stream_chat/test/src/client/client_with_persistence_test.dart +++ b/packages/stream_chat/test/src/client/client_with_persistence_test.dart @@ -21,7 +21,7 @@ void _clientWithPersistenceTest( when(() => persistence.updateLastSyncAt(any())).thenAnswer((_) => Future.value()); when(persistence.getLastSyncAt).thenAnswer((_) async => null); - tester.mockSuccessfulAuth(tester.user.id); + tester.mockSuccessfulAuth(); await tester.client.connectUser(tester.user, createTestToken(tester.user.id).rawValue); expect(tester.client.persistenceEnabled, isTrue); diff --git a/packages/stream_chat_test/lib/src/testers/base_tester.dart b/packages/stream_chat_test/lib/src/testers/base_tester.dart index d602548305..2aaaaaea70 100644 --- a/packages/stream_chat_test/lib/src/testers/base_tester.dart +++ b/packages/stream_chat_test/lib/src/testers/base_tester.dart @@ -77,12 +77,14 @@ abstract base class BaseTester with ApiMockerMixin { /// made. List get connectUris => _wsTester.connectUris; - /// Configures the fake server to accept a connection attempt for [userId]. + /// 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. - void mockSuccessfulAuth(String userId) { - return _wsTester.mockSuccessfulAuth(userId); + /// 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 @@ -280,7 +282,7 @@ FutureOr Function(BaseTester) _defaultConnect({ }) { return (tester) async { // Mock successful authentication for the configured user. - tester.mockSuccessfulAuth(tester.user.id); + tester.mockSuccessfulAuth(); // Connect the client. final ownUser = await switch (tokenProvider) { diff --git a/packages/stream_chat_test/lib/src/testers/websocket_tester.dart b/packages/stream_chat_test/lib/src/testers/websocket_tester.dart index 1055a51d1a..42876fec61 100644 --- a/packages/stream_chat_test/lib/src/testers/websocket_tester.dart +++ b/packages/stream_chat_test/lib/src/testers/websocket_tester.dart @@ -59,14 +59,18 @@ final class WebSocketTester { return _channel; } - /// Configures the fake server to accept a connection attempt for [userId]. + /// 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 [userId]. The token in the URI is always the harness token + /// does not match [user]. The token in the URI is always the harness token /// (see the README's "Token handling" section). - void mockSuccessfulAuth(String userId) { + /// + /// 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) { @@ -83,7 +87,12 @@ final class WebSocketTester { } // Correct credentials - simulate successful authentication. - return emitEvent(createDefaultConnectedEvent(userId: userId)); + return emitEvent( + createDefaultConnectedEvent( + userId: userId, + me: OwnUser.fromUser(user), + ), + ); }; } diff --git a/packages/stream_chat_test/test/chat_client_tester_test.dart b/packages/stream_chat_test/test/chat_client_tester_test.dart index c24e1a2a0c..0afbd98a4e 100644 --- a/packages/stream_chat_test/test/chat_client_tester_test.dart +++ b/packages/stream_chat_test/test/chat_client_tester_test.dart @@ -22,6 +22,16 @@ void main() { }, ); + 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: (userId) async => createTestToken(userId).rawValue, @@ -48,7 +58,7 @@ void main() { chatClientTest( 'rejects a connection attempt for a different user', - connect: (tester) => tester.mockSuccessfulAuth('darth_vader'), + connect: (tester) => tester.mockSuccessfulAuth(User(id: 'darth_vader')), body: (tester) async { final token = createTestToken(tester.user.id); @@ -68,7 +78,7 @@ void main() { // handling". chatClientTest( 'KNOWN LIMITATION: accepts a connectUser token minted for another user', - connect: (tester) => tester.mockSuccessfulAuth(tester.user.id), + connect: (tester) => tester.mockSuccessfulAuth(), body: (tester) async { final wrongToken = createTestToken('darth_vader'); From bd5b6638e176fd4b270168870e5d3ba28b7f2e19 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 13:58:00 +0200 Subject: [PATCH 090/103] test(llc): pin the parent id on typing events and the exact event on client sendEvent Co-Authored-By: Claude Opus 5 --- .../channel_typing_indicator_test.dart | 28 +++++++++--------- .../test/src/client/client_api_test.dart | 12 ++------ .../lib/src/helpers/matchers.dart | 29 +++++++++++++++---- .../test/stream_chat_test_test.dart | 16 ++++++++++ 4 files changed, 56 insertions(+), 29 deletions(-) diff --git a/packages/stream_chat/test/src/client/channel/channel_typing_indicator_test.dart b/packages/stream_chat/test/src/client/channel/channel_typing_indicator_test.dart index 8bf8c7ea4f..455f876700 100644 --- a/packages/stream_chat/test/src/client/channel/channel_typing_indicator_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_typing_indicator_test.dart @@ -49,7 +49,7 @@ void main() { (api) => api.channel.sendEvent( _channelId, _channelType, - any(that: isSameEventAs(typingEvent)), + any(that: isSameEventAs(typingEvent, matchParentId: true)), ), ); }, @@ -75,7 +75,7 @@ void main() { (api) => api.channel.sendEvent( _channelId, _channelType, - any(that: isSameEventAs(typingEvent)), + any(that: isSameEventAs(typingEvent, matchParentId: true)), ), ); }, @@ -100,7 +100,7 @@ void main() { (api) => api.channel.sendEvent( _channelId, _channelType, - any(that: isSameEventAs(startTypingEvent)), + any(that: isSameEventAs(startTypingEvent, matchParentId: true)), ), result: createDefaultEmptyResponse(), ) @@ -108,7 +108,7 @@ void main() { (api) => api.channel.sendEvent( _channelId, _channelType, - any(that: isSameEventAs(stopTypingEvent)), + any(that: isSameEventAs(stopTypingEvent, matchParentId: true)), ), result: createDefaultEmptyResponse(), ); @@ -120,14 +120,14 @@ void main() { (api) => api.channel.sendEvent( _channelId, _channelType, - any(that: isSameEventAs(startTypingEvent)), + any(that: isSameEventAs(startTypingEvent, matchParentId: true)), ), ) ..verifyApi( (api) => api.channel.sendEvent( _channelId, _channelType, - any(that: isSameEventAs(stopTypingEvent)), + any(that: isSameEventAs(stopTypingEvent, matchParentId: true)), ), ); }, @@ -150,7 +150,7 @@ void main() { (api) => api.channel.sendEvent( _channelId, _channelType, - any(that: isSameEventAs(typingStartEvent)), + any(that: isSameEventAs(typingStartEvent, matchParentId: true)), ), ); }, @@ -176,7 +176,7 @@ void main() { (api) => api.channel.sendEvent( _channelId, _channelType, - any(that: isSameEventAs(typingStartEvent)), + any(that: isSameEventAs(typingStartEvent, matchParentId: true)), ), ); }, @@ -198,7 +198,7 @@ void main() { (api) => api.channel.sendEvent( _channelId, _channelType, - any(that: isSameEventAs(typingStartEvent)), + any(that: isSameEventAs(typingStartEvent, matchParentId: true)), ), result: createDefaultEmptyResponse(), ); @@ -209,7 +209,7 @@ void main() { (api) => api.channel.sendEvent( _channelId, _channelType, - any(that: isSameEventAs(typingStartEvent)), + any(that: isSameEventAs(typingStartEvent, matchParentId: true)), ), ); }, @@ -232,7 +232,7 @@ void main() { (api) => api.channel.sendEvent( _channelId, _channelType, - any(that: isSameEventAs(typingStopEvent)), + any(that: isSameEventAs(typingStopEvent, matchParentId: true)), ), ); }, @@ -258,7 +258,7 @@ void main() { (api) => api.channel.sendEvent( _channelId, _channelType, - any(that: isSameEventAs(typingStopEvent)), + any(that: isSameEventAs(typingStopEvent, matchParentId: true)), ), ); }, @@ -280,7 +280,7 @@ void main() { (api) => api.channel.sendEvent( _channelId, _channelType, - any(that: isSameEventAs(typingStopEvent)), + any(that: isSameEventAs(typingStopEvent, matchParentId: true)), ), result: createDefaultEmptyResponse(), ); @@ -291,7 +291,7 @@ void main() { (api) => api.channel.sendEvent( _channelId, _channelType, - any(that: isSameEventAs(typingStopEvent)), + any(that: isSameEventAs(typingStopEvent, matchParentId: true)), ), ); }, diff --git a/packages/stream_chat/test/src/client/client_api_test.dart b/packages/stream_chat/test/src/client/client_api_test.dart index d410a3d3b9..2ff30c6e5b 100644 --- a/packages/stream_chat/test/src/client/client_api_test.dart +++ b/packages/stream_chat/test/src/client/client_api_test.dart @@ -941,11 +941,7 @@ void main() { final event = Event(type: EventType.any); tester.mockApi( - (api) => api.channel.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(event)), - ), + (api) => api.channel.sendEvent(channelId, channelType, event), result: createDefaultEmptyResponse(), ); @@ -954,11 +950,7 @@ void main() { tester ..verifyApi( - (api) => api.channel.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(event)), - ), + (api) => api.channel.sendEvent(channelId, channelType, event), ) ..verifyNoMoreApiInteractions((api) => api.channel); }, diff --git a/packages/stream_chat_test/lib/src/helpers/matchers.dart b/packages/stream_chat_test/lib/src/helpers/matchers.dart index 5008c15c08..202fdcc6cc 100644 --- a/packages/stream_chat_test/lib/src/helpers/matchers.dart +++ b/packages/stream_chat_test/lib/src/helpers/matchers.dart @@ -22,20 +22,39 @@ class _IsSameMultipartFileAs extends Matcher { /// Matches an [Event] with the same type as [targetEvent]. /// -/// Usable both as a test matcher and as a mocktail argument matcher via -/// `any(that: isSameEventAs(event))`. -Matcher isSameEventAs(Event targetEvent) => _IsSameEventAs(targetEvent: 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]. diff --git a/packages/stream_chat_test/test/stream_chat_test_test.dart b/packages/stream_chat_test/test/stream_chat_test_test.dart index 53485bbb64..5be3e45b2d 100644 --- a/packages/stream_chat_test/test/stream_chat_test_test.dart +++ b/packages/stream_chat_test/test/stream_chat_test_test.dart @@ -1,3 +1,4 @@ +import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_test/stream_chat_test.dart'; void main() { @@ -18,4 +19,19 @@ void main() { 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)); + }); + }); } From a3baf96fad6658c8bbf6c0d62856a4de30d1c46b Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 13:59:03 +0200 Subject: [PATCH 091/103] docs(stream_chat_test): describe verify and tearDown as a convention, not an isolation boundary Co-Authored-By: Claude Opus 5 --- packages/stream_chat_test/README.md | 4 ++++ packages/stream_chat_test/lib/src/testers/base_tester.dart | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/stream_chat_test/README.md b/packages/stream_chat_test/README.md index 0b3f899b7e..c57e5ea9bd 100644 --- a/packages/stream_chat_test/README.md +++ b/packages/stream_chat_test/README.md @@ -58,6 +58,10 @@ 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', diff --git a/packages/stream_chat_test/lib/src/testers/base_tester.dart b/packages/stream_chat_test/lib/src/testers/base_tester.dart index 2aaaaaea70..d1ba4dbedb 100644 --- a/packages/stream_chat_test/lib/src/testers/base_tester.dart +++ b/packages/stream_chat_test/lib/src/testers/base_tester.dart @@ -158,6 +158,10 @@ Future createTester>({ /// [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. [logLevel] and From d73c7d3f19a036b0bcb76abb9b4e13c9ce3ef297 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 14:09:06 +0200 Subject: [PATCH 092/103] test(llc): await the sync guard tests so they can actually detect an API call Co-Authored-By: Claude Opus 5 --- packages/stream_chat/test/src/client/client_sync_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stream_chat/test/src/client/client_sync_test.dart b/packages/stream_chat/test/src/client/client_sync_test.dart index 7968879e32..e1d101ee58 100644 --- a/packages/stream_chat/test/src/client/client_sync_test.dart +++ b/packages/stream_chat/test/src/client/client_sync_test.dart @@ -37,7 +37,7 @@ void main() { chatClientTest( 'should return if `cids` is not available', body: (tester) async { - expect(tester.client.sync, returnsNormally); + await expectLater(tester.client.sync(), completes); tester.verifyNeverCalled((api) => api.general.sync(any(), any())); }, ); @@ -45,7 +45,7 @@ void main() { chatClientTest( 'should return if `lastSyncAt` is not available', body: (tester) async { - expect(() => tester.client.sync(cids: ['test-cid-1']), returnsNormally); + await expectLater(tester.client.sync(cids: ['test-cid-1']), completes); tester.verifyNeverCalled((api) => api.general.sync(any(), any())); }, ); From 011a03def0af7fbbb7df593bb61f5a7aa23162ff Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 14:10:36 +0200 Subject: [PATCH 093/103] test(llc): cover the auth interceptor passing through a non token-expiry error Co-Authored-By: Claude Opus 5 --- .../interceptor/auth_interceptor_test.dart | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/stream_chat/test/src/core/http/interceptor/auth_interceptor_test.dart b/packages/stream_chat/test/src/core/http/interceptor/auth_interceptor_test.dart index cc92b01a8f..6a25cc227f 100644 --- a/packages/stream_chat/test/src/core/http/interceptor/auth_interceptor_test.dart +++ b/packages/stream_chat/test/src/core/http/interceptor/auth_interceptor_test.dart @@ -220,4 +220,37 @@ void main() { } }, ); + + // The test above sends a response with no data, so it never reaches the + // error-code check. This one carries a parseable error payload whose code is + // not `tokenExpired`: it has to pass straight through, without refreshing + // the token or replaying the request. + test( + '`onError` should pass through an error response that is not a token expiry', + () async { + const path = 'test-request-path'; + final options = RequestOptions(path: path); + const code = ChatErrorCode.inputError; + final errorResponse = ErrorResponse() + ..code = code.code + ..message = code.message; + final response = Response( + requestOptions: options, + data: errorResponse.toJson(), + ); + final err = DioException(requestOptions: options, response: response); + final handler = ErrorInterceptorHandler(); + + authInterceptor.onError(err, handler); + + await expectLater( + handler.future, + // the wrapper type is private in dio, so assert on its payload + throwsA(predicate((e) => (e as dynamic).data is DioException)), + ); + + verifyZeroInteractions(tokenManager); + verifyZeroInteractions(client); + }, + ); } From 89fefdf88182545f5dc7303cb796ee82983c29b6 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 14:12:39 +0200 Subject: [PATCH 094/103] test(llc): pin the client's default REST stack wiring Co-Authored-By: Claude Opus 5 --- .../client/client_default_wiring_test.dart | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 packages/stream_chat/test/src/client/client_default_wiring_test.dart diff --git a/packages/stream_chat/test/src/client/client_default_wiring_test.dart b/packages/stream_chat/test/src/client/client_default_wiring_test.dart new file mode 100644 index 0000000000..06e93822e2 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_default_wiring_test.dart @@ -0,0 +1,47 @@ +import 'package:dio/dio.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +// Every other client test injects a `chatApi`, so nothing exercises the +// constructor's default REST stack. This pins it: a client built without one +// has to reach the network through a real `StreamChatApi` / `StreamHttpClient` +// carrying the api key and the connected user's credentials. +void main() { + setUpAll(() => registerFallbackValue(RequestOptions())); + + test('builds a REST stack carrying the api key and user credentials', () async { + const apiKey = 'test-api-key'; + final adapter = _MockHttpClientAdapter(); + + when(() => adapter.fetch(any(), any(), any())).thenAnswer( + (_) async => ResponseBody.fromString( + '{"app":{}}', + 200, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ), + ); + + final client = StreamChatClient(apiKey, httpClientAdapter: adapter); + addTearDown(client.dispose); + + final user = User(id: 'test-user-id'); + final token = Token.development(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 {} From 14b8c4436df9fc64f329429354f55ced18c2c6df Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 14:30:01 +0200 Subject: [PATCH 095/103] test(stream_chat_test): stop tagging every harness test by default Co-Authored-By: Claude Opus 5 --- packages/stream_chat/dart_test.yaml | 3 --- packages/stream_chat_test/README.md | 9 ++++----- packages/stream_chat_test/dart_test.yaml | 3 --- .../stream_chat_test/lib/src/testers/channel_tester.dart | 2 +- .../lib/src/testers/chat_client_tester.dart | 2 +- 5 files changed, 6 insertions(+), 13 deletions(-) delete mode 100644 packages/stream_chat/dart_test.yaml delete mode 100644 packages/stream_chat_test/dart_test.yaml diff --git a/packages/stream_chat/dart_test.yaml b/packages/stream_chat/dart_test.yaml deleted file mode 100644 index 1eedd04f42..0000000000 --- a/packages/stream_chat/dart_test.yaml +++ /dev/null @@ -1,3 +0,0 @@ -tags: - chat-client: - channel: diff --git a/packages/stream_chat_test/README.md b/packages/stream_chat_test/README.md index c57e5ea9bd..1d2279ea9f 100644 --- a/packages/stream_chat_test/README.md +++ b/packages/stream_chat_test/README.md @@ -156,11 +156,10 @@ whose outcome changes is the pinning one above (flip it to expect rejection). ## Tags -Tests declared through `chatClientTest` / `channelTest` are tagged `chat-client` / -`channel` by default (`flutter test --tags channel` filters on them). This package -declares the tags in its own `dart_test.yaml`; consuming packages must declare them -in their own `dart_test.yaml` to avoid `package:test` warnings. Passing `tags:` -explicitly *replaces* the default tag. +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 diff --git a/packages/stream_chat_test/dart_test.yaml b/packages/stream_chat_test/dart_test.yaml deleted file mode 100644 index 1eedd04f42..0000000000 --- a/packages/stream_chat_test/dart_test.yaml +++ /dev/null @@ -1,3 +0,0 @@ -tags: - chat-client: - channel: diff --git a/packages/stream_chat_test/lib/src/testers/channel_tester.dart b/packages/stream_chat_test/lib/src/testers/channel_tester.dart index 4d60aeeb79..0d09888cd4 100644 --- a/packages/stream_chat_test/lib/src/testers/channel_tester.dart +++ b/packages/stream_chat_test/lib/src/testers/channel_tester.dart @@ -55,7 +55,7 @@ void channelTest( FutureOr Function(ChannelTester tester)? verify, FutureOr Function(ChannelTester tester)? tearDown, String? skip, - Iterable tags = const ['channel'], + Iterable tags = const [], test.Timeout? timeout, }) { return testWithTester( 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 index 114b02876b..ddb79c36b2 100644 --- a/packages/stream_chat_test/lib/src/testers/chat_client_tester.dart +++ b/packages/stream_chat_test/lib/src/testers/chat_client_tester.dart @@ -41,7 +41,7 @@ void chatClientTest( FutureOr Function(ChatClientTester tester)? verify, FutureOr Function(ChatClientTester tester)? tearDown, String? skip, - Iterable tags = const ['chat-client'], + Iterable tags = const [], test.Timeout? timeout, }) { return testWithTester( From fe6247262e4ec3933b7d6984f4147f39fad5cc30 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 14:35:05 +0200 Subject: [PATCH 096/103] docs(llc): make test comments stand on their own instead of pointing at other tests Co-Authored-By: Claude Opus 5 --- .../src/client/channel/channel_display_api_test.dart | 6 +++--- .../src/client/channel/channel_persistence_test.dart | 12 ++++++------ .../test/src/client/channel/channel_query_test.dart | 4 ++-- .../channel/state/message_updated_events_test.dart | 11 +++++------ .../test/src/client/client_connection_test.dart | 10 ++++------ .../client/client_persistence_connection_test.dart | 12 ++++++------ .../test/src/client/client_state_guards_test.dart | 4 ++-- .../src/client/client_with_persistence_test.dart | 6 +++--- .../core/http/interceptor/auth_interceptor_test.dart | 7 +++---- 9 files changed, 34 insertions(+), 38 deletions(-) diff --git a/packages/stream_chat/test/src/client/channel/channel_display_api_test.dart b/packages/stream_chat/test/src/client/channel/channel_display_api_test.dart index e491139ab6..760b4416c5 100644 --- a/packages/stream_chat/test/src/client/channel/channel_display_api_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_display_api_test.dart @@ -197,9 +197,9 @@ void main() { final eventReceived = expectLater( tester.channel.on(eventType), - // The old suite asserted on the event instance itself, which held - // because the event never left the process. Here it round-trips - // through the real wire decode, so pin every field it carries. + // 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) diff --git a/packages/stream_chat/test/src/client/channel/channel_persistence_test.dart b/packages/stream_chat/test/src/client/channel/channel_persistence_test.dart index c510220efd..e6811154bb 100644 --- a/packages/stream_chat/test/src/client/channel/channel_persistence_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_persistence_test.dart @@ -5,8 +5,8 @@ const _channelId = 'test-channel-id'; const _channelType = 'test-channel-type'; const _channelCid = '$_channelType:$_channelId'; -// Builds the channel already initialized from state, the way the monolith -// group's setUp did, so the state attaches while a persistence client is set. +// 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, @@ -16,10 +16,10 @@ Channel _buildInitializedChannel(StreamChatClient client) { ); } -// Stubs every persistence call the monolith group stubbed -// (`getChannelThreads`, `getChannelStateByCid`, `updateMessages`) plus the -// calls the real client makes on its own: `updateConnectionInfo` on connect -// and the debounced channel state/thread writes. +// 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()); diff --git a/packages/stream_chat/test/src/client/channel/channel_query_test.dart b/packages/stream_chat/test/src/client/channel/channel_query_test.dart index 91af0652a5..65887c168f 100644 --- a/packages/stream_chat/test/src/client/channel/channel_query_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_query_test.dart @@ -269,8 +269,8 @@ void main() { 'should NOT submit for delivery when querying with pagination (older messages)', channelType: _channelType, channelId: _channelId, - // Seeded exactly like the positive test above, so the absence of a - // receipt can only come from the pagination params. + // 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], diff --git a/packages/stream_chat/test/src/client/channel/state/message_updated_events_test.dart b/packages/stream_chat/test/src/client/channel/state/message_updated_events_test.dart index 1770dd8496..22a3ca38b3 100644 --- a/packages/stream_chat/test/src/client/channel/state/message_updated_events_test.dart +++ b/packages/stream_chat/test/src/client/channel/state/message_updated_events_test.dart @@ -326,12 +326,11 @@ void main() { endAt: DateTime.now().subtract(const Duration(minutes: 1)), ), ); - // NOTE(migration): 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 isn't loaded. Apply the update exactly as - // the `message.updated` listener would, to keep pinning the - // state-layer guard the original test covered. + // 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); diff --git a/packages/stream_chat/test/src/client/client_connection_test.dart b/packages/stream_chat/test/src/client/client_connection_test.dart index fbd7fd48e2..41a2525ff2 100644 --- a/packages/stream_chat/test/src/client/client_connection_test.dart +++ b/packages/stream_chat/test/src/client/client_connection_test.dart @@ -146,9 +146,8 @@ void main() { body: (tester) async { expect(tester.currentUser, isNull); - // NOTE(migration): the monolith establishes the connection through - // `.connectAnonymousUser`; the harness connects the regular user - // instead (see the anonymous carve-out note at the top). + // 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); @@ -183,9 +182,8 @@ void main() { ]), ); - // NOTE(migration): the monolith establishes the connection through - // `.connectAnonymousUser`; the harness connects the regular user - // instead (see the anonymous carve-out note at the top). + // 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); diff --git a/packages/stream_chat/test/src/client/client_persistence_connection_test.dart b/packages/stream_chat/test/src/client/client_persistence_connection_test.dart index bc99ba9386..920da8df2a 100644 --- a/packages/stream_chat/test/src/client/client_persistence_connection_test.dart +++ b/packages/stream_chat/test/src/client/client_persistence_connection_test.dart @@ -2,9 +2,9 @@ import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_test/stream_chat_test.dart'; // Runs [body] as a [chatClientTest] whose client is backed by a fresh -// [MockPersistenceClient] and whose WebSocket transport fails to connect, -// mirroring the monolith group's setUp: the connect attempt fails with a -// retriable error, so the client falls back to the persisted connection info. +// [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, @@ -19,9 +19,9 @@ void _failedConnectionWithPersistenceTest( ); } -// Runs [body] as a [chatClientTest] that never opens the socket, mirroring -// the monolith group's setUp/tearDown: persistence starts disabled and the -// persistence client is detached (and asserted disabled) after the body. +// 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, diff --git a/packages/stream_chat/test/src/client/client_state_guards_test.dart b/packages/stream_chat/test/src/client/client_state_guards_test.dart index 9cffc1c6ca..df863a566a 100644 --- a/packages/stream_chat/test/src/client/client_state_guards_test.dart +++ b/packages/stream_chat/test/src/client/client_state_guards_test.dart @@ -3,8 +3,8 @@ import 'package:stream_chat_test/stream_chat_test.dart'; void main() { group('ClientState mutation guards', () { - // The old suite exercised these guards on a client that was never - // connected; skipping the connect phase preserves that setup. + // The guards hold on a client that was never connected, so these skip + // the connect phase. chatClientTest( '`state.channels` returns an unmodifiable view', connect: (_) {}, diff --git a/packages/stream_chat/test/src/client/client_with_persistence_test.dart b/packages/stream_chat/test/src/client/client_with_persistence_test.dart index acffae2427..4e5f97e0d0 100644 --- a/packages/stream_chat/test/src/client/client_with_persistence_test.dart +++ b/packages/stream_chat/test/src/client/client_with_persistence_test.dart @@ -2,9 +2,9 @@ import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_test/stream_chat_test.dart'; // Runs [body] as a [chatClientTest] whose client is backed by a fresh -// [MockPersistenceClient], mirroring the monolith group's setUp: the -// persistence stubs the connect path needs are installed first, then the -// client is connected and persistence is asserted enabled. +// [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, diff --git a/packages/stream_chat/test/src/core/http/interceptor/auth_interceptor_test.dart b/packages/stream_chat/test/src/core/http/interceptor/auth_interceptor_test.dart index 6a25cc227f..acd12b2c1f 100644 --- a/packages/stream_chat/test/src/core/http/interceptor/auth_interceptor_test.dart +++ b/packages/stream_chat/test/src/core/http/interceptor/auth_interceptor_test.dart @@ -221,10 +221,9 @@ void main() { }, ); - // The test above sends a response with no data, so it never reaches the - // error-code check. This one carries a parseable error payload whose code is - // not `tokenExpired`: it has to pass straight through, without refreshing - // the token or replaying the request. + // A response whose error payload parses but is not a token expiry has to + // pass straight through, without refreshing the token or replaying the + // request. test( '`onError` should pass through an error response that is not a token expiry', () async { From 32fda919f03f607ab1185ade0f1ab20827a365d4 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 11 Sep 2026 15:51:57 +0200 Subject: [PATCH 097/103] docs(repo): document the stream_chat_test harness in the style and testing guides Co-Authored-By: Claude Opus 5 --- STYLE_GUIDE.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ TESTING.md | 6 ++++++ 2 files changed, 56 insertions(+) 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. From bb4c94fc8e0c04796dc3e6d40347bbd0424484a7 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 16 Sep 2026 11:19:03 +0200 Subject: [PATCH 098/103] test(llc): assert the non-member user merge does not write channel state Co-Authored-By: Claude Opus 5 --- .../test/src/client/channel/state/member_events_test.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/stream_chat/test/src/client/channel/state/member_events_test.dart b/packages/stream_chat/test/src/client/channel/state/member_events_test.dart index 27fb0685f0..781c1f7778 100644 --- a/packages/stream_chat/test/src/client/channel/state/member_events_test.dart +++ b/packages/stream_chat/test/src/client/channel/state/member_events_test.dart @@ -195,6 +195,11 @@ void main() { ), ); + // 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, @@ -204,6 +209,7 @@ void main() { ); expect(tester.channelState!.channelState.members?.single.user?.name, 'old-name'); + expect(identical(before, tester.channelState!.channelState), isTrue); }, ); From 3ec03a7ee5f2fb2bec4fe2e1a1acc3653c82e035 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 16 Sep 2026 19:42:29 +0200 Subject: [PATCH 099/103] test(llc): reassemble the migrated suite into the original test files Co-Authored-By: Claude Opus 5 --- .../channel_capability_check_test.dart | 97 +- .../channel/channel_client_state_test.dart | 6439 ++++++++++++++ .../channel/channel_count_events_test.dart | 369 - .../channel/channel_delete_message_test.dart | 269 - .../channel/channel_display_api_test.dart | 216 - .../client/channel/channel_drafts_test.dart | 144 - .../channel/channel_filter_tags_test.dart | 56 - .../channel/channel_location_api_test.dart | 146 - .../channel/channel_member_api_test.dart | 239 - .../channel/channel_message_queries_test.dart | 178 - .../channel/channel_misc_operations_test.dart | 121 - .../channel/channel_moderation_api_test.dart | 263 - .../channel/channel_persistence_test.dart | 65 - .../channel/channel_pin_message_test.dart | 283 - .../client/channel/channel_query_test.dart | 393 - .../channel/channel_reactions_test.dart | 731 -- .../channel/channel_read_helper_test.dart | 30 +- .../channel/channel_read_receipts_test.dart | 275 - .../channel/channel_reminders_test.dart | 169 - .../client/channel/channel_retry_test.dart | 354 - .../client/channel/channel_search_test.dart | 106 - .../channel/channel_send_action_test.dart | 84 - .../channel/channel_send_message_test.dart | 923 -- .../client/channel/channel_setters_test.dart | 170 - .../channel/channel_state_cleanup_test.dart | 227 - .../channel/channel_state_mutations_test.dart | 767 ++ .../channel_state_validation_test.dart | 391 - .../test/src/client/channel/channel_test.dart | 7800 +++++++++++++++++ .../channel_typing_indicator_test.dart | 300 - .../channel/channel_update_api_test.dart | 228 - .../channel/channel_update_message_test.dart | 1145 --- .../client/channel/channel_watch_test.dart | 177 - .../channel_push_preference_events_test.dart | 110 - .../state/channel_truncated_events_test.dart | 93 - .../state/channel_updated_events_test.dart | 67 - .../channel/state/draft_events_test.dart | 272 - .../state/local_unread_count_test.dart | 508 -- .../channel/state/location_events_test.dart | 687 -- .../channel/state/member_events_test.dart | 309 - .../state/message_deleted_events_test.dart | 244 - .../state/message_enrichment_merge_test.dart | 364 - .../state/message_new_events_test.dart | 590 -- .../state/message_updated_events_test.dart | 343 - .../channel/state/poll_events_test.dart | 385 - .../channel/state/reaction_events_test.dart | 200 - .../channel/state/read_events_test.dart | 753 -- .../channel/state/reminder_events_test.dart | 373 - .../state/shown_thread_reply_events_test.dart | 181 - .../channel/state/typing_events_test.dart | 99 - ...ate_channel_state_identity_guard_test.dart | 164 - .../update_message_quoted_rewrite_test.dart | 129 - .../user_messages_deleted_events_test.dart | 613 -- .../channel/state/watching_events_test.dart | 234 - .../test/src/client/client_api_test.dart | 1481 ---- .../src/client/client_block_user_test.dart | 181 - .../src/client/client_channel_api_test.dart | 864 -- .../src/client/client_connection_test.dart | 319 - .../client/client_default_wiring_test.dart | 47 - .../test/src/client/client_events_test.dart | 138 - .../src/client/client_live_location_test.dart | 279 - .../client_persistence_connection_test.dart | 310 - .../src/client/client_pin_message_test.dart | 188 - .../src/client/client_polls_api_test.dart | 377 - .../client/client_query_channels_test.dart | 290 - .../src/client/client_reconnection_test.dart | 553 -- .../src/client/client_state_guards_test.dart | 169 - .../test/src/client/client_sync_test.dart | 53 - .../test/src/client/client_test.dart | 6501 ++++++++++++++ .../test/src/client/client_user_api_test.dart | 572 -- .../client/client_with_persistence_test.dart | 725 -- 70 files changed, 21559 insertions(+), 21361 deletions(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_client_state_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_count_events_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_delete_message_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_display_api_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_drafts_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_filter_tags_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_location_api_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_member_api_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_message_queries_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_misc_operations_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_moderation_api_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_persistence_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_pin_message_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_query_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_reactions_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_read_receipts_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_reminders_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_retry_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_search_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_send_action_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_send_message_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_setters_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_state_cleanup_test.dart create mode 100644 packages/stream_chat/test/src/client/channel/channel_state_mutations_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_state_validation_test.dart create mode 100644 packages/stream_chat/test/src/client/channel/channel_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_typing_indicator_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_update_api_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_update_message_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/channel_watch_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/state/channel_push_preference_events_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/state/channel_truncated_events_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/state/channel_updated_events_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/state/draft_events_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/state/local_unread_count_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/state/location_events_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/state/member_events_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/state/message_deleted_events_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/state/message_enrichment_merge_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/state/message_new_events_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/state/message_updated_events_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/state/poll_events_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/state/reaction_events_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/state/read_events_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/state/reminder_events_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/state/shown_thread_reply_events_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/state/typing_events_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/state/update_channel_state_identity_guard_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/state/update_message_quoted_rewrite_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/state/user_messages_deleted_events_test.dart delete mode 100644 packages/stream_chat/test/src/client/channel/state/watching_events_test.dart delete mode 100644 packages/stream_chat/test/src/client/client_api_test.dart delete mode 100644 packages/stream_chat/test/src/client/client_block_user_test.dart delete mode 100644 packages/stream_chat/test/src/client/client_channel_api_test.dart delete mode 100644 packages/stream_chat/test/src/client/client_connection_test.dart delete mode 100644 packages/stream_chat/test/src/client/client_default_wiring_test.dart delete mode 100644 packages/stream_chat/test/src/client/client_events_test.dart delete mode 100644 packages/stream_chat/test/src/client/client_live_location_test.dart delete mode 100644 packages/stream_chat/test/src/client/client_persistence_connection_test.dart delete mode 100644 packages/stream_chat/test/src/client/client_pin_message_test.dart delete mode 100644 packages/stream_chat/test/src/client/client_polls_api_test.dart delete mode 100644 packages/stream_chat/test/src/client/client_query_channels_test.dart delete mode 100644 packages/stream_chat/test/src/client/client_reconnection_test.dart delete mode 100644 packages/stream_chat/test/src/client/client_state_guards_test.dart delete mode 100644 packages/stream_chat/test/src/client/client_sync_test.dart create mode 100644 packages/stream_chat/test/src/client/client_test.dart delete mode 100644 packages/stream_chat/test/src/client/client_user_api_test.dart delete mode 100644 packages/stream_chat/test/src/client/client_with_persistence_test.dart 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 693f184b1d..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 @@ -4,11 +4,7 @@ import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_test/stream_chat_test.dart'; void main() { - // ============================================================ - // FEATURE: Capability Getters - // ============================================================ - - group('Channel Capability Check - Capability Getters', () { + group('ChannelCapabilityCheck', () { /// Parameterized test for channel capability extension properties. void testCapability( String capabilityName, @@ -16,7 +12,7 @@ void main() { bool Function(Channel) getterMethod, ) { channelTest( - 'can$capabilityName - should return false when capability is absent', + 'can$capabilityName returns false when capability is absent', setUp: (tester) => tester.watch(), body: (tester) async { expect(getterMethod(tester.channel), false); @@ -24,7 +20,7 @@ void main() { ); channelTest( - 'can$capabilityName - should return true when capability is present', + 'can$capabilityName returns true when capability is present', setUp: (tester) => tester.watch( modifyResponse: (state) => state.copyWith( channel: createDefaultChannelModel(ownCapabilities: [capability]), @@ -290,7 +286,7 @@ void main() { ); channelTest( - 'should return correct values with multiple capabilities', + 'returns correct values with multiple capabilities', setUp: (tester) => tester.watch( modifyResponse: (state) => state.copyWith( channel: createDefaultChannelModel( @@ -310,57 +306,52 @@ void main() { expect(tester.channel.canUpdateChannel, false); }, ); - }); - - // ============================================================ - // FEATURE: Local Unread Count - // ============================================================ - - group('Channel Capability Check - Local Unread Count', () { - channelTest( - 'usesLocalUnreadCount - should be false when disabled and read receipts are unavailable', - setUp: (tester) => tester.watch(), - body: (tester) async { - expect(tester.channel.usesLocalUnreadCount, 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( - 'usesLocalUnreadCount - should be false when disabled and read receipts are available', - setUp: (tester) => tester.watch( - modifyResponse: (state) => state.copyWith( - channel: createDefaultChannelModel( - ownCapabilities: [ChannelCapability.readEvents], + 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); - }, - ); + body: (tester) async { + expect(tester.channel.usesLocalUnreadCount, false); + }, + ); - channelTest( - 'usesLocalUnreadCount - should be false when enabled but the channel supports read receipts', - isLocalUnreadCountEnabled: true, - setUp: (tester) => tester.watch( - modifyResponse: (state) => state.copyWith( - channel: createDefaultChannelModel( - ownCapabilities: [ChannelCapability.readEvents], + 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); - }, - ); + body: (tester) async { + expect(tester.channel.usesLocalUnreadCount, false); + }, + ); - channelTest( - 'usesLocalUnreadCount - should be true when enabled and read receipts are unavailable', - isLocalUnreadCountEnabled: true, - setUp: (tester) => tester.watch(), - body: (tester) async { - expect(tester.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); + }, + ); + }); }); } 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 new file mode 100644 index 0000000000..c88301b09e --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart @@ -0,0 +1,6439 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; +const _channelCid = '$_channelType:$_channelId'; + +void main() { + group('WS events', () { + 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'); + + await tester.emitEvent( + createDefaultEvent(type: EventType.typingStart, cid: tester.channel.cid, user: otherUser), + ); + + final typingEvents = tester.channelState!.typingEvents; + expect(typingEvents.keys.map((u) => u.id), ['other-user']); + expect(typingEvents.values.single.type, EventType.typingStart); + }, + ); + + 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'); + + 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)); + + await tester.emitEvent(createDefaultEvent(type: EventType.typingStop, cid: tester.channel.cid, user: user1)); + + expect(tester.channelState!.typingEvents.keys.map((u) => u.id), ['other-user-2']); + }, + ); + + 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); + + 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); + }, + ); + + 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)); + + 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, + ), + ); + } + + Event _newMessageEvent(Message message) => createDefaultEvent( + type: EventType.messageNew, + cid: _channelCid, + message: message, + ); + + channelTest( + "should update 'channel.lastMessageAt'", + 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, + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channel.lastMessageAt, equals(message.createdAt)); + expect(tester.channel.lastMessageAt, isNot(_initialLastMessageAt)); + }, + ); + + 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)); + + 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)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channel.lastMessageAt, equals(message.createdAt)); + expect(tester.channel.lastMessageAt, isNot(_initialLastMessageAt)); + }, + ); + + 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)); + + expect(tester.channel.lastMessageAt, isNot(message.createdAt)); + expect(tester.channel.lastMessageAt, equals(_initialLastMessageAt)); + }, + ); + + 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)); + + final message = Message( + id: 'test-message-id', + user: tester.currentUser, + shadowed: true, + 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 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)), + ); + + 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 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)); + + 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)), + ); + + 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', + silent: true, + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channelState?.unreadCount, equals(0)); + }, + ); + + 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', + shadowed: true, + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channelState?.unreadCount, equals(0)); + }, + ); + + 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', + type: MessageType.ephemeral, + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channelState?.unreadCount, equals(0)); + }, + ); + + 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', + parentId: 'test-parent-id', + showInChannel: false, + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channelState?.unreadCount, equals(0)); + }, + ); + + 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', + parentId: 'test-parent-id', + showInChannel: false, + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channelState?.unreadCount, equals(0)); + }, + ); + + 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: tester.currentUser, + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channelState?.unreadCount, equals(0)); + }, + ); + + 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( + id: 'test-message-id', + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + restrictedVisibility: const ['other-user-2'], + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channelState?.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)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + // The delivery reporter batches receipts behind a 1s trailing throttle. + await Future.delayed(const Duration(milliseconds: 1100)); + + 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'); + }, + ); + + 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, + ); + + // 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)), + ); + + await tester.channel.sendMessage(localMessage); + + expect(tester.channelState!.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)), + ); + await tester.emitEvent(_newMessageEvent(serverMessage)); + + // 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)); + }, + ); + + 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)); + + // 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)); + }, + ); + + 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)), + ); + + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.notificationMessageNew, + message: message, + ), + ); + + expect(tester.channelState!.messages.map((m) => m.id), ['notified-message-id']); + expect(tester.channelState!.unreadCount, 1); + }, + ); + + 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; + + final message = Message( + id: 'below-window-message-id', + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + await tester.emitEvent(_newMessageEvent(message)); + + expect(tester.channelState!.messages, isEmpty); + expect(tester.channelState!.unreadCount, 1); + }, + ); + + 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; + + final reply = Message( + id: 'thread-reply-id', + parentId: 'parent-message-id', + user: User(id: 'other-user'), + createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + await tester.emitEvent(_newMessageEvent(reply)); + + expect(tester.channelState!.messages, isEmpty); + expect(tester.channelState!.threads['parent-message-id']?.map((m) => m.id), ['thread-reply-id']); + }, + ); + + 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)); + + expect(tester.channelState!.messages, isEmpty); + expect(tester.channel.lastMessageAt, _initialLastMessageAt); + }, + ); + }); + + group(EventType.messageUpdated, () { + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); + } + + Event _updateMessageEvent(Message message) => createDefaultEvent( + type: EventType.messageUpdated, + cid: _channelCid, + message: message, + ); + + // 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, + ); + + await tester.emitEvent(_updateMessageEvent(message)); + + expect(tester.channelState?.pinnedMessages.length, equals(1)); + expect(tester.channelState?.pinnedMessages.first.id, equals(messageId)); + }, + ); + + 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, + ); + + await tester.emitEvent(_updateMessageEvent(message)); + + expect(tester.channelState?.pinnedMessages.length, equals(1)); + expect(tester.channelState?.pinnedMessages.first.id, equals(messageId)); + expect(tester.channelState?.pinnedMessages.first.text, equals(oldText)); + + final updatedMessage = message.copyWith(text: newText); + await tester.emitEvent(_updateMessageEvent(updatedMessage)); + + expect(tester.channelState?.pinnedMessages.length, equals(1)); + expect(tester.channelState?.pinnedMessages.first.id, equals(messageId)); + expect(tester.channelState?.pinnedMessages.first.text, equals(newText)); + }, + ); + + 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); + + await tester.emitEvent(_updateMessageEvent(firstMessage)); + + expect(tester.channelState?.pinnedMessages.length, equals(1)); + expect( + tester.channelState?.pinnedMessages.first.id, + equals(firstMessageId), + ); + + await tester.emitEvent(_updateMessageEvent(secondMessage)); + + expect(tester.channelState?.pinnedMessages.length, equals(2)); + expect( + tester.channelState?.pinnedMessages.first.id, + equals(firstMessageId), + ); + expect( + tester.channelState?.pinnedMessages[1].id, + equals(secondMessageId), + ); + }, + ); + + 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, + ); + + await tester.emitEvent(_updateMessageEvent(pinnedMessage)); + + expect(tester.channelState?.pinnedMessages.length, equals(1)); + expect(tester.channelState?.pinnedMessages.first.id, equals(messageId)); + + final unpinnedMessage = pinnedMessage.copyWith(pinned: false); + await tester.emitEvent(_updateMessageEvent(unpinnedMessage)); + + expect(tester.channelState?.pinnedMessages, isEmpty); + }, + ); + + 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)), + ), + ); + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith(messages: tail), + ); + 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)); + + // 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); + }, + ); + + 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 edited = seeded.copyWith(text: 'new'); + await tester.emitEvent(_updateMessageEvent(edited)); + + final stored = tester.channelState!.messages.singleWhere((m) => m.id == messageId); + expect(stored.text, equals('new')); + }, + ); + + 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); + + const messageId = 'pin-me'; + final pinned = Message( + id: messageId, + user: tester.currentUser, + pinned: true, + ); + await tester.emitEvent(_updateMessageEvent(pinned)); + + expect(tester.channelState!.messages, isEmpty); + expect(tester.channelState!.pinnedMessages.length, equals(1)); + expect(tester.channelState!.pinnedMessages.first.id, equals(messageId)); + }, + ); + + 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)); + + tester.channelState!.isUpToDate = false; + + final phantomReply = Message( + id: 'other-reply', + parentId: parentId, + user: tester.currentUser, + text: 'edited', + createdAt: DateTime.utc(2026, 1, 2), + ); + await tester.emitEvent(_updateMessageEvent(phantomReply)); + + expect(tester.channelState!.threads[parentId]!.map((m) => m.id), ['known-reply']); + }, + ); + + 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); + + tester.channelState!.isUpToDate = false; + + final phantomReply = Message( + id: 'phantom-reply', + parentId: parentId, + user: tester.currentUser, + text: 'edited', + createdAt: DateTime.utc(2026, 1, 2), + ); + await tester.emitEvent(_updateMessageEvent(phantomReply)); + + // The dropped reply must not leave behind an empty thread entry. + expect(tester.channelState!.threads.containsKey(parentId), isFalse); + }, + ); + + 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)), + ); + + // 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(tester.channelState!.messages, isEmpty); + expect(tester.channelState!.activeLiveLocations, isEmpty); + }, + ); + }); + }); + + group('reply events with `show_in_channel = true` and unloaded thread', () { + const _replyId = 'mirrored-reply-id'; + + const _parentId = 'parent-message-id'; + + // Pinned createdAt keeps oldIndex lookups stable in `updateMessage`. + final _createdAt = DateTime.utc(2026); + + ChannelState _seedChannel(ChannelState _) { + return createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); + } + + // 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, + ); + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith(messages: [reply]), + ); + return reply; + } + + // 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'), + ); + 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], + ), + ), + ); + + final stored = tester.channelState!.messages.firstWhere((it) => it.id == _replyId); + expect(stored.ownReactions, [ownReaction]); + }, + ); + + 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, + ), + ), + ); + + final stored = tester.channelState!.messages.firstWhere((it) => it.id == _replyId); + expect(stored.ownReactions, [kept]); + }, + ); + + 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); + + 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', + ), + ), + ); + + 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); + }, + ); + }); + + group('Reaction events', () { + const _messageId = 'reaction-message-id'; + + // Pinned createdAt keeps message index lookups stable in `updateMessage`. + final _createdAt = DateTime.utc(2021, 3); + + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); + } + + Message _seedMessage(ChannelTester tester, {String? parentId, List ownReactions = const []}) { + final message = Message( + id: _messageId, + parentId: parentId, + user: User(id: 'other-user'), + text: 'react to me', + createdAt: _createdAt, + ownReactions: ownReactions, + ); + tester.channelState!.updateMessage(message); + return message; + } + + 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], + ), + ), + ); + + final stored = tester.channelState!.messages.firstWhere((it) => it.id == _messageId); + expect(stored.ownReactions?.map((r) => r.type), ['like']); + }, + ); + + 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, + ), + ), + ); + + final stored = tester.channelState!.threads[parentId]!.firstWhere((it) => it.id == _messageId); + expect(stored.ownReactions?.map((r) => r.type), ['like']); + }, + ); + + 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, + ), + ), + ); + + final stored = tester.channelState!.messages.firstWhere((it) => it.id == _messageId); + expect(stored.ownReactions?.map((r) => r.type), ['like']); + }, + ); + + 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, + ), + ), + ); + + final stored = tester.channelState!.threads[parentId]!.firstWhere((it) => it.id == _messageId); + expect(stored.ownReactions, isEmpty); + }, + ); + }); + + group('Poll events', () { + const _pollMessageId = 'poll-message-id'; + + const _pollId = 'poll-id'; + + final _createdAt = DateTime.utc(2021, 3); + + ChannelState _seedChannel(ChannelState _) => + createDefaultChannelState(channel: createDefaultChannelModel(cid: _channelCid)); + + Poll _createPoll({ + String name = 'Favorite color?', + List latestAnswers = const [], + List ownVotesAndAnswers = const [], + }) { + return Poll( + id: _pollId, + name: name, + options: const [ + PollOption(id: 'option-a', text: 'A'), + PollOption(id: 'option-b', text: 'B'), + ], + latestAnswers: latestAnswers, + ownVotesAndAnswers: ownVotesAndAnswers, + ); + } + + Message _seedPollMessage( + ChannelTester tester, { + String? parentId, + List latestAnswers = const [], + List ownVotesAndAnswers = const [], + }) { + final message = Message( + id: _pollMessageId, + parentId: parentId, + user: User(id: 'other-user'), + createdAt: _createdAt, + poll: _createPoll( + latestAnswers: latestAnswers, + ownVotesAndAnswers: ownVotesAndAnswers, + ), + ); + tester.channelState!.updateMessage(message); + return message; + } + + Message _storedPollMessage(ChannelTester tester) { + return tester.channelState!.messages.firstWhere((it) => it.id == _pollMessageId); + } + + 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(), + ), + ), + ); + + expect(_storedPollMessage(tester).poll?.id, _pollId); + }, + ); + + 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, + ), + ), + ); + + expect(tester.channelState!.messages, isEmpty); + }, + ); + + 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'), + ), + ); + + final stored = _storedPollMessage(tester); + 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')], + ), + ), + ); + + 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'), + ), + ); + + final stored = tester.channelState!.threads[parentId]!.firstWhere((it) => it.id == _pollMessageId); + expect(stored.poll?.name, 'Renamed'); + }, + ); + + 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'), + ), + ); + + final stored = _storedPollMessage(tester); + expect(stored.poll?.isClosed, isTrue); + expect(stored.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, + ), + ); + + 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']); + + 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, + ), + ); + + 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']); + }, + ); + + 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, + ), + ); + + var stored = _storedPollMessage(tester); + expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-vote-id']); + + 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, + ), + ); + + stored = _storedPollMessage(tester); + expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-vote-id']); + }, + ); + + 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, + ), + ); + + expect( + _storedPollMessage(tester).poll?.ownVotesAndAnswers.map((v) => v.id), + contains('changed-vote-id'), + ); + }, + ); + + 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, + ), + ); + + 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); + }, + ); + }); + + group(EventType.messageDeleted, () { + final _deletedAt = DateTime.utc(2026, 7); + + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); + } + + Event _deleteMessageEvent(Message message, {bool hardDelete = false}) { + return createDefaultEvent( + cid: _channelCid, + type: EventType.messageDeleted, + message: message.copyWith( + type: MessageType.deleted, + deletedAt: _deletedAt, + ), + hardDelete: hardDelete, + ); + } + + // 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)); + + final olderPage = Message( + id: 'older-page-msg', + user: tester.currentUser, + text: 'gone', + createdAt: DateTime.utc(2025, 1, 1), + ); + await tester.emitEvent(_deleteMessageEvent(olderPage)); + + expect(tester.channelState!.messages.map((m) => m.id), ['tail-0', 'tail-1', 'tail-2']); + }, + ); + + 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; + + await tester.emitEvent(_deleteMessageEvent(seeded)); + + final stored = tester.channelState!.messages.singleWhere((m) => m.id == messageId); + expect(stored.type, equals(MessageType.deleted)); + expect(stored.deletedAt, isNotNull); + }, + ); + + 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); + }, + ); + + 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)), + ); + + // 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); + }, + ); + + 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)); + + expect(tester.channelState!.messages, isEmpty); + expect(tester.channelState!.pinnedMessages, isEmpty); + }, + ); + }); + + 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.utc(2026), + ); + tester.channelState!.updateMessage(message); + expect(tester.channelState!.messages, hasLength(1)); + + await tester.emitEvent(_deleteMessageEvent(message, hardDelete: true)); + + expect(tester.channelState!.messages, isEmpty); + }, + ); + + 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.utc(2026), + ); + tester.channelState!.updateMessage(message); + + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.messageDeleted, + deletedForMe: true, + message: message.copyWith( + type: MessageType.deleted, + deletedAt: _deletedAt, + ), + ), + ); + + final stored = tester.channelState!.messages.single; + expect(stored.deletedForMe, isTrue); + expect(stored.type, MessageType.deleted); + }, + ); + }); + + group('Channel updated events', () { + ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); + + 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'}, + ), + ), + ); + + expect(tester.channel.memberCount, 42); + expect(tester.channel.extraData['name'], 'updated-name'); + }, + ); + + 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'), + ], + ), + ); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.channelUpdated, + channel: createDefaultChannelModel( + cid: _channelCid, + members: [Member(userId: 'member-3')], + ), + ), + ); + + expect(tester.channelState!.channelState.members?.map((m) => m.userId), ['member-3']); + }, + ); + }); + + group('Channel truncated events', () { + final _seededCreatedAt = DateTime.utc(2021, 3); + + 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(ChannelTester tester, String id, {DateTime? createdAt}) { + final message = Message( + id: id, + user: User(id: 'other-user'), + text: 'to be truncated', + createdAt: createdAt ?? _seededCreatedAt, + ); + tester.channelState!.updateMessage(message); + return message; + } + + 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), + ), + ); + + expect(tester.channelState!.messages, isEmpty); + verify(() => truncatedPersistence.deleteMessageByCid(tester.channel.cid!)).called(1); + }, + ); + + 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(tester.channelState!.messages.map((m) => m.id), ['system-message-id']); + }, + ); + }); + + group('Member Events', () { + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); + } + + channelTest( + 'should update membership when member is updated and is current user', + 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.utc(2021, 3); + + // Setup initial membership + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + members: [currentMember], + membership: currentMember, + ), + ); + + // Verify initial state + 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( + channelRole: 'moderator', + isModerator: true, + pinnedAt: now, + archivedAt: now, + ); + + // Create member updated event + final memberUpdatedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberUpdated, + user: currentUser, + member: updatedMember, + ); + + // Dispatch event + await tester.emitEvent(memberUpdatedEvent); + + // Verify membership is updated with new properties + 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); + }, + ); + + channelTest( + 'should update membership user when any event containing user is updated', + 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 + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + members: [currentMember], + membership: currentMember, + ), + ); + + // Verify initial state + 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 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.any, + user: updatedUser, + ); + + // Dispatch event + await tester.emitEvent(anyEvent); + + // Verify membership is updated with new properties + expect(tester.channel.membership, isNotNull); + expect(tester.channel.membership?.user?.id, equals(updatedUser?.id)); + expect(tester.channel.membership?.user?.role, equals(updatedUser?.role)); + }, + ); + + 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(tester.channelState!.channelState.members?.map((m) => m.userId), ['new-member']); + }, + ); + + channelTest( + '${EventType.memberRemoved} removes the 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'), + Member(userId: 'member-2'), + ], + ), + ); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberRemoved, + user: User(id: 'member-1'), + ), + ); + + expect(tester.channelState!.channelState.members?.map((m) => m.userId), ['member-2']); + }, + ); + + 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')], + ), + ); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberUpdated, + member: Member(userId: 'member-1', channelRole: 'channel_moderator'), + ), + ); + + 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', () { + Future setUpBannedMember(ChannelTester tester) async { + await tester.watch(modifyResponse: _seedChannel()); + + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith( + members: [ + Member( + userId: 'bad-user', + user: User(id: 'bad-user'), + channelRole: 'channel_member', + ), + ], + ), + ); + + tester.mockApi( + (api) => api.general.queryMembers( + _channelType, + channelId: _channelId, + filter: Filter.equal('id', 'bad-user'), + members: any(named: 'members'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + result: QueryMembersResponse()..members = [Member(userId: 'bad-user', channelRole: 'channel_banned')], + ); + } + + 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(tester.channelState!.channelState.members?.single.channelRole, 'channel_banned'); + }, + ); + + 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(tester.channelState!.channelState.members?.single.channelRole, 'channel_banned'); + }, + ); + + 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(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', () { + final _messageCreatedAt = DateTime.utc(2021, 3); + + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); + } + + channelTest( + '${EventType.userWatchingStart} adds the watcher and updates watcherCount', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final watcher = User(id: 'watcher-1'); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userWatchingStart, + user: watcher, + watcherCount: 3, + ), + ); + + expect(tester.channelState!.watcherCount, 3); + expect( + tester.channelState!.channelState.watchers?.map((it) => it.id), + contains('watcher-1'), + ); + }, + ); + + channelTest( + '${EventType.userWatchingStop} removes the watcher and updates watcherCount', + 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). + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userWatchingStart, + user: watcher, + watcherCount: 2, + ), + ); + expect(tester.channelState!.watcherCount, 2); + expect( + tester.channelState!.channelState.watchers?.map((it) => it.id), + contains('watcher-1'), + ); + + // Then stops watching (count = 1). + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userWatchingStop, + user: watcher, + watcherCount: 1, + ), + ); + + expect(tester.channelState!.watcherCount, 1); + expect( + tester.channelState!.channelState.watchers?.map((it) => it.id), + isNot(contains('watcher-1')), + ); + }, + ); + + channelTest( + 'watching event without watcherCount preserves the existing count', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + // Seed an initial watcher count. + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith(watcherCount: 5), + ); + expect(tester.channelState!.watcherCount, 5); + + // A watching event that omits watcher_count must not wipe the count. + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userWatchingStart, + user: User(id: 'watcher-2'), + ), + ); + + expect(tester.channelState!.watcherCount, 5); + expect( + tester.channelState!.channelState.watchers?.map((it) => it.id), + contains('watcher-2'), + ); + }, + ); + + channelTest( + '${EventType.messageNew} updates watcherCount from the event', + 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: tester.currentUser, + createdAt: _messageCreatedAt, + ); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageNew, + message: message, + watcherCount: 7, + ), + ); + + expect(tester.channelState!.watcherCount, 7); + }, + ); + + channelTest( + '${EventType.messageNew} without watcherCount preserves the existing count', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + // Seed an initial watcher count. + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith(watcherCount: 4), + ); + expect(tester.channelState!.watcherCount, 4); + + // A local/optimistic message.new without watcher_count must not + // reset the count. + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageNew, + message: Message( + id: 'test-message-id-2', + user: tester.currentUser, + createdAt: _messageCreatedAt, + ), + ), + ); + + expect(tester.channelState!.watcherCount, 4); + }, + ); + + channelTest( + '${EventType.notificationMessageNew} does not overwrite watcherCount', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + // Seed a known watcher count. + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith(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. + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.notificationMessageNew, + message: Message( + id: 'notif-message-id', + user: User(id: 'other-user'), + createdAt: _messageCreatedAt, + ), + watcherCount: 0, + ), + ); + + expect(tester.channelState!.watcherCount, 5); + }, + ); + + 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); + + 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', () { + 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, + ); + } + + 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 + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [currentRead], + ), + ); + + // 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); + + // 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', + ); + + // Dispatch event + await tester.emitEvent(messageReadEvent); + + // 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); + }, + ); + + channelTest( + 'should add a new read state if not exist on message read 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'); + + // Verify initial state + final read = tester.channelState?.read; + expect(read, isEmpty); + + // Create mark read notification event + final markReadEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageRead, + user: currentUser, + createdAt: DateTime.utc(2022), + unreadMessages: 0, + lastReadMessageId: 'message-123', + ); + + // Dispatch event + await tester.emitEvent(markReadEvent); + + // Verify read list has not changed + final updated = tester.channelState?.read; + expect(updated?.length, 1); + expect(updated?.any((r) => r.user.id == currentUser.id), isTrue); + }, + ); + + channelTest( + 'should not update channel read state on thread 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, + lastReadMessageId: 'channel-msg-1', + ); + + // Setup initial channel read state + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [currentRead], + ), + ); + + // Verify initial state + final read = tester.channelState?.read.first; + expect(read?.unreadMessages, 10); + expect(read?.lastReadMessageId, 'channel-msg-1'); + expect(read?.lastRead.isAtSameMomentAs(DateTime.utc(2020)), isTrue); + + // Create a thread-scoped message.read event (thread != null) + final threadMessageReadEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageRead, + user: currentUser, + createdAt: DateTime.utc(2022), + lastReadMessageId: 'thread-reply-99', + thread: Thread( + channelCid: tester.channel.cid!, + parentMessageId: 'parent-msg-1', + createdByUserId: currentUser.id, + replyCount: 3, + participantCount: 2, + ), + ); + + // Dispatch event + await tester.emitEvent(threadMessageReadEvent); + + // Channel read state must be untouched — thread reads + // must not clobber the channel-level Read. + final after = tester.channelState?.read.first; + expect(after?.unreadMessages, 10); + expect(after?.lastReadMessageId, 'channel-msg-1'); + expect(after?.lastRead.isAtSameMomentAs(DateTime.utc(2020)), isTrue); + }, + ); + + 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 + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [currentRead], + ), + ); + + // 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); + + // 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', + ); + + // Dispatch event + await tester.emitEvent(markUnreadEvent); + + // 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); + }, + ); + + channelTest( + 'should add a new read state if not exist on notification mark unread', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + // Verify initial state + final read = tester.channelState?.read; + expect(read, isEmpty); + + // Create event for non-existing user + final markUnreadEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.notificationMarkUnread, + user: User(id: 'non-existing-user'), + lastReadAt: DateTime.utc(2019), + unreadMessages: 15, + lastReadMessageId: 'message-100', + ); + + // Dispatch event + await tester.emitEvent(markUnreadEvent); + + // Verify read list has not changed + final updated = tester.channelState?.read; + expect(updated?.length, 1); + expect(updated?.any((r) => r.user.id == 'non-existing-user'), isTrue); + }, + ); + + channelTest( + 'should preserve delivery info 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, + lastDeliveredAt: DateTime.utc(2021), + lastDeliveredMessageId: 'delivered-msg-456', + ); + + // Setup initial read state with delivery info + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [currentRead], + ), + ); + + // Verify initial state + final read = tester.channelState?.read.first; + expect(read?.lastDeliveredAt, isNotNull); + expect( + read?.lastDeliveredAt?.isAtSameMomentAs(DateTime.utc(2021)), + isTrue, + ); + expect(read?.lastDeliveredMessageId, 'delivered-msg-456'); + + // Create message read event (doesn't include delivery info) + final messageReadEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageRead, + user: currentUser, + createdAt: DateTime.utc(2022), + unreadMessages: 0, + lastReadMessageId: 'message-123', + ); + + // Dispatch event + await tester.emitEvent(messageReadEvent); + + // Verify read state is updated but delivery info is preserved + 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, + ); + // Delivery info should be preserved + expect(updatedRead?.lastDeliveredAt, isNotNull); + expect( + updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime.utc(2021)), + isTrue, + ); + expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); + }, + ); + + channelTest( + 'should reconcile delivery when message read event is from current user', + 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(), + ); + + // 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 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageRead, + user: tester.currentUser, + createdAt: DateTime.utc(2022), + unreadMessages: 0, + lastReadMessageId: 'message-123', + ); + + // Dispatch event + await tester.emitEvent(messageReadEvent); + + // 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 the reconciled receipt was never sent + tester.verifyNeverCalled((api) => api.channel.markChannelsDelivered(any())); + }, + ); + + channelTest( + 'should reset unread count on notification mark read event', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final currentUser = tester.currentUser!; + final currentRead = Read( + user: currentUser, + lastRead: DateTime.utc(2020), + unreadMessages: 10, + ); + + // Setup initial read state + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [currentRead], + ), + ); + + // Verify initial state + 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. + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.notificationMarkRead, + user: currentUser, + createdAt: DateTime.utc(2022), + lastReadMessageId: 'message-123', + ), + ); + + // Verify read state is updated + final updatedRead = tester.channelState?.read.first; + expect(updatedRead?.user.id, currentUser.id); + expect(tester.channelState?.unreadCount, 0); + expect(updatedRead?.lastReadMessageId, 'message-123'); + expect( + updatedRead?.lastRead.isAtSameMomentAs(DateTime.utc(2022)), + isTrue, + ); + }, + ); + + channelTest( + 'should preserve delivery info on notification mark 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, + lastDeliveredAt: DateTime.utc(2021), + lastDeliveredMessageId: 'delivered-msg-456', + ); + + // Setup initial read state + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [currentRead], + ), + ); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.notificationMarkRead, + user: currentUser, + createdAt: DateTime.utc(2022), + lastReadMessageId: 'message-123', + ), + ); + + // Verify read state is updated but delivery info is preserved + final updatedRead = tester.channelState?.read.first; + expect(updatedRead?.unreadMessages, 0); + expect( + updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime.utc(2021)), + isTrue, + ); + expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); + }, + ); + + channelTest( + 'should not update channel read state on thread notification mark ' + '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, + lastReadMessageId: 'channel-msg-1', + ); + + // Setup initial read state + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [currentRead], + ), + ); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.notificationMarkRead, + user: currentUser, + createdAt: DateTime.utc(2022), + lastReadMessageId: 'thread-reply-99', + thread: Thread( + channelCid: tester.channel.cid!, + parentMessageId: 'parent-msg-1', + createdByUserId: currentUser.id, + replyCount: 3, + participantCount: 2, + ), + ), + ); + + // Channel read state must be untouched — thread reads + // must not clobber the channel-level Read. + final after = tester.channelState?.read.first; + expect(after?.unreadMessages, 10); + expect(after?.lastReadMessageId, 'channel-msg-1'); + expect(after?.lastRead.isAtSameMomentAs(DateTime.utc(2020)), isTrue); + }, + ); + + channelTest( + 'should reconcile delivery when notification mark read event is from ' + 'current user', + 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(), + ); + + // 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), + ); + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.notificationMarkRead, + user: tester.currentUser, + createdAt: DateTime.utc(2022), + lastReadMessageId: 'message-123', + ), + ); + + // 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 the reconciled receipt was never sent + tester.verifyNeverCalled((api) => api.channel.markChannelsDelivered(any())); + }, + ); + + 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 + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [currentRead], + ), + ); + + // 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); + + // Create message delivered event + final messageDeliveredEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageDelivered, + user: currentUser, + lastDeliveredAt: DateTime.utc(2022), + lastDeliveredMessageId: 'message-456', + ); + + // Dispatch event + await tester.emitEvent(messageDeliveredEvent); + + // 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'); + }, + ); + + channelTest( + 'should add a new read state if not exist on message delivered event', + 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 = tester.channelState?.read; + expect(read, isEmpty); + + // Create message delivered event for new user + final messageDeliveredEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageDelivered, + user: newUser, + lastDeliveredAt: DateTime.utc(2022), + lastDeliveredMessageId: 'message-789', + ); + + // Dispatch event + await tester.emitEvent(messageDeliveredEvent); + + // Verify read state was created with delivery info + 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.utc(2022)), + isTrue, + ); + expect(newRead?.lastDeliveredMessageId, 'message-789'); + // lastRead should default to distantPast + expect( + newRead?.lastRead.isAtSameMomentAs(distantPast), + isTrue, + ); + }, + ); + + channelTest( + 'should preserve read info on message delivered 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, + lastReadMessageId: 'read-msg-123', + ); + + // Setup initial read state + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [currentRead], + ), + ); + + // Verify initial state + 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 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageDelivered, + user: currentUser, + lastDeliveredAt: DateTime.utc(2022), + lastDeliveredMessageId: 'delivered-msg-456', + ); + + // Dispatch event + await tester.emitEvent(messageDeliveredEvent); + + // Verify delivery state is updated but read info is preserved + final updatedRead = tester.channelState?.read.first; + expect(updatedRead?.user.id, 'test-user'); + expect( + updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime.utc(2022)), + isTrue, + ); + expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); + // Read info should be preserved + expect( + updatedRead?.lastRead.isAtSameMomentAs(DateTime.utc(2020)), + isTrue, + ); + expect(updatedRead?.unreadMessages, 10); + expect(updatedRead?.lastReadMessageId, 'read-msg-123'); + }, + ); + + channelTest( + 'should reconcile delivery when message delivered event is from current user', + 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(), + ); + + // 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 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageDelivered, + user: tester.currentUser, + lastDeliveredAt: DateTime.utc(2022), + lastDeliveredMessageId: 'message-456', + ); + + // Dispatch event + await tester.emitEvent(messageDeliveredEvent); + + // 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 the reconciled receipt was never sent + tester.verifyNeverCalled((api) => api.channel.markChannelsDelivered(any())); + }, + ); + }); + + group('Draft events', () { + final _draftCreatedAt = DateTime.utc(2021, 3); + + ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); + + 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 + final draft = Draft( + channelCid: tester.channel.cid!, + createdAt: _draftCreatedAt, + message: DraftMessage(text: 'test message'), + ); + + // 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(tester.channelState?.draft, isNotNull); + expect(tester.channelState?.draft?.message.text, 'test message'); + }, + ); + + 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'; + + // Setup initial state with a regular message + tester.channelState?.updateMessage( + Message( + id: threadParentMessageId, + user: tester.currentUser, + ), + ); + + // Verify initial state + expect(tester.channelState?.threadDraft(threadParentMessageId), isNull); + + // Create thread Draft + final draft = Draft( + channelCid: tester.channel.cid!, + createdAt: _draftCreatedAt, + parentId: threadParentMessageId, + message: DraftMessage(text: 'thread reply'), + ); + + // Create and dispatch draft.updated event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.draftUpdated, + draft: draft, + ), + ); + + // Verify thread draft was updated + final threadDraft = tester.channelState?.threadDraft(threadParentMessageId); + expect(threadDraft, isNotNull); + expect(threadDraft?.message.text, 'thread reply'); + }, + ); + + 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'), + ), + ), + ); + + // 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 channel draft was updated + expect(tester.channelState?.draft, isNull); + }, + ); + + 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'; + + // 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'), + ), + ), + ); + + // 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(tester.channelState?.threadDraft(threadParentMessageId), isNull); + }, + ); + + channelTest( + 'should update current channel draft if draft.updated event is emitted', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + // Setup initial state with a draft + final initialDraft = Draft( + channelCid: tester.channel.cid!, + createdAt: _draftCreatedAt, + message: DraftMessage(text: 'test message'), + ); + + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + draft: initialDraft, + ), + ); + + // Verify initial state + 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 and dispatch draft.updated event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.draftUpdated, + draft: updatedDraft, + ), + ); + + // Verify channel draft was updated + expect(tester.channelState?.draft, isNotNull); + expect(tester.channelState?.draft?.message.text, 'updated message'); + }, + ); + + channelTest( + 'should update current thread draft if draft.updated event is emitted', + 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: tester.channel.cid!, + createdAt: _draftCreatedAt, + parentId: threadParentMessageId, + message: DraftMessage(text: 'thread reply'), + ); + + tester.channelState?.updateMessage( + Message( + id: threadParentMessageId, + user: tester.currentUser, + draft: initialDraft, + ), + ); + + // Verify initial state + final draft = tester.channelState?.threadDraft(threadParentMessageId); + expect(draft, isNotNull); + expect(draft?.message.text, 'thread reply'); + + // Create Draft + final updatedDraft = initialDraft.copyWith( + message: DraftMessage(text: 'updated thread reply'), + ); + + // Create and dispatch draft.updated event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.draftUpdated, + draft: updatedDraft, + ), + ); + + // Verify thread draft was updated + final threadDraft = tester.channelState?.threadDraft(threadParentMessageId); + expect(threadDraft, isNotNull); + expect(threadDraft?.message.text, 'updated thread reply'); + }, + ); + + 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(tester.channelState?.draft, isNull); + }, + ); + }); + + group('Reminder events', () { + // 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); + + ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); + + channelTest( + 'should handle reminder.created event', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + const messageId = 'test-message-id'; + + // Setup initial state with a message without reminder + final message = Message( + id: messageId, + user: tester.currentUser, + text: 'Test message', + ); + + tester.channelState?.updateMessage(message); + + // Verify initial state - no reminder + final initialMessage = tester.channelState?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNull); + + // Create reminder + final reminder = createDefaultMessageReminder( + messageId: messageId, + channelCid: tester.channel.cid!, + userId: 'test-user-id', + remindAt: _now.add(const Duration(days: 30)), + ); + + // Emit reminder.created event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reminderCreated, + reminder: reminder, + ), + ); + + // 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); + }, + ); + + channelTest( + 'should handle reminder.updated event', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + const messageId = 'test-message-id'; + + // 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, + ); + + final message = Message( + id: messageId, + user: tester.currentUser, + text: 'Test message', + reminder: initialReminder, + ); + + tester.channelState?.updateMessage(message); + + // Verify initial state + final initialMessage = tester.channelState?.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: _now, + ); + + // Emit reminder.updated event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reminderUpdated, + reminder: updatedReminder, + ), + ); + + // 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); + }, + ); + + 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 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 message = Message( + id: messageId, + user: tester.currentUser, + text: 'Test message', + reminder: initialReminder, + ); + + tester.channelState?.updateMessage(message); + + // Verify initial state + final initialMessage = tester.channelState?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); + + // Emit reminder.deleted event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reminderDeleted, + reminder: initialReminder, + ), + ); + + // Verify message reminder was removed + final updatedMessage = tester.channelState?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNull); + }, + ); + + 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, + ); + + tester.channelState?.updateMessage(threadMessage); + + // Verify initial state - no reminder + final initialMessage = tester.channelState?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNull); + + // 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, + ); + + // Emit reminder.created event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reminderCreated, + reminder: reminder, + ), + ); + + // 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); + }, + ); + + 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, + ); + + tester.channelState?.updateMessage(threadMessage); + + // Verify initial state + final initialMessage = tester.channelState?.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: _now, + ); + + // Emit reminder.updated event + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.reminderUpdated, + reminder: updatedReminder, + ), + ); + + // 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); + }, + ); + + 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, + ); + + tester.channelState?.updateMessage(threadMessage); + + // Verify initial state + final initialMessage = tester.channelState?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); + + // 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 = tester.channelState?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNull); + }, + ); + + 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: tester.currentUser, + text: 'Test message', + ); + tester.channelState?.updateMessage(message); + + await tester.emitEvent(createDefaultEvent(cid: tester.channel.cid, type: EventType.reminderCreated)); + + final storedMessage = tester.channelState?.messages.firstWhere((m) => m.id == messageId); + expect(storedMessage?.reminder, isNull); + }, + ); + }); + + group('Location events', () { + ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); + + 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); + + // 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)), + ); + + 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, + ), + ); + + // Check if message was added + final messages = tester.channelState?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message, isNotNull); + + // Check if active live location was updated + final activeLiveLocations = tester.channelState?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.messageId, equals('msg1')); + }, + ); + + 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)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Add initial message + tester.channelState?.addNewMessage(locationMessage); + + // Create updated location + final updatedLocation = liveLocation.copyWith( + latitude: 40.7500, // Updated latitude + longitude: -74.1000, // Updated longitude + ); + + final updatedMessage = locationMessage.copyWith( + sharedLocation: updatedLocation, + ); + + // Dispatch location.updated event + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.locationUpdated, + message: updatedMessage, + ), + ); + + // 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)); + + // 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)); + }, + ); + + 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)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Add initial message + tester.channelState?.addNewMessage(locationMessage); + expect(tester.channelState?.activeLiveLocations, hasLength(1)); + + // Create expired location + final expiredLocation = liveLocation.copyWith( + endAt: DateTime.timestamp().subtract(const Duration(hours: 1)), + ); + + final expiredMessage = locationMessage.copyWith( + sharedLocation: expiredLocation, + ); + + // Dispatch location.expired event + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.locationExpired, + message: expiredMessage, + ), + ); + + // Check if message was updated + final messages = tester.channelState?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message?.sharedLocation?.isExpired, isTrue); + + // Check if active live location was removed + expect(tester.channelState?.activeLiveLocations, isEmpty); + }, + ); + + 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)), + ); + + // 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); + + tester.channelState?.addNewMessage( + Message(id: 'msg1', sharedLocation: liveLocation), + ); + expect(tester.channelState?.activeLiveLocations, hasLength(1)); + + // Before endAt no expiry event is emitted. + await Future.delayed(const Duration(milliseconds: 200)); + expect(captured, isEmpty); + + // 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'); + + await subscription.cancel(); + }, + ); + + 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)), + ); + + final captured = []; + final subscription = tester.client.on().listen(captured.add); + + tester.channelState?.addNewMessage( + Message(id: 'msg-own', sharedLocation: ownLocation), + ); + expect(tester.channelState?.activeLiveLocations, hasLength(1)); + + // 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); + + await subscription.cancel(); + }, + ); + + 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)), + ); + + 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. + tester.channelState?.updateChannelState( + ChannelState(messages: const [], activeLiveLocations: [expiredLocation]), + ); + expect(tester.channelState?.activeLiveLocations, hasLength(1)); + + // 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'); + + await subscription.cancel(); + }, + ); + + 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 + ); + + final staticMessage = Message( + id: 'msg1', + text: 'Static location shared', + sharedLocation: staticLocation, + ); + + // Dispatch location.shared event + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.locationShared, + message: staticMessage, + ), + ); + + // 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(tester.channelState?.activeLiveLocations, isEmpty); + }, + ); + + channelTest( + 'should update active locations when location message is deleted', + 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)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Verify initial state + tester.channelState?.addNewMessage(locationMessage); + expect(tester.channelState?.activeLiveLocations, hasLength(1)); + + // Dispatch message.deleted event + await tester.emitEvent( + createDefaultEvent( + type: EventType.messageDeleted, + cid: _channelCid, + message: locationMessage.copyWith( + type: MessageType.deleted, + deletedAt: DateTime.timestamp(), + ), + ), + ); + + // Verify active locations are updated + expect(tester.channelState?.activeLiveLocations, isEmpty); + }, + ); + + 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)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Add initial location for setup + tester.channelState?.addNewMessage(locationMessage); + expect(tester.channelState?.activeLiveLocations, hasLength(1)); + + // 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)), + ); + + final newMessage = Message( + id: 'msg2', + text: 'Updated location', + sharedLocation: newLocation, + ); + + // 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 = tester.channelState?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.messageId, equals('msg2')); + expect(activeLiveLocations?.first.latitude, equals(40.7500)); + }, + ); + + channelTest( + 'should handle multiple active locations from different devices', + 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)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Add first location for setup + tester.channelState?.addNewMessage(locationMessage); + expect(tester.channelState?.activeLiveLocations, hasLength(1)); + + // Create location from different device + final location2 = Location( + channelCid: _channelCid, + userId: 'user1', // Same user + messageId: 'msg2', + latitude: 34.0522, + longitude: -118.2437, + createdByDeviceId: 'device2', // Different device + endAt: DateTime.timestamp().add(const Duration(hours: 1)), + ); + + final message2 = Message( + id: 'msg2', + text: 'Location from device 2', + sharedLocation: location2, + ); + + // Dispatch location.shared event for the second message + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.locationShared, + message: message2, + ), + ); + + // Should have two active locations + expect(tester.channelState?.activeLiveLocations, hasLength(2)); + }, + ); + + 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', + ); + + // Add parent message first for setup + tester.channelState?.addNewMessage(parentMessage); + + 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)), + ); + + final threadLocationMessage = Message( + id: 'thread-msg1', + text: 'Live location in thread', + parentId: 'parent1', + sharedLocation: liveLocation, + ); + + // Dispatch location.shared event for the thread message + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.locationShared, + message: threadLocationMessage, + ), + ); + + // 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 location was added to active locations + final activeLiveLocations = tester.channelState?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.messageId, equals('thread-msg1')); + }, + ); + + 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', + ); + + 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)), + ); + + final threadLocationMessage = Message( + id: 'thread-msg1', + text: 'Live location in thread', + parentId: 'parent1', + sharedLocation: liveLocation, + ); + + // Add messages + tester.channelState?.addNewMessage(parentMessage); + tester.channelState?.addNewMessage(threadLocationMessage); + + // Update the location + final updatedLocation = liveLocation.copyWith( + latitude: 40.7500, + longitude: -74.1000, + ); + + final updatedThreadMessage = threadLocationMessage.copyWith( + sharedLocation: updatedLocation, + ); + + // Dispatch location.updated event for the thread message + await tester.emitEvent( + createDefaultEvent( + cid: _channelCid, + type: EventType.locationUpdated, + message: updatedThreadMessage, + ), + ); + + // 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)); + + // 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)); + }, + ); + }); + + group('Channel push preference events', () { + ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); + + 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); + + // Create channel push preference + final channelPushPreference = ChannelPushPreference( + chatLevel: ChatLevel.mentions, + disabledUntil: DateTime.utc(2021, 3), + ); + + // Dispatch channel.push_preference.updated event + await tester.emitEvent( + createDefaultEvent( + type: EventType.channelPushPreferenceUpdated, + cid: tester.channel.cid, + channelPushPreference: channelPushPreference, + ), + ); + + // 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, + ); + }, + ); + + 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, + ); + + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + pushPreferences: initialPushPreference, + ), + ); + + // 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 = tester.channelState?.channelState.pushPreferences; + expect(updatedPreferences?.chatLevel, ChatLevel.none); + expect( + updatedPreferences?.disabledUntil, + updatedPushPreference.disabledUntil, + ); + }, + ); + + 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(tester.channelState?.channelState.pushPreferences, isNull); + }, + ); + }); + + group('User messages deleted event', () { + 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'), + userId: any(named: 'userId'), + hardDelete: any(named: 'hardDelete'), + deletedAt: any(named: 'deletedAt'), + ), + ).thenAnswer((_) async {}); + when(() => persistenceClient.deleteMessageByIds(any())).thenAnswer((_) async {}); + when(() => persistenceClient.deletePinnedMessageByIds(any())).thenAnswer((_) async {}); + return persistenceClient; + } + + final softDeletePersistence = _createPersistenceClient(); + + final hardDeletePersistence = _createPersistenceClient(); + + final threadMessagesPersistence = _createPersistenceClient(); + + final nullUserPersistence = _createPersistenceClient(); + + 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', + 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'); + + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + final message2 = Message( + id: 'msg-2', + text: 'Another message from user 1', + user: user1, + ); + final message3 = Message( + id: 'msg-3', + text: 'Message from user 2', + user: user2, + ); + + tester.channelState?.addNewMessage(message1); + tester.channelState?.addNewMessage(message2); + tester.channelState?.addNewMessage(message3); + + // Verify initial state + expect(tester.channelState?.messages.length, equals(3)); + expect( + tester.channelState?.messages.where((m) => m.user?.id == 'user-1').length, + equals(2), + ); + expect( + tester.channelState?.messages.where((m) => m.user?.id == 'user-2').length, + equals(1), + ); + + // Create user.messages.deleted event (soft delete) + final deletedAt = DateTime.utc(2021, 3); + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + createdAt: deletedAt, + ); + + // Dispatch event and wait for it to be processed + await tester.emitEvent(userMessagesDeletedEvent); + + // Verify user1's messages are soft deleted + 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)); + expect(message.deletedAt, isNotNull); + expect(message.state.isDeleted, isTrue); + } + + // Verify user2's message is unaffected + final user2Message = tester.channelState?.messages.firstWhere((m) => m.id == 'msg-3'); + expect(user2Message?.type, isNot(MessageType.deleted)); + expect(user2Message?.deletedAt, isNull); + }, + ); + + channelTest( + 'should hard delete all messages from user when hardDelete is true', + 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'); + + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + final message2 = Message( + id: 'msg-2', + text: 'Another message from user 1', + user: user1, + ); + final message3 = Message( + id: 'msg-3', + text: 'Message from user 2', + user: user2, + ); + + tester.channelState?.addNewMessage(message1); + tester.channelState?.addNewMessage(message2); + tester.channelState?.addNewMessage(message3); + + // Verify initial state + expect(tester.channelState?.messages.length, equals(3)); + + // Create user.messages.deleted event (hard delete) + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: true, + ); + + // Dispatch event and wait for it to be processed + await tester.emitEvent(userMessagesDeletedEvent); + + // Verify user1's messages are removed + expect(tester.channelState?.messages.length, equals(1)); + expect( + tester.channelState?.messages.any((m) => m.user?.id == 'user-1'), + isFalse, + ); + + // Verify user2's message still exists + final user2Message = tester.channelState?.messages.firstWhere((m) => m.id == 'msg-3'); + expect(user2Message, isNotNull); + expect(user2Message?.user?.id, equals('user-2')); + }, + ); + + channelTest( + 'should handle thread messages from user', + 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'); + + final parentMessage = Message( + id: 'parent-msg', + text: 'Parent message', + user: user2, + ); + final threadMessage1 = Message( + id: 'thread-msg-1', + text: 'Thread message from user 1', + user: user1, + parentId: 'parent-msg', + ); + final threadMessage2 = Message( + id: 'thread-msg-2', + text: 'Another thread message from user 1', + user: user1, + parentId: 'parent-msg', + ); + + tester.channelState?.addNewMessage(parentMessage); + tester.channelState?.addNewMessage(threadMessage1); + tester.channelState?.addNewMessage(threadMessage2); + + // Verify initial state + 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 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + ); + + // Dispatch event and wait for it to be processed + await tester.emitEvent(userMessagesDeletedEvent); + + // Verify thread messages are soft deleted + final threadMessages = tester.channelState?.threads['parent-msg']; + expect(threadMessages?.length, equals(2)); + for (final message in threadMessages!) { + expect(message.type, equals(MessageType.deleted)); + expect(message.state.isDeleted, isTrue); + } + + // Verify parent message is unaffected + final parent = tester.channelState?.messages.first; + expect(parent?.type, isNot(MessageType.deleted)); + }, + ); + + channelTest( + 'should do nothing when user is null', + 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( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + + tester.channelState?.addNewMessage(message1); + + // Verify initial state + expect(tester.channelState?.messages.length, equals(1)); + + // Create user.messages.deleted event without user + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userMessagesDeleted, + hardDelete: false, + ); + + // Dispatch event and wait for it to be processed + await tester.emitEvent(userMessagesDeletedEvent); + + // Verify messages are unaffected + expect(tester.channelState?.messages.length, equals(1)); + expect( + tester.channelState?.messages.first.type, + isNot(MessageType.deleted), + ); + }, + ); + + channelTest( + 'should handle empty message list', + channelType: _channelType, + channelId: _channelId, + chatPersistenceClient: emptyChannelPersistence, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel), + body: (tester) async { + // Setup: Empty channel + expect(tester.channelState?.messages.length, equals(0)); + + // Create user.messages.deleted event + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userMessagesDeleted, + user: User(id: 'user-1'), + hardDelete: false, + ); + + // Dispatch event - should not throw + await tester.emitEvent(userMessagesDeletedEvent); + + // Verify state is still empty + expect(tester.channelState?.messages.length, equals(0)); + }, + ); + + channelTest( + 'should delete messages from persistence when hardDelete is true', + 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'); + + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + final message2 = Message( + id: 'msg-2', + text: 'Another message from user 1', + user: user1, + ); + final message3 = Message( + id: 'msg-3', + text: 'Message from user 2', + user: user2, + ); + + tester.channelState?.addNewMessage(message1); + tester.channelState?.addNewMessage(message2); + tester.channelState?.addNewMessage(message3); + + // Verify initial state + expect(tester.channelState?.messages.length, equals(3)); + + // Create user.messages.deleted event (hard delete) + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: true, + ); + + // Dispatch event and wait for it to be processed + await tester.emitEvent(userMessagesDeletedEvent); + + // Verify messages are removed from persistence + verify( + () => hardDeleteStoragePersistence.deleteMessageByIds(['msg-1', 'msg-2']), + ).called(1); + verify( + () => hardDeleteStoragePersistence.deletePinnedMessageByIds(['msg-1', 'msg-2']), + ).called(1); + + // Verify user1's messages are removed from state + expect(tester.channelState?.messages.length, equals(1)); + expect( + tester.channelState?.messages.any((m) => m.user?.id == 'user-1'), + isFalse, + ); + }, + ); + + channelTest( + 'should not delete from persistence when hardDelete is false', + 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( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + + tester.channelState?.addNewMessage(message1); + + // Create user.messages.deleted event (soft delete) + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + ); + + // Dispatch event and wait for it to be processed + await tester.emitEvent(userMessagesDeletedEvent); + + // Verify persistence deletion methods were NOT called + verifyNever(() => softDeleteStoragePersistence.deleteMessageByIds(any())); + verifyNever(() => softDeleteStoragePersistence.deletePinnedMessageByIds(any())); + + // Verify message is soft deleted (still in state) + expect(tester.channelState?.messages.length, equals(1)); + expect(tester.channelState?.messages.first.type, equals(MessageType.deleted)); + }, + ); + + channelTest( + 'should delete all user messages including those only in storage', + 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'); + + final stateMessage1 = Message( + id: 'msg-1', + text: 'Message from user 1 in state', + user: user1, + pinned: true, + ); + final stateMessage2 = Message( + id: 'msg-2', + text: 'Message from user 2 in state', + user: user2, + ); + final stateThreadMessage1 = Message( + id: 'thread-msg-1', + text: 'Thread message from user 1 in state', + user: user1, + parentId: 'msg-1', + ); + final stateThreadMessage2 = Message( + id: 'thread-msg-2', + text: 'Another thread message from user 2 in state', + user: user2, + parentId: 'msg-1', + ); + + // Load the state with only 2 messages and 1 thread with 2 replies. + // 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. + 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(tester.channelState?.messages.length, equals(2)); + expect(tester.channelState?.threads['msg-1']?.length, equals(2)); + + // Create user.messages.deleted event (hard delete) + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: true, + ); + + // Dispatch event and wait for it to be processed + await tester.emitEvent(userMessagesDeletedEvent); + + // Verify user1's messages are removed from state + expect(tester.channelState?.messages.length, equals(1)); + expect(tester.channelState?.threads['msg-1']?.length, equals(1)); + + expect( + tester.channelState?.messages.any((m) => m.user?.id == 'user-1'), + isFalse, + ); + + expect( + 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( + () => storageWidePersistence.deleteMessagesFromUser( + cid: tester.channel.cid, + userId: user1.id, + hardDelete: true, + deletedAt: any(named: 'deletedAt'), + ), + ).called(1); + + // Verify in-state messages were also removed from state's persistence + final capturedIds = + verify( + () => storageWidePersistence.deleteMessageByIds(captureAny()), + ).captured.first + as List; + + expect( + capturedIds, + containsAll([ + 'msg-1', // state message + 'thread-msg-1', // state thread message + ]), + ); + }, + ); + + channelTest( + 'should delete every authored message across threads without ' + 'cross-thread leakage (regression: _updateThreadMessages)', + 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 + // routes it through a single _updateMessages batch — historically + // this batch was passed unfiltered to every affected thread's + // merge, so replies to thread A leaked into thread B and v.v. + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final parentA = Message(id: 'parent-A', text: 'Thread A', user: user2); + final parentB = Message(id: 'parent-B', text: 'Thread B', user: user2); + + final topLevelFromUser1 = Message( + id: 'top-1', + text: 'user-1 top-level message', + user: user1, + ); + final replyA = Message( + id: 'reply-A', + text: 'user-1 reply in thread A', + user: user1, + parentId: 'parent-A', + ); + final replyB = Message( + id: 'reply-B', + text: 'user-1 reply in thread B', + user: user1, + parentId: 'parent-B', + ); + + 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( + tester.channelState?.threads['parent-A']?.map((m) => m.id), + equals(['reply-A']), + ); + expect( + tester.channelState?.threads['parent-B']?.map((m) => m.id), + equals(['reply-B']), + ); + + // Trigger the multi-thread batch via user.messages.deleted. + final userMessagesDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + ); + 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( + tester.channelState?.threads['parent-A']?.map((m) => m.id), + equals(['reply-A']), + reason: 'thread A must not contain replies from thread B', + ); + expect( + tester.channelState?.threads['parent-B']?.map((m) => m.id), + equals(['reply-B']), + reason: 'thread B must not contain replies from thread A', + ); + + // 2) Every message authored by user-1 is soft-deleted — top-level + // AND in both threads. The fix must not narrow this scope. + expect( + tester.channelState?.messages.firstWhere((m) => m.id == 'top-1').type, + equals(MessageType.deleted), + reason: 'top-level user-1 message must be deleted', + ); + expect( + tester.channelState?.threads['parent-A']?.first.type, + equals(MessageType.deleted), + reason: 'thread A reply from user-1 must be deleted', + ); + expect( + 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( + tester.channelState?.messages.firstWhere((m) => m.id == 'parent-A').type, + isNot(MessageType.deleted), + ); + expect( + tester.channelState?.messages.firstWhere((m) => m.id == 'parent-B').type, + isNot(MessageType.deleted), + ); + }, + ); + }); + + group('Dispatch error isolation', () { + // A bare channel state (no config, no capabilities). + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); + } + + 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(tester.channel.memberCount, equals(9)); + }, + ); + }); + }); + + group('Local unread count', () { + /// 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. + }) { + return createDefaultChannelState( + channel: createDefaultChannelModel( + cid: cid, + config: createDefaultChannelConfig(readEvents: false), + ownCapabilities: ownCapabilities, + ), + messages: messages, + read: read, + ); + } + + // 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', + 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.utc(2024), + ); + + await tester.emitEvent( + createDefaultEvent(cid: tester.channel.cid, type: EventType.messageNew, message: message), + ); + + expect(tester.channelState?.unreadCount, equals(1)); + }, + ); + + channelTest( + 'does not increment unreadCount when local unread count tracking is ' + 'disabled', + 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.utc(2024), + ); + + await tester.emitEvent( + createDefaultEvent(cid: tester.channel.cid, type: EventType.messageNew, message: message), + ); + + expect(tester.channelState?.unreadCount, equals(0)); + }, + ); + + 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, + ), + ); + + expect(tester.channelState?.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; + + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageDeleted, + message: countedMessage, + hardDelete: false, + ), + ); + + expect(tester.channelState?.unreadCount, equals(1)); + }, + ); + + channelTest( + 'markRead resets unreadCount locally without making a network request', + 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')), + ); + }, + ); + + channelTest( + 'markUnreadByTimestamp recomputes unreadCount locally without making a ' + 'network request', + 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( + tester.channel.markUnreadByTimestamp(now.add(const Duration(seconds: 30))), + completes, + ); + + // Only m2 and m3 were created after the given timestamp. + expect(tester.channelState?.unreadCount, equals(2)); + registerFallbackValue(DateTime.utc(2024)); + tester.verifyNeverCalled((api) => api.channel.markUnreadByTimestamp(any(), any(), any())); + }, + ); + + channelTest( + 'markUnread throws when the message is not locally known', + channelType: _channelType, + channelId: _channelId, + isLocalUnreadCountEnabled: true, + setUp: (tester) => tester.watch(modifyResponse: (_) => _livestreamChannelState()), + body: (tester) async { + await expectLater( + tester.channel.markUnread('unknown-message-id'), + throwsA(isA()), + ); + tester.verifyNeverCalled((api) => api.channel.markUnread(any(), any(), any())); + }, + ); + + channelTest( + 'markRead reconciles pending delivery receipts', + 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(tester.channel.markRead(), completes); + + // 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.utc(2024); + final messages = [ + Message( + id: 'm1', + text: '1', + user: User(id: 'other-user'), + createdAt: start, + ), + Message( + id: 'm2', + text: '2', + user: User(id: 'other-user'), + createdAt: start.add(const Duration(minutes: 1)), + ), + Message( + id: 'm3', + text: '3', + user: User(id: 'other-user'), + createdAt: start.add(const Duration(minutes: 2)), + ), + ]; + + 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', + 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(tester.channelState?.unreadCount, equals(2)); + expect(tester.channelState?.currentUserRead?.lastReadMessageId, equals('m1')); + tester.verifyNeverCalled((api) => api.channel.markUnread(any(), any(), any())); + }, + ); + + channelTest( + 'markUnread leaves lastReadMessageId null when the anchor is the oldest ' + 'known message', + 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); + }, + ); + + channelTest( + 'markUnreadByTimestamp is exclusive of the boundary and points ' + 'lastReadMessageId at the newest message at or before it', + 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(tester.channel.markUnreadByTimestamp(messages[1].createdAt), completes); + + 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())); + }, + ); + + channelTest( + 'markUnread(id) and markUnreadByTimestamp(createdAt) intentionally ' + 'differ by the anchor message', + 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); + + // `markUnread` includes m2, `markUnreadByTimestamp` excludes it. + expect(byId.state?.unreadCount, equals(2)); + expect(byTimestamp.state?.unreadCount, equals(1)); + + // ...and they agree once the timestamp is nudged below the anchor. + await byTimestamp.markUnreadByTimestamp( + messages[1].createdAt.subtract(const Duration(microseconds: 1)), + ); + expect(byTimestamp.state?.unreadCount, equals(2)); + expect(byTimestamp.state?.currentUserRead?.lastReadMessageId, equals('m1')); + }, + ); + }); + + channelTest( + 'server payloads do not clobber the locally-tracked read state', + channelType: _channelType, + channelId: _channelId, + isLocalUnreadCountEnabled: true, + setUp: (tester) => tester.watch(modifyResponse: (_) => _livestreamChannelState()), + body: (tester) async { + tester.channelState!.unreadCount = 5; + + final serverRead = Read( + user: tester.currentUser!, + lastRead: DateTime.utc(2024, 1, 2), + unreadMessages: 0, + ); + tester.channelState!.updateChannelStateFromServer( + tester.channelState!.channelState.copyWith(read: [serverRead]), + ); + + expect(tester.channelState?.unreadCount, equals(5)); + }, + ); + + channelTest( + 'local (non-remote) state updates are not affected by the server-merge ' + 'guard', + 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(tester.channel.markRead(), completes); + + expect(tester.channelState?.unreadCount, equals(0)); + }, + ); + }); + + group('updateChannelState identity guard', () { + // Pinned base timestamp for the seeded messages (m1, m2 +1s, m3 +2s). + final _baseCreatedAt = DateTime.utc(2021, 3); + + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + messages: [ + 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))), + ], + ); + } + + channelTest( + 'preserves messages reference when updatedState.messages is null', + 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 = tester.channelState!.messages; + + expect(identical(before, after), isTrue); + }, + ); + + channelTest( + 'preserves messages reference when updatedState.messages is identical', + 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. + tester.channelState!.updateChannelState( + tester.channelState!.channelState.copyWith( + read: [ + Read( + user: User(id: 'me'), + lastRead: DateTime.utc(2021, 3, 2), + unreadMessages: 1, + ), + ], + ), + ); + final after = tester.channelState!.messages; + + expect(identical(before, after), isTrue); + }, + ); + + channelTest( + 'still merges messages when updatedState.messages is a different list', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final newMessage = Message( + id: 'm4', + text: '4', + createdAt: _baseCreatedAt.add(const Duration(seconds: 10)), + ); + tester.channelState!.updateChannelState( + ChannelState( + channel: tester.channelState!.channelState.channel, + messages: [newMessage], + ), + ); + + expect( + tester.channelState!.messages.map((m) => m.id), + ['m1', 'm2', 'm3', 'm4'], + ); + }, + ); + + 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, + ), + ); + + expect( + tester.channelState!.messages.map((m) => m.id), + ['m1', 'm1.5', 'm2', 'm2.5', 'm3'], + ); + }, + ); + + 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: tester.channelState!.messages.firstWhere((m) => m.id == 'm2').createdAt, + ), + ]; + tester.channelState!.updateChannelState( + ChannelState( + channel: tester.channelState!.channelState.channel, + messages: incoming, + ), + ); + + 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', () { + ChannelState Function(ChannelState) _seedChannel({required List messages}) { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + messages: messages, + ); + } + + channelTest( + 'rewrites quotedMessage on every quoter when target is deleted', + 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', + text: 'reply', + quotedMessageId: 'target', + quotedMessage: target, + createdAt: now.add(const Duration(seconds: 1)), + ); + final unrelated = Message( + id: 'u1', + text: 'other', + createdAt: now.add(const Duration(seconds: 2)), + ); + final quoter2 = Message( + id: 'q2', + text: 'reply2', + quotedMessageId: 'target', + quotedMessage: target, + createdAt: now.add(const Duration(seconds: 3)), + ); + + await tester.watch( + modifyResponse: _seedChannel(messages: [target, quoter1, unrelated, quoter2]), + ); + + final unrelatedBefore = tester.channelState!.messages.firstWhere((m) => m.id == 'u1'); + + final deleted = target.copyWith( + type: MessageType.deleted, + deletedAt: now.add(const Duration(seconds: 5)), + ); + tester.channelState!.updateMessage(deleted); + + 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'); + + expect(q1After.quotedMessage?.deletedAt, isNotNull); + expect(q1After.quotedMessage?.type, MessageType.deleted); + expect(q2After.quotedMessage?.deletedAt, isNotNull); + expect(q2After.quotedMessage?.type, MessageType.deleted); + // Unrelated messages must not be rebuilt by the rewrite. + expect(identical(uAfter, unrelatedBefore), isTrue); + }, + ); + + channelTest( + 'preserves messages reference when no message quotes the deleted one', + 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', + text: 'other', + createdAt: now.add(const Duration(seconds: 1)), + ); + + await tester.watch(modifyResponse: _seedChannel(messages: [target, unrelated])); + + final deleted = target.copyWith( + type: MessageType.deleted, + deletedAt: now.add(const Duration(seconds: 5)), + ); + tester.channelState!.updateMessage(deleted); + + // No message quotes `target`, so `updateIf` short-circuits and the + // remaining messages keep their identities (only `target` itself was + // replaced by `sortedUpsert`). + final unrelatedAfter = tester.channelState!.messages.firstWhere((m) => m.id == 'u1'); + expect(identical(unrelatedAfter, unrelated), isTrue); + }, + ); + + channelTest( + 'does not rewrite quotes when an existing quoted target is updated ' + 'without being deleted', + 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', + text: 'reply', + quotedMessageId: 'target', + quotedMessage: target, + createdAt: now.add(const Duration(seconds: 1)), + ); + + await tester.watch(modifyResponse: _seedChannel(messages: [target, quoter])); + + final quoterBefore = tester.channelState!.messages.firstWhere((m) => m.id == 'q1'); + + // Plain text update — not a deletion. + tester.channelState!.updateMessage(target.copyWith(text: 'edited')); + + final quoterAfter = tester.channelState!.messages.firstWhere((m) => m.id == 'q1'); + // `updateIf` is gated on `message.isDeleted`, so the quoter must keep + // its identity (no allocation, no quoted-message overwrite). + expect(identical(quoterAfter, quoterBefore), isTrue); + }, + ); + }); + + group('Message enrichment preservation on merge', () { + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); + } + + Event _updateMessageEvent(Message message) => createDefaultEvent( + type: EventType.messageUpdated, + cid: _channelCid, + message: message, + ); + + channelTest( + 'preserves the `poll` on a quotedMessage when the server omits it during ' + 're-sync (regression: poll quote disappears after foregrounding)', + 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', + name: 'Pizza or pasta?', + options: const [ + PollOption(id: 'opt-1', text: 'Pizza'), + PollOption(id: 'opt-2', text: 'Pasta'), + ], + createdById: pollUser.id, + ); + + final pollMessage = Message( + id: 'poll-msg-1', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + ); + + final replyToPoll = Message( + id: 'reply-1', + text: 'Voting now', + quotedMessageId: pollMessage.id, + quotedMessage: pollMessage, + user: User(id: 'reply-user'), + createdAt: DateTime.utc(2026, 4, 29, 11), + ); + + // Seed channel state with the fully-enriched messages (mirrors what + // the local DB load produces). + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + messages: [pollMessage, replyToPoll], + ), + ); + + // Simulate a re-sync from the API: the server echoes the reply with + // a `quoted_message` that has only `poll_id` (no `poll` object). + // Constructed directly (not via copyWith) because copyWith cannot + // clear `poll` — see Message.copyWith. + final strippedPollSnapshot = Message( + id: pollMessage.id, + pollId: pollMessage.pollId, + user: pollUser, + createdAt: pollMessage.createdAt, + ); + final reSyncedReply = replyToPoll.copyWith(quotedMessage: strippedPollSnapshot); + + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + messages: [reSyncedReply], + ), + ); + + final mergedReply = tester.channelState?.messages.firstWhere((it) => it.id == replyToPoll.id); + + expect(mergedReply, isNotNull); + expect(mergedReply!.quotedMessage, isNotNull); + expect(mergedReply.quotedMessage!.id, pollMessage.id); + expect(mergedReply.quotedMessage!.poll, isNotNull); + expect(mergedReply.quotedMessage!.poll!.id, poll.id); + expect(mergedReply.quotedMessage!.poll!.name, poll.name); + }, + ); + + 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)', + 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', + name: 'Coffee or tea?', + options: const [ + PollOption(id: 'opt-a', text: 'Coffee'), + PollOption(id: 'opt-b', text: 'Tea'), + ], + createdById: pollUser.id, + ); + + final pollMessage = Message( + id: 'poll-msg-2', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + ); + + final replyToPoll = Message( + id: 'reply-A', + text: 'My pick', + quotedMessageId: pollMessage.id, + quotedMessage: pollMessage, + user: User(id: 'user-a'), + createdAt: DateTime.utc(2026, 4, 29, 11), + ); + + final replyToReply = Message( + id: 'reply-B', + text: 'Same here', + quotedMessageId: replyToPoll.id, + quotedMessage: replyToPoll, + user: User(id: 'user-b'), + createdAt: DateTime.utc(2026, 4, 29, 12), + ); + + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + messages: [pollMessage, replyToPoll, replyToReply], + ), + ); + + // Simulate the server response where: + // - replyA's nested quoted poll is missing the `poll` object. + // - replyB's nested quoted replyA is missing its own `quoted_message` + // (the server typically does not nest two levels deep). + // Stripped poll snapshot is constructed directly because copyWith + // cannot clear `poll` — see Message.copyWith. + final strippedPollSnapshot = Message( + id: pollMessage.id, + pollId: pollMessage.pollId, + user: pollUser, + createdAt: pollMessage.createdAt, + ); + final strippedReplyA = replyToPoll.copyWith(quotedMessage: null); + + final reSyncedReplyA = replyToPoll.copyWith(quotedMessage: strippedPollSnapshot); + final reSyncedReplyB = replyToReply.copyWith(quotedMessage: strippedReplyA); + + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + messages: [pollMessage, reSyncedReplyA, reSyncedReplyB], + ), + ); + + 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); + expect(mergedReplyA?.quotedMessage?.poll?.id, poll.id); + + // Second-level quote (reply B's quote of reply A) must keep reply A's + // own nested quotedMessage so the poll preview still resolves. + expect(mergedReplyB?.quotedMessage, isNotNull); + expect(mergedReplyB?.quotedMessage?.id, replyToPoll.id); + expect(mergedReplyB?.quotedMessage?.quotedMessage, isNotNull); + expect(mergedReplyB?.quotedMessage?.quotedMessage?.id, pollMessage.id); + expect(mergedReplyB?.quotedMessage?.quotedMessage?.poll, isNotNull); + expect(mergedReplyB?.quotedMessage?.quotedMessage?.poll?.id, poll.id); + }, + ); + + channelTest( + 'still preserves quotedMessage when the updated payload has no ' + 'quoted_message at all (existing behavior should not regress)', + 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', + name: 'Beach or mountains?', + options: const [ + PollOption(id: 'opt-x', text: 'Beach'), + PollOption(id: 'opt-y', text: 'Mountains'), + ], + createdById: pollUser.id, + ); + + final pollMessage = Message( + id: 'poll-msg-3', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + ); + + final replyToPoll = Message( + id: 'reply-3', + text: 'Definitely beach', + quotedMessageId: pollMessage.id, + quotedMessage: pollMessage, + user: User(id: 'reply-user'), + createdAt: DateTime.utc(2026, 4, 29, 11), + ); + + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + messages: [pollMessage, replyToPoll], + ), + ); + + // Simulate an update event that touches the reply but doesn't echo + // the nested quoted_message at all (only quotedMessageId is set). + final reSyncedReply = Message( + id: replyToPoll.id, + text: 'Definitely beach (edited)', + quotedMessageId: pollMessage.id, + user: replyToPoll.user, + createdAt: replyToPoll.createdAt, + ); + + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + messages: [reSyncedReply], + ), + ); + + final mergedReply = tester.channelState?.messages.firstWhere((it) => it.id == replyToPoll.id); + + expect(mergedReply, isNotNull); + expect(mergedReply!.text, 'Definitely beach (edited)'); + expect(mergedReply.quotedMessage, isNotNull); + expect(mergedReply.quotedMessage!.poll?.id, poll.id); + }, + ); + + 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)', + 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', + name: 'What is for lunch?', + options: const [ + PollOption(id: 'opt-1', text: 'Burgers'), + PollOption(id: 'opt-2', text: 'Salads'), + ], + createdById: pollUser.id, + ); + + final pollMessage = Message( + id: 'parent-poll-msg', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + replyCount: 0, + ); + + // Seed channel state with the fully-enriched parent poll message. + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + messages: [pollMessage], + ), + ); + + // Simulate the `message.updated` event the backend fires for the + // parent after a thread reply is added: bookkeeping fields are bumped + // (`reply_count`, `updated_at`) but the `poll` object is omitted from + // the payload — only `pollId` is set. Constructed directly because + // copyWith cannot clear `poll` — see Message.copyWith. + final strippedParentUpdate = Message( + id: pollMessage.id, + pollId: pollMessage.pollId, + user: pollUser, + createdAt: pollMessage.createdAt, + replyCount: 1, + updatedAt: DateTime.utc(2026, 4, 29, 11), + ); + + await tester.emitEvent(_updateMessageEvent(strippedParentUpdate)); + + 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); + // Bookkeeping fields from the event should still apply. + expect(merged!.replyCount, 1); + // Locally-known poll must be preserved when the server omits it from a + // `message.updated` payload (e.g. when a thread reply bumps reply_count). + expect(merged.poll, isNotNull); + expect(merged.poll!.id, poll.id); + expect(merged.poll!.name, poll.name); + expect(merged.pollId, poll.id); + }, + ); + + 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)', + 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', + name: 'Initial name', + options: const [ + PollOption(id: 'opt-1', text: 'Original A'), + ], + createdById: pollUser.id, + ); + + final pollMessage = Message( + id: 'edit-parent', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + ); + + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith( + messages: [pollMessage], + ), + ); + + final updatedPoll = poll.copyWith(name: 'Edited name'); + final updatedParent = pollMessage.copyWith(poll: updatedPoll, updatedAt: DateTime.utc(2026, 4, 29, 12)); + + await tester.emitEvent(_updateMessageEvent(updatedParent)); + + 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. + expect(merged?.poll, isNotNull); + expect(merged?.poll?.name, 'Edited name'); + }, + ); + }); +} diff --git a/packages/stream_chat/test/src/client/channel/channel_count_events_test.dart b/packages/stream_chat/test/src/client/channel/channel_count_events_test.dart deleted file mode 100644 index b3ab32bf8b..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_count_events_test.dart +++ /dev/null @@ -1,369 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -// A bare channel state (no config, no capabilities). -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: _channelCid), - ); -} - -void main() { - group('Channel message count events', () { - channelTest( - 'should update channel messageCount when event contains channelMessageCount', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - // Verify initial state - no messageCount - expect(tester.channel.messageCount, isNull); - - // Create event with channelMessageCount - final messageCountEvent = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.messageNew, - channelMessageCount: 42, - ); - - // Dispatch event and wait for it to be processed - await tester.emitEvent(messageCountEvent); - - // Verify channel messageCount was updated - expect(tester.channel.messageCount, equals(42)); - }, - ); - - channelTest( - 'should update channel messageCount from message.new and message.deleted events', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - // Test with message.new event - count increases - final messageNewEvent = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.messageNew, - message: createDefaultMessage( - id: 'new-message-1', - text: 'Hello world!', - user: User(id: 'user-1'), - ), - channelMessageCount: 1, - ); - - await tester.emitEvent(messageNewEvent); - expect(tester.channel.messageCount, equals(1)); - - // Test with another message.new event - count increases - final messageNewEvent2 = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.messageNew, - message: createDefaultMessage( - id: 'new-message-2', - text: 'Second message', - user: User(id: 'user-2'), - ), - channelMessageCount: 2, - ); - - await tester.emitEvent(messageNewEvent2); - expect(tester.channel.messageCount, equals(2)); - - // Test with message.deleted event - count decreases - final messageDeletedEvent = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.messageDeleted, - message: createDefaultMessage( - id: 'new-message-1', - text: 'Hello world!', - user: User(id: 'user-1'), - ), - channelMessageCount: 1, - ); - - await tester.emitEvent(messageDeletedEvent); - expect(tester.channel.messageCount, equals(1)); - }, - ); - - channelTest( - 'should preserve other channel properties when updating messageCount', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - // Set initial channel state with some properties - final initialChannel = tester.channelState?.channelState.channel?.copyWith( - extraData: {'name': 'Test Channel'}, - memberCount: 5, - frozen: true, - ); - - if (initialChannel != null) { - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith(channel: initialChannel), - ); - } - - // Verify initial state - 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 = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.messageNew, - channelMessageCount: 100, - ); - - await tester.emitEvent(messageCountEvent); - - // Verify messageCount was updated while preserving other properties - expect(tester.channel.messageCount, equals(100)); - expect(tester.channel.name, 'Test Channel'); - expect(tester.channel.memberCount, equals(5)); - expect(tester.channel.frozen, equals(true)); - }, - ); - - channelTest( - 'should provide messageCountStream for reactive updates', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final emitted = []; - 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 = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.messageNew, - message: createDefaultMessage( - id: 'msg-$index', - text: 'Message $count', - user: User(id: 'user-1'), - ), - channelMessageCount: count, - ); - - await tester.emitEvent(event); - } - - // The repeated count should not be emitted twice. - expect(emitted, equals([null, 1, 5, 10])); - }, - ); - }); - - group('Channel member count events', () { - channelTest( - 'should update channel memberCount when event contains channelMemberCount', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - // Verify initial state - default memberCount - expect(tester.channel.memberCount, equals(0)); - - // Create event with channelMemberCount - final memberCountEvent = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.memberAdded, - member: createDefaultMember(user: User(id: 'user-1')), - channelMemberCount: 42, - ); - - // Dispatch event and wait for it to be processed - await tester.emitEvent(memberCountEvent); - - // Verify channel memberCount was updated - expect(tester.channel.memberCount, equals(42)); - }, - ); - - channelTest( - 'should update channel memberCount from member.added and member.removed events', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - // Test with member.added event - count increases - final memberAddedEvent = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.memberAdded, - member: createDefaultMember(user: User(id: 'user-1')), - channelMemberCount: 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 = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.memberAdded, - member: createDefaultMember(user: User(id: 'user-2')), - channelMemberCount: 2, - ); - - await tester.emitEvent(memberAddedEvent2); - expect(tester.channel.memberCount, equals(2)); - expect( - tester.channelState?.channelState.members?.map((it) => it.userId), - equals(['user-1', 'user-2']), - ); - - // Test with member.removed event - count decreases - final memberRemovedEvent = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.memberRemoved, - user: User(id: 'user-1'), - channelMemberCount: 1, - ); - - await tester.emitEvent(memberRemovedEvent); - expect(tester.channel.memberCount, equals(1)); - expect(tester.channelState?.channelState.members?.map((it) => it.userId), equals(['user-2'])); - }, - ); - - channelTest( - 'should preserve other channel properties when updating memberCount', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - // Set initial channel state with some properties - final initialChannel = tester.channelState?.channelState.channel?.copyWith( - extraData: {'name': 'Test Channel'}, - messageCount: 7, - frozen: true, - ); - - if (initialChannel != null) { - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith(channel: initialChannel), - ); - } - - // Verify initial state - 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 = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.memberAdded, - member: createDefaultMember(user: User(id: 'user-1')), - channelMemberCount: 100, - ); - - await tester.emitEvent(memberCountEvent); - - // Verify memberCount was updated while preserving other properties - expect(tester.channel.memberCount, equals(100)); - expect(tester.channel.name, 'Test Channel'); - expect(tester.channel.messageCount, equals(7)); - expect(tester.channel.frozen, equals(true)); - }, - ); - - channelTest( - 'should not update memberCount when the event omits channelMemberCount', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - // Seed a known member count. - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.memberAdded, - member: createDefaultMember(user: User(id: 'user-1')), - channelMemberCount: 5, - ), - ); - - expect(tester.channel.memberCount, equals(5)); - - // An event without the field should leave the count untouched. - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.memberAdded, - member: createDefaultMember(user: User(id: 'user-2')), - ), - ); - - expect(tester.channel.memberCount, equals(5)); - }, - ); - - channelTest( - 'should provide memberCountStream for reactive updates', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final emitted = []; - 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 = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.memberAdded, - member: createDefaultMember(user: User(id: 'user-$index')), - channelMemberCount: count, - ); - - await tester.emitEvent(event); - } - - // The repeated count should not be emitted twice. - expect(emitted, equals([0, 1, 5, 10])); - }, - ); - - channelTest( - 'should still apply the count refresh when an earlier dispatch stage throws', - 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(tester.channel.memberCount, equals(9)); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_delete_message_test.dart b/packages/stream_chat/test/src/client/channel/channel_delete_message_test.dart deleted file mode 100644 index df0d422f46..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_delete_message_test.dart +++ /dev/null @@ -1,269 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: [ChannelCapability.readEvents], - ), - ); -} - -void main() { - group('`.deleteMessage`', () { - channelTest( - 'should work fine', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - const messageId = 'test-message-id'; - final message = Message( - id: messageId, - createdAt: DateTime.utc(2021, 3), - state: MessageState.sent, - ); - - tester.mockApi( - (api) => api.message.deleteMessage(messageId, hard: false), - 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. - tester.channelState?.addNewMessage(message); - - 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, - ), - ], - ]), - ); - - final res = await tester.channel.deleteMessage(message); - - expect(res, isNotNull); - - await messagesEmits; - - tester.verifyApi( - (api) => api.message.deleteMessage(messageId, hard: false), - ); - }, - ); - - channelTest( - 'should delete attachments for hard delete', - 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'), - imageUrl: index.isEven ? 'test-image-url-$index' : null, - assetUrl: index.isOdd ? 'test-asset-url-$index' : null, - uploadState: const UploadState.success(), - ), - ); - - const messageId = 'test-message-id'; - final message = Message( - id: messageId, - attachments: attachments, - createdAt: DateTime.utc(2021, 3), - state: MessageState.sent, - ); - - 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(), - ); - - final res = await tester.channel.deleteMessage(message, hard: true); - expect(res, isNotNull); - - 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), - ); - }, - ); - - channelTest( - 'should hard delete the message if the state is sending or failed', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - const messageId = 'test-message-id'; - final message = Message( - id: messageId, - text: 'Hello World!', - state: MessageState.sending, - ); - - final messagesEmits = expectLater( - // skipping first seed message list -> [] messages - tester.channelState?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith(state: MessageState.sending), - matchMessageState: true, - ), - ], - const [], // message is hard deleted from state - ]), - ); - - // Add message to channel state first - tester.channelState?.addNewMessage(message); - - final res = await tester.channel.deleteMessage(message); - - expect(res, isNotNull); - - await messagesEmits; - - tester.verifyNeverCalled( - (api) => api.message.deleteMessage(messageId, hard: false), - ); - }, - ); - }); - - group('`.deleteMessageForMe`', () { - channelTest( - 'should work fine', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - const messageId = 'test-message-id'; - final message = Message( - id: messageId, - createdAt: DateTime.utc(2021, 3), - state: MessageState.sent, - ); - - 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. - tester.channelState?.addNewMessage(message); - - 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 tester.channel.deleteMessageForMe(message); - - expect(res, isNotNull); - - await messagesEmits; - - tester.verifyApi( - (api) => api.message.deleteMessage(messageId, deleteForMe: true), - ); - }, - ); - - channelTest( - 'should hard delete the message if the state is sending or failed', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - const messageId = 'test-message-id'; - final message = Message( - id: messageId, - text: 'Hello World!', - state: MessageState.sending, - ); - - final messagesEmits = expectLater( - // skipping first seed message list -> [] messages - tester.channelState?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith(state: MessageState.sending), - matchMessageState: true, - ), - ], - const [], // message is hard deleted from state - ]), - ); - - // Add message to channel state first - tester.channelState?.addNewMessage(message); - - final res = await tester.channel.deleteMessageForMe(message); - - expect(res, isNotNull); - - await messagesEmits; - - tester.verifyNeverCalled( - (api) => api.message.deleteMessage(messageId, deleteForMe: true), - ); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_display_api_test.dart b/packages/stream_chat/test/src/client/channel/channel_display_api_test.dart deleted file mode 100644 index 760b4416c5..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_display_api_test.dart +++ /dev/null @@ -1,216 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: [ChannelCapability.readEvents], - ), - ); -} - -void main() { - channelTest( - '`.hide`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - const clearHistory = true; - - tester.mockApi( - (api) => api.channel.hideChannel( - _channelId, - _channelType, - clearHistory: clearHistory, - ), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.channel.hide(clearHistory: clearHistory); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.channel.hideChannel( - _channelId, - _channelType, - clearHistory: clearHistory, - ), - ); - }, - ); - - channelTest( - '`.show`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - tester.mockApi( - (api) => api.channel.showChannel(_channelId, _channelType), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.channel.show(); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.channel.showChannel(_channelId, _channelType), - ); - }, - ); - - // testing archiving - channelTest( - '`.archive`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - tester.mockApi( - (api) => api.channel.updateMemberPartial( - channelId: _channelId, - channelType: _channelType, - set: {'archived': true}, - ), - result: createDefaultPartialUpdateMemberResponse(), - ); - - final res = await tester.channel.archive(); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.channel.updateMemberPartial( - channelId: _channelId, - channelType: _channelType, - set: {'archived': true}, - ), - ); - }, - ); - - channelTest( - '`.unarchive`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - tester.mockApi( - (api) => api.channel.updateMemberPartial( - channelId: _channelId, - channelType: _channelType, - unset: ['archived'], - ), - result: createDefaultPartialUpdateMemberResponse(), - ); - - final res = await tester.channel.unarchive(); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.channel.updateMemberPartial( - channelId: _channelId, - channelType: _channelType, - unset: ['archived'], - ), - ); - }, - ); - - // testing pinning - channelTest( - '`.pin`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - tester.mockApi( - (api) => api.channel.updateMemberPartial( - channelId: _channelId, - channelType: _channelType, - set: {'pinned': true}, - ), - result: createDefaultPartialUpdateMemberResponse(), - ); - - final res = await tester.channel.pin(); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.channel.updateMemberPartial( - channelId: _channelId, - channelType: _channelType, - set: {'pinned': true}, - ), - ); - }, - ); - - channelTest( - '`.unpin`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - tester.mockApi( - (api) => api.channel.updateMemberPartial( - channelId: _channelId, - channelType: _channelType, - unset: ['pinned'], - ), - result: createDefaultPartialUpdateMemberResponse(), - ); - - final res = await tester.channel.unpin(); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.channel.updateMemberPartial( - channelId: _channelId, - channelType: _channelType, - unset: ['pinned'], - ), - ); - }, - ); - - channelTest( - '`.on`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - 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), - ]), - ); - - await tester.emitEvent(event); - - await eventReceived; - }, - ); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_drafts_test.dart b/packages/stream_chat/test/src/client/channel/channel_drafts_test.dart deleted file mode 100644 index a7ab77fcc0..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_drafts_test.dart +++ /dev/null @@ -1,144 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -Future _seedChannel(ChannelTester tester) => tester.watch( - modifyResponse: (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: const [ChannelCapability.readEvents], - ), - ), -); - -void main() { - group('`.createDraft`', () { - final draftMessage = DraftMessage(text: 'Draft message text'); - - channelTest( - 'should call client.createDraft', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - body: (tester) async { - tester.mockApi( - (api) => api.message.createDraft(_channelId, _channelType, draftMessage), - result: createDefaultCreateDraftResponse( - draft: createDefaultDraft(channelCid: _channelCid, message: draftMessage), - ), - ); - - final res = await tester.channel.createDraft(draftMessage); - - expect(res, isNotNull); - expect(res.draft.message, draftMessage); - - tester.verifyApi( - (api) => api.message.createDraft(_channelId, _channelType, draftMessage), - ); - }, - ); - }); - - group('`.getDraft`', () { - final draftMessage = DraftMessage(text: 'Draft message text'); - - channelTest( - 'should call client.getDraft', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - body: (tester) async { - tester.mockApi( - (api) => api.message.getDraft(_channelId, _channelType), - result: createDefaultGetDraftResponse( - draft: createDefaultDraft(channelCid: _channelCid, message: draftMessage), - ), - ); - - final res = await tester.channel.getDraft(); - - expect(res, isNotNull); - expect(res.draft.message, draftMessage); - - tester.verifyApi( - (api) => api.message.getDraft(_channelId, _channelType), - ); - }, - ); - - channelTest( - 'with parentId should pass parentId to client', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - 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), - ), - ); - - final res = await tester.channel.getDraft(parentId: parentId); - - expect(res, isNotNull); - expect(res.draft.message, draftMessage); - - tester.verifyApi( - (api) => api.message.getDraft(_channelId, _channelType, parentId: parentId), - ); - }, - ); - }); - - group('`.deleteDraft`', () { - channelTest( - 'should call client.deleteDraft', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - body: (tester) async { - tester.mockApi( - (api) => api.message.deleteDraft(_channelId, _channelType), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.channel.deleteDraft(); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.message.deleteDraft(_channelId, _channelType), - ); - }, - ); - - channelTest( - 'with parentId should pass parentId to client', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - body: (tester) async { - const parentId = 'parent-123'; - tester.mockApi( - (api) => api.message.deleteDraft(_channelId, _channelType, parentId: parentId), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.channel.deleteDraft(parentId: parentId); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.message.deleteDraft(_channelId, _channelType, parentId: parentId), - ); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_filter_tags_test.dart b/packages/stream_chat/test/src/client/channel/channel_filter_tags_test.dart deleted file mode 100644 index 548208391b..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_filter_tags_test.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel({ - List? filterTags, -}) { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - filterTags: filterTags, - ), - ); -} - -void main() { - group('Channel filterTags', () { - 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'])); - }, - ); - - 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 channelModel = tester.channelState!.channelState.channel!; - final updatedChannel = channelModel.copyWith( - filterTags: ['tag3', 'tag4', 'tag5'], - ); - - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith(channel: updatedChannel), - ); - - expect(tester.channel.filterTags, equals(['tag3', 'tag4', 'tag5'])); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_location_api_test.dart b/packages/stream_chat/test/src/client/channel/channel_location_api_test.dart deleted file mode 100644 index 305a86201d..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_location_api_test.dart +++ /dev/null @@ -1,146 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: const [ChannelCapability.readEvents], - ), - ); -} - -void main() { - group('`.sendStaticLocation`', () { - const deviceId = 'test-device-id'; - const locationId = 'test-location-id'; - const coordinates = LocationCoordinates( - latitude: 40.7128, - longitude: -74.0060, - ); - - channelTest( - 'should create a static location and call sendMessage', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - 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 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); - - tester.verifyApi( - (api) => api.message.sendMessage( - _channelId, - _channelType, - any(that: isSameMessageAs(Message(id: locationId))), - ), - ); - }, - ); - }); - - group('`.startLiveLocationSharing`', () { - const deviceId = 'test-device-id'; - const locationId = 'test-location-id'; - final endSharingAt = DateTime.timestamp().add(const Duration(hours: 1)); - const coordinates = LocationCoordinates( - latitude: 40.7128, - longitude: -74.0060, - ); - - channelTest( - 'should create message with live location and call sendMessage', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - 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, - endAt: endSharingAt, - ), - ), - ), - ); - - final response = await tester.channel.startLiveLocationSharing( - id: locationId, - messageText: 'Location shared', - createdByDeviceId: deviceId, - location: coordinates, - endSharingAt: endSharingAt, - 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.message.sharedLocation?.endAt, endSharingAt); - - tester.verifyApi( - (api) => api.message.sendMessage( - _channelId, - _channelType, - any(that: isSameMessageAs(Message(id: locationId))), - ), - ); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_member_api_test.dart b/packages/stream_chat/test/src/client/channel/channel_member_api_test.dart deleted file mode 100644 index d623d969c0..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_member_api_test.dart +++ /dev/null @@ -1,239 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -Future _seedChannel(ChannelTester tester) => tester.watch( - modifyResponse: (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: const [ChannelCapability.readEvents], - ), - ), -); - -void main() { - channelTest( - '`.acceptInvite`', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - body: (tester) async { - final message = Message(id: 'test-message-id', text: 'Invite Accepted'); - - final channelModel = createDefaultChannelModel(cid: _channelCid); - - tester.mockApi( - (api) => api.channel.acceptChannelInvite(_channelId, _channelType, message: message), - result: AcceptInviteResponse() - ..channel = channelModel - ..message = message, - ); - - final res = await tester.channel.acceptInvite(message); - - 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), - ); - }, - ); - - channelTest( - '`.rejectInvite`', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - body: (tester) async { - final message = Message(id: 'test-message-id', text: 'Invite Rejected'); - - final channelModel = createDefaultChannelModel(cid: _channelCid); - - tester.mockApi( - (api) => api.channel.rejectChannelInvite(_channelId, _channelType, message: message), - result: RejectInviteResponse() - ..channel = channelModel - ..message = message, - ); - - final res = await tester.channel.rejectInvite(message); - - 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), - ); - }, - ); - - channelTest( - '`.addMembers`', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - 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 channelModel = createDefaultChannelModel(cid: _channelCid); - - tester.mockApi( - (api) => api.channel.addMembers(_channelId, _channelType, memberIds, message: message), - result: createDefaultAddMembersResponse( - channel: channelModel, - members: members, - message: message, - ), - ); - - final res = await tester.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); - - tester.verifyApi( - (api) => api.channel.addMembers(_channelId, _channelType, memberIds, message: message), - ); - }, - ); - - channelTest( - '`.addMembers` with hideHistoryBefore', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - 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'); - - final channelModel = createDefaultChannelModel(cid: _channelCid); - - tester.mockApi( - (api) => api.channel.addMembers( - _channelId, - _channelType, - memberIds, - message: message, - hideHistoryBefore: hideHistoryBefore, - ), - result: createDefaultAddMembersResponse( - channel: channelModel, - members: members, - message: message, - ), - ); - - final res = await tester.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); - - tester.verifyApi( - (api) => api.channel.addMembers( - _channelId, - _channelType, - memberIds, - message: message, - hideHistoryBefore: hideHistoryBefore, - ), - ); - }, - ); - - channelTest( - '`.inviteMembers`', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - 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 = createDefaultChannelModel(cid: _channelCid); - - tester.mockApi( - (api) => api.channel.inviteChannelMembers(_channelId, _channelType, memberIds, message: message), - result: InviteMembersResponse() - ..channel = channelModel - ..members = members - ..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); - - tester.verifyApi( - (api) => api.channel.inviteChannelMembers(_channelId, _channelType, memberIds, message: message), - ); - }, - ); - - channelTest( - '`.removeMembers`', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - 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 = createDefaultChannelModel(cid: _channelCid); - - tester.mockApi( - (api) => api.channel.removeMembers(_channelId, _channelType, memberIds, message: message), - result: RemoveMembersResponse() - ..channel = channelModel - ..members = members - ..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); - - tester.verifyApi( - (api) => api.channel.removeMembers(_channelId, _channelType, memberIds, message: message), - ); - }, - ); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_message_queries_test.dart b/packages/stream_chat/test/src/client/channel/channel_message_queries_test.dart deleted file mode 100644 index 066e8b3dbb..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_message_queries_test.dart +++ /dev/null @@ -1,178 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: [ChannelCapability.readEvents], - ), - ); -} - -void main() { - channelTest( - '`.getReplies`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - const parentId = 'test-parent-id'; - - final messages = List.generate( - 3, - (index) => Message( - id: 'test-message-id-$index', - parentId: parentId, - ), - ); - - tester.mockApi( - (api) => api.message.getReplies(parentId), - result: createDefaultQueryRepliesResponse(messages: messages), - ); - - 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); - - tester.verifyApi((api) => api.message.getReplies(parentId)); - }, - ); - - channelTest( - '`.getReplies` keeps the parent message out of the thread', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - 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), - ), - ]; - - tester.mockApi( - (api) => api.message.getReplies(parentId), - result: createDefaultQueryRepliesResponse(messages: messages), - ); - - await tester.channel.getReplies(parentId); - - final threadMessages = tester.channelState!.threads[parentId]; - expect(threadMessages, isNotNull); - expect(threadMessages!.length, messages.length - 1); - expect(threadMessages.any((it) => it.id == parentId), isFalse); - }, - ); - - channelTest( - '`.getReactions`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - const messageId = 'test-message-id'; - - final reactions = List.generate( - 3, - (index) => Reaction( - type: 'test-reaction-type-$index', - messageId: messageId, - ), - ); - - tester.mockApi( - (api) => api.message.getReactions(messageId), - result: createDefaultQueryReactionsResponse(reactions: reactions), - ); - - 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); - - tester.verifyApi((api) => api.message.getReactions(messageId)); - }, - ); - - channelTest( - '`.getMessagesById`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - 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); - - tester.mockApi( - (api) => api.message.getMessagesById(_channelId, _channelType, messageIds), - result: createDefaultGetMessagesByIdResponse(messages: messages), - ); - - final res = await tester.channel.getMessagesById(messageIds); - - expect(res, isNotNull); - expect(res.messages.length, messageIds.length); - - tester.verifyApi( - (api) => api.message.getMessagesById(_channelId, _channelType, messageIds), - ); - }, - ); - - channelTest( - '`.translateMessage`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - const messageId = 'test-message-id'; - const language = 'hi'; // Hindi - const translatedMessageText = 'नमस्ते'; - - final message = Message(id: messageId, text: 'Hello'); - tester.channelState!.updateMessage(message); - - final translatedMessage = message.copyWith( - i18n: {'language': 'en', '${language}_text': translatedMessageText}, - ); - - tester.mockApi( - (api) => api.message.translateMessage(messageId, language), - result: createDefaultTranslateMessageResponse(message: translatedMessage), - ); - - final res = await tester.channel.translateMessage(messageId, language); - - 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 = tester.channelState!.messages.firstWhere((it) => it.id == messageId); - expect(stateMessage.i18n, translatedMessage.i18n); - - tester.verifyApi((api) => api.message.translateMessage(messageId, language)); - }, - ); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_misc_operations_test.dart b/packages/stream_chat/test/src/client/channel/channel_misc_operations_test.dart deleted file mode 100644 index b324fea916..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_misc_operations_test.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: [ChannelCapability.readEvents], - ), - ); -} - -void main() { - channelTest( - '`.deleteFile`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - const url = 'test-file-url'; - - tester.mockApi( - (api) => api.fileUploader.deleteFile(url, _channelId, _channelType), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.channel.deleteFile(url); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.fileUploader.deleteFile(url, _channelId, _channelType), - ); - }, - ); - - channelTest( - '`.deleteImage`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - const url = 'test-image-url'; - - tester.mockApi( - (api) => api.fileUploader.deleteImage(url, _channelId, _channelType), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.channel.deleteImage(url); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.fileUploader.deleteImage(url, _channelId, _channelType), - ); - }, - ); - - channelTest( - '`.stopAIResponse`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - 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 tester.channel.stopAIResponse(); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.channel.sendEvent( - _channelId, - _channelType, - any(that: isSameEventAs(stopAIEvent)), - ), - ); - }, - ); - - channelTest( - '`.sendEvent`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final event = Event(type: 'event.local'); - - // 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 tester.channel.sendEvent(event); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.channel.sendEvent(_channelId, _channelType, event), - ); - }, - ); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_moderation_api_test.dart b/packages/stream_chat/test/src/client/channel/channel_moderation_api_test.dart deleted file mode 100644 index 4cb7bf20c7..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_moderation_api_test.dart +++ /dev/null @@ -1,263 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: [ChannelCapability.readEvents], - ), - ); -} - -void main() { - channelTest( - '`.mute`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - tester.mockApi( - (api) => api.moderation.muteChannel(_channelCid), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.channel.mute(); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.moderation.muteChannel(_channelCid), - ); - }, - ); - - channelTest( - '`.mute with expiration`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - 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(), - ); - - final res = await tester.channel.mute(expiration: expiration); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.moderation.muteChannel(_channelCid, expiration: expiration), - ); - - // wait for expiration - await Future.delayed(expiration); - tester.verifyApi((api) => api.moderation.unmuteChannel(_channelCid)); - }, - ); - - channelTest( - '`.unmute`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - tester.mockApi( - (api) => api.moderation.unmuteChannel(_channelCid), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.channel.unmute(); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.moderation.unmuteChannel(_channelCid), - ); - }, - ); - - channelTest( - '`.enableSlowMode`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - const cooldown = 10; - - final channelModel = ChannelModel( - cid: _channelCid, - cooldown: cooldown, - ); - - tester.mockApi( - (api) => api.channel.enableSlowdown(_channelId, _channelType, cooldown), - result: createDefaultPartialUpdateChannelResponse(channel: channelModel), - ); - - final res = await tester.channel.enableSlowMode(cooldownInterval: 10); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.channel.enableSlowdown(_channelId, _channelType, cooldown), - ); - }, - ); - - channelTest( - '`.disableSlowMode`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final channelModel = ChannelModel( - cid: _channelCid, - ); - - tester.mockApi( - (api) => api.channel.disableSlowdown(_channelId, _channelType), - result: createDefaultPartialUpdateChannelResponse(channel: channelModel), - ); - - final res = await tester.channel.disableSlowMode(); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.channel.disableSlowdown(_channelId, _channelType), - ); - }, - ); - - channelTest( - '`.banUser`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - const userId = 'test-user-id'; - const options = {'key': 'value'}; - - tester.mockApi( - (api) => api.moderation.banUser( - userId, - options: {'type': _channelType, 'id': _channelId, ...options}, - ), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.channel.banMember(userId, options); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.moderation.banUser( - userId, - options: {'type': _channelType, 'id': _channelId, ...options}, - ), - ); - }, - ); - - channelTest( - '`.unbanUser`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - const userId = 'test-user-id'; - - tester.mockApi( - (api) => api.moderation.unbanUser( - userId, - options: {'type': _channelType, 'id': _channelId}, - ), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.channel.unbanMember(userId); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.moderation.unbanUser( - userId, - options: {'type': _channelType, 'id': _channelId}, - ), - ); - }, - ); - - channelTest( - '`.shadowBan`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - const userId = 'test-user-id'; - const options = {'key': 'value'}; - - tester.mockApi( - (api) => api.moderation.banUser( - userId, - options: {'shadow': true, 'type': _channelType, 'id': _channelId, ...options}, - ), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.channel.shadowBan(userId, options); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.moderation.banUser( - userId, - options: {'shadow': true, 'type': _channelType, 'id': _channelId, ...options}, - ), - ); - }, - ); - - channelTest( - '`.removeShadowBan`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - const userId = 'test-user-id'; - - tester.mockApi( - (api) => api.moderation.unbanUser( - userId, - options: {'shadow': true, 'type': _channelType, 'id': _channelId}, - ), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.channel.removeShadowBan(userId); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.moderation.unbanUser( - userId, - options: {'shadow': true, 'type': _channelType, 'id': _channelId}, - ), - ); - }, - ); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_persistence_test.dart b/packages/stream_chat/test/src/client/channel/channel_persistence_test.dart deleted file mode 100644 index e6811154bb..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_persistence_test.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:stream_chat/stream_chat.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; -} - -void main() { - group('Initialized Channel with Persistence', () { - final persistenceClient = _createPersistenceClient(); - 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); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_pin_message_test.dart b/packages/stream_chat/test/src/client/channel/channel_pin_message_test.dart deleted file mode 100644 index c70d1b99cc..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_pin_message_test.dart +++ /dev/null @@ -1,283 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: [ChannelCapability.readEvents], - ), - ); -} - -void main() { - group('`.pinMessage`', () { - channelTest( - 'should work fine without passing timeoutOrExpirationDate', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final message = Message(id: 'test-message-id'); - - tester.mockApi( - (api) => api.message.partialUpdateMessage( - message.id, - set: {'pinned': true, 'pin_expires': null}, - ), - 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 tester.channel.pinMessage(message); - - expect(res, isNotNull); - expect(res.message.pinned, isTrue); - expect(res.message.pinExpires, isNull); - - await messagesEmits; - - tester.verifyApi( - (api) => api.message.partialUpdateMessage( - message.id, - set: {'pinned': true, 'pin_expires': null}, - ), - ); - }, - ); - - channelTest( - 'should work fine if passed timeoutOrExpirationDate as num(seconds)', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final message = Message(id: 'test-message-id'); - const timeoutOrExpirationDate = 300; // 300 seconds - - // 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'), - ), - result: createDefaultUpdateMessageResponse( - message: message.copyWith( - pinned: true, - pinExpires: DateTime.utc(2021, 3).add( - const Duration(seconds: timeoutOrExpirationDate), - ), - ), - ), - ); - - 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 tester.channel.pinMessage( - message, - timeoutOrExpirationDate: timeoutOrExpirationDate, - ); - - expect(res, isNotNull); - expect(res.message.pinned, isTrue); - expect(res.message.pinExpires, isNotNull); - - await messagesEmits; - - tester.verifyApi( - (api) => api.message.partialUpdateMessage( - message.id, - set: any(named: 'set'), - ), - ); - }, - ); - - channelTest( - 'should work fine if passed timeoutOrExpirationDate as DateTime', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final message = Message(id: 'test-message-id'); - final timeoutOrExpirationDate = DateTime.utc(2021, 3).add(const Duration(days: 3)); // 3 days - - tester.mockApi( - (api) => api.message.partialUpdateMessage( - message.id, - set: { - 'pinned': true, - 'pin_expires': timeoutOrExpirationDate.toUtc().toIso8601String(), - }, - ), - result: createDefaultUpdateMessageResponse( - message: message.copyWith( - pinned: true, - pinExpires: timeoutOrExpirationDate, - ), - ), - ); - - 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 tester.channel.pinMessage( - message, - timeoutOrExpirationDate: timeoutOrExpirationDate, - ); - - expect(res, isNotNull); - expect(res.message.pinned, isTrue); - expect(res.message.pinExpires, isNotNull); - expect(res.message.pinExpires, timeoutOrExpirationDate.toUtc()); - - await messagesEmits; - - tester.verifyApi( - (api) => api.message.partialUpdateMessage( - message.id, - set: { - 'pinned': true, - 'pin_expires': timeoutOrExpirationDate.toUtc().toIso8601String(), - }, - ), - ); - }, - ); - - channelTest( - 'should throw if invalid timeoutOrExpirationDate is passed', - 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 tester.channel.pinMessage( - message, - timeoutOrExpirationDate: timeoutOrExpirationDate, - ); - } catch (e) { - expect(e, isA()); - } - }, - ); - }); - - channelTest( - '`.unpinMessage`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final message = Message(id: 'test-message-id', pinned: true); - - tester.mockApi( - (api) => api.message.partialUpdateMessage( - message.id, - set: {'pinned': false}, - ), - result: createDefaultUpdateMessageResponse( - message: message.copyWith(pinned: false), - ), - ); - - 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 tester.channel.unpinMessage(message); - - expect(res, isNotNull); - expect(res.message.pinned, isFalse); - - await messagesEmits; - - tester.verifyApi( - (api) => api.message.partialUpdateMessage( - message.id, - set: {'pinned': false}, - ), - ); - }, - ); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_query_test.dart b/packages/stream_chat/test/src/client/channel/channel_query_test.dart deleted file mode 100644 index 65887c168f..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_query_test.dart +++ /dev/null @@ -1,393 +0,0 @@ -import 'package:stream_chat/stream_chat.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 — 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, - ), - ); -} - -void main() { - group('`.query`', () { - 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 tester.channel.query(); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.channel.queryChannel( - _channelType, - channelId: _channelId, - channelData: tester.channel.extraData, - ), - ); - }, - ); - - 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(errorCode: ChatErrorCode.inputError), - ); - - try { - await tester.channel.query(); - } catch (e) { - expect(e, isA()); - } - - tester.verifyApi( - (api) => api.channel.queryChannel( - _channelType, - channelId: _channelId, - channelData: tester.channel.extraData, - ), - ); - }, - ); - - 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, - ); - - 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'), - ], - ); - - const pagination = PaginationParams(idAround: 'target-message-id'); - - tester.mockApi( - (api) => api.channel.queryChannel( - _channelType, - channelId: _channelId, - channelData: tester.channel.extraData, - messagesPagination: pagination, - ), - result: newState, - ); - - final res = await tester.channel.query(messagesPagination: pagination); - - 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, - ), - ); - }, - ); - - 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, - ); - - 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'), - ], - ); - - final pagination = PaginationParams(createdAtAround: targetDate); - - tester.mockApi( - (api) => api.channel.queryChannel( - _channelType, - channelId: _channelId, - channelData: tester.channel.extraData, - messagesPagination: pagination, - ), - result: newState, - ); - - final res = await tester.channel.query(messagesPagination: pagination); - - 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, - ), - ); - }, - ); - - 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), - ), - ], - ), - body: (tester) async { - tester.mockApi( - (api) => api.channel.queryChannel( - _channelType, - channelId: _channelId, - channelData: tester.channel.extraData, - ), - result: createDefaultChannelState( - channel: createDefaultChannelModel(cid: _channelCid), - ), - ); - - registerFallbackValue([]); - tester.mockApi( - (api) => api.channel.markChannelsDelivered(any()), - result: createDefaultEmptyResponse(), - ); - - // 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)); - - // 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'); - }, - ); - - 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), - ), - ], - ), - body: (tester) async { - registerFallbackValue([]); - tester.mockApi( - (api) => api.channel.markChannelsDelivered(any()), - result: createDefaultEmptyResponse(), - ); - - const pagination = PaginationParams(limit: 20, lessThan: 'some-message-id'); - - tester.mockApi( - (api) => api.channel.queryChannel( - _channelType, - channelId: _channelId, - channelData: tester.channel.extraData, - messagesPagination: pagination, - ), - result: createDefaultChannelState( - channel: createDefaultChannelModel(cid: _channelCid), - ), - ); - - // Query with pagination params (fetching older messages) - await tester.channel.query(messagesPagination: pagination); - - // 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)); - - // Verify no delivery receipt was sent - tester.verifyNeverCalled( - (api) => api.channel.markChannelsDelivered(any()), - ); - }, - ); - }); - - channelTest( - '`.queryMembers`', - channelType: _channelType, - channelId: _channelId, - build: _buildInitializedChannel, - body: (tester) async { - final filter = Filter.in_('cid', const [_channelCid]); - - final members = List.generate( - 3, - (index) => Member(userId: 'test-user-id-$index'), - ); - - 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, - ); - - final res = await tester.channel.queryMembers(filter: filter); - - expect(res, isNotNull); - expect(res.members.length, members.length); - - tester.verifyApi( - (api) => api.general.queryMembers( - _channelType, - channelId: _channelId, - filter: filter, - members: const [], - ), - ); - }, - ); - - channelTest( - '`.queryBannedUsers`', - channelType: _channelType, - channelId: _channelId, - build: _buildInitializedChannel, - body: (tester) async { - final filter = Filter.equal('channel_cid', _channelCid); - - final bans = List.generate( - 3, - (index) => BannedUser( - user: User(id: 'test-user-id-$index'), - bannedBy: User(id: 'test-user-id-${index + 1}'), - ), - ); - - tester.mockApi( - (api) => api.moderation.queryBannedUsers(filter: filter), - result: QueryBannedUsersResponse()..bans = bans, - ); - - final res = await tester.channel.queryBannedUsers(); - - expect(res, isNotNull); - expect(res.bans.length, bans.length); - - tester.verifyApi( - (api) => api.moderation.queryBannedUsers(filter: filter), - ); - }, - ); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_reactions_test.dart b/packages/stream_chat/test/src/client/channel/channel_reactions_test.dart deleted file mode 100644 index 389fd25f30..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_reactions_test.dart +++ /dev/null @@ -1,731 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: const [ChannelCapability.readEvents], - ), - ); -} - -void main() { - group('`.sendReaction`', () { - channelTest( - 'should work fine', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final message = Message( - id: 'test-message-id', - state: MessageState.sent, - ); - - const type = 'like'; - const emojiCode = '👍'; - const score = 4; - - final reaction = Reaction( - type: type, - messageId: message.id, - emojiCode: emojiCode, - score: score, - user: tester.currentUser, - ); - - tester.mockApi( - (api) => api.message.sendReaction(message.id, reaction), - result: createDefaultSendReactionResponse(message: message, reaction: 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, - ), - ], - ]), - ); - - 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); - - tester.verifyApi((api) => api.message.sendReaction(message.id, reaction)); - - await messagesEmission; - }, - ); - - channelTest( - 'should restore previous message if `client.sendReaction` throws', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - const type = 'test-reaction-type'; - final message = Message( - id: 'test-message-id', - state: MessageState.sent, - ); - - final reaction = Reaction( - type: type, - messageId: message.id, - user: tester.currentUser, - ); - - tester.mockApiFailure( - (api) => api.message.sendReaction(message.id, reaction), - error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError), - ); - - 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, - ), - ], - [ - isSameMessageAs( - message, - matchReactions: true, - matchMessageState: true, - ), - ], - ]), - ); - - try { - await tester.channel.sendReaction(message, reaction); - } catch (e) { - expect(e, isA()); - } - - tester.verifyApi((api) => api.message.sendReaction(message.id, reaction)); - - await messagesEmission; - }, - ); - - channelTest( - 'should override previous reaction if present and `enforceUnique` is true', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - const messageId = 'test-message-id'; - const prevType = 'test-reaction-type'; - final prevReaction = Reaction( - type: prevType, - messageId: messageId, - user: tester.currentUser, - ); - final message = Message( - id: messageId, - ownReactions: [prevReaction], - latestReactions: [prevReaction], - reactionGroups: { - prevType: ReactionGroup( - count: 1, - sumScores: 1, - ), - }, - state: MessageState.sent, - ); - - const type = 'test-reaction-type-2'; - final newReaction = Reaction( - type: type, - messageId: messageId, - user: tester.currentUser, - ); - final newMessage = message.copyWith( - ownReactions: [newReaction], - latestReactions: [newReaction], - ); - - const enforceUnique = true; - - tester.mockApi( - (api) => api.message.sendReaction( - messageId, - newReaction, - enforceUnique: enforceUnique, - ), - result: createDefaultSendReactionResponse(message: newMessage, reaction: newReaction), - ); - - final messagesEmission = expectLater( - // skipping first seed message list -> [] messages - tester.channelState?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - newMessage, - matchReactions: true, - matchMessageState: true, - ), - ], - ]), - ); - - final res = await tester.channel.sendReaction( - message, - newReaction, - enforceUnique: enforceUnique, - ); - - expect(res, isNotNull); - expect(res.reaction.type, type); - expect(res.reaction.messageId, messageId); - - tester.verifyApi( - (api) => api.message.sendReaction( - messageId, - newReaction, - enforceUnique: enforceUnique, - ), - ); - - await messagesEmission; - }, - ); - }); - - group('`.sendReaction in thread`', () { - channelTest( - 'should work fine', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - 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: tester.currentUser, - ); - - tester.mockApi( - (api) => api.message.sendReaction(message.id, reaction), - result: createDefaultSendReactionResponse(message: message, reaction: 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, - ), - ], - ]), - ); - - final res = await tester.channel.sendReaction(message, reaction); - - expect(res, isNotNull); - expect(res.reaction.type, type); - expect(res.reaction.messageId, message.id); - - tester.verifyApi((api) => api.message.sendReaction(message.id, reaction)); - - await threadsEmission; - }, - ); - - channelTest( - 'should restore previous thread message if `client.sendReaction` throws', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - 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, - // `Message.createdAt` falls back to `DateTime.now()` per call - // when not provided, which breaks merge/sort keyed on createdAt. - createdAt: DateTime.utc(2021, 3), - ); - - final reaction = Reaction( - type: type, - messageId: message.id, - user: tester.currentUser, - ); - - tester.mockApiFailure( - (api) => api.message.sendReaction(message.id, reaction), - error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError), - ); - - 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, - reactionGroups: { - type: ReactionGroup( - count: 1, - sumScores: 1, - ), - }, - latestReactions: [reaction], - ownReactions: [reaction], - ), - matchReactions: true, - matchMessageState: true, - matchParentId: true, - ), - ], - [ - isSameMessageAs( - message, - matchReactions: true, - matchMessageState: true, - matchParentId: true, - ), - ], - ]), - ); - - try { - await tester.channel.sendReaction(message, reaction); - } catch (e) { - expect(e, isA()); - } - - tester.verifyApi((api) => api.message.sendReaction(message.id, reaction)); - - await threadsEmission; - }, - ); - - channelTest( - 'should override previous thread reaction if present and `enforceUnique` is true', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - 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: tester.currentUser, - ); - final message = Message( - id: messageId, - parentId: parentId, - ownReactions: [prevReaction], - latestReactions: [prevReaction], - reactionGroups: { - prevType: ReactionGroup( - count: 1, - sumScores: 1, - ), - }, - state: MessageState.sent, - ); - - const type = 'test-reaction-type-2'; - final newReaction = Reaction( - type: type, - messageId: messageId, - user: tester.currentUser, - ); - final newMessage = message.copyWith( - ownReactions: [newReaction], - latestReactions: [newReaction], - ); - - const enforceUnique = true; - - tester.mockApi( - (api) => api.message.sendReaction( - messageId, - newReaction, - enforceUnique: enforceUnique, - ), - result: createDefaultSendReactionResponse(message: newMessage, reaction: newReaction), - ); - - final threadsEmission = expectLater( - // skipping first seed message list -> [] messages - tester.channelState?.threadsStream.skip(1).map((event) => event['test-parent-id']), - emitsInOrder([ - [ - isSameMessageAs( - newMessage.copyWith(state: MessageState.sent), - matchReactions: true, - matchMessageState: true, - matchParentId: true, - ), - ], - ]), - ); - - final res = await tester.channel.sendReaction( - message, - newReaction, - enforceUnique: enforceUnique, - ); - - expect(res, isNotNull); - expect(res.reaction.type, type); - expect(res.reaction.messageId, messageId); - - tester.verifyApi( - (api) => api.message.sendReaction( - messageId, - newReaction, - enforceUnique: enforceUnique, - ), - ); - - await threadsEmission; - }, - ); - }); - - group('`.deleteReaction`', () { - channelTest( - 'should work fine', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - 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, - ); - - tester.mockApi( - (api) => api.message.deleteReaction(messageId, type), - result: createDefaultEmptyResponse(), - ); - - 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, - ), - ], - ]), - ); - - final res = await tester.channel.deleteReaction(message, reaction); - - expect(res, isNotNull); - - tester.verifyApi((api) => api.message.deleteReaction(messageId, type)); - - await messagesEmission; - }, - ); - - channelTest( - 'should restore prev message state if `client.deleteReaction` throws', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - 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, - ); - - tester.mockApiFailure( - (api) => api.message.deleteReaction(messageId, type), - error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError), - ); - - 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, - ), - ], - [ - isSameMessageAs( - message, - matchReactions: true, - matchMessageState: true, - ), - ], - ]), - ); - - try { - await tester.channel.deleteReaction(message, reaction); - } catch (e) { - expect(e, isA()); - } - - tester.verifyApi((api) => api.message.deleteReaction(messageId, type)); - - await messagesEmission; - }, - ); - }); - - group('`.deleteReaction in thread`', () { - channelTest( - 'should work fine', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - 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), - ); - - tester.mockApi( - (api) => api.message.deleteReaction(messageId, type), - result: createDefaultEmptyResponse(), - ); - - 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, - ), - ], - ]), - ); - - final res = await tester.channel.deleteReaction(message, reaction); - - expect(res, isNotNull); - - tester.verifyApi((api) => api.message.deleteReaction(messageId, type)); - - await threadsEmission; - }, - ); - - channelTest( - 'should restore prev message state if `client.deleteReaction` throws', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - 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, - 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), - ); - - tester.mockApiFailure( - (api) => api.message.deleteReaction(messageId, type), - error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError), - ); - - 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, - ), - ], - [ - isSameMessageAs( - message, - matchReactions: true, - matchMessageState: true, - matchParentId: true, - ), - ], - ]), - ); - - try { - await tester.channel.deleteReaction(message, reaction); - } catch (e) { - expect(e, isA()); - } - - tester.verifyApi((api) => api.message.deleteReaction(messageId, type)); - - await threadsEmission; - }, - ); - }); -} 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 7573d488f3..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 @@ -25,9 +25,9 @@ void main() { // FEATURE: User Reads // ============================================================ - group('Channel Read Helper - User Reads', () { + group('ChannelReadHelper', () { channelTest( - 'userReadOf - should return read for specific user', + 'userReadOf should return read for specific user', setUp: (tester) => tester.watch( modifyResponse: (state) => state.copyWith( read: [ @@ -52,7 +52,7 @@ void main() { ); channelTest( - 'userReadOf - should return null when userId is null', + 'userReadOf should return null when userId is null', setUp: (tester) => tester.watch(), body: (tester) async { final read = tester.channelState!.userReadOf(userId: null); @@ -61,7 +61,7 @@ void main() { ); channelTest( - 'userReadStreamOf - should emit read updates for specific user', + 'userReadStreamOf should emit read updates for specific user', setUp: (tester) => tester.watch(), body: (tester) async { final readStream = tester.channelState!.userReadStreamOf(userId: 'user-1'); @@ -84,15 +84,8 @@ void main() { await readEmitted; }, ); - }); - - // ============================================================ - // FEATURE: Message Reads - // ============================================================ - - group('Channel Read Helper - Message Reads', () { channelTest( - 'readsOf - should return reads that have marked message as read', + 'readsOf should return reads that have marked message as read', setUp: (tester) => tester.watch( modifyResponse: (state) => state.copyWith( read: [ @@ -118,7 +111,7 @@ void main() { ); channelTest( - 'readsOfStream - should emit read updates for a message', + 'readsOfStream should emit read updates for a message', setUp: (tester) => tester.watch(), body: (tester) async { final readsStream = tester.channelState!.readsOfStream(message: message); @@ -143,15 +136,8 @@ void main() { await readsEmitted; }, ); - }); - - // ============================================================ - // FEATURE: Message Deliveries - // ============================================================ - - group('Channel Read Helper - Message Deliveries', () { channelTest( - 'deliveriesOf - should return reads that have delivered the message', + 'deliveriesOf should return reads that have delivered the message', setUp: (tester) => tester.watch( modifyResponse: (state) => state.copyWith( read: [ @@ -198,7 +184,7 @@ void main() { ); channelTest( - 'deliveriesOfStream - should emit delivery updates for a message', + 'deliveriesOfStream should emit delivery updates for a message', setUp: (tester) => tester.watch(), body: (tester) async { final deliveriesStream = tester.channelState!.deliveriesOfStream(message: message); diff --git a/packages/stream_chat/test/src/client/channel/channel_read_receipts_test.dart b/packages/stream_chat/test/src/client/channel/channel_read_receipts_test.dart deleted file mode 100644 index b72572dda5..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_read_receipts_test.dart +++ /dev/null @@ -1,275 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel({ - List ownCapabilities = const [], -}) { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - ownCapabilities: ownCapabilities, - ), - ); -} - -void main() { - group('Read Receipts', () { - channelTest( - ".markRead should throw if we don't have the capability", - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch( - // no readEvents capability - modifyResponse: _seedChannel(ownCapabilities: []), - ), - body: (tester) async { - await expectLater( - tester.channel.markRead(messageId: 'message-id-123'), - throwsA(isA()), - ); - }, - ); - - channelTest( - '.markRead should succeed if we have the capability', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch( - modifyResponse: _seedChannel( - ownCapabilities: [ChannelCapability.readEvents], - ), - ), - body: (tester) async { - tester.mockApi( - (api) => api.channel.markRead( - _channelId, - _channelType, - messageId: 'message-id-123', - ), - result: createDefaultEmptyResponse(), - ); - - await expectLater( - tester.channel.markRead(messageId: 'message-id-123'), - completes, - ); - - tester.verifyApi( - (api) => api.channel.markRead( - _channelId, - _channelType, - messageId: 'message-id-123', - ), - ); - }, - ); - - channelTest( - ".markUnread should throw if we don't have the capability", - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch( - // no readEvents capability - modifyResponse: _seedChannel(ownCapabilities: []), - ), - body: (tester) async { - await expectLater( - tester.channel.markUnread('message-id-123'), - throwsA(isA()), - ); - }, - ); - - channelTest( - '.markUnread should succeed if we have the capability', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch( - modifyResponse: _seedChannel( - ownCapabilities: [ChannelCapability.readEvents], - ), - ), - body: (tester) async { - tester.mockApi( - (api) => api.channel.markUnread( - _channelId, - _channelType, - 'message-id-123', - ), - result: createDefaultEmptyResponse(), - ); - - await expectLater( - tester.channel.markUnread('message-id-123'), - completes, - ); - - tester.verifyApi( - (api) => api.channel.markUnread( - _channelId, - _channelType, - 'message-id-123', - ), - ); - }, - ); - - channelTest( - ".markUnreadByTimestamp should throw if we don't have the capability", - 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( - tester.channel.markUnreadByTimestamp(timestamp), - throwsA(isA()), - ); - }, - ); - - channelTest( - '.markUnreadByTimestamp should succeed if we have the capability', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch( - modifyResponse: _seedChannel( - ownCapabilities: [ChannelCapability.readEvents], - ), - ), - body: (tester) async { - final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); - - tester.mockApi( - (api) => api.channel.markUnreadByTimestamp( - _channelId, - _channelType, - timestamp, - ), - result: createDefaultEmptyResponse(), - ); - - await expectLater( - tester.channel.markUnreadByTimestamp(timestamp), - completes, - ); - - tester.verifyApi( - (api) => api.channel.markUnreadByTimestamp( - _channelId, - _channelType, - timestamp, - ), - ); - }, - ); - - channelTest( - ".markThreadRead should throw if we don't have the capability", - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch( - // no readEvents capability - modifyResponse: _seedChannel(ownCapabilities: []), - ), - body: (tester) async { - await expectLater( - tester.channel.markThreadRead('thread-id-123'), - throwsA(isA()), - ); - }, - ); - - channelTest( - '.markThreadRead should succeed if we have the capability', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch( - modifyResponse: _seedChannel( - ownCapabilities: [ChannelCapability.readEvents], - ), - ), - body: (tester) async { - tester.mockApi( - (api) => api.channel.markThreadRead( - _channelId, - _channelType, - 'thread-id-123', - ), - result: createDefaultEmptyResponse(), - ); - - await expectLater( - tester.channel.markThreadRead('thread-id-123'), - completes, - ); - - tester.verifyApi( - (api) => api.channel.markThreadRead( - _channelId, - _channelType, - 'thread-id-123', - ), - ); - }, - ); - - channelTest( - ".markThreadUnread should throw if we don't have the capability", - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch( - // no readEvents capability - modifyResponse: _seedChannel(ownCapabilities: []), - ), - body: (tester) async { - await expectLater( - tester.channel.markThreadUnread('thread-id-123'), - throwsA(isA()), - ); - }, - ); - - channelTest( - '.markThreadUnread should succeed if we have the capability', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch( - modifyResponse: _seedChannel( - ownCapabilities: [ChannelCapability.readEvents], - ), - ), - body: (tester) async { - tester.mockApi( - (api) => api.channel.markThreadUnread( - _channelId, - _channelType, - 'thread-id-123', - ), - result: createDefaultEmptyResponse(), - ); - - await expectLater( - tester.channel.markThreadUnread('thread-id-123'), - completes, - ); - - tester.verifyApi( - (api) => api.channel.markThreadUnread( - _channelId, - _channelType, - 'thread-id-123', - ), - ); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_reminders_test.dart b/packages/stream_chat/test/src/client/channel/channel_reminders_test.dart deleted file mode 100644 index 71cc59e079..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_reminders_test.dart +++ /dev/null @@ -1,169 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -Future _seedChannel(ChannelTester tester) => tester.watch( - modifyResponse: (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: const [ChannelCapability.readEvents], - ), - ), -); - -void main() { - group('`.createReminder`', () { - const messageId = 'test-message-id'; - final reminderRemindAt = DateTime.utc(2024, 6, 15, 14, 30); - - channelTest( - 'should call client.createReminder', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - body: (tester) async { - tester.mockApi( - (api) => api.reminders.createReminder(messageId), - result: createDefaultCreateReminderResponse( - reminder: createDefaultMessageReminder( - messageId: messageId, - channelCid: _channelCid, - remindAt: reminderRemindAt, - ), - ), - ); - - final res = await tester.channel.createReminder(messageId); - - expect(res, isNotNull); - expect(res.reminder.messageId, messageId); - - tester.verifyApi( - (api) => api.reminders.createReminder(messageId), - ); - }, - ); - - channelTest( - 'with remindAt should pass remindAt to client', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - 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, - ), - ), - ); - - final res = await tester.channel.createReminder(messageId, remindAt: remindAt); - - 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'; - final reminderRemindAt = DateTime.utc(2024, 8, 20, 16, 45); - - channelTest( - 'should call client.updateReminder', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - body: (tester) async { - tester.mockApi( - (api) => api.reminders.updateReminder(messageId), - result: createDefaultUpdateReminderResponse( - reminder: createDefaultMessageReminder( - messageId: messageId, - channelCid: _channelCid, - remindAt: reminderRemindAt, - ), - ), - ); - - final res = await tester.channel.updateReminder(messageId); - - expect(res, isNotNull); - expect(res.reminder.messageId, messageId); - - tester.verifyApi( - (api) => api.reminders.updateReminder(messageId), - ); - }, - ); - - channelTest( - 'with remindAt should pass remindAt to client', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - 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, - ), - ), - ); - - final res = await tester.channel.updateReminder(messageId, remindAt: remindAt); - - expect(res, isNotNull); - expect(res.reminder.messageId, messageId); - expect(res.reminder.remindAt, remindAt); - - tester.verifyApi( - (api) => api.reminders.updateReminder(messageId, remindAt: remindAt), - ); - }, - ); - }); - - group('`.deleteReminder`', () { - const messageId = 'test-message-id'; - - channelTest( - 'should call client.deleteReminder', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - body: (tester) async { - tester.mockApi( - (api) => api.reminders.deleteReminder(messageId), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.channel.deleteReminder(messageId); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.reminders.deleteReminder(messageId), - ); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_retry_test.dart b/packages/stream_chat/test/src/client/channel/channel_retry_test.dart deleted file mode 100644 index 0e86cac4fc..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_retry_test.dart +++ /dev/null @@ -1,354 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: _channelCid), - ); -} - -void main() { - group('Retry functionality with parameter preservation', () { - group('retryMessage method', () { - 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, - ), - ); - - tester.mockApi( - (api) => api.message.sendMessage( - _channelId, - _channelType, - any(that: isSameMessageAs(message)), - skipPush: true, - skipEnrichUrl: true, - ), - result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), - ); - - final result = await tester.channel.retryMessage(message); - - expect(result, isNotNull); - expect(result, isA()); - - tester.verifyApi( - (api) => api.message.sendMessage( - _channelId, - _channelType, - any(that: isSameMessageAs(message)), - skipPush: true, - skipEnrichUrl: true, - ), - ); - }, - ); - - 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, - ), - ); - - tester.mockApi( - (api) => api.message.sendMessage( - _channelId, - _channelType, - any(that: isSameMessageAs(message)), - skipPush: true, - ), - result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), - ); - - final result = await tester.channel.retryMessage(message); - - expect(result, isNotNull); - expect(result, isA()); - - tester.verifyApi( - (api) => api.message.sendMessage( - _channelId, - _channelType, - any(that: isSameMessageAs(message)), - skipPush: true, - ), - ); - }, - ); - - 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, - ), - ); - - tester.mockApi( - (api) => api.message.sendMessage( - _channelId, - _channelType, - any(that: isSameMessageAs(message)), - skipEnrichUrl: true, - ), - result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), - ); - - final result = await tester.channel.retryMessage(message); - - expect(result, isNotNull); - expect(result, isA()); - - tester.verifyApi( - (api) => api.message.sendMessage( - _channelId, - _channelType, - any(that: isSameMessageAs(message)), - skipEnrichUrl: true, - ), - ); - }, - ); - - 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, - ), - ); - - tester.mockApi( - (api) => api.message.sendMessage( - _channelId, - _channelType, - any(that: isSameMessageAs(message)), - ), - result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), - ); - - final result = await tester.channel.retryMessage(message); - - expect(result, isNotNull); - expect(result, isA()); - - tester.verifyApi( - (api) => api.message.sendMessage( - _channelId, - _channelType, - any(that: isSameMessageAs(message)), - ), - ); - }, - ); - - 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, - ), - ); - - tester.mockApi( - (api) => api.message.updateMessage( - any(that: isSameMessageAs(message)), - skipPush: true, - skipEnrichUrl: true, - ), - result: createDefaultUpdateMessageResponse(message: message.copyWith(state: MessageState.updated)), - ); - - final result = await tester.channel.retryMessage(message); - - expect(result, isNotNull); - expect(result, isA()); - - tester.verifyApi( - (api) => api.message.updateMessage( - any(that: isSameMessageAs(message)), - skipPush: true, - skipEnrichUrl: true, - ), - ); - }, - ); - - 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, - ), - ); - - tester.mockApi( - (api) => api.message.updateMessage( - any(that: isSameMessageAs(message)), - ), - result: createDefaultUpdateMessageResponse(message: message.copyWith(state: MessageState.updated)), - ); - - final result = await tester.channel.retryMessage(message); - - expect(result, isNotNull); - expect(result, isA()); - - tester.verifyApi( - (api) => api.message.updateMessage( - any(that: isSameMessageAs(message)), - ), - ); - }, - ); - - 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, - ); - - tester.mockApi( - (api) => api.message.deleteMessage(message.id, hard: true), - result: createDefaultEmptyResponse(), - ); - - final result = await tester.channel.retryMessage(message); - - expect(result, isNotNull); - expect(result, isA()); - - tester.verifyApi( - (api) => api.message.deleteMessage(message.id, hard: true), - ); - }, - ); - - 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, - ); - - tester.mockApi( - (api) => api.message.deleteMessage(message.id, hard: false), - result: createDefaultEmptyResponse(), - ); - - final result = await tester.channel.retryMessage(message); - - expect(result, isNotNull); - expect(result, isA()); - - tester.verifyApi( - (api) => api.message.deleteMessage(message.id, hard: false), - ); - }, - ); - - 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, - ); - - tester.mockApi( - (api) => api.message.deleteMessage(message.id, deleteForMe: true), - result: createDefaultEmptyResponse(), - ); - - final result = await tester.channel.retryMessage(message); - - expect(result, isNotNull); - expect(result, isA()); - - tester.verifyApi( - (api) => api.message.deleteMessage(message.id, deleteForMe: true), - ); - }, - ); - - 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(() => tester.channel.retryMessage(message), throwsA(isA())); - }, - ); - }); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_search_test.dart b/packages/stream_chat/test/src/client/channel/channel_search_test.dart deleted file mode 100644 index c27b09aa20..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_search_test.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: const [ChannelCapability.readEvents], - ), - ); -} - -void main() { - group('`.search`', () { - final filter = Filter.in_('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'; - const sort = [SortOption.asc('test-sort-field')]; - const pagination = PaginationParams(); - - final results = List.generate(3, (index) => createDefaultGetMessageResponse()); - - tester.mockApi( - (api) => api.general.searchMessages( - filter, - query: query, - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - ), - result: createDefaultSearchMessagesResponse(results: results), - ); - - final res = await tester.channel.search( - query: query, - sort: sort, - paginationParams: pagination, - ); - - expect(res, isNotNull); - expect(res.results.length, results.length); - - tester.verifyApi( - (api) => api.general.searchMessages( - filter, - query: query, - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - ), - ); - }, - ); - - channelTest( - 'should work fine with `messageFilters`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final messageFilters = Filter.query('key', 'text'); - const sort = [SortOption.desc('test-sort-field')]; - const pagination = PaginationParams(); - - final results = List.generate(3, (index) => createDefaultGetMessageResponse()); - - tester.mockApi( - (api) => api.general.searchMessages( - filter, - messageFilters: messageFilters, - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - ), - result: createDefaultSearchMessagesResponse(results: results), - ); - - final res = await tester.channel.search( - sort: sort, - paginationParams: pagination, - messageFilters: messageFilters, - ); - - expect(res, isNotNull); - expect(res.results.length, results.length); - - tester.verifyApi( - (api) => api.general.searchMessages( - filter, - messageFilters: messageFilters, - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - ), - ); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_send_action_test.dart b/packages/stream_chat/test/src/client/channel/channel_send_action_test.dart deleted file mode 100644 index 26eddc12ce..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_send_action_test.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: const [ChannelCapability.readEvents], - ), - ); -} - -void main() { - group('`.sendAction`', () { - 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 tester.channel.sendAction(message, formData); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.message.sendAction(_channelId, _channelType, message.id, formData), - ); - }, - ); - - 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), - ); - - final messagesEmission = expectLater( - // skipping first seed message list -> [] messages - tester.channelState?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message, - matchMessageState: true, - ), - ], - ]), - ); - - final res = await tester.channel.sendAction(message, formData); - - expect(res, isNotNull); - expect(res.message?.id, message.id); - - tester.verifyApi( - (api) => api.message.sendAction(_channelId, _channelType, message.id, formData), - ); - - await messagesEmission; - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_send_message_test.dart b/packages/stream_chat/test/src/client/channel/channel_send_message_test.dart deleted file mode 100644 index 41df7e0ef7..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_send_message_test.dart +++ /dev/null @@ -1,923 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: const [ChannelCapability.readEvents], - ), - ); -} - -void main() { - group('`.sendMessage`', () { - 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, - ); - - tester.mockApi( - (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), - result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), - ); - - 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, - ), - ], - ]), - ); - - final res = await tester.channel.sendMessage(message); - - expect(res, isNotNull); - expect(res.message.id, message.id); - - tester.verifyApi( - (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), - ); - - await messagesEmission; - }, - ); - - channelTest( - 'should handle StreamChatNetworkError by adding message to retry queue with skipPush: true, skipEnrichUrl: false', - 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, - ); - - // 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.sendMessage( - _channelId, - _channelType, - any(that: isSameMessageAs(message)), - skipPush: true, - ), - error: createDefaultNetworkError(errorCode: ChatErrorCode.notAllowed, retriable: true), - result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), - ); - - 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.sendingFailed( - skipPush: true, - skipEnrichUrl: false, - ), - ), - matchMessageState: true, - ), - ], - // The retry queue re-sends the queued message with the same flags. - [ - isSameMessageAs( - message.copyWith(state: MessageState.sending), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith(state: MessageState.sent), - matchMessageState: true, - ), - ], - ]), - ); - - try { - await tester.channel.sendMessage( - message, - skipPush: true, - ); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); - } - - await messagesEmission; - - // Initial attempt + the retry-queue retry, both with the same flags. - tester.verifyApiCalled( - (api) => api.message.sendMessage( - _channelId, - _channelType, - any(that: isSameMessageAs(message)), - skipPush: true, - ), - times: 2, - ); - }, - ); - - channelTest( - 'should handle StreamChatNetworkError by adding message to retry queue with skipPush: true, skipEnrichUrl: true', - 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: tester.currentUser, - ); - - // 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.sendMessage( - _channelId, - _channelType, - any(that: isSameMessageAs(message)), - skipPush: true, - skipEnrichUrl: true, - ), - error: createDefaultNetworkError(errorCode: ChatErrorCode.notAllowed, retriable: true), - result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), - ); - - 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.sendingFailed( - skipPush: true, - skipEnrichUrl: true, - ), - ), - matchMessageState: true, - ), - ], - // The retry queue re-sends the queued message with the same flags. - [ - isSameMessageAs( - message.copyWith(state: MessageState.sending), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith(state: MessageState.sent), - matchMessageState: true, - ), - ], - ]), - ); - - try { - await tester.channel.sendMessage( - message, - skipPush: true, - skipEnrichUrl: true, - ); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); - } - - await messagesEmission; - - // Initial attempt + the retry-queue retry, both with the same flags. - tester.verifyApiCalled( - (api) => api.message.sendMessage( - _channelId, - _channelType, - any(that: isSameMessageAs(message)), - skipPush: true, - skipEnrichUrl: true, - ), - times: 2, - ); - }, - ); - - channelTest( - 'should handle StreamChatNetworkError by adding message to retry queue with skipPush: false, skipEnrichUrl: true', - 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: tester.currentUser, - ); - - // 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.sendMessage( - _channelId, - _channelType, - any(that: isSameMessageAs(message)), - skipEnrichUrl: true, - ), - error: createDefaultNetworkError(errorCode: ChatErrorCode.notAllowed, retriable: true), - result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), - ); - - 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.sendingFailed( - skipPush: false, - skipEnrichUrl: true, - ), - ), - matchMessageState: true, - ), - ], - // The retry queue re-sends the queued message with the same flags. - [ - isSameMessageAs( - message.copyWith(state: MessageState.sending), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith(state: MessageState.sent), - matchMessageState: true, - ), - ], - ]), - ); - - try { - await tester.channel.sendMessage( - message, - skipEnrichUrl: true, - ); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); - } - - await messagesEmission; - - // Initial attempt + the retry-queue retry, both with the same flags. - tester.verifyApiCalled( - (api) => api.message.sendMessage( - _channelId, - _channelType, - any(that: isSameMessageAs(message)), - skipEnrichUrl: true, - ), - times: 2, - ); - }, - ); - - channelTest( - 'should handle StreamChatNetworkError by adding message to retry queue with skipPush: false, skipEnrichUrl: false', - 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: tester.currentUser, - ); - - // 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.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), - error: createDefaultNetworkError(errorCode: ChatErrorCode.notAllowed, retriable: true), - result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), - ); - - 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.sendingFailed( - skipPush: false, - skipEnrichUrl: false, - ), - ), - matchMessageState: true, - ), - ], - // The retry queue re-sends the queued message with the same flags. - [ - isSameMessageAs( - message.copyWith(state: MessageState.sending), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith(state: MessageState.sent), - matchMessageState: true, - ), - ], - ]), - ); - - try { - await tester.channel.sendMessage( - message, - ); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); - } - - await messagesEmission; - - // Initial attempt + the retry-queue retry, both with the same flags. - tester.verifyApiCalled( - (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), - times: 2, - ); - }, - ); - - 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, - ); - - tester.mockApiFailure( - (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), - error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError, statusCode: 400), - ); - - 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.sendingFailed( - skipPush: false, - skipEnrichUrl: false, - ), - ), - matchMessageState: true, - ), - ], - ]), - ); - - try { - await tester.channel.sendMessage(message); - } catch (e) { - expect(e, isA()); - } - - await messagesEmission; - }, - ); - - 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'), - ), - ); - - final message = Message( - id: 'test-message-id', - attachments: attachments, - ); - - 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, - ), - ), - ); - - 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, - ), - ], - ], - ), - ); - - final res = await tester.channel.sendMessage(message); - - expect(res, isNotNull); - expect(res.message.id, message.id); - expect(res.message.attachments.length, message.attachments.length); - expect( - res.message.attachments.every( - (it) => it.uploadState == const UploadState.success(), - ), - isTrue, - ); - - 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))), - ); - - await messagesEmission; - }, - ); - - 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'); - - await expectLater( - () => tester.channel.sendMessage(message), - throwsA(isA()), - ); - - tester.verifyNeverCalled( - (api) => api.message.sendMessage(_channelId, _channelType, any()), - ); - }, - ); - - channelTest( - 'should not send empty message when all attachments are cancelled', - 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'), - ); - - final message = Message( - id: 'test-message-id', - attachments: [attachment], - ); - - tester.mockApiFailure( - (api) => api.fileUploader.sendImage( - any(), - _channelId, - _channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - error: StreamChatNetworkError.raw( - code: 0, - message: 'Request cancelled', - type: StreamChatNetworkErrorType.cancel, - ), - ); - - await expectLater( - () => tester.channel.sendMessage(message), - throwsA(isA()), - ); - - 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()), - ); - }, - ); - - channelTest( - 'should send message when attachment is cancelled but text exists', - 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'), - ); - - final message = Message( - id: 'test-message-id', - text: 'Hello world!', - attachments: [attachment], - ); - - tester - ..mockApiFailure( - (api) => api.fileUploader.sendImage( - any(), - _channelId, - _channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - error: StreamChatNetworkError.raw( - code: 0, - message: 'Request cancelled', - type: StreamChatNetworkErrorType.cancel, - ), - ) - ..mockApi( - (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), - result: createDefaultSendMessageResponse( - message: message.copyWith( - attachments: [], - state: MessageState.sent, - ), - ), - ); - - final res = await tester.channel.sendMessage(message); - - expect(res, isNotNull); - expect(res.message.text, 'Hello world!'); - - 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))), - ); - }, - ); - - channelTest( - 'should send message when attachment is cancelled but quoted message exists', - 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'), - ); - - final quotedMessage = Message( - id: 'quoted-123', - text: 'Original message', - ); - - final message = Message( - id: 'test-message-id', - attachments: [attachment], - quotedMessageId: quotedMessage.id, - ); - - tester - ..mockApiFailure( - (api) => api.fileUploader.sendImage( - any(), - _channelId, - _channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - error: StreamChatNetworkError.raw( - code: 0, - message: 'Request cancelled', - type: StreamChatNetworkErrorType.cancel, - ), - ) - ..mockApi( - (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), - result: createDefaultSendMessageResponse( - message: message.copyWith( - attachments: [], - state: MessageState.sent, - ), - ), - ); - - final res = await tester.channel.sendMessage(message); - - expect(res, isNotNull); - expect(res.message.quotedMessageId, quotedMessage.id); - - 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))), - ); - }, - ); - - channelTest( - 'should send message when attachment is cancelled but poll exists', - 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'), - ); - - final message = Message( - id: 'test-message-id', - attachments: [attachment], - pollId: 'poll-123', - ); - - tester - ..mockApiFailure( - (api) => api.fileUploader.sendImage( - any(), - _channelId, - _channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - ), - error: StreamChatNetworkError.raw( - code: 0, - message: 'Request cancelled', - type: StreamChatNetworkErrorType.cancel, - ), - ) - ..mockApi( - (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), - result: createDefaultSendMessageResponse( - message: message.copyWith( - attachments: [], - state: MessageState.sent, - ), - ), - ); - - final res = await tester.channel.sendMessage(message); - - expect(res, isNotNull); - expect(res.message.pollId, 'poll-123'); - - 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))), - ); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_setters_test.dart b/packages/stream_chat/test/src/client/channel/channel_setters_test.dart deleted file mode 100644 index cd5c2b0968..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_setters_test.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: [ChannelCapability.readEvents], - ), -); - -void main() { - 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'); - }, - ); - - channelTest( - 'should be able to get and set `image`', - channelType: _channelType, - channelId: _channelId, - body: (tester) async { - final channel = tester.channel; - - expect(channel.extraData.isEmpty, isTrue); - - const imageUrl = 'https://getstream.io/some-image'; - channel.image = imageUrl; - - expect(channel.image, imageUrl); - expect(channel.extraData['image'], imageUrl); - - const newImage = 'https://getstream.io/new-image'; - final newChannelInstance = Channel(tester.client, _channelType, _channelId, image: newImage); - - expect(newChannelInstance.image, newImage); - expect(newChannelInstance.extraData['image'], newImage); - }, - ); - - channelTest( - 'should be able to get and set `name`', - channelType: _channelType, - channelId: _channelId, - body: (tester) async { - final channel = tester.channel; - - expect(channel.extraData.isEmpty, isTrue); - - const name = 'Channel name'; - channel.name = name; - - expect(channel.name, name); - expect(channel.extraData['name'], name); - - const newName = 'New channel name'; - final newChannelInstance = Channel(tester.client, _channelType, _channelId, name: newName); - - expect(newChannelInstance.name, newName); - expect(newChannelInstance.extraData['name'], newName); - }, - ); - - 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(errorCode: ChatErrorCode.inputError), - ); - - // 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()), - ); - - await expectLater( - channel.watch(), - throwsA(isA()), - ); - 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'); - }, - ); - }); - - group('Initialized Channel', () { - 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()); - } - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_state_cleanup_test.dart b/packages/stream_chat/test/src/client/channel/channel_state_cleanup_test.dart deleted file mode 100644 index 4bf4fbd61d..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_state_cleanup_test.dart +++ /dev/null @@ -1,227 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel() { - 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, - ); -} - -List _generateMessages(int count) => List.generate( - count, - (i) => Message( - id: 'msg-$i', - text: 'Hello $i', - createdAt: DateTime.utc(2024).add(Duration(seconds: i)), - ), -); - -void main() { - group('stale error message cleanup', () { - final errorMessage = Message(type: MessageType.error); - final bouncedErrorMessage = Message( - type: MessageType.error, - moderation: const Moderation( - action: ModerationAction.bounce, - originalText: 'original text', - ), - ); - - // 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 - setUp: (tester) => tester.watch( - modifyResponse: (_) => _channelStateWith( - messages: [errorMessage, bouncedErrorMessage], - ), - ), - body: (tester) async { - // Set up the mock response for sending message - final newMessage = Message(text: 'New message'); - - 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 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); - - tester.verifyApi( - (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(newMessage))), - ); - }, - ); - }); - - group('`.state.pruneOldest`', () { - 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']); - }, - ); - - 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)); - - final next = tester.channelState!.messagesStream.firstWhere((messages) => messages.length == 3); - - tester.channelState!.pruneOldest(3); - - final emitted = await next; - expect(emitted.map((m) => m.id), ['msg-3', 'msg-4', 'msg-5']); - }, - ); - - 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)); - - tester.channelState!.pruneOldest(5); - expect(tester.channelState!.messages, hasLength(3)); - - tester.channelState!.pruneOldest(3); - expect(tester.channelState!.messages, hasLength(3)); - }, - ); - - 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)); - - tester.channelState!.pruneOldest(0); - expect(tester.channelState!.messages, hasLength(5)); - - tester.channelState!.pruneOldest(-1); - expect(tester.channelState!.messages, hasLength(5)); - }, - ); - - 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)); - }, - ); - - 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), - ), - ]; - - tester.channelState!.updateChannelState( - _channelStateWith(messages: initial, pinnedMessages: pinned), - ); - - tester.channelState!.pruneOldest(3); - - expect(tester.channelState!.messages, hasLength(3)); - expect(tester.channelState!.pinnedMessages, equals(pinned)); - }, - ); - - 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(); - - final emissions = >[]; - final sub = tester.channelState!.messagesStream.skip(1).listen(emissions.add); - addTearDown(sub.cancel); - - 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); - }, - ); - }); -} 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 new file mode 100644 index 0000000000..62f4e41818 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_state_mutations_test.dart @@ -0,0 +1,767 @@ +// ignore_for_file: cascade_invocations + +import 'package:stream_chat/src/client/channel/channel_state_mutations.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_test/stream_chat_test.dart'; + +import '../../mocks.dart'; + +class MockChannelClientState extends Mock implements ChannelClientState {} + +void main() { + late Channel channel; + late MockStreamChatClient client; + late MockChannelClientState state; + late ChannelStateMutations mutations; + + late List<(User, Event)> upsertTypingEventCalls; + late List removeTypingEventCalls; + late List<(User, int?)> removeWatcherCalls; + late List updateMemberCalls; + late List<(String, bool, DateTime?)> deleteMessagesFromUserCalls; + + // Matches the default current user of [FakeClientState]. + const currentUserId = 'test-user-id'; + final otherUser = User(id: 'other-user'); + + setUpAll(() { + registerChatFallbackValues(); + registerFallbackValue(0); + }); + + setUp(() { + client = MockStreamChatClient(); + state = MockChannelClientState(); + + when(() => client.logger).thenReturn(MockLogger()); + when(() => client.state).thenReturn(FakeClientState()); + + // A real channel (without an attached state) so that the capability and + // unread-count lookups the mutations rely on resolve to their defaults. + channel = Channel(client, 'messaging', 'test'); + + upsertTypingEventCalls = []; + removeTypingEventCalls = []; + removeWatcherCalls = []; + updateMemberCalls = []; + deleteMessagesFromUserCalls = []; + + mutations = ChannelStateMutations( + channel: channel, + state: state, + upsertTypingEvent: (user, event) => upsertTypingEventCalls.add((user, event)), + removeTypingEvent: removeTypingEventCalls.add, + removeWatcher: (watcher, {watcherCount}) => removeWatcherCalls.add((watcher, watcherCount)), + updateMember: updateMemberCalls.add, + deleteMessagesFromUser: ({required userId, hardDelete = false, deletedAt}) async { + deleteMessagesFromUserCalls.add((userId, hardDelete, deletedAt)); + }, + ); + }); + + ChannelState stubChannelState(ChannelState channelState) { + when(() => state.channelState).thenReturn(channelState); + return channelState; + } + + ChannelState capturedChannelState() { + return verify(() => state.updateChannelState(captureAny())).captured.single as ChannelState; + } + + group('typing', () { + test('onTypingStart records the typing event for the user', () { + final event = Event(type: EventType.typingStart, user: otherUser); + + mutations.onTypingStart(otherUser, event); + + expect(upsertTypingEventCalls, [(otherUser, event)]); + }); + + test('onTypingStop clears the typing event for the user', () { + mutations.onTypingStop(otherUser); + + expect(removeTypingEventCalls, [otherUser]); + }); + }); + + group('messages', () { + final message = Message(id: 'message-id', user: otherUser); + + test('onMessageNew adds the message', () { + mutations.onMessageNew(message); + + verify(() => state.addNewMessage(message)).called(1); + verifyNever(() => state.updateChannelState(any())); + }); + + test('onMessageNew also applies the watcher count when provided', () { + stubChannelState(ChannelState(channel: ChannelModel(cid: 'messaging:test'))); + + mutations.onMessageNew(message, watcherCount: 7); + + verify(() => state.addNewMessage(message)).called(1); + expect(capturedChannelState().watcherCount, 7); + }); + + test('onMessageDeleted soft-deletes without touching the unread count', () { + mutations.onMessageDeleted(message); + + verify(() => state.deleteMessage(message, hardDelete: false)).called(1); + verifyNever(() => state.unreadCount = any()); + }); + + test('onMessageDeleted decrements the local unread count on hard delete', () { + client.isLocalUnreadCountEnabled = true; + when(() => state.unreadCount).thenReturn(5); + + mutations.onMessageDeleted(message, hardDelete: true); + + verify(() => state.unreadCount = 4).called(1); + verify(() => state.deleteMessage(message, hardDelete: true)).called(1); + }); + + test('onMessageDeleted never decrements the unread count below zero', () { + client.isLocalUnreadCountEnabled = true; + when(() => state.unreadCount).thenReturn(0); + + mutations.onMessageDeleted(message, hardDelete: true); + + verify(() => state.unreadCount = 0).called(1); + }); + + test('onMessageDeleted skips the unread count on server-driven channels', () { + client.isLocalUnreadCountEnabled = false; + + mutations.onMessageDeleted(message, hardDelete: true); + + verifyNever(() => state.unreadCount = any()); + verify(() => state.deleteMessage(message, hardDelete: true)).called(1); + }); + + test('onMessageUpdated applies the message without upserting', () { + mutations.onMessageUpdated(message); + + verify(() => state.updateMessage(message, upsert: false)).called(1); + }); + }); + + group('drafts', () { + final draft = Draft( + channelCid: 'messaging:test', + createdAt: DateTime.now(), + message: DraftMessage(text: 'draft'), + ); + + test('onDraftUpdated applies the draft', () { + mutations.onDraftUpdated(draft); + + verify(() => state.updateDraft(draft)).called(1); + }); + + test('onDraftDeleted removes the draft', () { + mutations.onDraftDeleted(draft); + + verify(() => state.deleteDraft(draft)).called(1); + }); + }); + + group('reactions', () { + final ownReaction = Reaction( + type: 'like', + messageId: 'message-id', + userId: currentUserId, + createdAt: DateTime.now(), + ); + + Message capturedMessage() { + return verify(() => state.updateMessage(captureAny())).captured.single as Message; + } + + test('onReactionNew adds an own reaction of the current user', () { + final existing = Message(id: 'message-id', user: otherUser); + when(() => state.messages).thenReturn([existing]); + when(() => state.threads).thenReturn(const {}); + + final eventMessage = Message(id: 'message-id', user: otherUser, latestReactions: [ownReaction]); + mutations.onReactionNew(eventMessage, ownReaction); + + expect(capturedMessage().ownReactions, [ownReaction]); + }); + + test('onReactionNew preserves own reactions for other users', () { + final existing = Message(id: 'message-id', user: otherUser, ownReactions: [ownReaction]); + when(() => state.messages).thenReturn([existing]); + when(() => state.threads).thenReturn(const {}); + + final theirReaction = Reaction( + type: 'love', + messageId: 'message-id', + userId: otherUser.id, + createdAt: DateTime.now(), + ); + final eventMessage = Message(id: 'message-id', user: otherUser); + mutations.onReactionNew(eventMessage, theirReaction); + + expect(capturedMessage().ownReactions, [ownReaction]); + }); + + test('onReactionNew finds the message in a thread', () { + final existing = Message(id: 'message-id', parentId: 'parent-id', user: otherUser); + when(() => state.messages).thenReturn([]); + when(() => state.threads).thenReturn({ + 'parent-id': [existing], + }); + + final eventMessage = Message(id: 'message-id', parentId: 'parent-id', user: otherUser); + mutations.onReactionNew(eventMessage, ownReaction); + + expect(capturedMessage().ownReactions, [ownReaction]); + }); + + test('onReactionNew ignores reactions to unknown messages', () { + when(() => state.messages).thenReturn([]); + when(() => state.threads).thenReturn(const {}); + + mutations.onReactionNew(Message(id: 'message-id'), ownReaction); + + verifyNever(() => state.updateMessage(any())); + }); + + test('onReactionUpdated replaces own reactions of the current user', () { + final oldReaction = Reaction( + type: 'love', + messageId: 'message-id', + userId: currentUserId, + createdAt: DateTime.now(), + ); + final existing = Message(id: 'message-id', user: otherUser, ownReactions: [oldReaction]); + when(() => state.messages).thenReturn([existing]); + when(() => state.threads).thenReturn(const {}); + + mutations.onReactionUpdated(Message(id: 'message-id', user: otherUser), ownReaction); + + expect(capturedMessage().ownReactions, [ownReaction]); + }); + + test('onReactionDeleted removes the own reaction of the current user', () { + final existing = Message(id: 'message-id', user: otherUser, ownReactions: [ownReaction]); + when(() => state.messages).thenReturn([existing]); + when(() => state.threads).thenReturn(const {}); + + mutations.onReactionDeleted(Message(id: 'message-id', user: otherUser), ownReaction); + + expect(capturedMessage().ownReactions, isEmpty); + }); + }); + + group('polls', () { + const pollId = 'poll-id'; + + Poll createPoll({ + String name = 'Favorite color?', + List latestAnswers = const [], + List ownVotesAndAnswers = const [], + bool isClosed = false, + }) { + return Poll( + id: pollId, + name: name, + options: const [PollOption(id: 'option-a', text: 'A')], + latestAnswers: latestAnswers, + ownVotesAndAnswers: ownVotesAndAnswers, + isClosed: isClosed, + ); + } + + PollVote createVote(String id, {String? userId, String optionId = 'option-a'}) { + return PollVote( + id: id, + pollId: pollId, + userId: userId ?? currentUserId, + optionId: optionId, + ); + } + + Message capturedMessage() { + return verify(() => state.updateMessage(captureAny())).captured.single as Message; + } + + void seedPollMessage(Poll poll) { + final message = Message(id: 'poll-message-id', poll: poll); + when(() => state.messages).thenReturn([message]); + when(() => state.threads).thenReturn(const {}); + } + + test('onPollCreated adds the poll message', () { + final message = Message(id: 'poll-message-id', poll: createPoll()); + + mutations.onPollCreated(message); + + verify(() => state.addNewMessage(message)).called(1); + }); + + test('onPollUpdated applies the poll preserving known answers and votes', () { + final answer = createVote('answer-1'); + final ownVote = createVote('vote-1'); + seedPollMessage(createPoll(latestAnswers: [answer], ownVotesAndAnswers: [ownVote])); + + mutations.onPollUpdated(createPoll(name: 'Updated?')); + + final poll = capturedMessage().poll!; + expect(poll.name, 'Updated?'); + expect(poll.latestAnswers, [answer]); + expect(poll.ownVotesAndAnswers, [ownVote]); + }); + + test('onPollUpdated ignores polls without a matching message', () { + when(() => state.messages).thenReturn([]); + when(() => state.threads).thenReturn(const {}); + + mutations.onPollUpdated(createPoll()); + + verifyNever(() => state.updateMessage(any())); + }); + + test('onPollClosed closes the known poll', () { + seedPollMessage(createPoll()); + + mutations.onPollClosed(createPoll(isClosed: true)); + + final poll = capturedMessage().poll!; + expect(poll.isClosed, isTrue); + expect(poll.name, 'Favorite color?'); + }); + + test('onPollAnswerCasted appends the answer and tracks own answers', () { + final existingAnswer = createVote('answer-1', userId: otherUser.id); + seedPollMessage(createPoll(latestAnswers: [existingAnswer])); + + final castedAnswer = createVote('answer-2'); + mutations.onPollAnswerCasted(createPoll(), castedAnswer); + + final poll = capturedMessage().poll!; + expect(poll.latestAnswers, [existingAnswer, castedAnswer]); + expect(poll.ownVotesAndAnswers, [castedAnswer]); + }); + + test('onPollAnswerCasted skips own tracking for other users', () { + seedPollMessage(createPoll()); + + final castedAnswer = createVote('answer-2', userId: otherUser.id); + mutations.onPollAnswerCasted(createPoll(), castedAnswer); + + final poll = capturedMessage().poll!; + expect(poll.latestAnswers, [castedAnswer]); + expect(poll.ownVotesAndAnswers, isEmpty); + }); + + test('onPollVoteCasted tracks own votes preserving known answers', () { + final answer = createVote('answer-1', userId: otherUser.id); + seedPollMessage(createPoll(latestAnswers: [answer])); + + final vote = createVote('vote-1'); + mutations.onPollVoteCasted(createPoll(), vote); + + final poll = capturedMessage().poll!; + expect(poll.latestAnswers, [answer]); + expect(poll.ownVotesAndAnswers, [vote]); + }); + + test('onPollVoteChanged replaces the own vote with the same id', () { + final oldVote = createVote('vote-1'); + seedPollMessage(createPoll(ownVotesAndAnswers: [oldVote])); + + final changedVote = createVote('vote-1', optionId: 'option-b'); + mutations.onPollVoteChanged(createPoll(), changedVote); + + final poll = capturedMessage().poll!; + expect(poll.ownVotesAndAnswers, [changedVote]); + }); + + test('onPollAnswerRemoved removes the answer from both lists', () { + final answer = createVote('answer-1'); + seedPollMessage(createPoll(latestAnswers: [answer], ownVotesAndAnswers: [answer])); + + mutations.onPollAnswerRemoved(createPoll(), answer); + + final poll = capturedMessage().poll!; + expect(poll.latestAnswers, isEmpty); + expect(poll.ownVotesAndAnswers, isEmpty); + }); + + test('onPollVoteRemoved removes the own vote preserving known answers', () { + final answer = createVote('answer-1', userId: otherUser.id); + final vote = createVote('vote-1'); + seedPollMessage(createPoll(latestAnswers: [answer], ownVotesAndAnswers: [vote])); + + mutations.onPollVoteRemoved(createPoll(), vote); + + final poll = capturedMessage().poll!; + expect(poll.latestAnswers, [answer]); + expect(poll.ownVotesAndAnswers, isEmpty); + }); + }); + + group('reads', () { + test('onMessageRead resets the unread count preserving delivery info', () { + final deliveredAt = DateTime.now(); + final existingRead = Read( + user: otherUser, + lastRead: DateTime.now().subtract(const Duration(days: 1)), + unreadMessages: 5, + lastDeliveredAt: deliveredAt, + lastDeliveredMessageId: 'delivered-id', + ); + when(() => state.read).thenReturn([existingRead]); + + final lastRead = DateTime.now(); + mutations.onMessageRead(otherUser, lastRead: lastRead, lastReadMessageId: 'read-id'); + + final expectedRead = Read( + user: otherUser, + lastRead: lastRead, + unreadMessages: 0, + lastReadMessageId: 'read-id', + lastDeliveredAt: deliveredAt, + lastDeliveredMessageId: 'delivered-id', + ); + verify(() => state.updateRead([expectedRead])).called(1); + }); + + test('onNotificationMarkUnread applies the unread mark preserving delivery info', () { + final deliveredAt = DateTime.now(); + final existingRead = Read( + user: otherUser, + lastRead: DateTime.now().subtract(const Duration(days: 1)), + lastDeliveredAt: deliveredAt, + ); + when(() => state.read).thenReturn([existingRead]); + + final lastRead = DateTime.now(); + mutations.onNotificationMarkUnread( + otherUser, + lastRead: lastRead, + unreadMessages: 3, + lastReadMessageId: 'read-id', + ); + + final expectedRead = Read( + user: otherUser, + lastRead: lastRead, + unreadMessages: 3, + lastReadMessageId: 'read-id', + lastDeliveredAt: deliveredAt, + ); + verify(() => state.updateRead([expectedRead])).called(1); + }); + + test('onMessageDelivered applies the delivery preserving read info', () { + final lastRead = DateTime.now().subtract(const Duration(days: 1)); + final existingRead = Read( + user: otherUser, + lastRead: lastRead, + unreadMessages: 2, + lastReadMessageId: 'read-id', + ); + when(() => state.read).thenReturn([existingRead]); + + final deliveredAt = DateTime.now(); + mutations.onMessageDelivered( + otherUser, + lastDeliveredAt: deliveredAt, + lastDeliveredMessageId: 'delivered-id', + ); + + final expectedRead = Read( + user: otherUser, + lastRead: lastRead, + unreadMessages: 2, + lastReadMessageId: 'read-id', + lastDeliveredAt: deliveredAt, + lastDeliveredMessageId: 'delivered-id', + ); + verify(() => state.updateRead([expectedRead])).called(1); + }); + + test('onMessageDelivered falls back to the epoch without a current read', () { + when(() => state.read).thenReturn([]); + + mutations.onMessageDelivered(otherUser); + + final expectedRead = Read( + user: otherUser, + lastRead: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + ); + verify(() => state.updateRead([expectedRead])).called(1); + }); + }); + + group('channel', () { + test('onChannelTruncated truncates before applying the system message', () { + final systemMessage = Message(id: 'system-id'); + + mutations.onChannelTruncated(message: systemMessage); + + verifyInOrder([ + () => state.truncate(), + () => state.updateMessage(systemMessage), + ]); + }); + + test('onChannelTruncated only truncates without a system message', () { + mutations.onChannelTruncated(); + + verify(() => state.truncate()).called(1); + verifyNever(() => state.updateMessage(any())); + }); + + test('onChannelUpdated merges the channel and replaces the members', () { + final member = Member(user: otherUser); + stubChannelState(ChannelState(channel: ChannelModel(cid: 'messaging:test'))); + + mutations.onChannelUpdated( + ChannelModel(cid: 'messaging:test', frozen: true, members: [member]), + ); + + final updated = capturedChannelState(); + expect(updated.channel?.frozen, isTrue); + expect(updated.members, [member]); + }); + + test('onChannelCounts updates the member and message counts', () { + stubChannelState(ChannelState(channel: ChannelModel(cid: 'messaging:test'))); + + mutations.onChannelCounts(memberCount: 7, messageCount: 42); + + final updated = capturedChannelState(); + expect(updated.channel?.memberCount, 7); + expect(updated.channel?.messageCount, 42); + }); + + test('onChannelCounts leaves a null count unchanged', () { + stubChannelState( + ChannelState( + channel: ChannelModel( + cid: 'messaging:test', + memberCount: 7, + messageCount: 42, + ), + ), + ); + + mutations.onChannelCounts(memberCount: null, messageCount: 43); + + final updated = capturedChannelState(); + expect(updated.channel?.memberCount, 7); + expect(updated.channel?.messageCount, 43); + }); + + test('onChannelPushPreferenceUpdated applies the preferences', () { + const pushPreference = ChannelPushPreference(chatLevel: ChatLevel.mentions); + stubChannelState(ChannelState(channel: ChannelModel(cid: 'messaging:test'))); + + mutations.onChannelPushPreferenceUpdated(pushPreference); + + expect(capturedChannelState().pushPreferences, pushPreference); + }); + }); + + group('members', () { + final member = Member(user: otherUser); + + test('onMemberAdded appends the member', () { + final existingMember = Member(user: User(id: 'existing-user')); + stubChannelState(ChannelState(members: [existingMember])); + + mutations.onMemberAdded(member); + + 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()), + ], + ), + ); + + mutations.onMemberRemoved(otherUser); + + final updated = capturedChannelState(); + expect(updated.members, [existingMember]); + expect(updated.read, [existingRead]); + }); + + test('onMemberUserUpdated merges the user into the member and membership', () { + final updatedUser = User(id: otherUser.id, name: 'Updated'); + stubChannelState(ChannelState(members: [member], membership: member)); + + mutations.onMemberUserUpdated(updatedUser); + + final updated = capturedChannelState(); + expect(updated.members?.single.user, updatedUser); + expect(updated.membership?.user, updatedUser); + }); + + test('onMemberUserUpdated ignores users that are not members', () { + stubChannelState(ChannelState(members: [member])); + + mutations.onMemberUserUpdated(User(id: 'not-a-member')); + + verifyNever(() => state.updateChannelState(any())); + }); + + test('onMemberUpdated replaces the matching member and membership', () { + final updatedMember = Member(user: otherUser, channelRole: 'admin'); + stubChannelState(ChannelState(members: [member], membership: member)); + + mutations.onMemberUpdated(updatedMember); + + final updated = capturedChannelState(); + expect(updated.members, [updatedMember]); + expect(updated.membership, updatedMember); + }); + + test('onMemberBanned and onMemberUnbanned replace the refreshed member', () { + mutations.onMemberBanned(member); + mutations.onMemberUnbanned(member); + + expect(updateMemberCalls, [member, member]); + }); + + test('onUserMessagesDeleted deletes the user messages', () async { + final deletedAt = DateTime.now(); + + await mutations.onUserMessagesDeleted( + userId: otherUser.id, + hardDelete: true, + deletedAt: deletedAt, + ); + + expect(deleteMessagesFromUserCalls, [(otherUser.id, true, deletedAt)]); + }); + }); + + group('watchers', () { + test('onUserStartWatching upserts the watcher and count', () { + final existingWatcher = User(id: 'existing-watcher'); + stubChannelState(ChannelState(watchers: [existingWatcher, otherUser])); + + final rejoined = User(id: otherUser.id, name: 'Rejoined'); + mutations.onUserStartWatching(rejoined, watcherCount: 3); + + final updated = capturedChannelState(); + expect(updated.watchers, [rejoined, existingWatcher]); + expect(updated.watcherCount, 3); + }); + + test('onUserStopWatching removes the watcher', () { + mutations.onUserStopWatching(otherUser, watcherCount: 1); + + expect(removeWatcherCalls, [(otherUser, 1)]); + }); + }); + + group('reminders', () { + final reminder = MessageReminder( + messageId: 'message-id', + channelCid: 'messaging:test', + userId: currentUserId, + remindAt: DateTime.now(), + ); + + test('onReminderCreated and onReminderUpdated apply the reminder', () { + mutations.onReminderCreated(reminder); + mutations.onReminderUpdated(reminder); + + verify(() => state.updateReminder(reminder)).called(2); + }); + + test('onReminderDeleted removes the reminder', () { + mutations.onReminderDeleted(reminder); + + verify(() => state.deleteReminder(reminder)).called(1); + }); + }); + + group('locations', () { + Location createLocation({String? messageId = 'message-id', double latitude = 1}) { + return Location( + channelCid: 'messaging:test', + messageId: messageId, + userId: currentUserId, + latitude: latitude, + longitude: 2, + createdByDeviceId: 'device-id', + ); + } + + test('onLocationShared adds the location message', () { + final message = Message(id: 'message-id', sharedLocation: createLocation()); + + mutations.onLocationShared(message); + + verify(() => state.addNewMessage(message)).called(1); + }); + + test('onLocationUpdated applies the location to the message sharing it', () { + final message = Message(id: 'message-id', sharedLocation: createLocation()); + when(() => state.messages).thenReturn([message]); + when(() => state.threads).thenReturn(const {}); + + final updatedLocation = createLocation(latitude: 42); + mutations.onLocationUpdated(updatedLocation); + + final captured = verify(() => state.updateMessage(captureAny())).captured.single as Message; + expect(captured.sharedLocation, updatedLocation); + }); + + test('onLocationUpdated finds the message in a thread', () { + final message = Message(id: 'message-id', parentId: 'parent-id', sharedLocation: createLocation()); + when(() => state.messages).thenReturn([]); + when(() => state.threads).thenReturn({ + 'parent-id': [message], + }); + + mutations.onLocationUpdated(createLocation(latitude: 42)); + + verify(() => state.updateMessage(any())).called(1); + }); + + test('onLocationUpdated ignores locations without a message id', () { + mutations.onLocationUpdated(createLocation(messageId: null)); + + verifyNever(() => state.updateMessage(any())); + }); + + test('onLocationUpdated ignores locations of unknown messages', () { + when(() => state.messages).thenReturn([]); + when(() => state.threads).thenReturn(const {}); + + mutations.onLocationUpdated(createLocation()); + + verifyNever(() => state.updateMessage(any())); + }); + + test('onLocationExpired applies the expired location', () { + final message = Message(id: 'message-id', sharedLocation: createLocation()); + when(() => state.messages).thenReturn([message]); + when(() => state.threads).thenReturn(const {}); + + final expiredLocation = createLocation(latitude: 42); + mutations.onLocationExpired(expiredLocation); + + final captured = verify(() => state.updateMessage(captureAny())).captured.single as Message; + expect(captured.sharedLocation, expiredLocation); + }); + }); +} diff --git a/packages/stream_chat/test/src/client/channel/channel_state_validation_test.dart b/packages/stream_chat/test/src/client/channel/channel_state_validation_test.dart deleted file mode 100644 index aa75cb07c2..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_state_validation_test.dart +++ /dev/null @@ -1,391 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -const _cooldownDuration = 30; // seconds - -// A bare channel state (no config, no capabilities). -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: _channelCid), - ); -} - -// 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], - ), - ); -} - -void main() { - group('Channel State Validation and Cooldown', () { - group('Non-initialized channel state validation', () { - channelTest( - 'should throw StateError when accessing cooldown on non-initialized channel', - channelType: _channelType, - channelId: _channelId, - body: (tester) async { - final channel = tester.channel; - expect(() => channel.cooldown, throwsA(isA())); - }, - ); - - 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())); - }, - ); - - channelTest( - 'should throw StateError when accessing getRemainingCooldown on non-initialized channel', - channelType: _channelType, - channelId: _channelId, - body: (tester) async { - final channel = tester.channel; - expect(channel.getRemainingCooldown, throwsA(isA())); - }, - ); - - channelTest( - 'should throw StateError when accessing cooldownStream on non-initialized channel', - channelType: _channelType, - channelId: _channelId, - body: (tester) async { - final channel = tester.channel; - expect(() => channel.cooldownStream, throwsA(isA())); - }, - ); - }); - - group('Initialized channel cooldown functionality', () { - channelTest( - 'should return default cooldown value of 0 for initialized channel', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) => expect(tester.channel.cooldown, equals(0)), - ); - - 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); - }, - ); - - 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(tester.client, stateWithCooldown); - addTearDown(testChannel.dispose); - - expect(testChannel.cooldown, equals(30)); - }, - ); - - 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)); - }, - ); - - 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', () { - channelTest( - 'should return positive cooldown after current user sends a thread reply', - 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: tester.currentUser!.id), - ); - tester.channelState!.updateThreadInfo('parent-msg-1', [threadReply]); - - expect(tester.channel.getRemainingCooldown(), greaterThan(0)); - }, - ); - - channelTest( - 'should return 0 cooldown when thread reply was sent outside the cooldown window', - 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), - ), - user: User(id: tester.currentUser!.id), - ); - tester.channelState!.updateThreadInfo('parent-msg-1', [oldReply]); - - expect(tester.channel.getRemainingCooldown(), equals(0)); - }, - ); - - channelTest( - 'should not trigger cooldown for a thread reply from another user', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannelWithCooldown()), - body: (tester) async { - final otherUserReply = Message( - id: 'thread-reply-other', - parentId: 'parent-msg-1', - showInChannel: false, - createdAt: DateTime.timestamp(), - user: User(id: 'other-user-id'), - ); - tester.channelState!.updateThreadInfo('parent-msg-1', [otherUserReply]); - - expect(tester.channel.getRemainingCooldown(), equals(0)); - }, - ); - - channelTest( - 'should clear cooldown when the most-recent own message is hard-deleted', - 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: tester.currentUser!.id), - ); - tester.channelState!.updateMessage(ownMessage); - expect(tester.channel.getRemainingCooldown(), greaterThan(0)); - - tester.channelState!.deleteMessage(ownMessage, hardDelete: true); - expect(tester.channel.getRemainingCooldown(), equals(0)); - }, - ); - - channelTest( - 'currentUserLastMessageAtStream emits a new timestamp when own message is added', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannelWithCooldown()), - body: (tester) async { - final emissions = []; - 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; - - tester.channelState!.updateMessage( - Message( - id: 'msg-1', - createdAt: DateTime.timestamp(), - user: User(id: tester.currentUser!.id), - ), - ); - await Future.delayed(Duration.zero); - - expect(emissions.last, isNotNull); - expect(emissions.last, isNot(equals(seededLast))); - }, - ); - - channelTest( - 'getRemainingCooldown uses the explicit [lastMessageAt] override', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannelWithCooldown()), - body: (tester) async { - // No messages in state, so the default path returns 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(tester.channel.getRemainingCooldown(lastMessageAt: recent), greaterThan(0)); - - // Override pointing outside the window → 0. - final old = DateTime.timestamp().subtract( - const Duration(seconds: _cooldownDuration + 5), - ); - expect(tester.channel.getRemainingCooldown(lastMessageAt: old), equals(0)); - }, - ); - - channelTest( - 'currentUserLastMessageAt picks the latest across channel messages and threads', - 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. - tester.channelState!.updateMessage( - Message( - id: 'msg-1', - createdAt: older, - user: User(id: tester.currentUser!.id), - ), - ); - // Newer reply in a thread. - tester.channelState!.updateThreadInfo('parent-msg-1', [ - Message( - id: 'thread-reply-1', - parentId: 'parent-msg-1', - showInChannel: false, - createdAt: newer, - user: User(id: tester.currentUser!.id), - ), - ]); - - // Should pick the newer thread reply, not the older channel message. - final result = tester.channel.currentUserLastMessageAt; - expect(result, isNotNull); - expect(result!.isAtSameMomentAs(newer), isTrue); - }, - ); - }); - - group('Disposed channel state validation', () { - 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)); - - // Dispose the channel - channel.dispose(); - - // Now accessing cooldown should throw - expect(() => channel.cooldown, throwsA(isA())); - }, - ); - - 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)); - - // Dispose the channel - channel.dispose(); - - // Now accessing getRemainingCooldown should throw - expect(channel.getRemainingCooldown, throwsA(isA())); - }, - ); - - 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 - await expectLater(channel.cooldownStream.take(1), emits(0)); - - // Dispose the channel - channel.dispose(); - - // Now accessing cooldownStream should throw - expect(() => channel.cooldownStream, throwsA(isA())); - }, - ); - - 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 = createDefaultChannelState( - channel: createDefaultChannelModel(cid: _channelCid), - ); - final raceChannel = Channel.fromState(tester.client, channelState); - - // Verify it works initially - expect(raceChannel.cooldown, equals(0)); - - // Simulate quick disposal (like what happens with rapid navigation) - raceChannel.dispose(); - - // This should throw StateError instead of crashing with null check operator - expect(() => raceChannel.cooldown, throwsA(isA())); - - expect(raceChannel.getRemainingCooldown, throwsA(isA())); - }, - ); - }); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_test.dart b/packages/stream_chat/test/src/client/channel/channel_test.dart new file mode 100644 index 0000000000..c2ca993b2d --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -0,0 +1,7800 @@ +import 'package:stream_chat/stream_chat.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; +} + +final persistenceClient = _createPersistenceClient(); + +void main() { + 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'); + }, + ); + + channelTest( + 'should be able to get and set `image`', + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final channel = tester.channel; + + expect(channel.extraData.isEmpty, isTrue); + + const imageUrl = 'https://getstream.io/some-image'; + channel.image = imageUrl; + + expect(channel.image, imageUrl); + expect(channel.extraData['image'], imageUrl); + + const newImage = 'https://getstream.io/new-image'; + final newChannelInstance = Channel(tester.client, _channelType, _channelId, image: newImage); + + expect(newChannelInstance.image, newImage); + expect(newChannelInstance.extraData['image'], newImage); + }, + ); + + channelTest( + 'should be able to get and set `name`', + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final channel = tester.channel; + + expect(channel.extraData.isEmpty, isTrue); + + const name = 'Channel name'; + channel.name = name; + + expect(channel.name, name); + expect(channel.extraData['name'], name); + + const newName = 'New channel name'; + final newChannelInstance = Channel(tester.client, _channelType, _channelId, name: newName); + + expect(newChannelInstance.name, newName); + expect(newChannelInstance.extraData['name'], newName); + }, + ); + + 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(errorCode: ChatErrorCode.inputError), + ); + + // 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()), + ); + + await expectLater( + channel.watch(), + throwsA(isA()), + ); + 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'); + }, + ); + }); + + 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], + ), + ); + } + + Future _seedChannelDrafts(ChannelTester tester) => tester.watch( + modifyResponse: (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ), + ); + + Future _seedChannelReminders(ChannelTester tester) => tester.watch( + modifyResponse: (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ), + ); + + ChannelState Function(ChannelState) _seedChannelUpdateMessage() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); + } + + ChannelState Function(ChannelState) _seedChannelDeleteMessage() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); + } + + ChannelState Function(ChannelState) _seedChannelPinMessage() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); + } + + ChannelState Function(ChannelState) _seedChannelMiscOperations() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); + } + + ChannelState Function(ChannelState) _seedChannelReactions() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); + } + + Future _seedChannelUpdateApi(ChannelTester tester) => tester.watch( + modifyResponse: (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ), + ); + + Future _seedChannelMemberApi(ChannelTester tester) => tester.watch( + modifyResponse: (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ), + ); + + ChannelState Function(ChannelState) _seedChannelWatch() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); + } + + ChannelState Function(ChannelState) _seedChannelMessageQueries() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); + } + + // 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, + ), + ); + } + + ChannelState Function(ChannelState) _seedChannelModerationApi() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); + } + + 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`', () { + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); + } + + 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, + ); + + tester.mockApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); + + 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, + ), + ], + ]), + ); + + final res = await tester.channel.sendMessage(message); + + expect(res, isNotNull); + expect(res.message.id, message.id); + + tester.verifyApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + ); + + await messagesEmission; + }, + ); + + channelTest( + 'should handle StreamChatNetworkError by adding message to retry queue with skipPush: true, skipEnrichUrl: false', + 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, + ); + + // 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.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.notAllowed, retriable: true), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); + + 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.sendingFailed( + skipPush: true, + skipEnrichUrl: false, + ), + ), + matchMessageState: true, + ), + ], + // The retry queue re-sends the queued message with the same flags. + [ + isSameMessageAs( + message.copyWith(state: MessageState.sending), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.sent), + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.sendMessage( + message, + skipPush: true, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } + + await messagesEmission; + + // Initial attempt + the retry-queue retry, both with the same flags. + tester.verifyApiCalled( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + ), + times: 2, + ); + }, + ); + + channelTest( + 'should handle StreamChatNetworkError by adding message to retry queue with skipPush: true, skipEnrichUrl: true', + 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: tester.currentUser, + ); + + // 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.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.notAllowed, retriable: true), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); + + 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.sendingFailed( + skipPush: true, + skipEnrichUrl: true, + ), + ), + matchMessageState: true, + ), + ], + // The retry queue re-sends the queued message with the same flags. + [ + isSameMessageAs( + message.copyWith(state: MessageState.sending), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.sent), + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.sendMessage( + message, + skipPush: true, + skipEnrichUrl: true, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } + + await messagesEmission; + + // Initial attempt + the retry-queue retry, both with the same flags. + tester.verifyApiCalled( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + times: 2, + ); + }, + ); + + channelTest( + 'should handle StreamChatNetworkError by adding message to retry queue with skipPush: false, skipEnrichUrl: true', + 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: tester.currentUser, + ); + + // 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.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipEnrichUrl: true, + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.notAllowed, retriable: true), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); + + 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.sendingFailed( + skipPush: false, + skipEnrichUrl: true, + ), + ), + matchMessageState: true, + ), + ], + // The retry queue re-sends the queued message with the same flags. + [ + isSameMessageAs( + message.copyWith(state: MessageState.sending), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.sent), + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.sendMessage( + message, + skipEnrichUrl: true, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } + + await messagesEmission; + + // Initial attempt + the retry-queue retry, both with the same flags. + tester.verifyApiCalled( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipEnrichUrl: true, + ), + times: 2, + ); + }, + ); + + channelTest( + 'should handle StreamChatNetworkError by adding message to retry queue with skipPush: false, skipEnrichUrl: false', + 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: tester.currentUser, + ); + + // 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.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + error: createDefaultNetworkError(errorCode: ChatErrorCode.notAllowed, retriable: true), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); + + 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.sendingFailed( + skipPush: false, + skipEnrichUrl: false, + ), + ), + matchMessageState: true, + ), + ], + // The retry queue re-sends the queued message with the same flags. + [ + isSameMessageAs( + message.copyWith(state: MessageState.sending), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.sent), + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.sendMessage( + message, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } + + await messagesEmission; + + // Initial attempt + the retry-queue retry, both with the same flags. + tester.verifyApiCalled( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + times: 2, + ); + }, + ); + + 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, + ); + + tester.mockApiFailure( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError, statusCode: 400), + ); + + 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.sendingFailed( + skipPush: false, + skipEnrichUrl: false, + ), + ), + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.sendMessage(message); + } catch (e) { + expect(e, isA()); + } + + await messagesEmission; + }, + ); + + 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'), + ), + ); + + final message = Message( + id: 'test-message-id', + attachments: attachments, + ); + + 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, + ), + ), + ); + + 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, + ), + ], + ], + ), + ); + + final res = await tester.channel.sendMessage(message); + + expect(res, isNotNull); + expect(res.message.id, message.id); + expect(res.message.attachments.length, message.attachments.length); + expect( + res.message.attachments.every( + (it) => it.uploadState == const UploadState.success(), + ), + isTrue, + ); + + 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))), + ); + + await messagesEmission; + }, + ); + + 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'); + + await expectLater( + () => tester.channel.sendMessage(message), + throwsA(isA()), + ); + + tester.verifyNeverCalled( + (api) => api.message.sendMessage(_channelId, _channelType, any()), + ); + }, + ); + + channelTest( + 'should not send empty message when all attachments are cancelled', + 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'), + ); + + final message = Message( + id: 'test-message-id', + attachments: [attachment], + ); + + tester.mockApiFailure( + (api) => api.fileUploader.sendImage( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + error: StreamChatNetworkError.raw( + code: 0, + message: 'Request cancelled', + type: StreamChatNetworkErrorType.cancel, + ), + ); + + await expectLater( + () => tester.channel.sendMessage(message), + throwsA(isA()), + ); + + 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()), + ); + }, + ); + + channelTest( + 'should send message when attachment is cancelled but text exists', + 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'), + ); + + final message = Message( + id: 'test-message-id', + text: 'Hello world!', + attachments: [attachment], + ); + + tester + ..mockApiFailure( + (api) => api.fileUploader.sendImage( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + error: StreamChatNetworkError.raw( + code: 0, + message: 'Request cancelled', + type: StreamChatNetworkErrorType.cancel, + ), + ) + ..mockApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + result: createDefaultSendMessageResponse( + message: message.copyWith( + attachments: [], + state: MessageState.sent, + ), + ), + ); + + final res = await tester.channel.sendMessage(message); + + expect(res, isNotNull); + expect(res.message.text, 'Hello world!'); + + 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))), + ); + }, + ); + + channelTest( + 'should send message when attachment is cancelled but quoted message exists', + 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'), + ); + + final quotedMessage = Message( + id: 'quoted-123', + text: 'Original message', + ); + + final message = Message( + id: 'test-message-id', + attachments: [attachment], + quotedMessageId: quotedMessage.id, + ); + + tester + ..mockApiFailure( + (api) => api.fileUploader.sendImage( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + error: StreamChatNetworkError.raw( + code: 0, + message: 'Request cancelled', + type: StreamChatNetworkErrorType.cancel, + ), + ) + ..mockApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + result: createDefaultSendMessageResponse( + message: message.copyWith( + attachments: [], + state: MessageState.sent, + ), + ), + ); + + final res = await tester.channel.sendMessage(message); + + expect(res, isNotNull); + expect(res.message.quotedMessageId, quotedMessage.id); + + 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))), + ); + }, + ); + + channelTest( + 'should send message when attachment is cancelled but poll exists', + 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'), + ); + + final message = Message( + id: 'test-message-id', + attachments: [attachment], + pollId: 'poll-123', + ); + + tester + ..mockApiFailure( + (api) => api.fileUploader.sendImage( + any(), + _channelId, + _channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + error: StreamChatNetworkError.raw( + code: 0, + message: 'Request cancelled', + type: StreamChatNetworkErrorType.cancel, + ), + ) + ..mockApi( + (api) => api.message.sendMessage(_channelId, _channelType, any(that: isSameMessageAs(message))), + result: createDefaultSendMessageResponse( + message: message.copyWith( + attachments: [], + state: MessageState.sent, + ), + ), + ); + + final res = await tester.channel.sendMessage(message); + + expect(res, isNotNull); + expect(res.message.pollId, 'poll-123'); + + 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))), + ); + }, + ); + }); + + group('`.sendStaticLocation`', () { + const deviceId = 'test-device-id'; + const locationId = 'test-location-id'; + const coordinates = LocationCoordinates( + latitude: 40.7128, + longitude: -74.0060, + ); + + 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 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); + + tester.verifyApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(Message(id: locationId))), + ), + ); + }, + ); + }); + + group('`.startLiveLocationSharing`', () { + const deviceId = 'test-device-id'; + const locationId = 'test-location-id'; + final endSharingAt = DateTime.timestamp().add(const Duration(hours: 1)); + const coordinates = LocationCoordinates( + latitude: 40.7128, + longitude: -74.0060, + ); + + channelTest( + 'should create message with live 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, + endAt: endSharingAt, + ), + ), + ), + ); + + final response = await tester.channel.startLiveLocationSharing( + id: locationId, + messageText: 'Location shared', + createdByDeviceId: deviceId, + location: coordinates, + endSharingAt: endSharingAt, + 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.message.sharedLocation?.endAt, endSharingAt); + + tester.verifyApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(Message(id: locationId))), + ), + ); + }, + ); + }); + + group('`.createDraft`', () { + final draftMessage = DraftMessage(text: 'Draft message text'); + + 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), + ), + ); + + final res = await tester.channel.createDraft(draftMessage); + + expect(res, isNotNull); + expect(res.draft.message, draftMessage); + + tester.verifyApi( + (api) => api.message.createDraft(_channelId, _channelType, draftMessage), + ); + }, + ); + }); + + group('`.getDraft`', () { + final draftMessage = DraftMessage(text: 'Draft message text'); + + 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), + ), + ); + + final res = await tester.channel.getDraft(); + + expect(res, isNotNull); + expect(res.draft.message, draftMessage); + + tester.verifyApi( + (api) => api.message.getDraft(_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.getDraft(_channelId, _channelType, parentId: parentId), + result: createDefaultGetDraftResponse( + draft: createDefaultDraft(channelCid: _channelCid, message: draftMessage), + ), + ); + + final res = await tester.channel.getDraft(parentId: parentId); + + expect(res, isNotNull); + expect(res.draft.message, draftMessage); + + tester.verifyApi( + (api) => api.message.getDraft(_channelId, _channelType, parentId: parentId), + ); + }, + ); + }); + + group('`.deleteDraft`', () { + channelTest( + 'should call client.deleteDraft', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelDrafts, + body: (tester) async { + tester.mockApi( + (api) => api.message.deleteDraft(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.deleteDraft(); + + expect(res, isNotNull); + + 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(), + ); + + final res = await tester.channel.deleteDraft(parentId: parentId); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.message.deleteDraft(_channelId, _channelType, parentId: parentId), + ); + }, + ); + }); + + group('`.createReminder`', () { + const messageId = 'test-message-id'; + 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, + ), + ), + ); + + final res = await tester.channel.createReminder(messageId); + + expect(res, isNotNull); + expect(res.reminder.messageId, messageId); + + tester.verifyApi( + (api) => api.reminders.createReminder(messageId), + ); + }, + ); + + 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, + ), + ), + ); + + final res = await tester.channel.createReminder(messageId, remindAt: remindAt); + + 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'; + 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, + ), + ), + ); + + final res = await tester.channel.updateReminder(messageId); + + expect(res, isNotNull); + expect(res.reminder.messageId, messageId); + + tester.verifyApi( + (api) => api.reminders.updateReminder(messageId), + ); + }, + ); + + 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, + ), + ), + ); + + final res = await tester.channel.updateReminder(messageId, remindAt: remindAt); + + expect(res, isNotNull); + expect(res.reminder.messageId, messageId); + expect(res.reminder.remindAt, remindAt); + + tester.verifyApi( + (api) => api.reminders.updateReminder(messageId, remindAt: remindAt), + ); + }, + ); + }); + + group('`.deleteReminder`', () { + const messageId = 'test-message-id'; + + channelTest( + 'should call client.deleteReminder', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelReminders, + body: (tester) async { + tester.mockApi( + (api) => api.reminders.deleteReminder(messageId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.deleteReminder(messageId); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.reminders.deleteReminder(messageId), + ); + }, + ); + }); + + group('`.updateMessage`', () { + 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, + ); + + tester.mockApi( + (api) => api.message.updateMessage(any(that: isSameMessageAs(message))), + 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), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.updated), + matchMessageState: true, + ), + ], + ]), + ); + + final res = await tester.channel.updateMessage(message); + + expect(res, isNotNull); + expect(res.message.id, message.id); + + await messagesEmits; + + tester.verifyApi( + (api) => api.message.updateMessage(any(that: isSameMessageAs(message))), + ); + }, + ); + + 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, + ); + + 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), + ), + ), + ); + + 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, + ), + ], + ], + ), + ); + + 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, + ); + + 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 error is not StreamChatNetworkError', + 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), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.updatingFailed( + skipPush: false, + skipEnrichUrl: true, + ), + ), + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.updateMessage(message, skipEnrichUrl: true); + } catch (e) { + expect(e, isA()); + } + + await messagesEmits; + }, + ); + + channelTest( + 'should add message to retry queue when retriable StreamChatNetworkError occurs 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(errorCode: ChatErrorCode.requestTimeout, retriable: true), + 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), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.updatingFailed( + skipPush: false, + skipEnrichUrl: 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 tester.channel.updateMessage(message, skipEnrichUrl: true); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.requestTimeout.code)); + 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)), + skipEnrichUrl: true, + ), + times: 2, + ); + }, + ); + + channelTest( + 'should add message to retry queue when retriable StreamChatNetworkError occurs 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, + ); + + // 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(errorCode: ChatErrorCode.internalSystemError, retriable: true), + 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), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.updatingFailed( + skipPush: true, + skipEnrichUrl: false, + ), + ), + 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 tester.channel.updateMessage(message, skipPush: true); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.internalSystemError.code)); + 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 non-retriable StreamChatNetworkError 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-error-2', + state: MessageState.sent, + ); + + tester.mockApiFailure( + (api) => api.message.updateMessage( + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.notAllowed), + ); + + 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: true, + ), + ), + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.updateMessage( + message, + skipPush: true, + skipEnrichUrl: true, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } + + await messagesEmits; + }, + ); + + channelTest( + 'should handle non-retriable StreamChatNetworkError 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-error-3', + state: MessageState.sent, + ); + + tester.mockApiFailure( + (api) => api.message.updateMessage( + any(that: isSameMessageAs(message)), + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.notAllowed), + ); + + 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: false, + skipEnrichUrl: false, + ), + ), + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.updateMessage(message); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } + + await messagesEmits; + }, + ); + }); + + 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), + ); + + expect(tester.channelState!.messages, isEmpty); + + tester.channelState!.updateMessage(message); + + 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), + ); + + expect(tester.channelState!.messages, isEmpty); + + 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', + state: MessageState.sent, + ); + + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + final updateMessageResponse = createDefaultUpdateMessageResponse( + message: message.copyWith(text: set['text'], pinExpires: null), + ); + + tester.mockApi( + (api) => api.message.partialUpdateMessage(message.id, set: set, unset: unset), + result: updateMessageResponse, + ); + + 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( + updateMessageResponse.message.copyWith( + state: MessageState.updated, + ), + matchText: true, + matchMessageState: true, + ), + ], + ]), + ); + + final res = await tester.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); + + await messagesEmits; + + tester.verifyApi( + (api) => api.message.partialUpdateMessage(message.id, set: set, unset: unset), + ); + }, + ); + + group('`.partialUpdateMessage` error handling', () { + channelTest( + 'should update message state even when error is not StreamChatNetworkError', + 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 + tester.channelState?.updateMessage(message); + + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + tester.mockApiFailure( + (api) => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + ), + 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, + ), + matchText: true, + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.partialUpdatingFailed( + set: set, + unset: unset, + skipEnrichUrl: false, + ), + ), + matchText: true, + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.partialUpdateMessage( + message, + set: set, + unset: unset, + ); + } catch (e) { + expect(e, isA()); + } + + await messagesEmits; + }, + ); + + channelTest( + 'should add message to retry queue when retriable StreamChatNetworkError occurs with skipEnrichUrl: true', + 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 + tester.channelState?.updateMessage(message); + + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + // 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, + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.requestTimeout, retriable: true), + 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, + ), + matchText: true, + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.partialUpdatingFailed( + set: set, + unset: unset, + skipEnrichUrl: true, + ), + ), + matchText: true, + 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 tester.channel.partialUpdateMessage( + message, + set: set, + unset: unset, + skipEnrichUrl: true, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.requestTimeout.code)); + 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, + ); + }, + ); + + channelTest( + 'should add message to retry queue when retriable StreamChatNetworkError occurs with skipEnrichUrl: false', + 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 + tester.channelState?.updateMessage(message); + + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + // 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, + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.internalSystemError, retriable: true), + 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, + ), + matchText: true, + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.partialUpdatingFailed( + set: set, + unset: unset, + skipEnrichUrl: false, + ), + ), + matchText: true, + 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 tester.channel.partialUpdateMessage( + message, + set: set, + unset: unset, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.internalSystemError.code)); + 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, + ); + }, + ); + + channelTest( + 'should handle non-retriable StreamChatNetworkError 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 + tester.channelState?.updateMessage(message); + + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + tester.mockApiFailure( + (api) => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + skipEnrichUrl: true, + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.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: true, + ), + ), + matchText: true, + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.partialUpdateMessage( + message, + set: set, + unset: unset, + skipEnrichUrl: true, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } + + await messagesEmits; + }, + ); + + channelTest( + 'should handle non-retriable StreamChatNetworkError 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, + ); + + // Add message to channel state first + tester.channelState?.updateMessage(message); + + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + tester.mockApiFailure( + (api) => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.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, + ), + ], + ]), + ); + + try { + await tester.channel.partialUpdateMessage( + message, + set: set, + unset: unset, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } + + await messagesEmits; + }, + ); + }); + + 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, + ); + + tester.mockApi( + (api) => api.message.deleteMessage(messageId, hard: false), + 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. + tester.channelState?.addNewMessage(message); + + 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, + ), + ], + ]), + ); + + final res = await tester.channel.deleteMessage(message); + + expect(res, isNotNull); + + await messagesEmits; + + tester.verifyApi( + (api) => api.message.deleteMessage(messageId, hard: false), + ); + }, + ); + + 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(), + ), + ); + + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + attachments: attachments, + createdAt: DateTime.utc(2021, 3), + state: MessageState.sent, + ); + + 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(), + ); + + final res = await tester.channel.deleteMessage(message, hard: true); + expect(res, isNotNull); + + 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), + ); + }, + ); + + channelTest( + 'should hard delete the message if the state is sending or failed', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelDeleteMessage()), + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + text: 'Hello World!', + state: MessageState.sending, + ); + + final messagesEmits = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.sending), + matchMessageState: true, + ), + ], + const [], // message is hard deleted from state + ]), + ); + + // Add message to channel state first + tester.channelState?.addNewMessage(message); + + final res = await tester.channel.deleteMessage(message); + + expect(res, isNotNull); + + await messagesEmits; + + tester.verifyNeverCalled( + (api) => api.message.deleteMessage(messageId, hard: false), + ); + }, + ); + }); + + group('`.deleteMessageForMe`', () { + 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, + ); + + 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. + tester.channelState?.addNewMessage(message); + + 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 tester.channel.deleteMessageForMe(message); + + expect(res, isNotNull); + + await messagesEmits; + + tester.verifyApi( + (api) => api.message.deleteMessage(messageId, deleteForMe: true), + ); + }, + ); + + channelTest( + 'should hard delete the message if the state is sending or failed', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelDeleteMessage()), + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + text: 'Hello World!', + state: MessageState.sending, + ); + + final messagesEmits = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.sending), + matchMessageState: true, + ), + ], + const [], // message is hard deleted from state + ]), + ); + + // Add message to channel state first + tester.channelState?.addNewMessage(message); + + final res = await tester.channel.deleteMessageForMe(message); + + expect(res, isNotNull); + + await messagesEmits; + + tester.verifyNeverCalled( + (api) => api.message.deleteMessage(messageId, deleteForMe: true), + ); + }, + ); + }); + + group('`.pinMessage`', () { + 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'); + + tester.mockApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: {'pinned': true, 'pin_expires': null}, + ), + 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 tester.channel.pinMessage(message); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNull); + + await messagesEmits; + + tester.verifyApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: {'pinned': true, 'pin_expires': null}, + ), + ); + }, + ); + + channelTest( + 'should work fine if passed timeoutOrExpirationDate as num(seconds)', + 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 + + // 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'), + ), + result: createDefaultUpdateMessageResponse( + message: message.copyWith( + pinned: true, + pinExpires: DateTime.utc(2021, 3).add( + const Duration(seconds: timeoutOrExpirationDate), + ), + ), + ), + ); + + 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 tester.channel.pinMessage( + message, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNotNull); + + await messagesEmits; + + tester.verifyApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: any(named: 'set'), + ), + ); + }, + ); + + channelTest( + 'should work fine if passed timeoutOrExpirationDate as DateTime', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelPinMessage()), + body: (tester) async { + final message = Message(id: 'test-message-id'); + final timeoutOrExpirationDate = DateTime.utc(2021, 3).add(const Duration(days: 3)); // 3 days + + tester.mockApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: { + 'pinned': true, + 'pin_expires': timeoutOrExpirationDate.toUtc().toIso8601String(), + }, + ), + result: createDefaultUpdateMessageResponse( + message: message.copyWith( + pinned: true, + pinExpires: timeoutOrExpirationDate, + ), + ), + ); + + 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 tester.channel.pinMessage( + message, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNotNull); + expect(res.message.pinExpires, timeoutOrExpirationDate.toUtc()); + + await messagesEmits; + + tester.verifyApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: { + 'pinned': true, + 'pin_expires': timeoutOrExpirationDate.toUtc().toIso8601String(), + }, + ), + ); + }, + ); + + chatClientTest( + 'should throw if invalid timeoutOrExpirationDate is passed', + body: (tester) async { + const messageId = 'test-message-id'; + const timeoutOrExpirationDate = 'invalid-value'; + + try { + await tester.client.pinMessage( + messageId, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + } catch (e) { + expect(e, isA()); + } + }, + ); + }); + + channelTest( + '`.unpinMessage`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelPinMessage()), + body: (tester) async { + final message = Message(id: 'test-message-id', pinned: true); + + tester.mockApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: {'pinned': false}, + ), + result: createDefaultUpdateMessageResponse( + message: message.copyWith(pinned: false), + ), + ); + + 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 tester.channel.unpinMessage(message); + + expect(res, isNotNull); + expect(res.message.pinned, isFalse); + + await messagesEmits; + + tester.verifyApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: {'pinned': false}, + ), + ); + }, + ); + + group('`.search`', () { + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); + } + + final filter = Filter.in_('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'; + const sort = [SortOption.asc('test-sort-field')]; + const pagination = PaginationParams(); + + final results = List.generate(3, (index) => createDefaultGetMessageResponse()); + + tester.mockApi( + (api) => api.general.searchMessages( + filter, + query: query, + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + result: createDefaultSearchMessagesResponse(results: results), + ); + + final res = await tester.channel.search( + query: query, + sort: sort, + paginationParams: pagination, + ); + + expect(res, isNotNull); + expect(res.results.length, results.length); + + tester.verifyApi( + (api) => api.general.searchMessages( + filter, + query: query, + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ); + }, + ); + + channelTest( + 'should work fine with `messageFilters`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) async { + final messageFilters = Filter.query('key', 'text'); + const sort = [SortOption.desc('test-sort-field')]; + const pagination = PaginationParams(); + + final results = List.generate(3, (index) => createDefaultGetMessageResponse()); + + tester.mockApi( + (api) => api.general.searchMessages( + filter, + messageFilters: messageFilters, + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + result: createDefaultSearchMessagesResponse(results: results), + ); + + final res = await tester.channel.search( + sort: sort, + paginationParams: pagination, + messageFilters: messageFilters, + ); + + expect(res, isNotNull); + expect(res.results.length, results.length); + + tester.verifyApi( + (api) => api.general.searchMessages( + filter, + messageFilters: messageFilters, + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ); + }, + ); + }); + + channelTest( + '`.deleteFile`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelMiscOperations()), + body: (tester) async { + const url = 'test-file-url'; + + tester.mockApi( + (api) => api.fileUploader.deleteFile(url, _channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.deleteFile(url); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.fileUploader.deleteFile(url, _channelId, _channelType), + ); + }, + ); + + channelTest( + '`.deleteImage`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelMiscOperations()), + body: (tester) async { + const url = 'test-image-url'; + + tester.mockApi( + (api) => api.fileUploader.deleteImage(url, _channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.deleteImage(url); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.fileUploader.deleteImage(url, _channelId, _channelType), + ); + }, + ); + + 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 tester.channel.stopAIResponse(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(stopAIEvent)), + ), + ); + }, + ); + + channelTest( + '`.sendEvent`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelMiscOperations()), + body: (tester) async { + final event = Event(type: 'event.local'); + + // 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 tester.channel.sendEvent(event); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.sendEvent(_channelId, _channelType, event), + ); + }, + ); + + group('`.sendReaction`', () { + 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; + + final reaction = Reaction( + type: type, + messageId: message.id, + emojiCode: emojiCode, + score: score, + user: tester.currentUser, + ); + + tester.mockApi( + (api) => api.message.sendReaction(message.id, reaction), + result: createDefaultSendReactionResponse(message: message, reaction: 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, + ), + ], + ]), + ); + + 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); + + tester.verifyApi((api) => api.message.sendReaction(message.id, reaction)); + + await messagesEmission; + }, + ); + + channelTest( + 'should restore previous message if `client.sendReaction` throws', + 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', + state: MessageState.sent, + ); + + final reaction = Reaction( + type: type, + messageId: message.id, + user: tester.currentUser, + ); + + tester.mockApiFailure( + (api) => api.message.sendReaction(message.id, reaction), + error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError), + ); + + 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, + ), + ], + [ + isSameMessageAs( + message, + matchReactions: true, + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.sendReaction(message, reaction); + } catch (e) { + expect(e, isA()); + } + + tester.verifyApi((api) => api.message.sendReaction(message.id, reaction)); + + await messagesEmission; + }, + ); + + channelTest( + '''should override previous reaction if present and `enforceUnique` is true''', + 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: tester.currentUser, + ); + final message = Message( + id: messageId, + ownReactions: [prevReaction], + latestReactions: [prevReaction], + reactionGroups: { + prevType: ReactionGroup( + count: 1, + sumScores: 1, + ), + }, + state: MessageState.sent, + ); + + const type = 'test-reaction-type-2'; + final newReaction = Reaction( + type: type, + messageId: messageId, + user: tester.currentUser, + ); + final newMessage = message.copyWith( + ownReactions: [newReaction], + latestReactions: [newReaction], + ); + + const enforceUnique = true; + + tester.mockApi( + (api) => api.message.sendReaction( + messageId, + newReaction, + enforceUnique: enforceUnique, + ), + result: createDefaultSendReactionResponse(message: newMessage, reaction: newReaction), + ); + + final messagesEmission = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + newMessage, + matchReactions: true, + matchMessageState: true, + ), + ], + ]), + ); + + final res = await tester.channel.sendReaction( + message, + newReaction, + enforceUnique: enforceUnique, + ); + + expect(res, isNotNull); + expect(res.reaction.type, type); + expect(res.reaction.messageId, messageId); + + tester.verifyApi( + (api) => api.message.sendReaction( + messageId, + newReaction, + enforceUnique: enforceUnique, + ), + ); + + await messagesEmission; + }, + ); + }); + + group('`.sendReaction in thread`', () { + 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: tester.currentUser, + ); + + tester.mockApi( + (api) => api.message.sendReaction(message.id, reaction), + result: createDefaultSendReactionResponse(message: message, reaction: 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, + ), + ], + ]), + ); + + final res = await tester.channel.sendReaction(message, reaction); + + expect(res, isNotNull); + expect(res.reaction.type, type); + expect(res.reaction.messageId, message.id); + + tester.verifyApi((api) => api.message.sendReaction(message.id, reaction)); + + await threadsEmission; + }, + ); + + channelTest( + '''should restore previous thread message if `client.sendReaction` throws''', + 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, + // `Message.createdAt` falls back to `DateTime.now()` per call + // when not provided, which breaks merge/sort keyed on createdAt. + createdAt: DateTime.utc(2021, 3), + ); + + final reaction = Reaction( + type: type, + messageId: message.id, + user: tester.currentUser, + ); + + tester.mockApiFailure( + (api) => api.message.sendReaction(message.id, reaction), + error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError), + ); + + 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, + reactionGroups: { + type: ReactionGroup( + count: 1, + sumScores: 1, + ), + }, + latestReactions: [reaction], + ownReactions: [reaction], + ), + matchReactions: true, + matchMessageState: true, + matchParentId: true, + ), + ], + [ + isSameMessageAs( + message, + matchReactions: true, + matchMessageState: true, + matchParentId: true, + ), + ], + ]), + ); + + try { + await tester.channel.sendReaction(message, reaction); + } catch (e) { + expect(e, isA()); + } + + tester.verifyApi((api) => api.message.sendReaction(message.id, reaction)); + + await threadsEmission; + }, + ); + + channelTest( + '''should override previous thread reaction if present and `enforceUnique` is true''', + 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: tester.currentUser, + ); + final message = Message( + id: messageId, + parentId: parentId, + ownReactions: [prevReaction], + latestReactions: [prevReaction], + reactionGroups: { + prevType: ReactionGroup( + count: 1, + sumScores: 1, + ), + }, + state: MessageState.sent, + ); + + const type = 'test-reaction-type-2'; + final newReaction = Reaction( + type: type, + messageId: messageId, + user: tester.currentUser, + ); + final newMessage = message.copyWith( + ownReactions: [newReaction], + latestReactions: [newReaction], + ); + + const enforceUnique = true; + + tester.mockApi( + (api) => api.message.sendReaction( + messageId, + newReaction, + enforceUnique: enforceUnique, + ), + result: createDefaultSendReactionResponse(message: newMessage, reaction: newReaction), + ); + + final threadsEmission = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.threadsStream.skip(1).map((event) => event['test-parent-id']), + emitsInOrder([ + [ + isSameMessageAs( + newMessage.copyWith(state: MessageState.sent), + matchReactions: true, + matchMessageState: true, + matchParentId: true, + ), + ], + ]), + ); + + final res = await tester.channel.sendReaction( + message, + newReaction, + enforceUnique: enforceUnique, + ); + + expect(res, isNotNull); + expect(res.reaction.type, type); + expect(res.reaction.messageId, messageId); + + tester.verifyApi( + (api) => api.message.sendReaction( + messageId, + newReaction, + enforceUnique: enforceUnique, + ), + ); + + await threadsEmission; + }, + ); + }); + + group('`.deleteReaction`', () { + 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, + ); + + tester.mockApi( + (api) => api.message.deleteReaction(messageId, type), + result: createDefaultEmptyResponse(), + ); + + 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, + ), + ], + ]), + ); + + final res = await tester.channel.deleteReaction(message, reaction); + + expect(res, isNotNull); + + tester.verifyApi((api) => api.message.deleteReaction(messageId, type)); + + await messagesEmission; + }, + ); + + channelTest( + 'should restore prev message state if `client.deleteReaction` throws', + 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, + ); + + tester.mockApiFailure( + (api) => api.message.deleteReaction(messageId, type), + error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError), + ); + + 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, + ), + ], + [ + isSameMessageAs( + message, + matchReactions: true, + matchMessageState: true, + ), + ], + ]), + ); + + try { + await tester.channel.deleteReaction(message, reaction); + } catch (e) { + expect(e, isA()); + } + + tester.verifyApi((api) => api.message.deleteReaction(messageId, type)); + + await messagesEmission; + }, + ); + }); + + group('`.deleteReaction in thread`', () { + 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), + ); + + tester.mockApi( + (api) => api.message.deleteReaction(messageId, type), + result: createDefaultEmptyResponse(), + ); + + 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, + ), + ], + ]), + ); + + final res = await tester.channel.deleteReaction(message, reaction); + + expect(res, isNotNull); + + tester.verifyApi((api) => api.message.deleteReaction(messageId, type)); + + await threadsEmission; + }, + ); + + channelTest( + 'should restore prev message state if `client.deleteReaction` throws', + 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, + 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), + ); + + tester.mockApiFailure( + (api) => api.message.deleteReaction(messageId, type), + error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError), + ); + + 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, + ), + ], + [ + isSameMessageAs( + message, + matchReactions: true, + matchMessageState: true, + matchParentId: true, + ), + ], + ]), + ); + + try { + await tester.channel.deleteReaction(message, reaction); + } catch (e) { + expect(e, isA()); + } + + tester.verifyApi((api) => api.message.deleteReaction(messageId, type)); + + await threadsEmission; + }, + ); + }); + + 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', + ); + + final channelModel = createDefaultChannelModel( + cid: _channelCid, + extraData: channelData, + ); + + tester.mockApi( + (api) => api.channel.updateChannel(_channelId, _channelType, channelData, message: updateMessage), + result: UpdateChannelResponse() + ..channel = channelModel + ..message = updateMessage, + ); + + final res = await tester.channel.update( + channelData, + updateMessage: updateMessage, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.channel.extraData, channelData); + expect(res.message?.id, updateMessage.id); + + tester.verifyApi( + (api) => api.channel.updateChannel(_channelId, _channelType, channelData, message: updateMessage), + ); + }, + ); + + channelTest( + '`.updateImage`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelUpdateApi, + body: (tester) async { + const image = 'https://getstream.io/new-image'; + + final channelModel = createDefaultChannelModel( + cid: _channelCid, + extraData: {'image': image}, + ); + + tester.mockApi( + (api) => api.channel.updateChannelPartial( + _channelId, + _channelType, + set: {'image': image}, + ), + result: createDefaultPartialUpdateChannelResponse(channel: channelModel), + ); + + final res = await tester.channel.updateImage(image); + + expect(res, isNotNull); + expect(res.channel.extraData['image'], image); + + tester.verifyApi( + (api) => api.channel.updateChannelPartial( + _channelId, + _channelType, + set: {'image': image}, + ), + ); + }, + ); + + channelTest( + '`.updateName`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelUpdateApi, + body: (tester) async { + const name = 'Name'; + + final channelModel = createDefaultChannelModel( + cid: _channelCid, + extraData: {'name': name}, + ); + + tester.mockApi( + (api) => api.channel.updateChannelPartial( + _channelId, + _channelType, + set: {'name': name}, + ), + result: createDefaultPartialUpdateChannelResponse(channel: channelModel), + ); + + final res = await tester.channel.updateName(name); + + expect(res, isNotNull); + expect(res.channel.extraData['name'], name); + + tester.verifyApi( + (api) => api.channel.updateChannelPartial( + _channelId, + _channelType, + set: {'name': 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, + }, + ); + + tester.mockApi( + (api) => api.channel.updateChannelPartial( + _channelId, + _channelType, + set: set, + unset: unset, + ), + result: createDefaultPartialUpdateChannelResponse(channel: channelModel), + ); + + final res = await tester.channel.updatePartial(set: set, unset: unset); + + 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, + ), + ); + }, + ); + + channelTest( + '`.delete`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelUpdateApi, + body: (tester) async { + tester.mockApi( + (api) => api.channel.deleteChannel(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.delete(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.deleteChannel(_channelId, _channelType), + ); + }, + ); + + channelTest( + '`.truncate`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelUpdateApi, + body: (tester) async { + tester.mockApi( + (api) => api.channel.truncateChannel(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.truncate(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.truncateChannel(_channelId, _channelType), + ); + }, + ); + + channelTest( + '`.acceptInvite`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelMemberApi, + body: (tester) async { + final message = Message(id: 'test-message-id', text: 'Invite Accepted'); + + final channelModel = createDefaultChannelModel(cid: _channelCid); + + tester.mockApi( + (api) => api.channel.acceptChannelInvite(_channelId, _channelType, message: message), + result: AcceptInviteResponse() + ..channel = channelModel + ..message = message, + ); + + final res = await tester.channel.acceptInvite(message); + + 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), + ); + }, + ); + + channelTest( + '`.rejectInvite`', + channelType: _channelType, + channelId: _channelId, + setUp: _seedChannelMemberApi, + body: (tester) async { + final message = Message(id: 'test-message-id', text: 'Invite Rejected'); + + final channelModel = createDefaultChannelModel(cid: _channelCid); + + tester.mockApi( + (api) => api.channel.rejectChannelInvite(_channelId, _channelType, message: message), + result: RejectInviteResponse() + ..channel = channelModel + ..message = message, + ); + + final res = await tester.channel.rejectInvite(message); + + 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), + ); + }, + ); + + 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'); + + final channelModel = createDefaultChannelModel(cid: _channelCid); + + tester.mockApi( + (api) => api.channel.addMembers(_channelId, _channelType, memberIds, message: message), + result: createDefaultAddMembersResponse( + channel: channelModel, + members: members, + message: message, + ), + ); + + final res = await tester.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); + + tester.verifyApi( + (api) => api.channel.addMembers(_channelId, _channelType, memberIds, message: message), + ); + }, + ); + + 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'); + + final channelModel = createDefaultChannelModel(cid: _channelCid); + + tester.mockApi( + (api) => api.channel.addMembers( + _channelId, + _channelType, + memberIds, + message: message, + hideHistoryBefore: hideHistoryBefore, + ), + result: createDefaultAddMembersResponse( + channel: channelModel, + members: members, + message: message, + ), + ); + + final res = await tester.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); + + tester.verifyApi( + (api) => api.channel.addMembers( + _channelId, + _channelType, + memberIds, + message: message, + hideHistoryBefore: hideHistoryBefore, + ), + ); + }, + ); + + 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 = createDefaultChannelModel(cid: _channelCid); + + tester.mockApi( + (api) => api.channel.inviteChannelMembers(_channelId, _channelType, memberIds, message: message), + result: InviteMembersResponse() + ..channel = channelModel + ..members = members + ..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); + + tester.verifyApi( + (api) => api.channel.inviteChannelMembers(_channelId, _channelType, memberIds, message: message), + ); + }, + ); + + 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 = createDefaultChannelModel(cid: _channelCid); + + tester.mockApi( + (api) => api.channel.removeMembers(_channelId, _channelType, memberIds, message: message), + result: RemoveMembersResponse() + ..channel = channelModel + ..members = members + ..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); + + tester.verifyApi( + (api) => api.channel.removeMembers(_channelId, _channelType, memberIds, message: message), + ); + }, + ); + + group('`.sendAction`', () { + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: const [ChannelCapability.readEvents], + ), + ); + } + + 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 tester.channel.sendAction(message, formData); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.message.sendAction(_channelId, _channelType, message.id, formData), + ); + }, + ); + + 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), + ); + + final messagesEmission = expectLater( + // skipping first seed message list -> [] messages + tester.channelState?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message, + matchMessageState: true, + ), + ], + ]), + ); + + final res = await tester.channel.sendAction(message, formData); + + expect(res, isNotNull); + expect(res.message?.id, message.id); + + tester.verifyApi( + (api) => api.message.sendAction(_channelId, _channelType, message.id, formData), + ); + + await messagesEmission; + }, + ); + }); + + group('`.watch`', () { + // 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], + ), + ), + ); + } + + 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), + ), + ); + + final res = await tester.channel.watch(); + + 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, + ), + ); + }, + ); + + 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(errorCode: ChatErrorCode.inputError), + result: createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ), + ); + + // 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)); + }, + ); + + 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(errorCode: ChatErrorCode.inputError), + ); + + 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, + ), + ); + }, + ); + }); + + 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 tester.channel.stopWatching(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.stopWatching(_channelId, _channelType), + ); + }, + ); + + 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, + ), + ); + + tester.mockApi( + (api) => api.message.getReplies(parentId), + result: createDefaultQueryRepliesResponse(messages: messages), + ); + + 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); + + tester.verifyApi((api) => api.message.getReplies(parentId)); + }, + ); + + 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), + ), + ]; + + tester.mockApi( + (api) => api.message.getReplies(parentId), + result: createDefaultQueryRepliesResponse(messages: messages), + ); + + await tester.channel.getReplies(parentId); + + final threadMessages = tester.channelState!.threads[parentId]; + expect(threadMessages, isNotNull); + expect(threadMessages!.length, messages.length - 1); + expect(threadMessages.any((it) => it.id == parentId), isFalse); + }, + ); + + 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, + ), + ); + + tester.mockApi( + (api) => api.message.getReactions(messageId), + result: createDefaultQueryReactionsResponse(reactions: reactions), + ); + + 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); + + tester.verifyApi((api) => api.message.getReactions(messageId)); + }, + ); + + 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); + + tester.mockApi( + (api) => api.message.getMessagesById(_channelId, _channelType, messageIds), + result: createDefaultGetMessagesByIdResponse(messages: messages), + ); + + final res = await tester.channel.getMessagesById(messageIds); + + expect(res, isNotNull); + expect(res.messages.length, messageIds.length); + + tester.verifyApi( + (api) => api.message.getMessagesById(_channelId, _channelType, messageIds), + ); + }, + ); + + 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'); + tester.channelState!.updateMessage(message); + + final translatedMessage = message.copyWith( + i18n: {'language': 'en', '${language}_text': translatedMessageText}, + ); + + tester.mockApi( + (api) => api.message.translateMessage(messageId, language), + result: createDefaultTranslateMessageResponse(message: translatedMessage), + ); + + final res = await tester.channel.translateMessage(messageId, language); + + 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 = tester.channelState!.messages.firstWhere((it) => it.id == messageId); + expect(stateMessage.i18n, translatedMessage.i18n); + + tester.verifyApi((api) => api.message.translateMessage(messageId, language)); + }, + ); + + group('`.query`', () { + 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 tester.channel.query(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + ), + ); + }, + ); + + 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(errorCode: ChatErrorCode.inputError), + ); + + try { + await tester.channel.query(); + } catch (e) { + expect(e, isA()); + } + + tester.verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + ), + ); + }, + ); + + 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, + ); + + 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'), + ], + ); + + const pagination = PaginationParams(idAround: 'target-message-id'); + + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + messagesPagination: pagination, + ), + result: newState, + ); + + final res = await tester.channel.query(messagesPagination: pagination); + + 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, + ), + ); + }, + ); + + 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, + ); + + 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'), + ], + ); + + final pagination = PaginationParams(createdAtAround: targetDate); + + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + messagesPagination: pagination, + ), + result: newState, + ); + + final res = await tester.channel.query(messagesPagination: pagination); + + 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, + ), + ); + }, + ); + + 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), + ), + ], + ), + body: (tester) async { + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + ), + result: createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ), + ); + + registerFallbackValue([]); + tester.mockApi( + (api) => api.channel.markChannelsDelivered(any()), + result: createDefaultEmptyResponse(), + ); + + // 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)); + + // 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'); + }, + ); + + 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), + ), + ], + ), + body: (tester) async { + registerFallbackValue([]); + tester.mockApi( + (api) => api.channel.markChannelsDelivered(any()), + result: createDefaultEmptyResponse(), + ); + + const pagination = PaginationParams(limit: 20, lessThan: 'some-message-id'); + + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: tester.channel.extraData, + messagesPagination: pagination, + ), + result: createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ), + ); + + // Query with pagination params (fetching older messages) + await tester.channel.query(messagesPagination: pagination); + + // 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)); + + // Verify no delivery receipt was sent + tester.verifyNeverCalled( + (api) => api.channel.markChannelsDelivered(any()), + ); + }, + ); + }); + + channelTest( + '`.queryMembers`', + channelType: _channelType, + channelId: _channelId, + build: _buildInitializedChannel, + body: (tester) async { + final filter = Filter.in_('cid', const [_channelCid]); + + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); + + 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, + ); + + final res = await tester.channel.queryMembers(filter: filter); + + expect(res, isNotNull); + expect(res.members.length, members.length); + + tester.verifyApi( + (api) => api.general.queryMembers( + _channelType, + channelId: _channelId, + filter: filter, + members: const [], + ), + ); + }, + ); + + channelTest( + '`.queryBannedUsers`', + channelType: _channelType, + channelId: _channelId, + build: _buildInitializedChannel, + body: (tester) async { + final filter = Filter.equal('channel_cid', _channelCid); + + final bans = List.generate( + 3, + (index) => BannedUser( + user: User(id: 'test-user-id-$index'), + bannedBy: User(id: 'test-user-id-${index + 1}'), + ), + ); + + tester.mockApi( + (api) => api.moderation.queryBannedUsers(filter: filter), + result: QueryBannedUsersResponse()..bans = bans, + ); + + final res = await tester.channel.queryBannedUsers(); + + expect(res, isNotNull); + expect(res.bans.length, bans.length); + + tester.verifyApi( + (api) => api.moderation.queryBannedUsers(filter: filter), + ); + }, + ); + + 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 tester.channel.mute(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.moderation.muteChannel(_channelCid), + ); + }, + ); + + 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(), + ); + + final res = await tester.channel.mute(expiration: expiration); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.moderation.muteChannel(_channelCid, expiration: expiration), + ); + + // wait for expiration + await Future.delayed(expiration); + tester.verifyApi((api) => api.moderation.unmuteChannel(_channelCid)); + }, + ); + + 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 res = await tester.channel.unmute(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.moderation.unmuteChannel(_channelCid), + ); + }, + ); + + channelTest( + '`.enableSlowMode`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelModerationApi()), + body: (tester) async { + const cooldown = 10; + + final channelModel = ChannelModel( + cid: _channelCid, + cooldown: cooldown, + ); + + tester.mockApi( + (api) => api.channel.enableSlowdown(_channelId, _channelType, cooldown), + result: createDefaultPartialUpdateChannelResponse(channel: channelModel), + ); + + final res = await tester.channel.enableSlowMode(cooldownInterval: 10); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.enableSlowdown(_channelId, _channelType, cooldown), + ); + }, + ); + + channelTest( + '`.disableSlowMode`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelModerationApi()), + body: (tester) async { + final channelModel = ChannelModel( + cid: _channelCid, + ); + + tester.mockApi( + (api) => api.channel.disableSlowdown(_channelId, _channelType), + result: createDefaultPartialUpdateChannelResponse(channel: channelModel), + ); + + final res = await tester.channel.disableSlowMode(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.disableSlowdown(_channelId, _channelType), + ); + }, + ); + + channelTest( + '`.banUser`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelModerationApi()), + body: (tester) async { + const userId = 'test-user-id'; + const options = {'key': 'value'}; + + tester.mockApi( + (api) => api.moderation.banUser( + userId, + options: {'type': _channelType, 'id': _channelId, ...options}, + ), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.banMember(userId, options); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.moderation.banUser( + userId, + options: {'type': _channelType, 'id': _channelId, ...options}, + ), + ); + }, + ); + + channelTest( + '`.unbanUser`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelModerationApi()), + body: (tester) async { + const userId = 'test-user-id'; + + tester.mockApi( + (api) => api.moderation.unbanUser( + userId, + options: {'type': _channelType, 'id': _channelId}, + ), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.unbanMember(userId); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.moderation.unbanUser( + userId, + options: {'type': _channelType, 'id': _channelId}, + ), + ); + }, + ); + + channelTest( + '`.shadowBan`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelModerationApi()), + body: (tester) async { + const userId = 'test-user-id'; + const options = {'key': 'value'}; + + tester.mockApi( + (api) => api.moderation.banUser( + userId, + options: {'shadow': true, 'type': _channelType, 'id': _channelId, ...options}, + ), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.shadowBan(userId, options); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.moderation.banUser( + userId, + options: {'shadow': true, 'type': _channelType, 'id': _channelId, ...options}, + ), + ); + }, + ); + + channelTest( + '`.removeShadowBan`', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelModerationApi()), + body: (tester) async { + const userId = 'test-user-id'; + + tester.mockApi( + (api) => api.moderation.unbanUser( + userId, + options: {'shadow': true, 'type': _channelType, 'id': _channelId}, + ), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.channel.removeShadowBan(userId); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.moderation.unbanUser( + userId, + options: {'shadow': true, 'type': _channelType, 'id': _channelId}, + ), + ); + }, + ); + + 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(), + ); + + final res = await tester.channel.hide(clearHistory: clearHistory); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.hideChannel( + _channelId, + _channelType, + clearHistory: clearHistory, + ), + ); + }, + ); + + 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(), + ); + + final res = await tester.channel.show(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.showChannel(_channelId, _channelType), + ); + }, + ); + + // testing archiving + 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 tester.channel.archive(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: {'archived': true}, + ), + ); + }, + ); + + 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 tester.channel.unarchive(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + unset: ['archived'], + ), + ); + }, + ); + + // testing pinning + 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 tester.channel.pin(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: {'pinned': true}, + ), + ); + }, + ); + + 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 tester.channel.unpin(); + + expect(res, isNotNull); + + tester.verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + unset: ['pinned'], + ), + ); + }, + ); + + 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), + ]), + ); + + await tester.emitEvent(event); + + await eventReceived; + }, + ); + + group('stale error message cleanup', () { + final errorMessage = Message(type: MessageType.error); + final bouncedErrorMessage = Message( + type: MessageType.error, + moderation: const Moderation( + action: ModerationAction.bounce, + originalText: 'original text', + ), + ); + + // Test case: sending a message cleans up stale error messages + + // 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 + setUp: (tester) => tester.watch( + modifyResponse: (_) => _channelStateWith( + messages: [errorMessage, bouncedErrorMessage], + ), + ), + body: (tester) async { + // Set up the mock response for sending message + final newMessage = Message(text: 'New message'); + + 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 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); + + 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.utc(2024).add(Duration(seconds: i)), + ), + ); + + 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']); + }, + ); + + 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)); + + final next = tester.channelState!.messagesStream.firstWhere((messages) => messages.length == 3); + + tester.channelState!.pruneOldest(3); + + final emitted = await next; + expect(emitted.map((m) => m.id), ['msg-3', 'msg-4', 'msg-5']); + }, + ); + + 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)); + + tester.channelState!.pruneOldest(5); + expect(tester.channelState!.messages, hasLength(3)); + + tester.channelState!.pruneOldest(3); + expect(tester.channelState!.messages, hasLength(3)); + }, + ); + + 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)); + + tester.channelState!.pruneOldest(0); + expect(tester.channelState!.messages, hasLength(5)); + + tester.channelState!.pruneOldest(-1); + expect(tester.channelState!.messages, hasLength(5)); + }, + ); + + 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)); + }, + ); + + 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), + ), + ]; + + tester.channelState!.updateChannelState( + _channelStateWith(messages: initial, pinnedMessages: pinned), + ); + + tester.channelState!.pruneOldest(3); + + expect(tester.channelState!.messages, hasLength(3)); + expect(tester.channelState!.pinnedMessages, equals(pinned)); + }, + ); + + 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(); + + final emissions = >[]; + final sub = tester.channelState!.messagesStream.skip(1).listen(emissions.add); + addTearDown(sub.cancel); + + 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); + }, + ); + }); + }); + + group('Channel State Validation and Cooldown', () { + // seconds + + // A bare channel state (no config, no capabilities). + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); + } + + // A bare channel state (no config, no capabilities). + ChannelState Function(ChannelState) _seedChannelCountEvents() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); + } + + group('Non-initialized channel state validation', () { + channelTest( + 'should throw StateError when accessing cooldown on non-initialized channel', + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final channel = tester.channel; + expect(() => channel.cooldown, throwsA(isA())); + }, + ); + + channelTest( + 'should throw StateError when accessing getRemainingCooldown on non-initialized channel', + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final channel = tester.channel; + expect(channel.getRemainingCooldown, throwsA(isA())); + }, + ); + + channelTest( + 'should throw StateError when accessing cooldownStream on non-initialized channel', + channelType: _channelType, + channelId: _channelId, + body: (tester) async { + final channel = tester.channel; + expect(() => channel.cooldownStream, throwsA(isA())); + }, + ); + }); + + group('Initialized channel cooldown functionality', () { + channelTest( + 'should return default cooldown value of 0 for initialized channel', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), + body: (tester) => expect(tester.channel.cooldown, equals(0)), + ); + + 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(tester.client, stateWithCooldown); + addTearDown(testChannel.dispose); + + expect(testChannel.cooldown, equals(30)); + }, + ); + + 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)); + }, + ); + + 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 _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], + ), + ); + } + + channelTest( + 'should return positive cooldown after current user sends a thread reply', + 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: tester.currentUser!.id), + ); + tester.channelState!.updateThreadInfo('parent-msg-1', [threadReply]); + + expect(tester.channel.getRemainingCooldown(), greaterThan(0)); + }, + ); + + channelTest( + 'should return 0 cooldown when thread reply was sent outside the cooldown window', + 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), + ), + user: User(id: tester.currentUser!.id), + ); + tester.channelState!.updateThreadInfo('parent-msg-1', [oldReply]); + + expect(tester.channel.getRemainingCooldown(), equals(0)); + }, + ); + + channelTest( + 'should not trigger cooldown for a thread reply from another user', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelWithCooldown()), + body: (tester) async { + final otherUserReply = Message( + id: 'thread-reply-other', + parentId: 'parent-msg-1', + showInChannel: false, + createdAt: DateTime.timestamp(), + user: User(id: 'other-user-id'), + ); + tester.channelState!.updateThreadInfo('parent-msg-1', [otherUserReply]); + + expect(tester.channel.getRemainingCooldown(), equals(0)); + }, + ); + + channelTest( + 'should clear cooldown when the most-recent own message is hard-deleted', + 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: tester.currentUser!.id), + ); + tester.channelState!.updateMessage(ownMessage); + expect(tester.channel.getRemainingCooldown(), greaterThan(0)); + + tester.channelState!.deleteMessage(ownMessage, hardDelete: true); + expect(tester.channel.getRemainingCooldown(), equals(0)); + }, + ); + + channelTest( + 'currentUserLastMessageAtStream emits a new timestamp when own message is added', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelWithCooldown()), + body: (tester) async { + final emissions = []; + 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; + + tester.channelState!.updateMessage( + Message( + id: 'msg-1', + createdAt: DateTime.timestamp(), + user: User(id: tester.currentUser!.id), + ), + ); + await Future.delayed(Duration.zero); + + expect(emissions.last, isNotNull); + expect(emissions.last, isNot(equals(seededLast))); + }, + ); + + channelTest( + 'getRemainingCooldown uses the explicit [lastMessageAt] override', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelWithCooldown()), + body: (tester) async { + // No messages in state, so the default path returns 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(tester.channel.getRemainingCooldown(lastMessageAt: recent), greaterThan(0)); + + // Override pointing outside the window → 0. + final old = DateTime.timestamp().subtract( + const Duration(seconds: _cooldownDuration + 5), + ); + expect(tester.channel.getRemainingCooldown(lastMessageAt: old), equals(0)); + }, + ); + + channelTest( + 'currentUserLastMessageAt picks the latest across channel messages and threads', + 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. + tester.channelState!.updateMessage( + Message( + id: 'msg-1', + createdAt: older, + user: User(id: tester.currentUser!.id), + ), + ); + // Newer reply in a thread. + tester.channelState!.updateThreadInfo('parent-msg-1', [ + Message( + id: 'thread-reply-1', + parentId: 'parent-msg-1', + showInChannel: false, + createdAt: newer, + user: User(id: tester.currentUser!.id), + ), + ]); + + // Should pick the newer thread reply, not the older channel message. + final result = tester.channel.currentUserLastMessageAt; + expect(result, isNotNull); + expect(result!.isAtSameMomentAs(newer), isTrue); + }, + ); + }); + + group('Disposed channel state validation', () { + 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)); + + // Dispose the channel + channel.dispose(); + + // Now accessing cooldown should throw + expect(() => channel.cooldown, throwsA(isA())); + }, + ); + + 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)); + + // Dispose the channel + channel.dispose(); + + // Now accessing getRemainingCooldown should throw + expect(channel.getRemainingCooldown, throwsA(isA())); + }, + ); + + 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 + await expectLater(channel.cooldownStream.take(1), emits(0)); + + // Dispose the channel + channel.dispose(); + + // Now accessing cooldownStream should throw + expect(() => channel.cooldownStream, throwsA(isA())); + }, + ); + + 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 = createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); + final raceChannel = Channel.fromState(tester.client, channelState); + + // Verify it works initially + expect(raceChannel.cooldown, equals(0)); + + // Simulate quick disposal (like what happens with rapid navigation) + raceChannel.dispose(); + + // This should throw StateError instead of crashing with null check operator + expect(() => raceChannel.cooldown, throwsA(isA())); + + expect(raceChannel.getRemainingCooldown, throwsA(isA())); + }, + ); + }); + + group('Channel message count events', () { + channelTest( + 'should update channel messageCount when event contains channelMessageCount', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelCountEvents()), + body: (tester) async { + // Verify initial state - no messageCount + expect(tester.channel.messageCount, isNull); + + // Create event with channelMessageCount + final messageCountEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageNew, + channelMessageCount: 42, + ); + + // Dispatch event and wait for it to be processed + await tester.emitEvent(messageCountEvent); + + // Verify channel messageCount was updated + expect(tester.channel.messageCount, equals(42)); + }, + ); + + channelTest( + 'should update channel messageCount from message.new and message.deleted events', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelCountEvents()), + body: (tester) async { + // Test with message.new event - count increases + final messageNewEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageNew, + message: createDefaultMessage( + id: 'new-message-1', + text: 'Hello world!', + user: User(id: 'user-1'), + ), + channelMessageCount: 1, + ); + + await tester.emitEvent(messageNewEvent); + expect(tester.channel.messageCount, equals(1)); + + // Test with another message.new event - count increases + final messageNewEvent2 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageNew, + message: createDefaultMessage( + id: 'new-message-2', + text: 'Second message', + user: User(id: 'user-2'), + ), + channelMessageCount: 2, + ); + + await tester.emitEvent(messageNewEvent2); + expect(tester.channel.messageCount, equals(2)); + + // Test with message.deleted event - count decreases + final messageDeletedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageDeleted, + message: createDefaultMessage( + id: 'new-message-1', + text: 'Hello world!', + user: User(id: 'user-1'), + ), + channelMessageCount: 1, + ); + + await tester.emitEvent(messageDeletedEvent); + expect(tester.channel.messageCount, equals(1)); + }, + ); + + channelTest( + 'should preserve other channel properties when updating messageCount', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelCountEvents()), + body: (tester) async { + // Set initial channel state with some properties + final initialChannel = tester.channelState?.channelState.channel?.copyWith( + extraData: {'name': 'Test Channel'}, + memberCount: 5, + frozen: true, + ); + + if (initialChannel != null) { + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith(channel: initialChannel), + ); + } + + // Verify initial state + 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 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageNew, + channelMessageCount: 100, + ); + + await tester.emitEvent(messageCountEvent); + + // Verify messageCount was updated while preserving other properties + expect(tester.channel.messageCount, equals(100)); + expect(tester.channel.name, 'Test Channel'); + expect(tester.channel.memberCount, equals(5)); + expect(tester.channel.frozen, equals(true)); + }, + ); + + channelTest( + 'should provide messageCountStream for reactive updates', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelCountEvents()), + body: (tester) async { + final emitted = []; + 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 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.messageNew, + message: createDefaultMessage( + id: 'msg-$index', + text: 'Message $count', + user: User(id: 'user-1'), + ), + channelMessageCount: count, + ); + + await tester.emitEvent(event); + } + + // The repeated count should not be emitted twice. + expect(emitted, equals([null, 1, 5, 10])); + }, + ); + }); + + group('Channel member count events', () { + channelTest( + 'should update channel memberCount when event contains channelMemberCount', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelCountEvents()), + body: (tester) async { + // Verify initial state - default memberCount + expect(tester.channel.memberCount, equals(0)); + + // Create event with channelMemberCount + final memberCountEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberAdded, + member: createDefaultMember(user: User(id: 'user-1')), + channelMemberCount: 42, + ); + + // Dispatch event and wait for it to be processed + await tester.emitEvent(memberCountEvent); + + // Verify channel memberCount was updated + expect(tester.channel.memberCount, equals(42)); + }, + ); + + channelTest( + 'should update channel memberCount from member.added and member.removed events', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelCountEvents()), + body: (tester) async { + // Test with member.added event - count increases + final memberAddedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberAdded, + member: createDefaultMember(user: User(id: 'user-1')), + channelMemberCount: 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 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberAdded, + member: createDefaultMember(user: User(id: 'user-2')), + channelMemberCount: 2, + ); + + await tester.emitEvent(memberAddedEvent2); + expect(tester.channel.memberCount, equals(2)); + expect( + tester.channelState?.channelState.members?.map((it) => it.userId), + equals(['user-1', 'user-2']), + ); + + // Test with member.removed event - count decreases + final memberRemovedEvent = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberRemoved, + user: User(id: 'user-1'), + channelMemberCount: 1, + ); + + await tester.emitEvent(memberRemovedEvent); + expect(tester.channel.memberCount, equals(1)); + expect(tester.channelState?.channelState.members?.map((it) => it.userId), equals(['user-2'])); + }, + ); + + channelTest( + 'should preserve other channel properties when updating memberCount', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelCountEvents()), + body: (tester) async { + // Set initial channel state with some properties + final initialChannel = tester.channelState?.channelState.channel?.copyWith( + extraData: {'name': 'Test Channel'}, + messageCount: 7, + frozen: true, + ); + + if (initialChannel != null) { + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith(channel: initialChannel), + ); + } + + // Verify initial state + 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 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberAdded, + member: createDefaultMember(user: User(id: 'user-1')), + channelMemberCount: 100, + ); + + await tester.emitEvent(memberCountEvent); + + // Verify memberCount was updated while preserving other properties + expect(tester.channel.memberCount, equals(100)); + expect(tester.channel.name, 'Test Channel'); + expect(tester.channel.messageCount, equals(7)); + expect(tester.channel.frozen, equals(true)); + }, + ); + + channelTest( + 'should not update memberCount when the event omits channelMemberCount', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelCountEvents()), + body: (tester) async { + // Seed a known member count. + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberAdded, + member: createDefaultMember(user: User(id: 'user-1')), + channelMemberCount: 5, + ), + ); + + expect(tester.channel.memberCount, equals(5)); + + // An event without the field should leave the count untouched. + await tester.emitEvent( + createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberAdded, + member: createDefaultMember(user: User(id: 'user-2')), + ), + ); + + expect(tester.channel.memberCount, equals(5)); + }, + ); + + channelTest( + 'should provide memberCountStream for reactive updates', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch(modifyResponse: _seedChannelCountEvents()), + body: (tester) async { + final emitted = []; + 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 = createDefaultEvent( + cid: tester.channel.cid, + type: EventType.memberAdded, + member: createDefaultMember(user: User(id: 'user-$index')), + channelMemberCount: count, + ); + + await tester.emitEvent(event); + } + + // The repeated count should not be emitted twice. + expect(emitted, equals([0, 1, 5, 10])); + }, + ); + }); + + 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())); + }, + ); + + 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); + }, + ); + }); + + group('Channel filterTags', () { + ChannelState Function(ChannelState) _seedChannel({ + List? filterTags, + }) { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + filterTags: filterTags, + ), + ); + } + + 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'])); + }, + ); + + 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 channelModel = tester.channelState!.channelState.channel!; + final updatedChannel = channelModel.copyWith( + filterTags: ['tag3', 'tag4', 'tag5'], + ); + + tester.channelState?.updateChannelState( + tester.channelState!.channelState.copyWith(channel: updatedChannel), + ); + + expect(tester.channel.filterTags, equals(['tag3', 'tag4', 'tag5'])); + }, + ); + }); + + group('Typing Indicator', () { + ChannelState Function(ChannelState) _seedChannel({ + List ownCapabilities = const [], + }) { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + ownCapabilities: ownCapabilities, + ), + ); + } + + // 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), + ), + ), + ), + ); + } + + channelTest( + ".keystore 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 typingEvent = Event(type: EventType.typingStart); + + await expectLater(tester.channel.keyStroke(), completes); + + tester.verifyNeverCalled( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingEvent, matchParentId: true)), + ), + ); + }, + ); + + channelTest( + '.keystore should return when user privacy settings is disabled', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.typingEvents], + ), + ), + body: (tester) async { + await _disableTypingIndicators(tester); + + final typingEvent = Event(type: EventType.typingStart); + + await expectLater(tester.channel.keyStroke(), completes); + + tester.verifyNeverCalled( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingEvent, matchParentId: true)), + ), + ); + }, + ); + + channelTest( + ".keystore should send 'typingStart' event if there is not already a typingEvent or the difference between the two is > 3 seconds", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.typingEvents], + ), + ), + body: (tester) async { + final startTypingEvent = Event(type: EventType.typingStart); + final stopTypingEvent = Event(type: EventType.typingStop); + + 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(), + ); + + await expectLater(tester.channel.keyStroke(), completes); + + 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)), + ), + ); + }, + ); + + channelTest( + ".startTyping 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 typingStartEvent = Event(type: EventType.typingStart); + + await expectLater(tester.channel.startTyping(), completes); + + tester.verifyNeverCalled( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingStartEvent, matchParentId: true)), + ), + ); + }, + ); + + channelTest( + '.startTyping should return when user privacy settings is disabled', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.typingEvents], + ), + ), + body: (tester) async { + await _disableTypingIndicators(tester); + + final typingStartEvent = Event(type: EventType.typingStart); + + await expectLater(tester.channel.startTyping(), completes); + + tester.verifyNeverCalled( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingStartEvent, matchParentId: true)), + ), + ); + }, + ); + + channelTest( + ".startTyping should send 'typingStart' successfully", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.typingEvents], + ), + ), + body: (tester) async { + final typingStartEvent = Event(type: EventType.typingStart); + + tester.mockApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingStartEvent, matchParentId: true)), + ), + result: createDefaultEmptyResponse(), + ); + + await expectLater(tester.channel.startTyping(), completes); + + tester.verifyApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingStartEvent, matchParentId: true)), + ), + ); + }, + ); + + 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); + + await expectLater(tester.channel.stopTyping(), completes); + + tester.verifyNeverCalled( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingStopEvent, matchParentId: true)), + ), + ); + }, + ); + + channelTest( + '.stopTyping should return when user privacy settings is disabled', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.typingEvents], + ), + ), + body: (tester) async { + await _disableTypingIndicators(tester); + + final typingStopEvent = Event(type: EventType.typingStop); + + await expectLater(tester.channel.stopTyping(), completes); + + tester.verifyNeverCalled( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingStopEvent, matchParentId: true)), + ), + ); + }, + ); + + channelTest( + ".stopTyping should send 'typingStop' successfully", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.typingEvents], + ), + ), + 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(tester.channel.stopTyping(), completes); + + tester.verifyApi( + (api) => api.channel.sendEvent( + _channelId, + _channelType, + any(that: isSameEventAs(typingStopEvent, matchParentId: true)), + ), + ); + }, + ); + }); + + group('Read Receipts', () { + ChannelState Function(ChannelState) _seedChannel({ + List ownCapabilities = const [], + }) { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + ownCapabilities: ownCapabilities, + ), + ); + } + + channelTest( + ".markRead should throw if we don't have the capability", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + // no readEvents capability + modifyResponse: _seedChannel(ownCapabilities: []), + ), + body: (tester) async { + await expectLater( + tester.channel.markRead(messageId: 'message-id-123'), + throwsA(isA()), + ); + }, + ); + + channelTest( + '.markRead should succeed if we have the capability', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.readEvents], + ), + ), + body: (tester) async { + tester.mockApi( + (api) => api.channel.markRead( + _channelId, + _channelType, + messageId: 'message-id-123', + ), + result: createDefaultEmptyResponse(), + ); + + await expectLater( + tester.channel.markRead(messageId: 'message-id-123'), + completes, + ); + + tester.verifyApi( + (api) => api.channel.markRead( + _channelId, + _channelType, + messageId: 'message-id-123', + ), + ); + }, + ); + + channelTest( + ".markUnread should throw if we don't have the capability", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + // no readEvents capability + modifyResponse: _seedChannel(ownCapabilities: []), + ), + body: (tester) async { + await expectLater( + tester.channel.markUnread('message-id-123'), + throwsA(isA()), + ); + }, + ); + + channelTest( + '.markUnread should succeed if we have the capability', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.readEvents], + ), + ), + body: (tester) async { + tester.mockApi( + (api) => api.channel.markUnread( + _channelId, + _channelType, + 'message-id-123', + ), + result: createDefaultEmptyResponse(), + ); + + await expectLater( + tester.channel.markUnread('message-id-123'), + completes, + ); + + tester.verifyApi( + (api) => api.channel.markUnread( + _channelId, + _channelType, + 'message-id-123', + ), + ); + }, + ); + + channelTest( + ".markUnreadByTimestamp should throw if we don't have the capability", + 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( + tester.channel.markUnreadByTimestamp(timestamp), + throwsA(isA()), + ); + }, + ); + + channelTest( + '.markUnreadByTimestamp should succeed if we have the capability', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.readEvents], + ), + ), + body: (tester) async { + final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); + + tester.mockApi( + (api) => api.channel.markUnreadByTimestamp( + _channelId, + _channelType, + timestamp, + ), + result: createDefaultEmptyResponse(), + ); + + await expectLater( + tester.channel.markUnreadByTimestamp(timestamp), + completes, + ); + + tester.verifyApi( + (api) => api.channel.markUnreadByTimestamp( + _channelId, + _channelType, + timestamp, + ), + ); + }, + ); + + channelTest( + ".markThreadRead should throw if we don't have the capability", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + // no readEvents capability + modifyResponse: _seedChannel(ownCapabilities: []), + ), + body: (tester) async { + await expectLater( + tester.channel.markThreadRead('thread-id-123'), + throwsA(isA()), + ); + }, + ); + + channelTest( + '.markThreadRead should succeed if we have the capability', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.readEvents], + ), + ), + body: (tester) async { + tester.mockApi( + (api) => api.channel.markThreadRead( + _channelId, + _channelType, + 'thread-id-123', + ), + result: createDefaultEmptyResponse(), + ); + + await expectLater( + tester.channel.markThreadRead('thread-id-123'), + completes, + ); + + tester.verifyApi( + (api) => api.channel.markThreadRead( + _channelId, + _channelType, + 'thread-id-123', + ), + ); + }, + ); + + channelTest( + ".markThreadUnread should throw if we don't have the capability", + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + // no readEvents capability + modifyResponse: _seedChannel(ownCapabilities: []), + ), + body: (tester) async { + await expectLater( + tester.channel.markThreadUnread('thread-id-123'), + throwsA(isA()), + ); + }, + ); + + channelTest( + '.markThreadUnread should succeed if we have the capability', + channelType: _channelType, + channelId: _channelId, + setUp: (tester) => tester.watch( + modifyResponse: _seedChannel( + ownCapabilities: [ChannelCapability.readEvents], + ), + ), + body: (tester) async { + tester.mockApi( + (api) => api.channel.markThreadUnread( + _channelId, + _channelType, + 'thread-id-123', + ), + result: createDefaultEmptyResponse(), + ); + + await expectLater( + tester.channel.markThreadUnread('thread-id-123'), + completes, + ); + + tester.verifyApi( + (api) => api.channel.markThreadUnread( + _channelId, + _channelType, + 'thread-id-123', + ), + ); + }, + ); + }); + + group('Retry functionality with parameter preservation', () { + group('retryMessage method', () { + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); + } + + 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, + ), + ); + + tester.mockApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); + + final result = await tester.channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); + + tester.verifyApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + ); + }, + ); + + 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, + ), + ); + + tester.mockApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + ), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); + + final result = await tester.channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); + + tester.verifyApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipPush: true, + ), + ); + }, + ); + + 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, + ), + ); + + tester.mockApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipEnrichUrl: true, + ), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); + + final result = await tester.channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); + + tester.verifyApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + skipEnrichUrl: true, + ), + ); + }, + ); + + 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, + ), + ); + + tester.mockApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + ), + result: createDefaultSendMessageResponse(message: message.copyWith(state: MessageState.sent)), + ); + + final result = await tester.channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); + + tester.verifyApi( + (api) => api.message.sendMessage( + _channelId, + _channelType, + any(that: isSameMessageAs(message)), + ), + ); + }, + ); + + 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, + ), + ); + + tester.mockApi( + (api) => api.message.updateMessage( + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + result: createDefaultUpdateMessageResponse(message: message.copyWith(state: MessageState.updated)), + ); + + final result = await tester.channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); + + tester.verifyApi( + (api) => api.message.updateMessage( + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + ); + }, + ); + + 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, + ), + ); + + tester.mockApi( + (api) => api.message.updateMessage( + any(that: isSameMessageAs(message)), + ), + result: createDefaultUpdateMessageResponse(message: message.copyWith(state: MessageState.updated)), + ); + + final result = await tester.channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); + + tester.verifyApi( + (api) => api.message.updateMessage( + any(that: isSameMessageAs(message)), + ), + ); + }, + ); + + 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, + ); + + tester.mockApi( + (api) => api.message.deleteMessage(message.id, hard: true), + result: createDefaultEmptyResponse(), + ); + + final result = await tester.channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); + + tester.verifyApi( + (api) => api.message.deleteMessage(message.id, hard: true), + ); + }, + ); + + 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, + ); + + tester.mockApi( + (api) => api.message.deleteMessage(message.id, hard: false), + result: createDefaultEmptyResponse(), + ); + + final result = await tester.channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); + + tester.verifyApi( + (api) => api.message.deleteMessage(message.id, hard: false), + ); + }, + ); + + 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, + ); + + tester.mockApi( + (api) => api.message.deleteMessage(message.id, deleteForMe: true), + result: createDefaultEmptyResponse(), + ); + + final result = await tester.channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); + + tester.verifyApi( + (api) => api.message.deleteMessage(message.id, deleteForMe: true), + ); + }, + ); + + 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(() => tester.channel.retryMessage(message), throwsA(isA())); + }, + ); + }); + }); +} diff --git a/packages/stream_chat/test/src/client/channel/channel_typing_indicator_test.dart b/packages/stream_chat/test/src/client/channel/channel_typing_indicator_test.dart deleted file mode 100644 index 455f876700..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_typing_indicator_test.dart +++ /dev/null @@ -1,300 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel({ - List ownCapabilities = const [], -}) { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - ownCapabilities: ownCapabilities, - ), - ); -} - -// 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), - ), - ), - ), - ); -} - -void main() { - group('Typing Indicator', () { - channelTest( - ".keystore 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 typingEvent = Event(type: EventType.typingStart); - - await expectLater(tester.channel.keyStroke(), completes); - - tester.verifyNeverCalled( - (api) => api.channel.sendEvent( - _channelId, - _channelType, - any(that: isSameEventAs(typingEvent, matchParentId: true)), - ), - ); - }, - ); - - channelTest( - '.keystore should return when user privacy settings is disabled', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch( - modifyResponse: _seedChannel( - ownCapabilities: [ChannelCapability.typingEvents], - ), - ), - body: (tester) async { - await _disableTypingIndicators(tester); - - final typingEvent = Event(type: EventType.typingStart); - - await expectLater(tester.channel.keyStroke(), completes); - - tester.verifyNeverCalled( - (api) => api.channel.sendEvent( - _channelId, - _channelType, - any(that: isSameEventAs(typingEvent, matchParentId: true)), - ), - ); - }, - ); - - channelTest( - ".keystore should send 'typingStart' event if there is not already a typingEvent or the difference between " - 'the two is > 3 seconds', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch( - modifyResponse: _seedChannel( - ownCapabilities: [ChannelCapability.typingEvents], - ), - ), - body: (tester) async { - final startTypingEvent = Event(type: EventType.typingStart); - final stopTypingEvent = Event(type: EventType.typingStop); - - 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(), - ); - - await expectLater(tester.channel.keyStroke(), completes); - - 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)), - ), - ); - }, - ); - - channelTest( - ".startTyping 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 typingStartEvent = Event(type: EventType.typingStart); - - await expectLater(tester.channel.startTyping(), completes); - - tester.verifyNeverCalled( - (api) => api.channel.sendEvent( - _channelId, - _channelType, - any(that: isSameEventAs(typingStartEvent, matchParentId: true)), - ), - ); - }, - ); - - channelTest( - '.startTyping should return when user privacy settings is disabled', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch( - modifyResponse: _seedChannel( - ownCapabilities: [ChannelCapability.typingEvents], - ), - ), - body: (tester) async { - await _disableTypingIndicators(tester); - - final typingStartEvent = Event(type: EventType.typingStart); - - await expectLater(tester.channel.startTyping(), completes); - - tester.verifyNeverCalled( - (api) => api.channel.sendEvent( - _channelId, - _channelType, - any(that: isSameEventAs(typingStartEvent, matchParentId: true)), - ), - ); - }, - ); - - channelTest( - ".startTyping should send 'typingStart' successfully", - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch( - modifyResponse: _seedChannel( - ownCapabilities: [ChannelCapability.typingEvents], - ), - ), - body: (tester) async { - final typingStartEvent = Event(type: EventType.typingStart); - - tester.mockApi( - (api) => api.channel.sendEvent( - _channelId, - _channelType, - any(that: isSameEventAs(typingStartEvent, matchParentId: true)), - ), - result: createDefaultEmptyResponse(), - ); - - await expectLater(tester.channel.startTyping(), completes); - - tester.verifyApi( - (api) => api.channel.sendEvent( - _channelId, - _channelType, - any(that: isSameEventAs(typingStartEvent, matchParentId: true)), - ), - ); - }, - ); - - 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); - - await expectLater(tester.channel.stopTyping(), completes); - - tester.verifyNeverCalled( - (api) => api.channel.sendEvent( - _channelId, - _channelType, - any(that: isSameEventAs(typingStopEvent, matchParentId: true)), - ), - ); - }, - ); - - channelTest( - '.stopTyping should return when user privacy settings is disabled', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch( - modifyResponse: _seedChannel( - ownCapabilities: [ChannelCapability.typingEvents], - ), - ), - body: (tester) async { - await _disableTypingIndicators(tester); - - final typingStopEvent = Event(type: EventType.typingStop); - - await expectLater(tester.channel.stopTyping(), completes); - - tester.verifyNeverCalled( - (api) => api.channel.sendEvent( - _channelId, - _channelType, - any(that: isSameEventAs(typingStopEvent, matchParentId: true)), - ), - ); - }, - ); - - channelTest( - ".stopTyping should send 'typingStop' successfully", - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch( - modifyResponse: _seedChannel( - ownCapabilities: [ChannelCapability.typingEvents], - ), - ), - 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(tester.channel.stopTyping(), completes); - - tester.verifyApi( - (api) => api.channel.sendEvent( - _channelId, - _channelType, - any(that: isSameEventAs(typingStopEvent, matchParentId: true)), - ), - ); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_update_api_test.dart b/packages/stream_chat/test/src/client/channel/channel_update_api_test.dart deleted file mode 100644 index ec93be83f6..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_update_api_test.dart +++ /dev/null @@ -1,228 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -Future _seedChannel(ChannelTester tester) => tester.watch( - modifyResponse: (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: const [ChannelCapability.readEvents], - ), - ), -); - -void main() { - channelTest( - '`.update`', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - body: (tester) async { - const channelData = { - 'name': 'Stream Team', - 'profile_image': 'test-profile-image', - }; - final updateMessage = Message( - id: 'test-message-id', - text: 'updated channel', - ); - - final channelModel = createDefaultChannelModel( - cid: _channelCid, - extraData: channelData, - ); - - tester.mockApi( - (api) => api.channel.updateChannel(_channelId, _channelType, channelData, message: updateMessage), - result: UpdateChannelResponse() - ..channel = channelModel - ..message = updateMessage, - ); - - final res = await tester.channel.update( - channelData, - updateMessage: updateMessage, - ); - - expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect(res.channel.extraData, channelData); - expect(res.message?.id, updateMessage.id); - - tester.verifyApi( - (api) => api.channel.updateChannel(_channelId, _channelType, channelData, message: updateMessage), - ); - }, - ); - - channelTest( - '`.updateImage`', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - body: (tester) async { - const image = 'https://getstream.io/new-image'; - - final channelModel = createDefaultChannelModel( - cid: _channelCid, - extraData: {'image': image}, - ); - - tester.mockApi( - (api) => api.channel.updateChannelPartial( - _channelId, - _channelType, - set: {'image': image}, - ), - result: createDefaultPartialUpdateChannelResponse(channel: channelModel), - ); - - final res = await tester.channel.updateImage(image); - - expect(res, isNotNull); - expect(res.channel.extraData['image'], image); - - tester.verifyApi( - (api) => api.channel.updateChannelPartial( - _channelId, - _channelType, - set: {'image': image}, - ), - ); - }, - ); - - channelTest( - '`.updateName`', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - body: (tester) async { - const name = 'Name'; - - final channelModel = createDefaultChannelModel( - cid: _channelCid, - extraData: {'name': name}, - ); - - tester.mockApi( - (api) => api.channel.updateChannelPartial( - _channelId, - _channelType, - set: {'name': name}, - ), - result: createDefaultPartialUpdateChannelResponse(channel: channelModel), - ); - - final res = await tester.channel.updateName(name); - - expect(res, isNotNull); - expect(res.channel.extraData['name'], name); - - tester.verifyApi( - (api) => api.channel.updateChannelPartial( - _channelId, - _channelType, - set: {'name': name}, - ), - ); - }, - ); - - channelTest( - '`.updatePartial`', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - 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, - }, - ); - - tester.mockApi( - (api) => api.channel.updateChannelPartial( - _channelId, - _channelType, - set: set, - unset: unset, - ), - result: createDefaultPartialUpdateChannelResponse(channel: channelModel), - ); - - final res = await tester.channel.updatePartial(set: set, unset: unset); - - 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, - ), - ); - }, - ); - - channelTest( - '`.delete`', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - body: (tester) async { - tester.mockApi( - (api) => api.channel.deleteChannel(_channelId, _channelType), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.channel.delete(); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.channel.deleteChannel(_channelId, _channelType), - ); - }, - ); - - channelTest( - '`.truncate`', - channelType: _channelType, - channelId: _channelId, - setUp: _seedChannel, - body: (tester) async { - tester.mockApi( - (api) => api.channel.truncateChannel(_channelId, _channelType), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.channel.truncate(); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.channel.truncateChannel(_channelId, _channelType), - ); - }, - ); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_update_message_test.dart b/packages/stream_chat/test/src/client/channel/channel_update_message_test.dart deleted file mode 100644 index c162dc2019..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_update_message_test.dart +++ /dev/null @@ -1,1145 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: [ChannelCapability.readEvents], - ), - ); -} - -void main() { - group('`.updateMessage`', () { - channelTest( - 'should work fine', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final message = Message( - id: 'test-message-id', - state: MessageState.sent, - ); - - tester.mockApi( - (api) => api.message.updateMessage(any(that: isSameMessageAs(message))), - 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), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith(state: MessageState.updated), - matchMessageState: true, - ), - ], - ]), - ); - - final res = await tester.channel.updateMessage(message); - - expect(res, isNotNull); - expect(res.message.id, message.id); - - await messagesEmits; - - tester.verifyApi( - (api) => api.message.updateMessage(any(that: isSameMessageAs(message))), - ); - }, - ); - - 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'), - ), - ); - - final message = Message( - id: 'test-message-id', - attachments: attachments, - ); - - 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), - ), - ), - ); - - 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, - ), - ], - ], - ), - ); - - 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, - ); - - 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 error is not StreamChatNetworkError', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - 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), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith( - state: MessageState.updatingFailed( - skipPush: false, - skipEnrichUrl: true, - ), - ), - matchMessageState: true, - ), - ], - ]), - ); - - try { - await tester.channel.updateMessage(message, skipEnrichUrl: true); - } catch (e) { - expect(e, isA()); - } - - await messagesEmits; - }, - ); - - channelTest( - 'should add message to retry queue when retriable StreamChatNetworkError occurs with skipPush: false, skipEnrichUrl: true', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - 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(errorCode: ChatErrorCode.requestTimeout, retriable: true), - 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), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith( - state: MessageState.updatingFailed( - skipPush: false, - skipEnrichUrl: 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 tester.channel.updateMessage(message, skipEnrichUrl: true); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.requestTimeout.code)); - 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)), - skipEnrichUrl: true, - ), - times: 2, - ); - }, - ); - - channelTest( - 'should add message to retry queue when retriable StreamChatNetworkError occurs with skipPush: true, skipEnrichUrl: false', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final message = Message( - id: 'test-message-id-retry-2', - 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)), - skipPush: true, - ), - error: createDefaultNetworkError(errorCode: ChatErrorCode.internalSystemError, retriable: true), - 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), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith( - state: MessageState.updatingFailed( - skipPush: true, - skipEnrichUrl: false, - ), - ), - 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 tester.channel.updateMessage(message, skipPush: true); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.internalSystemError.code)); - 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 non-retriable StreamChatNetworkError with skipPush: true, skipEnrichUrl: true', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final message = Message( - id: 'test-message-id-error-2', - state: MessageState.sent, - ); - - tester.mockApiFailure( - (api) => api.message.updateMessage( - any(that: isSameMessageAs(message)), - skipPush: true, - skipEnrichUrl: true, - ), - error: createDefaultNetworkError(errorCode: ChatErrorCode.notAllowed), - ); - - 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: true, - ), - ), - matchMessageState: true, - ), - ], - ]), - ); - - try { - await tester.channel.updateMessage( - message, - skipPush: true, - skipEnrichUrl: true, - ); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); - } - - await messagesEmits; - }, - ); - - channelTest( - 'should handle non-retriable StreamChatNetworkError with skipPush: false, skipEnrichUrl: false', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final message = Message( - id: 'test-message-id-error-3', - state: MessageState.sent, - ); - - tester.mockApiFailure( - (api) => api.message.updateMessage( - any(that: isSameMessageAs(message)), - ), - error: createDefaultNetworkError(errorCode: ChatErrorCode.notAllowed), - ); - - 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: false, - skipEnrichUrl: false, - ), - ), - matchMessageState: true, - ), - ], - ]), - ); - - try { - await tester.channel.updateMessage(message); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); - } - - await messagesEmits; - }, - ); - }); - - group('`ChannelClientState.updateMessage`', () { - channelTest( - 'upsert: true (default) adds an unknown message', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final message = Message( - id: 'unknown-message', - user: tester.currentUser, - text: 'hello', - createdAt: DateTime.utc(2026), - ); - - expect(tester.channelState!.messages, isEmpty); - - tester.channelState!.updateMessage(message); - - 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: _seedChannel()), - body: (tester) async { - final message = Message( - id: 'unknown-message', - user: tester.currentUser, - text: 'hello', - createdAt: DateTime.utc(2026), - ); - - expect(tester.channelState!.messages, isEmpty); - - 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: _seedChannel()), - 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: _seedChannel()), - body: (tester) async { - final message = Message( - id: 'test-message-id', - state: MessageState.sent, - ); - - const set = {'text': 'Update Message text'}; - const unset = ['pinExpires']; - - final updateMessageResponse = createDefaultUpdateMessageResponse( - message: message.copyWith(text: set['text'], pinExpires: null), - ); - - tester.mockApi( - (api) => api.message.partialUpdateMessage(message.id, set: set, unset: unset), - result: updateMessageResponse, - ); - - 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( - updateMessageResponse.message.copyWith( - state: MessageState.updated, - ), - matchText: true, - matchMessageState: true, - ), - ], - ]), - ); - - final res = await tester.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); - - await messagesEmits; - - tester.verifyApi( - (api) => api.message.partialUpdateMessage(message.id, set: set, unset: unset), - ); - }, - ); - - group('`.partialUpdateMessage` error handling', () { - channelTest( - 'should update message state even when error is not StreamChatNetworkError', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final message = Message( - id: 'test-message-id-error-partial-1', - state: MessageState.sent, - ); - - // Add message to channel state first - tester.channelState?.updateMessage(message); - - const set = {'text': 'Update Message text'}; - const unset = ['pinExpires']; - - tester.mockApiFailure( - (api) => api.message.partialUpdateMessage( - message.id, - set: set, - unset: unset, - ), - 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, - ), - matchText: true, - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith( - state: MessageState.partialUpdatingFailed( - set: set, - unset: unset, - skipEnrichUrl: false, - ), - ), - matchText: true, - matchMessageState: true, - ), - ], - ]), - ); - - try { - await tester.channel.partialUpdateMessage( - message, - set: set, - unset: unset, - ); - } catch (e) { - expect(e, isA()); - } - - await messagesEmits; - }, - ); - - channelTest( - 'should add message to retry queue when retriable StreamChatNetworkError occurs with skipEnrichUrl: true', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final message = Message( - id: 'test-message-id-retry-partial-1', - state: MessageState.sent, - ); - - // Add message to channel state first - tester.channelState?.updateMessage(message); - - const set = {'text': 'Update Message text'}; - const unset = ['pinExpires']; - - // 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, - ), - error: createDefaultNetworkError(errorCode: ChatErrorCode.requestTimeout, retriable: true), - 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, - ), - matchText: true, - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith( - state: MessageState.partialUpdatingFailed( - set: set, - unset: unset, - skipEnrichUrl: true, - ), - ), - matchText: true, - 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 tester.channel.partialUpdateMessage( - message, - set: set, - unset: unset, - skipEnrichUrl: true, - ); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.requestTimeout.code)); - 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, - ); - }, - ); - - channelTest( - 'should add message to retry queue when retriable StreamChatNetworkError occurs with skipEnrichUrl: false', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final message = Message( - id: 'test-message-id-retry-partial-2', - state: MessageState.sent, - ); - - // Add message to channel state first - tester.channelState?.updateMessage(message); - - const set = {'text': 'Update Message text'}; - const unset = ['pinExpires']; - - // 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, - ), - error: createDefaultNetworkError(errorCode: ChatErrorCode.internalSystemError, retriable: true), - 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, - ), - matchText: true, - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith( - state: MessageState.partialUpdatingFailed( - set: set, - unset: unset, - skipEnrichUrl: false, - ), - ), - matchText: true, - 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 tester.channel.partialUpdateMessage( - message, - set: set, - unset: unset, - ); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.internalSystemError.code)); - 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, - ); - }, - ); - - channelTest( - 'should handle non-retriable StreamChatNetworkError with skipEnrichUrl: true', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final message = Message( - id: 'test-message-id-error-partial-2', - state: MessageState.sent, - ); - - // Add message to channel state first - tester.channelState?.updateMessage(message); - - const set = {'text': 'Update Message text'}; - const unset = ['pinExpires']; - - tester.mockApiFailure( - (api) => api.message.partialUpdateMessage( - message.id, - set: set, - unset: unset, - skipEnrichUrl: true, - ), - error: createDefaultNetworkError(errorCode: ChatErrorCode.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: true, - ), - ), - matchText: true, - matchMessageState: true, - ), - ], - ]), - ); - - try { - await tester.channel.partialUpdateMessage( - message, - set: set, - unset: unset, - skipEnrichUrl: true, - ); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); - } - - await messagesEmits; - }, - ); - - channelTest( - 'should handle non-retriable StreamChatNetworkError with skipEnrichUrl: false', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final message = Message( - id: 'test-message-id-error-partial-3', - state: MessageState.sent, - ); - - // Add message to channel state first - tester.channelState?.updateMessage(message); - - const set = {'text': 'Update Message text'}; - const unset = ['pinExpires']; - - tester.mockApiFailure( - (api) => api.message.partialUpdateMessage( - message.id, - set: set, - unset: unset, - ), - error: createDefaultNetworkError(errorCode: ChatErrorCode.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, - ), - ], - ]), - ); - - try { - await tester.channel.partialUpdateMessage( - message, - set: set, - unset: unset, - ); - } catch (e) { - expect(e, isA()); - - final networkError = e as StreamChatNetworkError; - expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); - } - - await messagesEmits; - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/channel_watch_test.dart b/packages/stream_chat/test/src/client/channel/channel_watch_test.dart deleted file mode 100644 index aa009c36f0..0000000000 --- a/packages/stream_chat/test/src/client/channel/channel_watch_test.dart +++ /dev/null @@ -1,177 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: [ChannelCapability.readEvents], - ), - ); -} - -// 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], - ), - ), - ); -} - -void main() { - group('`.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), - ), - ); - - final res = await tester.channel.watch(); - - 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, - ), - ); - }, - ); - - 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(errorCode: ChatErrorCode.inputError), - result: createDefaultChannelState( - channel: createDefaultChannelModel(cid: _channelCid), - ), - ); - - // 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)); - }, - ); - - 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(errorCode: ChatErrorCode.inputError), - ); - - 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, - ), - ); - }, - ); - }); - - channelTest( - '`.stopWatching`', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - tester.mockApi( - (api) => api.channel.stopWatching(_channelId, _channelType), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.channel.stopWatching(); - - expect(res, isNotNull); - - tester.verifyApi( - (api) => api.channel.stopWatching(_channelId, _channelType), - ); - }, - ); -} diff --git a/packages/stream_chat/test/src/client/channel/state/channel_push_preference_events_test.dart b/packages/stream_chat/test/src/client/channel/state/channel_push_preference_events_test.dart deleted file mode 100644 index 6d28256ec8..0000000000 --- a/packages/stream_chat/test/src/client/channel/state/channel_push_preference_events_test.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: _channelCid), -); - -void main() { - group('Channel push preference events', () { - 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); - - // Create channel push preference - final channelPushPreference = ChannelPushPreference( - chatLevel: ChatLevel.mentions, - disabledUntil: DateTime.utc(2021, 3), - ); - - // Dispatch channel.push_preference.updated event - await tester.emitEvent( - createDefaultEvent( - type: EventType.channelPushPreferenceUpdated, - cid: tester.channel.cid, - channelPushPreference: channelPushPreference, - ), - ); - - // 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, - ); - }, - ); - - 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, - ); - - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith( - pushPreferences: initialPushPreference, - ), - ); - - // 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 = tester.channelState?.channelState.pushPreferences; - expect(updatedPreferences?.chatLevel, ChatLevel.none); - expect( - updatedPreferences?.disabledUntil, - updatedPushPreference.disabledUntil, - ); - }, - ); - - 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(tester.channelState?.channelState.pushPreferences, isNull); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/state/channel_truncated_events_test.dart b/packages/stream_chat/test/src/client/channel/state/channel_truncated_events_test.dart deleted file mode 100644 index 41ccd60a70..0000000000 --- a/packages/stream_chat/test/src/client/channel/state/channel_truncated_events_test.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -final _seededCreatedAt = DateTime.utc(2021, 3); - -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(ChannelTester tester, String id, {DateTime? createdAt}) { - final message = Message( - id: id, - user: User(id: 'other-user'), - text: 'to be truncated', - createdAt: createdAt ?? _seededCreatedAt, - ); - tester.channelState!.updateMessage(message); - return message; -} - -void main() { - group('Channel truncated events', () { - final truncatedPersistence = _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), - ), - ); - - expect(tester.channelState!.messages, isEmpty); - verify(() => truncatedPersistence.deleteMessageByCid(tester.channel.cid!)).called(1); - }, - ); - - final notifiedPersistence = _createPersistenceClient(); - 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(tester.channelState!.messages.map((m) => m.id), ['system-message-id']); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/state/channel_updated_events_test.dart b/packages/stream_chat/test/src/client/channel/state/channel_updated_events_test.dart deleted file mode 100644 index 4aa434cae1..0000000000 --- a/packages/stream_chat/test/src/client/channel/state/channel_updated_events_test.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: _channelCid), -); - -void main() { - group('Channel updated events', () { - 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'}, - ), - ), - ); - - expect(tester.channel.memberCount, 42); - expect(tester.channel.extraData['name'], 'updated-name'); - }, - ); - - 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'), - ], - ), - ); - - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.channelUpdated, - channel: createDefaultChannelModel( - cid: _channelCid, - members: [Member(userId: 'member-3')], - ), - ), - ); - - expect(tester.channelState!.channelState.members?.map((m) => m.userId), ['member-3']); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/state/draft_events_test.dart b/packages/stream_chat/test/src/client/channel/state/draft_events_test.dart deleted file mode 100644 index f90f680ad1..0000000000 --- a/packages/stream_chat/test/src/client/channel/state/draft_events_test.dart +++ /dev/null @@ -1,272 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -final _draftCreatedAt = DateTime.utc(2021, 3); - -ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: _channelCid), -); - -void main() { - group('Draft events', () { - 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 - final draft = Draft( - channelCid: tester.channel.cid!, - createdAt: _draftCreatedAt, - message: DraftMessage(text: 'test message'), - ); - - // 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(tester.channelState?.draft, isNotNull); - expect(tester.channelState?.draft?.message.text, 'test message'); - }, - ); - - 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'; - - // Setup initial state with a regular message - tester.channelState?.updateMessage( - Message( - id: threadParentMessageId, - user: tester.currentUser, - ), - ); - - // Verify initial state - expect(tester.channelState?.threadDraft(threadParentMessageId), isNull); - - // Create thread Draft - final draft = Draft( - channelCid: tester.channel.cid!, - createdAt: _draftCreatedAt, - parentId: threadParentMessageId, - message: DraftMessage(text: 'thread reply'), - ); - - // Create and dispatch draft.updated event - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.draftUpdated, - draft: draft, - ), - ); - - // Verify thread draft was updated - final threadDraft = tester.channelState?.threadDraft(threadParentMessageId); - expect(threadDraft, isNotNull); - expect(threadDraft?.message.text, 'thread reply'); - }, - ); - - 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'), - ), - ), - ); - - // 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 channel draft was updated - expect(tester.channelState?.draft, isNull); - }, - ); - - 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'; - - // 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'), - ), - ), - ); - - // 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(tester.channelState?.threadDraft(threadParentMessageId), isNull); - }, - ); - - channelTest( - 'should update current channel draft if draft.updated event is emitted', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel), - body: (tester) async { - // Setup initial state with a draft - final initialDraft = Draft( - channelCid: tester.channel.cid!, - createdAt: _draftCreatedAt, - message: DraftMessage(text: 'test message'), - ); - - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith( - draft: initialDraft, - ), - ); - - // Verify initial state - 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 and dispatch draft.updated event - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.draftUpdated, - draft: updatedDraft, - ), - ); - - // Verify channel draft was updated - expect(tester.channelState?.draft, isNotNull); - expect(tester.channelState?.draft?.message.text, 'updated message'); - }, - ); - - channelTest( - 'should update current thread draft if draft.updated event is emitted', - 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: tester.channel.cid!, - createdAt: _draftCreatedAt, - parentId: threadParentMessageId, - message: DraftMessage(text: 'thread reply'), - ); - - tester.channelState?.updateMessage( - Message( - id: threadParentMessageId, - user: tester.currentUser, - draft: initialDraft, - ), - ); - - // Verify initial state - final draft = tester.channelState?.threadDraft(threadParentMessageId); - expect(draft, isNotNull); - expect(draft?.message.text, 'thread reply'); - - // Create Draft - final updatedDraft = initialDraft.copyWith( - message: DraftMessage(text: 'updated thread reply'), - ); - - // Create and dispatch draft.updated event - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.draftUpdated, - draft: updatedDraft, - ), - ); - - // Verify thread draft was updated - final threadDraft = tester.channelState?.threadDraft(threadParentMessageId); - expect(threadDraft, isNotNull); - expect(threadDraft?.message.text, 'updated thread reply'); - }, - ); - - 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(tester.channelState?.draft, isNull); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/state/local_unread_count_test.dart b/packages/stream_chat/test/src/client/channel/state/local_unread_count_test.dart deleted file mode 100644 index 3e9a7df73c..0000000000 --- a/packages/stream_chat/test/src/client/channel/state/local_unread_count_test.dart +++ /dev/null @@ -1,508 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -/// 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. -}) { - return createDefaultChannelState( - channel: createDefaultChannelModel( - cid: cid, - config: createDefaultChannelConfig(readEvents: false), - ownCapabilities: ownCapabilities, - ), - messages: messages, - read: read, - ); -} - -void main() { - group('Local unread count', () { - // 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), - ); - - channelTest( - 'increments unreadCount locally for new messages when the channel has ' - 'no read events capability', - 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.utc(2024), - ); - - await tester.emitEvent( - createDefaultEvent(cid: tester.channel.cid, type: EventType.messageNew, message: message), - ); - - expect(tester.channelState?.unreadCount, equals(1)); - }, - ); - - channelTest( - 'does not increment unreadCount when local unread count tracking is ' - 'disabled', - 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.utc(2024), - ); - - await tester.emitEvent( - createDefaultEvent(cid: tester.channel.cid, type: EventType.messageNew, message: message), - ); - - expect(tester.channelState?.unreadCount, equals(0)); - }, - ); - - 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, - ), - ); - - expect(tester.channelState?.unreadCount, equals(0)); - }, - ); - - channelTest( - 'does not decrement unreadCount below zero 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 { - // The count is already exhausted, so the decrement has nothing left - // to take. - expect(tester.channelState?.unreadCount, equals(0)); - - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.messageDeleted, - message: countedMessage, - hardDelete: true, - ), - ); - - expect(tester.channelState?.unreadCount, equals(0)); - }, - ); - - channelTest( - 'does not decrement unreadCount on hard delete when local unread count ' - 'tracking is disabled', - channelType: _channelType, - channelId: _channelId, - // Local unread count tracking stays at its default, disabled. - isLocalUnreadCountEnabled: false, - setUp: (tester) => tester.watch( - modifyResponse: (_) => _livestreamChannelState( - messages: [countedMessage], - read: [ - createDefaultRead(lastRead: countedMessage.createdAt.subtract(const Duration(days: 1))), - ], - ), - ), - body: (tester) async { - // A server-driven count the client must leave alone. - tester.channelState!.unreadCount = 1; - - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.messageDeleted, - message: countedMessage, - hardDelete: true, - ), - ); - - expect(tester.channelState?.unreadCount, equals(1)); - }, - ); - - 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; - - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.messageDeleted, - message: countedMessage, - hardDelete: false, - ), - ); - - expect(tester.channelState?.unreadCount, equals(1)); - }, - ); - - channelTest( - 'markRead resets unreadCount locally without making a network request', - 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')), - ); - }, - ); - - // 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( - 'markUnreadByTimestamp recomputes unreadCount locally without making a ' - 'network request', - 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( - tester.channel.markUnreadByTimestamp(now.add(const Duration(seconds: 30))), - completes, - ); - - // Only m2 and m3 were created after the given timestamp. - expect(tester.channelState?.unreadCount, equals(2)); - registerFallbackValue(DateTime.utc(2024)); - tester.verifyNeverCalled((api) => api.channel.markUnreadByTimestamp(any(), any(), any())); - }, - ); - - channelTest( - 'markUnread throws when the message is not locally known', - channelType: _channelType, - channelId: _channelId, - isLocalUnreadCountEnabled: true, - setUp: (tester) => tester.watch(modifyResponse: (_) => _livestreamChannelState()), - body: (tester) async { - await expectLater( - tester.channel.markUnread('unknown-message-id'), - throwsA(isA()), - ); - tester.verifyNeverCalled((api) => api.channel.markUnread(any(), any(), any())); - }, - ); - - channelTest( - 'markRead reconciles pending delivery receipts', - 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(tester.channel.markRead(), completes); - - // 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.utc(2024); - final messages = [ - Message( - id: 'm1', - text: '1', - user: User(id: 'other-user'), - createdAt: start, - ), - Message( - id: 'm2', - text: '2', - user: User(id: 'other-user'), - createdAt: start.add(const Duration(minutes: 1)), - ), - Message( - id: 'm3', - text: '3', - user: User(id: 'other-user'), - createdAt: start.add(const Duration(minutes: 2)), - ), - ]; - - 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', - 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(tester.channelState?.unreadCount, equals(2)); - expect(tester.channelState?.currentUserRead?.lastReadMessageId, equals('m1')); - tester.verifyNeverCalled((api) => api.channel.markUnread(any(), any(), any())); - }, - ); - - channelTest( - 'markUnread leaves lastReadMessageId null when the anchor is the oldest ' - 'known message', - 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); - }, - ); - - channelTest( - 'markUnreadByTimestamp is exclusive of the boundary and points ' - 'lastReadMessageId at the newest message at or before it', - 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(tester.channel.markUnreadByTimestamp(messages[1].createdAt), completes); - - 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())); - }, - ); - - channelTest( - 'markUnread(id) and markUnreadByTimestamp(createdAt) intentionally ' - 'differ by the anchor message', - 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); - - // `markUnread` includes m2, `markUnreadByTimestamp` excludes it. - expect(byId.state?.unreadCount, equals(2)); - expect(byTimestamp.state?.unreadCount, equals(1)); - - // ...and they agree once the timestamp is nudged below the anchor. - await byTimestamp.markUnreadByTimestamp( - messages[1].createdAt.subtract(const Duration(microseconds: 1)), - ); - expect(byTimestamp.state?.unreadCount, equals(2)); - expect(byTimestamp.state?.currentUserRead?.lastReadMessageId, equals('m1')); - }, - ); - }); - - channelTest( - 'server payloads do not clobber the locally-tracked read state', - channelType: _channelType, - channelId: _channelId, - isLocalUnreadCountEnabled: true, - setUp: (tester) => tester.watch(modifyResponse: (_) => _livestreamChannelState()), - body: (tester) async { - tester.channelState!.unreadCount = 5; - - final serverRead = Read( - user: tester.currentUser!, - lastRead: DateTime.utc(2024, 1, 2), - unreadMessages: 0, - ); - tester.channelState!.updateChannelStateFromServer( - tester.channelState!.channelState.copyWith(read: [serverRead]), - ); - - expect(tester.channelState?.unreadCount, equals(5)); - }, - ); - - channelTest( - 'local (non-remote) state updates are not affected by the server-merge ' - 'guard', - 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(tester.channel.markRead(), completes); - - expect(tester.channelState?.unreadCount, equals(0)); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/state/location_events_test.dart b/packages/stream_chat/test/src/client/channel/state/location_events_test.dart deleted file mode 100644 index 995a8e4c7b..0000000000 --- a/packages/stream_chat/test/src/client/channel/state/location_events_test.dart +++ /dev/null @@ -1,687 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: _channelCid), -); - -void main() { - group('Location events', () { - 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); - - // 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)), - ); - - 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, - ), - ); - - // Check if message was added - final messages = tester.channelState?.messages; - final message = messages?.firstWhere((m) => m.id == 'msg1'); - expect(message, isNotNull); - - // Check if active live location was updated - final activeLiveLocations = tester.channelState?.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations?.first.messageId, equals('msg1')); - }, - ); - - 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)), - ); - - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - - // Add initial message - tester.channelState?.addNewMessage(locationMessage); - - // Create updated location - final updatedLocation = liveLocation.copyWith( - latitude: 40.7500, // Updated latitude - longitude: -74.1000, // Updated longitude - ); - - final updatedMessage = locationMessage.copyWith( - sharedLocation: updatedLocation, - ); - - // Dispatch location.updated event - await tester.emitEvent( - createDefaultEvent( - cid: _channelCid, - type: EventType.locationUpdated, - message: updatedMessage, - ), - ); - - // 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)); - - // 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)); - }, - ); - - 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)), - ); - - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - - // Add initial message - tester.channelState?.addNewMessage(locationMessage); - expect(tester.channelState?.activeLiveLocations, hasLength(1)); - - // Create expired location - final expiredLocation = liveLocation.copyWith( - endAt: DateTime.timestamp().subtract(const Duration(hours: 1)), - ); - - final expiredMessage = locationMessage.copyWith( - sharedLocation: expiredLocation, - ); - - // Dispatch location.expired event - await tester.emitEvent( - createDefaultEvent( - cid: _channelCid, - type: EventType.locationExpired, - message: expiredMessage, - ), - ); - - // Check if message was updated - final messages = tester.channelState?.messages; - final message = messages?.firstWhere((m) => m.id == 'msg1'); - expect(message?.sharedLocation?.isExpired, isTrue); - - // Check if active live location was removed - expect(tester.channelState?.activeLiveLocations, isEmpty); - }, - ); - - 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)), - ); - - // 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); - - tester.channelState?.addNewMessage( - Message(id: 'msg1', sharedLocation: liveLocation), - ); - expect(tester.channelState?.activeLiveLocations, hasLength(1)); - - // Before endAt no expiry event is emitted. - await Future.delayed(const Duration(milliseconds: 200)); - expect(captured, isEmpty); - - // 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'); - - await subscription.cancel(); - }, - ); - - 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)), - ); - - final captured = []; - final subscription = tester.client.on().listen(captured.add); - - tester.channelState?.addNewMessage( - Message(id: 'msg-own', sharedLocation: ownLocation), - ); - expect(tester.channelState?.activeLiveLocations, hasLength(1)); - - // 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); - - await subscription.cancel(); - }, - ); - - 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)), - ); - - 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. - tester.channelState?.updateChannelState( - ChannelState(messages: const [], activeLiveLocations: [expiredLocation]), - ); - expect(tester.channelState?.activeLiveLocations, hasLength(1)); - - // 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'); - - await subscription.cancel(); - }, - ); - - 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 - ); - - final staticMessage = Message( - id: 'msg1', - text: 'Static location shared', - sharedLocation: staticLocation, - ); - - // Dispatch location.shared event - await tester.emitEvent( - createDefaultEvent( - cid: _channelCid, - type: EventType.locationShared, - message: staticMessage, - ), - ); - - // 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(tester.channelState?.activeLiveLocations, isEmpty); - }, - ); - - channelTest( - 'should update active locations when location message is deleted', - 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)), - ); - - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - - // Verify initial state - tester.channelState?.addNewMessage(locationMessage); - expect(tester.channelState?.activeLiveLocations, hasLength(1)); - - // Dispatch message.deleted event - await tester.emitEvent( - createDefaultEvent( - type: EventType.messageDeleted, - cid: _channelCid, - message: locationMessage.copyWith( - type: MessageType.deleted, - deletedAt: DateTime.timestamp(), - ), - ), - ); - - // Verify active locations are updated - expect(tester.channelState?.activeLiveLocations, isEmpty); - }, - ); - - 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)), - ); - - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - - // Add initial location for setup - tester.channelState?.addNewMessage(locationMessage); - expect(tester.channelState?.activeLiveLocations, hasLength(1)); - - // 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)), - ); - - final newMessage = Message( - id: 'msg2', - text: 'Updated location', - sharedLocation: newLocation, - ); - - // 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 = tester.channelState?.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations?.first.messageId, equals('msg2')); - expect(activeLiveLocations?.first.latitude, equals(40.7500)); - }, - ); - - channelTest( - 'should handle multiple active locations from different devices', - 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)), - ); - - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - - // Add first location for setup - tester.channelState?.addNewMessage(locationMessage); - expect(tester.channelState?.activeLiveLocations, hasLength(1)); - - // Create location from different device - final location2 = Location( - channelCid: _channelCid, - userId: 'user1', // Same user - messageId: 'msg2', - latitude: 34.0522, - longitude: -118.2437, - createdByDeviceId: 'device2', // Different device - endAt: DateTime.timestamp().add(const Duration(hours: 1)), - ); - - final message2 = Message( - id: 'msg2', - text: 'Location from device 2', - sharedLocation: location2, - ); - - // Dispatch location.shared event for the second message - await tester.emitEvent( - createDefaultEvent( - cid: _channelCid, - type: EventType.locationShared, - message: message2, - ), - ); - - // Should have two active locations - expect(tester.channelState?.activeLiveLocations, hasLength(2)); - }, - ); - - 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', - ); - - // Add parent message first for setup - tester.channelState?.addNewMessage(parentMessage); - - 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)), - ); - - final threadLocationMessage = Message( - id: 'thread-msg1', - text: 'Live location in thread', - parentId: 'parent1', - sharedLocation: liveLocation, - ); - - // Dispatch location.shared event for the thread message - await tester.emitEvent( - createDefaultEvent( - cid: _channelCid, - type: EventType.locationShared, - message: threadLocationMessage, - ), - ); - - // 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 location was added to active locations - final activeLiveLocations = tester.channelState?.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations?.first.messageId, equals('thread-msg1')); - }, - ); - - 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', - ); - - 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)), - ); - - final threadLocationMessage = Message( - id: 'thread-msg1', - text: 'Live location in thread', - parentId: 'parent1', - sharedLocation: liveLocation, - ); - - // Add messages - tester.channelState?.addNewMessage(parentMessage); - tester.channelState?.addNewMessage(threadLocationMessage); - - // Update the location - final updatedLocation = liveLocation.copyWith( - latitude: 40.7500, - longitude: -74.1000, - ); - - final updatedThreadMessage = threadLocationMessage.copyWith( - sharedLocation: updatedLocation, - ); - - // Dispatch location.updated event for the thread message - await tester.emitEvent( - createDefaultEvent( - cid: _channelCid, - type: EventType.locationUpdated, - message: updatedThreadMessage, - ), - ); - - // 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)); - - // 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)); - }, - ); - - channelTest( - 'should ignore location.updated event without a message id', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel), - body: (tester) async { - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: Location( - channelCid: _channelCid, - userId: 'user1', - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.timestamp().add(const Duration(hours: 1)), - ), - ); - - tester.channelState?.addNewMessage(locationMessage); - - await tester.emitEvent( - createDefaultEvent( - cid: _channelCid, - type: EventType.locationUpdated, - message: locationMessage.copyWith( - sharedLocation: Location( - channelCid: _channelCid, - userId: 'user1', - latitude: 40.7500, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.timestamp().add(const Duration(hours: 1)), - ), - ), - ), - ); - - final message = tester.channelState?.messages.firstWhere((m) => m.id == 'msg1'); - expect(message?.sharedLocation?.latitude, equals(40.7128)); - }, - ); - - channelTest( - 'should ignore location.updated event for a message outside the loaded window', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel), - body: (tester) async { - // No message is seeded, so the location has nothing to attach to. - expect(tester.channelState?.messages, isEmpty); - - await tester.emitEvent( - createDefaultEvent( - cid: _channelCid, - type: EventType.locationUpdated, - message: Message( - id: 'unknown-msg', - text: 'Live location shared', - sharedLocation: Location( - channelCid: _channelCid, - userId: 'user1', - messageId: 'unknown-msg', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.timestamp().add(const Duration(hours: 1)), - ), - ), - ), - ); - - // The unknown message is not inserted as a side effect of the update. - expect(tester.channelState?.messages, isEmpty); - expect(tester.channelState?.activeLiveLocations, isEmpty); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/state/member_events_test.dart b/packages/stream_chat/test/src/client/channel/state/member_events_test.dart deleted file mode 100644 index 781c1f7778..0000000000 --- a/packages/stream_chat/test/src/client/channel/state/member_events_test.dart +++ /dev/null @@ -1,309 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: _channelCid), - ); -} - -void main() { - group('Member Events', () { - channelTest( - 'should update membership when member is updated and is current user', - 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.utc(2021, 3); - - // Setup initial membership - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith( - members: [currentMember], - membership: currentMember, - ), - ); - - // Verify initial state - 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( - channelRole: 'moderator', - isModerator: true, - pinnedAt: now, - archivedAt: now, - ); - - // Create member updated event - final memberUpdatedEvent = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.memberUpdated, - user: currentUser, - member: updatedMember, - ); - - // Dispatch event - await tester.emitEvent(memberUpdatedEvent); - - // Verify membership is updated with new properties - 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); - }, - ); - - channelTest( - 'should update membership user when any event containing user is updated', - 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 - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith( - members: [currentMember], - membership: currentMember, - ), - ); - - // Verify initial state - 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 = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.any, - user: updatedUser, - ); - - // Dispatch event - await tester.emitEvent(anyEvent); - - // Verify membership is updated with new properties - expect(tester.channel.membership, isNotNull); - expect(tester.channel.membership?.user?.id, equals(updatedUser?.id)); - expect(tester.channel.membership?.user?.role, equals(updatedUser?.role)); - }, - ); - - 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(tester.channelState!.channelState.members?.map((m) => m.userId), ['new-member']); - }, - ); - - channelTest( - '${EventType.memberRemoved} removes the 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'), - Member(userId: 'member-2'), - ], - ), - ); - - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.memberRemoved, - user: User(id: 'member-1'), - ), - ); - - expect(tester.channelState!.channelState.members?.map((m) => m.userId), ['member-2']); - }, - ); - - 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')], - ), - ); - - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.memberUpdated, - member: Member(userId: 'member-1', channelRole: 'channel_moderator'), - ), - ); - - 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', () { - Future setUpBannedMember(ChannelTester tester) async { - await tester.watch(modifyResponse: _seedChannel()); - - tester.channelState!.updateChannelState( - tester.channelState!.channelState.copyWith( - members: [ - Member( - userId: 'bad-user', - user: User(id: 'bad-user'), - channelRole: 'channel_member', - ), - ], - ), - ); - - tester.mockApi( - (api) => api.general.queryMembers( - _channelType, - channelId: _channelId, - filter: Filter.equal('id', 'bad-user'), - members: any(named: 'members'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - ), - result: QueryMembersResponse()..members = [Member(userId: 'bad-user', channelRole: 'channel_banned')], - ); - } - - 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(tester.channelState!.channelState.members?.single.channelRole, 'channel_banned'); - }, - ); - - 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(tester.channelState!.channelState.members?.single.channelRole, 'channel_banned'); - }, - ); - - 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(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'), - ), - ); - }, - ); - }); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/state/message_deleted_events_test.dart b/packages/stream_chat/test/src/client/channel/state/message_deleted_events_test.dart deleted file mode 100644 index 6a77ab69a0..0000000000 --- a/packages/stream_chat/test/src/client/channel/state/message_deleted_events_test.dart +++ /dev/null @@ -1,244 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -final _deletedAt = DateTime.utc(2026, 7); - -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: const [ChannelCapability.readEvents], - ), - ); -} - -Event _deleteMessageEvent(Message message, {bool hardDelete = false}) { - return createDefaultEvent( - cid: _channelCid, - type: EventType.messageDeleted, - message: message.copyWith( - type: MessageType.deleted, - deletedAt: _deletedAt, - ), - hardDelete: hardDelete, - ); -} - -void main() { - // 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, () { - // 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)); - - final olderPage = Message( - id: 'older-page-msg', - user: tester.currentUser, - text: 'gone', - createdAt: DateTime.utc(2025, 1, 1), - ); - await tester.emitEvent(_deleteMessageEvent(olderPage)); - - expect(tester.channelState!.messages.map((m) => m.id), ['tail-0', 'tail-1', 'tail-2']); - }, - ); - - 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; - - await tester.emitEvent(_deleteMessageEvent(seeded)); - - final stored = tester.channelState!.messages.singleWhere((m) => m.id == messageId); - expect(stored.type, equals(MessageType.deleted)); - expect(stored.deletedAt, isNotNull); - }, - ); - - 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); - }, - ); - - 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)), - ); - - // 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); - }, - ); - - 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)); - - expect(tester.channelState!.messages, isEmpty); - expect(tester.channelState!.pinnedMessages, isEmpty); - }, - ); - }); - - 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.utc(2026), - ); - tester.channelState!.updateMessage(message); - expect(tester.channelState!.messages, hasLength(1)); - - await tester.emitEvent(_deleteMessageEvent(message, hardDelete: true)); - - expect(tester.channelState!.messages, isEmpty); - }, - ); - - 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.utc(2026), - ); - tester.channelState!.updateMessage(message); - - await tester.emitEvent( - createDefaultEvent( - cid: _channelCid, - type: EventType.messageDeleted, - deletedForMe: true, - message: message.copyWith( - type: MessageType.deleted, - deletedAt: _deletedAt, - ), - ), - ); - - final stored = tester.channelState!.messages.single; - expect(stored.deletedForMe, isTrue); - expect(stored.type, MessageType.deleted); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/state/message_enrichment_merge_test.dart b/packages/stream_chat/test/src/client/channel/state/message_enrichment_merge_test.dart deleted file mode 100644 index c62ff822d9..0000000000 --- a/packages/stream_chat/test/src/client/channel/state/message_enrichment_merge_test.dart +++ /dev/null @@ -1,364 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: _channelCid), - ); -} - -Event _updateMessageEvent(Message message) => createDefaultEvent( - type: EventType.messageUpdated, - cid: _channelCid, - message: message, -); - -void main() { - group('Message enrichment preservation on merge', () { - channelTest( - 'preserves the `poll` on a quotedMessage when the server omits it during ' - 're-sync (regression: poll quote disappears after foregrounding)', - 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', - name: 'Pizza or pasta?', - options: const [ - PollOption(id: 'opt-1', text: 'Pizza'), - PollOption(id: 'opt-2', text: 'Pasta'), - ], - createdById: pollUser.id, - ); - - final pollMessage = Message( - id: 'poll-msg-1', - poll: poll, - pollId: poll.id, - user: pollUser, - createdAt: DateTime.utc(2026, 4, 29, 10), - ); - - final replyToPoll = Message( - id: 'reply-1', - text: 'Voting now', - quotedMessageId: pollMessage.id, - quotedMessage: pollMessage, - user: User(id: 'reply-user'), - createdAt: DateTime.utc(2026, 4, 29, 11), - ); - - // Seed channel state with the fully-enriched messages (mirrors what - // the local DB load produces). - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith( - messages: [pollMessage, replyToPoll], - ), - ); - - // Simulate a re-sync from the API: the server echoes the reply with - // a `quoted_message` that has only `poll_id` (no `poll` object). - // Constructed directly (not via copyWith) because copyWith cannot - // clear `poll` — see Message.copyWith. - final strippedPollSnapshot = Message( - id: pollMessage.id, - pollId: pollMessage.pollId, - user: pollUser, - createdAt: pollMessage.createdAt, - ); - final reSyncedReply = replyToPoll.copyWith(quotedMessage: strippedPollSnapshot); - - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith( - messages: [reSyncedReply], - ), - ); - - final mergedReply = tester.channelState?.messages.firstWhere((it) => it.id == replyToPoll.id); - - expect(mergedReply, isNotNull); - expect(mergedReply!.quotedMessage, isNotNull); - expect(mergedReply.quotedMessage!.id, pollMessage.id); - expect(mergedReply.quotedMessage!.poll, isNotNull); - expect(mergedReply.quotedMessage!.poll!.id, poll.id); - expect(mergedReply.quotedMessage!.poll!.name, poll.name); - }, - ); - - 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)', - 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', - name: 'Coffee or tea?', - options: const [ - PollOption(id: 'opt-a', text: 'Coffee'), - PollOption(id: 'opt-b', text: 'Tea'), - ], - createdById: pollUser.id, - ); - - final pollMessage = Message( - id: 'poll-msg-2', - poll: poll, - pollId: poll.id, - user: pollUser, - createdAt: DateTime.utc(2026, 4, 29, 10), - ); - - final replyToPoll = Message( - id: 'reply-A', - text: 'My pick', - quotedMessageId: pollMessage.id, - quotedMessage: pollMessage, - user: User(id: 'user-a'), - createdAt: DateTime.utc(2026, 4, 29, 11), - ); - - final replyToReply = Message( - id: 'reply-B', - text: 'Same here', - quotedMessageId: replyToPoll.id, - quotedMessage: replyToPoll, - user: User(id: 'user-b'), - createdAt: DateTime.utc(2026, 4, 29, 12), - ); - - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith( - messages: [pollMessage, replyToPoll, replyToReply], - ), - ); - - // Simulate the server response where: - // - replyA's nested quoted poll is missing the `poll` object. - // - replyB's nested quoted replyA is missing its own `quoted_message` - // (the server typically does not nest two levels deep). - // Stripped poll snapshot is constructed directly because copyWith - // cannot clear `poll` — see Message.copyWith. - final strippedPollSnapshot = Message( - id: pollMessage.id, - pollId: pollMessage.pollId, - user: pollUser, - createdAt: pollMessage.createdAt, - ); - final strippedReplyA = replyToPoll.copyWith(quotedMessage: null); - - final reSyncedReplyA = replyToPoll.copyWith(quotedMessage: strippedPollSnapshot); - final reSyncedReplyB = replyToReply.copyWith(quotedMessage: strippedReplyA); - - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith( - messages: [pollMessage, reSyncedReplyA, reSyncedReplyB], - ), - ); - - 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); - expect(mergedReplyA?.quotedMessage?.poll?.id, poll.id); - - // Second-level quote (reply B's quote of reply A) must keep reply A's - // own nested quotedMessage so the poll preview still resolves. - expect(mergedReplyB?.quotedMessage, isNotNull); - expect(mergedReplyB?.quotedMessage?.id, replyToPoll.id); - expect(mergedReplyB?.quotedMessage?.quotedMessage, isNotNull); - expect(mergedReplyB?.quotedMessage?.quotedMessage?.id, pollMessage.id); - expect(mergedReplyB?.quotedMessage?.quotedMessage?.poll, isNotNull); - expect(mergedReplyB?.quotedMessage?.quotedMessage?.poll?.id, poll.id); - }, - ); - - channelTest( - 'still preserves quotedMessage when the updated payload has no ' - 'quoted_message at all (existing behavior should not regress)', - 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', - name: 'Beach or mountains?', - options: const [ - PollOption(id: 'opt-x', text: 'Beach'), - PollOption(id: 'opt-y', text: 'Mountains'), - ], - createdById: pollUser.id, - ); - - final pollMessage = Message( - id: 'poll-msg-3', - poll: poll, - pollId: poll.id, - user: pollUser, - createdAt: DateTime.utc(2026, 4, 29, 10), - ); - - final replyToPoll = Message( - id: 'reply-3', - text: 'Definitely beach', - quotedMessageId: pollMessage.id, - quotedMessage: pollMessage, - user: User(id: 'reply-user'), - createdAt: DateTime.utc(2026, 4, 29, 11), - ); - - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith( - messages: [pollMessage, replyToPoll], - ), - ); - - // Simulate an update event that touches the reply but doesn't echo - // the nested quoted_message at all (only quotedMessageId is set). - final reSyncedReply = Message( - id: replyToPoll.id, - text: 'Definitely beach (edited)', - quotedMessageId: pollMessage.id, - user: replyToPoll.user, - createdAt: replyToPoll.createdAt, - ); - - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith( - messages: [reSyncedReply], - ), - ); - - final mergedReply = tester.channelState?.messages.firstWhere((it) => it.id == replyToPoll.id); - - expect(mergedReply, isNotNull); - expect(mergedReply!.text, 'Definitely beach (edited)'); - expect(mergedReply.quotedMessage, isNotNull); - expect(mergedReply.quotedMessage!.poll?.id, poll.id); - }, - ); - - 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)', - 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', - name: 'What is for lunch?', - options: const [ - PollOption(id: 'opt-1', text: 'Burgers'), - PollOption(id: 'opt-2', text: 'Salads'), - ], - createdById: pollUser.id, - ); - - final pollMessage = Message( - id: 'parent-poll-msg', - poll: poll, - pollId: poll.id, - user: pollUser, - createdAt: DateTime.utc(2026, 4, 29, 10), - replyCount: 0, - ); - - // Seed channel state with the fully-enriched parent poll message. - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith( - messages: [pollMessage], - ), - ); - - // Simulate the `message.updated` event the backend fires for the - // parent after a thread reply is added: bookkeeping fields are bumped - // (`reply_count`, `updated_at`) but the `poll` object is omitted from - // the payload — only `pollId` is set. Constructed directly because - // copyWith cannot clear `poll` — see Message.copyWith. - final strippedParentUpdate = Message( - id: pollMessage.id, - pollId: pollMessage.pollId, - user: pollUser, - createdAt: pollMessage.createdAt, - replyCount: 1, - updatedAt: DateTime.utc(2026, 4, 29, 11), - ); - - await tester.emitEvent(_updateMessageEvent(strippedParentUpdate)); - - 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); - // Bookkeeping fields from the event should still apply. - expect(merged!.replyCount, 1); - // Locally-known poll must be preserved when the server omits it from a - // `message.updated` payload (e.g. when a thread reply bumps reply_count). - expect(merged.poll, isNotNull); - expect(merged.poll!.id, poll.id); - expect(merged.poll!.name, poll.name); - expect(merged.pollId, poll.id); - }, - ); - - 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)', - 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', - name: 'Initial name', - options: const [ - PollOption(id: 'opt-1', text: 'Original A'), - ], - createdById: pollUser.id, - ); - - final pollMessage = Message( - id: 'edit-parent', - poll: poll, - pollId: poll.id, - user: pollUser, - createdAt: DateTime.utc(2026, 4, 29, 10), - ); - - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith( - messages: [pollMessage], - ), - ); - - final updatedPoll = poll.copyWith(name: 'Edited name'); - final updatedParent = pollMessage.copyWith(poll: updatedPoll, updatedAt: DateTime.utc(2026, 4, 29, 12)); - - await tester.emitEvent(_updateMessageEvent(updatedParent)); - - 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. - expect(merged?.poll, isNotNull); - expect(merged?.poll?.name, 'Edited name'); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/state/message_new_events_test.dart b/packages/stream_chat/test/src/client/channel/state/message_new_events_test.dart deleted file mode 100644 index 88bccf7ef9..0000000000 --- a/packages/stream_chat/test/src/client/channel/state/message_new_events_test.dart +++ /dev/null @@ -1,590 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -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, - ), - ); -} - -Event _newMessageEvent(Message message) => createDefaultEvent( - type: EventType.messageNew, - cid: _channelCid, - message: message, -); - -void main() { - group('${EventType.messageNew} or ${EventType.notificationMessageNew}', () { - channelTest( - "should update 'channel.lastMessageAt'", - 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, - createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), - ); - - await tester.emitEvent(_newMessageEvent(message)); - - expect(tester.channel.lastMessageAt, equals(message.createdAt)); - expect(tester.channel.lastMessageAt, isNot(_initialLastMessageAt)); - }, - ); - - 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)); - - 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)), - ); - - await tester.emitEvent(_newMessageEvent(message)); - - expect(tester.channel.lastMessageAt, equals(message.createdAt)); - expect(tester.channel.lastMessageAt, isNot(_initialLastMessageAt)); - }, - ); - - 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)); - - expect(tester.channel.lastMessageAt, isNot(message.createdAt)); - expect(tester.channel.lastMessageAt, equals(_initialLastMessageAt)); - }, - ); - - 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)); - - final message = Message( - id: 'test-message-id', - user: tester.currentUser, - shadowed: true, - 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 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)), - ); - - 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 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)); - - 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)), - ); - - 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', - silent: true, - user: User(id: 'other-user'), - createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), - ); - - await tester.emitEvent(_newMessageEvent(message)); - - expect(tester.channelState?.unreadCount, equals(0)); - }, - ); - - 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', - shadowed: true, - user: User(id: 'other-user'), - createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), - ); - - await tester.emitEvent(_newMessageEvent(message)); - - expect(tester.channelState?.unreadCount, equals(0)); - }, - ); - - 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', - type: MessageType.ephemeral, - user: User(id: 'other-user'), - createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), - ); - - await tester.emitEvent(_newMessageEvent(message)); - - expect(tester.channelState?.unreadCount, equals(0)); - }, - ); - - 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', - parentId: 'test-parent-id', - showInChannel: false, - user: User(id: 'other-user'), - createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), - ); - - await tester.emitEvent(_newMessageEvent(message)); - - expect(tester.channelState?.unreadCount, equals(0)); - }, - ); - - 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', - parentId: 'test-parent-id', - showInChannel: false, - user: User(id: 'other-user'), - createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), - ); - - await tester.emitEvent(_newMessageEvent(message)); - - expect(tester.channelState?.unreadCount, equals(0)); - }, - ); - - 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: tester.currentUser, - createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), - ); - - await tester.emitEvent(_newMessageEvent(message)); - - expect(tester.channelState?.unreadCount, equals(0)); - }, - ); - - 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( - id: 'test-message-id', - user: User(id: 'other-user'), - createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), - restrictedVisibility: const ['other-user-2'], - ); - - await tester.emitEvent(_newMessageEvent(message)); - - expect(tester.channelState?.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)), - ); - - await tester.emitEvent(_newMessageEvent(message)); - - // The delivery reporter batches receipts behind a 1s trailing throttle. - await Future.delayed(const Duration(milliseconds: 1100)); - - 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'); - }, - ); - - 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, - ); - - // 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)), - ); - - await tester.channel.sendMessage(localMessage); - - expect(tester.channelState!.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)), - ); - await tester.emitEvent(_newMessageEvent(serverMessage)); - - // 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)); - }, - ); - - 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)); - - // 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)); - }, - ); - - 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)), - ); - - await tester.emitEvent( - createDefaultEvent( - cid: _channelCid, - type: EventType.notificationMessageNew, - message: message, - ), - ); - - expect(tester.channelState!.messages.map((m) => m.id), ['notified-message-id']); - expect(tester.channelState!.unreadCount, 1); - }, - ); - - 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; - - final message = Message( - id: 'below-window-message-id', - user: User(id: 'other-user'), - createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), - ); - - await tester.emitEvent(_newMessageEvent(message)); - - expect(tester.channelState!.messages, isEmpty); - expect(tester.channelState!.unreadCount, 1); - }, - ); - - 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; - - final reply = Message( - id: 'thread-reply-id', - parentId: 'parent-message-id', - user: User(id: 'other-user'), - createdAt: _initialLastMessageAt.add(const Duration(seconds: 3)), - ); - - await tester.emitEvent(_newMessageEvent(reply)); - - expect(tester.channelState!.messages, isEmpty); - expect(tester.channelState!.threads['parent-message-id']?.map((m) => m.id), ['thread-reply-id']); - }, - ); - - 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)); - - expect(tester.channelState!.messages, isEmpty); - expect(tester.channel.lastMessageAt, _initialLastMessageAt); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/state/message_updated_events_test.dart b/packages/stream_chat/test/src/client/channel/state/message_updated_events_test.dart deleted file mode 100644 index 22a3ca38b3..0000000000 --- a/packages/stream_chat/test/src/client/channel/state/message_updated_events_test.dart +++ /dev/null @@ -1,343 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: const [ChannelCapability.readEvents], - ), - ); -} - -Event _updateMessageEvent(Message message) => createDefaultEvent( - type: EventType.messageUpdated, - cid: _channelCid, - message: message, -); - -void main() { - group(EventType.messageUpdated, () { - 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, - ); - - await tester.emitEvent(_updateMessageEvent(message)); - - expect(tester.channelState?.pinnedMessages.length, equals(1)); - expect(tester.channelState?.pinnedMessages.first.id, equals(messageId)); - }, - ); - - 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, - ); - - await tester.emitEvent(_updateMessageEvent(message)); - - expect(tester.channelState?.pinnedMessages.length, equals(1)); - expect(tester.channelState?.pinnedMessages.first.id, equals(messageId)); - expect(tester.channelState?.pinnedMessages.first.text, equals(oldText)); - - final updatedMessage = message.copyWith(text: newText); - await tester.emitEvent(_updateMessageEvent(updatedMessage)); - - expect(tester.channelState?.pinnedMessages.length, equals(1)); - expect(tester.channelState?.pinnedMessages.first.id, equals(messageId)); - expect(tester.channelState?.pinnedMessages.first.text, equals(newText)); - }, - ); - - 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); - - await tester.emitEvent(_updateMessageEvent(firstMessage)); - - expect(tester.channelState?.pinnedMessages.length, equals(1)); - expect( - tester.channelState?.pinnedMessages.first.id, - equals(firstMessageId), - ); - - await tester.emitEvent(_updateMessageEvent(secondMessage)); - - expect(tester.channelState?.pinnedMessages.length, equals(2)); - expect( - tester.channelState?.pinnedMessages.first.id, - equals(firstMessageId), - ); - expect( - tester.channelState?.pinnedMessages[1].id, - equals(secondMessageId), - ); - }, - ); - - 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, - ); - - await tester.emitEvent(_updateMessageEvent(pinnedMessage)); - - expect(tester.channelState?.pinnedMessages.length, equals(1)); - expect(tester.channelState?.pinnedMessages.first.id, equals(messageId)); - - final unpinnedMessage = pinnedMessage.copyWith(pinned: false); - await tester.emitEvent(_updateMessageEvent(unpinnedMessage)); - - expect(tester.channelState?.pinnedMessages, isEmpty); - }, - ); - - // 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', () { - 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)), - ), - ); - tester.channelState!.updateChannelState( - tester.channelState!.channelState.copyWith(messages: tail), - ); - 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)); - - // 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); - }, - ); - - 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 edited = seeded.copyWith(text: 'new'); - await tester.emitEvent(_updateMessageEvent(edited)); - - final stored = tester.channelState!.messages.singleWhere((m) => m.id == messageId); - expect(stored.text, equals('new')); - }, - ); - - 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); - - const messageId = 'pin-me'; - final pinned = Message( - id: messageId, - user: tester.currentUser, - pinned: true, - ); - await tester.emitEvent(_updateMessageEvent(pinned)); - - expect(tester.channelState!.messages, isEmpty); - expect(tester.channelState!.pinnedMessages.length, equals(1)); - expect(tester.channelState!.pinnedMessages.first.id, equals(messageId)); - }, - ); - - 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)); - - tester.channelState!.isUpToDate = false; - - final phantomReply = Message( - id: 'other-reply', - parentId: parentId, - user: tester.currentUser, - text: 'edited', - createdAt: DateTime.utc(2026, 1, 2), - ); - await tester.emitEvent(_updateMessageEvent(phantomReply)); - - expect(tester.channelState!.threads[parentId]!.map((m) => m.id), ['known-reply']); - }, - ); - - 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); - - tester.channelState!.isUpToDate = false; - - final phantomReply = Message( - id: 'phantom-reply', - parentId: parentId, - user: tester.currentUser, - text: 'edited', - createdAt: DateTime.utc(2026, 1, 2), - ); - await tester.emitEvent(_updateMessageEvent(phantomReply)); - - // The dropped reply must not leave behind an empty thread entry. - expect(tester.channelState!.threads.containsKey(parentId), isFalse); - }, - ); - - 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)), - ); - - // 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(tester.channelState!.messages, isEmpty); - expect(tester.channelState!.activeLiveLocations, isEmpty); - }, - ); - }); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/state/poll_events_test.dart b/packages/stream_chat/test/src/client/channel/state/poll_events_test.dart deleted file mode 100644 index af2574c9ee..0000000000 --- a/packages/stream_chat/test/src/client/channel/state/poll_events_test.dart +++ /dev/null @@ -1,385 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; -const _pollMessageId = 'poll-message-id'; -const _pollId = 'poll-id'; - -final _createdAt = DateTime.utc(2021, 3); - -ChannelState _seedChannel(ChannelState _) => - createDefaultChannelState(channel: createDefaultChannelModel(cid: _channelCid)); - -Poll _createPoll({ - String name = 'Favorite color?', - List latestAnswers = const [], - List ownVotesAndAnswers = const [], -}) { - return Poll( - id: _pollId, - name: name, - options: const [ - PollOption(id: 'option-a', text: 'A'), - PollOption(id: 'option-b', text: 'B'), - ], - latestAnswers: latestAnswers, - ownVotesAndAnswers: ownVotesAndAnswers, - ); -} - -Message _seedPollMessage( - ChannelTester tester, { - String? parentId, - List latestAnswers = const [], - List ownVotesAndAnswers = const [], -}) { - final message = Message( - id: _pollMessageId, - parentId: parentId, - user: User(id: 'other-user'), - createdAt: _createdAt, - poll: _createPoll( - latestAnswers: latestAnswers, - ownVotesAndAnswers: ownVotesAndAnswers, - ), - ); - tester.channelState!.updateMessage(message); - return message; -} - -Message _storedPollMessage(ChannelTester tester) { - return tester.channelState!.messages.firstWhere((it) => it.id == _pollMessageId); -} - -void main() { - group('Poll events', () { - 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(), - ), - ), - ); - - expect(_storedPollMessage(tester).poll?.id, _pollId); - }, - ); - - 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, - ), - ), - ); - - expect(tester.channelState!.messages, isEmpty); - }, - ); - - 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'), - ), - ); - - final stored = _storedPollMessage(tester); - 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')], - ), - ), - ); - - 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'), - ), - ); - - final stored = tester.channelState!.threads[parentId]!.firstWhere((it) => it.id == _pollMessageId); - expect(stored.poll?.name, 'Renamed'); - }, - ); - - 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'), - ), - ); - - final stored = _storedPollMessage(tester); - expect(stored.poll?.isClosed, isTrue); - expect(stored.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, - ), - ); - - 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']); - - 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, - ), - ); - - 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']); - }, - ); - - 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, - ), - ); - - var stored = _storedPollMessage(tester); - expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-vote-id']); - - 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, - ), - ); - - stored = _storedPollMessage(tester); - expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-vote-id']); - }, - ); - - 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, - ), - ); - - expect( - _storedPollMessage(tester).poll?.ownVotesAndAnswers.map((v) => v.id), - contains('changed-vote-id'), - ); - }, - ); - - 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, - ), - ); - - 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); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/state/reaction_events_test.dart b/packages/stream_chat/test/src/client/channel/state/reaction_events_test.dart deleted file mode 100644 index 6033413bf6..0000000000 --- a/packages/stream_chat/test/src/client/channel/state/reaction_events_test.dart +++ /dev/null @@ -1,200 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; -const _messageId = 'reaction-message-id'; - -// Pinned createdAt keeps message index lookups stable in `updateMessage`. -final _createdAt = DateTime.utc(2021, 3); - -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: _channelCid), - ); -} - -Message _seedMessage(ChannelTester tester, {String? parentId, List ownReactions = const []}) { - final message = Message( - id: _messageId, - parentId: parentId, - user: User(id: 'other-user'), - text: 'react to me', - createdAt: _createdAt, - ownReactions: ownReactions, - ); - tester.channelState!.updateMessage(message); - return message; -} - -void main() { - group('Reaction events', () { - 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], - ), - ), - ); - - final stored = tester.channelState!.messages.firstWhere((it) => it.id == _messageId); - expect(stored.ownReactions?.map((r) => r.type), ['like']); - }, - ); - - 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, - ), - ), - ); - - final stored = tester.channelState!.threads[parentId]!.firstWhere((it) => it.id == _messageId); - expect(stored.ownReactions?.map((r) => r.type), ['like']); - }, - ); - - 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, - ), - ), - ); - - final stored = tester.channelState!.messages.firstWhere((it) => it.id == _messageId); - expect(stored.ownReactions?.map((r) => r.type), ['like']); - }, - ); - - 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, - ), - ), - ); - - final stored = tester.channelState!.threads[parentId]!.firstWhere((it) => it.id == _messageId); - expect(stored.ownReactions, isEmpty); - }, - ); - - channelTest( - '${EventType.reactionNew} for a message outside the loaded window is ignored', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - // No message is seeded, so the reaction has nothing to attach to. - expect(tester.channelState!.messages, isEmpty); - - 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], - ), - ), - ); - - // The unknown message is not inserted as a side effect of the reaction. - expect(tester.channelState!.messages, isEmpty); - expect(tester.channelState!.threads, isEmpty); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/state/read_events_test.dart b/packages/stream_chat/test/src/client/channel/state/read_events_test.dart deleted file mode 100644 index 9df783ee2f..0000000000 --- a/packages/stream_chat/test/src/client/channel/state/read_events_test.dart +++ /dev/null @@ -1,753 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -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, - ); -} - -void main() { - group('Read Events', () { - 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 - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith( - read: [currentRead], - ), - ); - - // 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); - - // 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', - ); - - // Dispatch event - await tester.emitEvent(messageReadEvent); - - // 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); - }, - ); - - channelTest( - 'should add a new read state if not exist on message read 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'); - - // Verify initial state - final read = tester.channelState?.read; - expect(read, isEmpty); - - // Create mark read notification event - final markReadEvent = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.messageRead, - user: currentUser, - createdAt: DateTime.utc(2022), - unreadMessages: 0, - lastReadMessageId: 'message-123', - ); - - // Dispatch event - await tester.emitEvent(markReadEvent); - - // Verify read list has not changed - final updated = tester.channelState?.read; - expect(updated?.length, 1); - expect(updated?.any((r) => r.user.id == currentUser.id), isTrue); - }, - ); - - channelTest( - 'should not update channel read state on thread 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, - lastReadMessageId: 'channel-msg-1', - ); - - // Setup initial channel read state - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith( - read: [currentRead], - ), - ); - - // Verify initial state - final read = tester.channelState?.read.first; - expect(read?.unreadMessages, 10); - expect(read?.lastReadMessageId, 'channel-msg-1'); - expect(read?.lastRead.isAtSameMomentAs(DateTime.utc(2020)), isTrue); - - // Create a thread-scoped message.read event (thread != null) - final threadMessageReadEvent = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.messageRead, - user: currentUser, - createdAt: DateTime.utc(2022), - lastReadMessageId: 'thread-reply-99', - thread: Thread( - channelCid: tester.channel.cid!, - parentMessageId: 'parent-msg-1', - createdByUserId: currentUser.id, - replyCount: 3, - participantCount: 2, - ), - ); - - // Dispatch event - await tester.emitEvent(threadMessageReadEvent); - - // Channel read state must be untouched — thread reads - // must not clobber the channel-level Read. - final after = tester.channelState?.read.first; - expect(after?.unreadMessages, 10); - expect(after?.lastReadMessageId, 'channel-msg-1'); - expect(after?.lastRead.isAtSameMomentAs(DateTime.utc(2020)), isTrue); - }, - ); - - 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 - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith( - read: [currentRead], - ), - ); - - // 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); - - // 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', - ); - - // Dispatch event - await tester.emitEvent(markUnreadEvent); - - // 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); - }, - ); - - channelTest( - 'should add a new read state if not exist on notification mark unread', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - // Verify initial state - final read = tester.channelState?.read; - expect(read, isEmpty); - - // Create event for non-existing user - final markUnreadEvent = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.notificationMarkUnread, - user: User(id: 'non-existing-user'), - lastReadAt: DateTime.utc(2019), - unreadMessages: 15, - lastReadMessageId: 'message-100', - ); - - // Dispatch event - await tester.emitEvent(markUnreadEvent); - - // Verify read list has not changed - final updated = tester.channelState?.read; - expect(updated?.length, 1); - expect(updated?.any((r) => r.user.id == 'non-existing-user'), isTrue); - }, - ); - - channelTest( - 'should preserve delivery info 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, - lastDeliveredAt: DateTime.utc(2021), - lastDeliveredMessageId: 'delivered-msg-456', - ); - - // Setup initial read state with delivery info - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith( - read: [currentRead], - ), - ); - - // Verify initial state - final read = tester.channelState?.read.first; - expect(read?.lastDeliveredAt, isNotNull); - expect( - read?.lastDeliveredAt?.isAtSameMomentAs(DateTime.utc(2021)), - isTrue, - ); - expect(read?.lastDeliveredMessageId, 'delivered-msg-456'); - - // Create message read event (doesn't include delivery info) - final messageReadEvent = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.messageRead, - user: currentUser, - createdAt: DateTime.utc(2022), - unreadMessages: 0, - lastReadMessageId: 'message-123', - ); - - // Dispatch event - await tester.emitEvent(messageReadEvent); - - // Verify read state is updated but delivery info is preserved - 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, - ); - // Delivery info should be preserved - expect(updatedRead?.lastDeliveredAt, isNotNull); - expect( - updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime.utc(2021)), - isTrue, - ); - expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); - }, - ); - - channelTest( - 'should reconcile delivery when message read event is from current user', - 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(), - ); - - // 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 = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.messageRead, - user: tester.currentUser, - createdAt: DateTime.utc(2022), - unreadMessages: 0, - lastReadMessageId: 'message-123', - ); - - // Dispatch event - await tester.emitEvent(messageReadEvent); - - // 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 the reconciled receipt was never sent - tester.verifyNeverCalled((api) => api.channel.markChannelsDelivered(any())); - }, - ); - - channelTest( - 'should reset unread count on notification mark read event', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final currentUser = tester.currentUser!; - final currentRead = Read( - user: currentUser, - lastRead: DateTime.utc(2020), - unreadMessages: 10, - ); - - // Setup initial read state - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith( - read: [currentRead], - ), - ); - - // Verify initial state - 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. - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.notificationMarkRead, - user: currentUser, - createdAt: DateTime.utc(2022), - lastReadMessageId: 'message-123', - ), - ); - - // Verify read state is updated - final updatedRead = tester.channelState?.read.first; - expect(updatedRead?.user.id, currentUser.id); - expect(tester.channelState?.unreadCount, 0); - expect(updatedRead?.lastReadMessageId, 'message-123'); - expect( - updatedRead?.lastRead.isAtSameMomentAs(DateTime.utc(2022)), - isTrue, - ); - }, - ); - - channelTest( - 'should preserve delivery info on notification mark 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, - lastDeliveredAt: DateTime.utc(2021), - lastDeliveredMessageId: 'delivered-msg-456', - ); - - // Setup initial read state - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith( - read: [currentRead], - ), - ); - - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.notificationMarkRead, - user: currentUser, - createdAt: DateTime.utc(2022), - lastReadMessageId: 'message-123', - ), - ); - - // Verify read state is updated but delivery info is preserved - final updatedRead = tester.channelState?.read.first; - expect(updatedRead?.unreadMessages, 0); - expect( - updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime.utc(2021)), - isTrue, - ); - expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); - }, - ); - - channelTest( - 'should not update channel read state on thread notification mark ' - '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, - lastReadMessageId: 'channel-msg-1', - ); - - // Setup initial read state - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith( - read: [currentRead], - ), - ); - - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.notificationMarkRead, - user: currentUser, - createdAt: DateTime.utc(2022), - lastReadMessageId: 'thread-reply-99', - thread: Thread( - channelCid: tester.channel.cid!, - parentMessageId: 'parent-msg-1', - createdByUserId: currentUser.id, - replyCount: 3, - participantCount: 2, - ), - ), - ); - - // Channel read state must be untouched — thread reads - // must not clobber the channel-level Read. - final after = tester.channelState?.read.first; - expect(after?.unreadMessages, 10); - expect(after?.lastReadMessageId, 'channel-msg-1'); - expect(after?.lastRead.isAtSameMomentAs(DateTime.utc(2020)), isTrue); - }, - ); - - channelTest( - 'should reconcile delivery when notification mark read event is from ' - 'current user', - 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(), - ); - - // 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), - ); - - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.notificationMarkRead, - user: tester.currentUser, - createdAt: DateTime.utc(2022), - lastReadMessageId: 'message-123', - ), - ); - - // 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 the reconciled receipt was never sent - tester.verifyNeverCalled((api) => api.channel.markChannelsDelivered(any())); - }, - ); - - 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 - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith( - read: [currentRead], - ), - ); - - // 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); - - // Create message delivered event - final messageDeliveredEvent = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.messageDelivered, - user: currentUser, - lastDeliveredAt: DateTime.utc(2022), - lastDeliveredMessageId: 'message-456', - ); - - // Dispatch event - await tester.emitEvent(messageDeliveredEvent); - - // 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'); - }, - ); - - channelTest( - 'should add a new read state if not exist on message delivered event', - 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 = tester.channelState?.read; - expect(read, isEmpty); - - // Create message delivered event for new user - final messageDeliveredEvent = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.messageDelivered, - user: newUser, - lastDeliveredAt: DateTime.utc(2022), - lastDeliveredMessageId: 'message-789', - ); - - // Dispatch event - await tester.emitEvent(messageDeliveredEvent); - - // Verify read state was created with delivery info - 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.utc(2022)), - isTrue, - ); - expect(newRead?.lastDeliveredMessageId, 'message-789'); - // lastRead should default to distantPast - expect( - newRead?.lastRead.isAtSameMomentAs(distantPast), - isTrue, - ); - }, - ); - - channelTest( - 'should preserve read info on message delivered 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, - lastReadMessageId: 'read-msg-123', - ); - - // Setup initial read state - tester.channelState?.updateChannelState( - tester.channelState!.channelState.copyWith( - read: [currentRead], - ), - ); - - // Verify initial state - 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 = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.messageDelivered, - user: currentUser, - lastDeliveredAt: DateTime.utc(2022), - lastDeliveredMessageId: 'delivered-msg-456', - ); - - // Dispatch event - await tester.emitEvent(messageDeliveredEvent); - - // Verify delivery state is updated but read info is preserved - final updatedRead = tester.channelState?.read.first; - expect(updatedRead?.user.id, 'test-user'); - expect( - updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime.utc(2022)), - isTrue, - ); - expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); - // Read info should be preserved - expect( - updatedRead?.lastRead.isAtSameMomentAs(DateTime.utc(2020)), - isTrue, - ); - expect(updatedRead?.unreadMessages, 10); - expect(updatedRead?.lastReadMessageId, 'read-msg-123'); - }, - ); - - channelTest( - 'should reconcile delivery when message delivered event is from current user', - 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(), - ); - - // 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 = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.messageDelivered, - user: tester.currentUser, - lastDeliveredAt: DateTime.utc(2022), - lastDeliveredMessageId: 'message-456', - ); - - // Dispatch event - await tester.emitEvent(messageDeliveredEvent); - - // 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 the reconciled receipt was never sent - tester.verifyNeverCalled((api) => api.channel.markChannelsDelivered(any())); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/state/reminder_events_test.dart b/packages/stream_chat/test/src/client/channel/state/reminder_events_test.dart deleted file mode 100644 index d2e9947406..0000000000 --- a/packages/stream_chat/test/src/client/channel/state/reminder_events_test.dart +++ /dev/null @@ -1,373 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -// 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); - -ChannelState _seedChannel(ChannelState _) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: _channelCid), -); - -void main() { - group('Reminder events', () { - channelTest( - 'should handle reminder.created event', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel), - body: (tester) async { - const messageId = 'test-message-id'; - - // Setup initial state with a message without reminder - final message = Message( - id: messageId, - user: tester.currentUser, - text: 'Test message', - ); - - tester.channelState?.updateMessage(message); - - // Verify initial state - no reminder - final initialMessage = tester.channelState?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNull); - - // Create reminder - final reminder = createDefaultMessageReminder( - messageId: messageId, - channelCid: tester.channel.cid!, - userId: 'test-user-id', - remindAt: _now.add(const Duration(days: 30)), - ); - - // Emit reminder.created event - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.reminderCreated, - reminder: reminder, - ), - ); - - // 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); - }, - ); - - channelTest( - 'should handle reminder.updated event', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel), - body: (tester) async { - const messageId = 'test-message-id'; - - // 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, - ); - - final message = Message( - id: messageId, - user: tester.currentUser, - text: 'Test message', - reminder: initialReminder, - ); - - tester.channelState?.updateMessage(message); - - // Verify initial state - final initialMessage = tester.channelState?.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: _now, - ); - - // Emit reminder.updated event - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.reminderUpdated, - reminder: updatedReminder, - ), - ); - - // 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); - }, - ); - - 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 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 message = Message( - id: messageId, - user: tester.currentUser, - text: 'Test message', - reminder: initialReminder, - ); - - tester.channelState?.updateMessage(message); - - // Verify initial state - final initialMessage = tester.channelState?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNotNull); - - // Emit reminder.deleted event - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.reminderDeleted, - reminder: initialReminder, - ), - ); - - // Verify message reminder was removed - final updatedMessage = tester.channelState?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNull); - }, - ); - - 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, - ); - - tester.channelState?.updateMessage(threadMessage); - - // Verify initial state - no reminder - final initialMessage = tester.channelState?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNull); - - // 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, - ); - - // Emit reminder.created event - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.reminderCreated, - reminder: reminder, - ), - ); - - // 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); - }, - ); - - 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, - ); - - tester.channelState?.updateMessage(threadMessage); - - // Verify initial state - final initialMessage = tester.channelState?.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: _now, - ); - - // Emit reminder.updated event - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.reminderUpdated, - reminder: updatedReminder, - ), - ); - - // 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); - }, - ); - - 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, - ); - - tester.channelState?.updateMessage(threadMessage); - - // Verify initial state - final initialMessage = tester.channelState?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNotNull); - - // 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 = tester.channelState?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNull); - }, - ); - - 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: tester.currentUser, - text: 'Test message', - ); - tester.channelState?.updateMessage(message); - - await tester.emitEvent(createDefaultEvent(cid: tester.channel.cid, type: EventType.reminderCreated)); - - final storedMessage = tester.channelState?.messages.firstWhere((m) => m.id == messageId); - expect(storedMessage?.reminder, isNull); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/state/shown_thread_reply_events_test.dart b/packages/stream_chat/test/src/client/channel/state/shown_thread_reply_events_test.dart deleted file mode 100644 index 12f40bae78..0000000000 --- a/packages/stream_chat/test/src/client/channel/state/shown_thread_reply_events_test.dart +++ /dev/null @@ -1,181 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; -const _replyId = 'mirrored-reply-id'; -const _parentId = 'parent-message-id'; - -// Pinned createdAt keeps oldIndex lookups stable in `updateMessage`. -final _createdAt = DateTime.utc(2026); - -ChannelState _seedChannel(ChannelState _) { - return createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: const [ChannelCapability.readEvents], - ), - ); -} - -// 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, - ); - tester.channelState!.updateChannelState( - tester.channelState!.channelState.copyWith(messages: [reply]), - ); - return reply; -} - -void main() { - // 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', () { - 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'), - ); - 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], - ), - ), - ); - - final stored = tester.channelState!.messages.firstWhere((it) => it.id == _replyId); - expect(stored.ownReactions, [ownReaction]); - }, - ); - - 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, - ), - ), - ); - - final stored = tester.channelState!.messages.firstWhere((it) => it.id == _replyId); - expect(stored.ownReactions, [kept]); - }, - ); - - 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); - - 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', - ), - ), - ); - - 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); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/state/typing_events_test.dart b/packages/stream_chat/test/src/client/channel/state/typing_events_test.dart deleted file mode 100644 index 65925e6f63..0000000000 --- a/packages/stream_chat/test/src/client/channel/state/typing_events_test.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -void main() { - 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'); - - await tester.emitEvent( - createDefaultEvent(type: EventType.typingStart, cid: tester.channel.cid, user: otherUser), - ); - - final typingEvents = tester.channelState!.typingEvents; - expect(typingEvents.keys.map((u) => u.id), ['other-user']); - expect(typingEvents.values.single.type, EventType.typingStart); - }, - ); - - 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'); - - 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)); - - await tester.emitEvent(createDefaultEvent(type: EventType.typingStop, cid: tester.channel.cid, user: user1)); - - expect(tester.channelState!.typingEvents.keys.map((u) => u.id), ['other-user-2']); - }, - ); - - 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); - - 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); - }, - ); - - 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)); - - expect(tester.channelState!.typingEvents, isEmpty); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/state/update_channel_state_identity_guard_test.dart b/packages/stream_chat/test/src/client/channel/state/update_channel_state_identity_guard_test.dart deleted file mode 100644 index d17566a405..0000000000 --- a/packages/stream_chat/test/src/client/channel/state/update_channel_state_identity_guard_test.dart +++ /dev/null @@ -1,164 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -// Pinned base timestamp for the seeded messages (m1, m2 +1s, m3 +2s). -final _baseCreatedAt = DateTime.utc(2021, 3); - -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: _channelCid), - messages: [ - 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))), - ], - ); -} - -void main() { - group('updateChannelState identity guard', () { - channelTest( - 'preserves messages reference when updatedState.messages is null', - 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 = tester.channelState!.messages; - - expect(identical(before, after), isTrue); - }, - ); - - channelTest( - 'preserves messages reference when updatedState.messages is identical', - 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. - tester.channelState!.updateChannelState( - tester.channelState!.channelState.copyWith( - read: [ - Read( - user: User(id: 'me'), - lastRead: DateTime.utc(2021, 3, 2), - unreadMessages: 1, - ), - ], - ), - ); - final after = tester.channelState!.messages; - - expect(identical(before, after), isTrue); - }, - ); - - channelTest( - 'still merges messages when updatedState.messages is a different list', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final newMessage = Message( - id: 'm4', - text: '4', - createdAt: _baseCreatedAt.add(const Duration(seconds: 10)), - ); - tester.channelState!.updateChannelState( - ChannelState( - channel: tester.channelState!.channelState.channel, - messages: [newMessage], - ), - ); - - expect( - tester.channelState!.messages.map((m) => m.id), - ['m1', 'm2', 'm3', 'm4'], - ); - }, - ); - - 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, - ), - ); - - expect( - tester.channelState!.messages.map((m) => m.id), - ['m1', 'm1.5', 'm2', 'm2.5', 'm3'], - ); - }, - ); - - 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: tester.channelState!.messages.firstWhere((m) => m.id == 'm2').createdAt, - ), - ]; - tester.channelState!.updateChannelState( - ChannelState( - channel: tester.channelState!.channelState.channel, - messages: incoming, - ), - ); - - 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); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/state/update_message_quoted_rewrite_test.dart b/packages/stream_chat/test/src/client/channel/state/update_message_quoted_rewrite_test.dart deleted file mode 100644 index f5b63f8bcb..0000000000 --- a/packages/stream_chat/test/src/client/channel/state/update_message_quoted_rewrite_test.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -ChannelState Function(ChannelState) _seedChannel({required List messages}) { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: _channelCid), - messages: messages, - ); -} - -void main() { - group('updateMessage quoted-rewrite', () { - channelTest( - 'rewrites quotedMessage on every quoter when target is deleted', - 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', - text: 'reply', - quotedMessageId: 'target', - quotedMessage: target, - createdAt: now.add(const Duration(seconds: 1)), - ); - final unrelated = Message( - id: 'u1', - text: 'other', - createdAt: now.add(const Duration(seconds: 2)), - ); - final quoter2 = Message( - id: 'q2', - text: 'reply2', - quotedMessageId: 'target', - quotedMessage: target, - createdAt: now.add(const Duration(seconds: 3)), - ); - - await tester.watch( - modifyResponse: _seedChannel(messages: [target, quoter1, unrelated, quoter2]), - ); - - final unrelatedBefore = tester.channelState!.messages.firstWhere((m) => m.id == 'u1'); - - final deleted = target.copyWith( - type: MessageType.deleted, - deletedAt: now.add(const Duration(seconds: 5)), - ); - tester.channelState!.updateMessage(deleted); - - 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'); - - expect(q1After.quotedMessage?.deletedAt, isNotNull); - expect(q1After.quotedMessage?.type, MessageType.deleted); - expect(q2After.quotedMessage?.deletedAt, isNotNull); - expect(q2After.quotedMessage?.type, MessageType.deleted); - // Unrelated messages must not be rebuilt by the rewrite. - expect(identical(uAfter, unrelatedBefore), isTrue); - }, - ); - - channelTest( - 'preserves messages reference when no message quotes the deleted one', - 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', - text: 'other', - createdAt: now.add(const Duration(seconds: 1)), - ); - - await tester.watch(modifyResponse: _seedChannel(messages: [target, unrelated])); - - final deleted = target.copyWith( - type: MessageType.deleted, - deletedAt: now.add(const Duration(seconds: 5)), - ); - tester.channelState!.updateMessage(deleted); - - // No message quotes `target`, so `updateIf` short-circuits and the - // remaining messages keep their identities (only `target` itself was - // replaced by `sortedUpsert`). - final unrelatedAfter = tester.channelState!.messages.firstWhere((m) => m.id == 'u1'); - expect(identical(unrelatedAfter, unrelated), isTrue); - }, - ); - - channelTest( - 'does not rewrite quotes when an existing quoted target is updated ' - 'without being deleted', - 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', - text: 'reply', - quotedMessageId: 'target', - quotedMessage: target, - createdAt: now.add(const Duration(seconds: 1)), - ); - - await tester.watch(modifyResponse: _seedChannel(messages: [target, quoter])); - - final quoterBefore = tester.channelState!.messages.firstWhere((m) => m.id == 'q1'); - - // Plain text update — not a deletion. - tester.channelState!.updateMessage(target.copyWith(text: 'edited')); - - final quoterAfter = tester.channelState!.messages.firstWhere((m) => m.id == 'q1'); - // `updateIf` is gated on `message.isDeleted`, so the quoter must keep - // its identity (no allocation, no quoted-message overwrite). - expect(identical(quoterAfter, quoterBefore), isTrue); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/state/user_messages_deleted_events_test.dart b/packages/stream_chat/test/src/client/channel/state/user_messages_deleted_events_test.dart deleted file mode 100644 index 6f97922f8b..0000000000 --- a/packages/stream_chat/test/src/client/channel/state/user_messages_deleted_events_test.dart +++ /dev/null @@ -1,613 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -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'), - userId: any(named: 'userId'), - hardDelete: any(named: 'hardDelete'), - deletedAt: any(named: 'deletedAt'), - ), - ).thenAnswer((_) async {}); - when(() => persistenceClient.deleteMessageByIds(any())).thenAnswer((_) async {}); - when(() => persistenceClient.deletePinnedMessageByIds(any())).thenAnswer((_) async {}); - return persistenceClient; -} - -void main() { - group('User messages deleted event', () { - final softDeletePersistence = _createPersistenceClient(); - channelTest( - 'should soft delete all messages from user when hardDelete is false', - 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'); - - final message1 = Message( - id: 'msg-1', - text: 'Message from user 1', - user: user1, - ); - final message2 = Message( - id: 'msg-2', - text: 'Another message from user 1', - user: user1, - ); - final message3 = Message( - id: 'msg-3', - text: 'Message from user 2', - user: user2, - ); - - tester.channelState?.addNewMessage(message1); - tester.channelState?.addNewMessage(message2); - tester.channelState?.addNewMessage(message3); - - // Verify initial state - expect(tester.channelState?.messages.length, equals(3)); - expect( - tester.channelState?.messages.where((m) => m.user?.id == 'user-1').length, - equals(2), - ); - expect( - tester.channelState?.messages.where((m) => m.user?.id == 'user-2').length, - equals(1), - ); - - // Create user.messages.deleted event (soft delete) - final deletedAt = DateTime.utc(2021, 3); - final userMessagesDeletedEvent = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: false, - createdAt: deletedAt, - ); - - // Dispatch event and wait for it to be processed - await tester.emitEvent(userMessagesDeletedEvent); - - // Verify user1's messages are soft deleted - 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)); - expect(message.deletedAt, isNotNull); - expect(message.state.isDeleted, isTrue); - } - - // Verify user2's message is unaffected - final user2Message = tester.channelState?.messages.firstWhere((m) => m.id == 'msg-3'); - expect(user2Message?.type, isNot(MessageType.deleted)); - expect(user2Message?.deletedAt, isNull); - }, - ); - - final hardDeletePersistence = _createPersistenceClient(); - channelTest( - 'should hard delete all messages from user when hardDelete is true', - 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'); - - final message1 = Message( - id: 'msg-1', - text: 'Message from user 1', - user: user1, - ); - final message2 = Message( - id: 'msg-2', - text: 'Another message from user 1', - user: user1, - ); - final message3 = Message( - id: 'msg-3', - text: 'Message from user 2', - user: user2, - ); - - tester.channelState?.addNewMessage(message1); - tester.channelState?.addNewMessage(message2); - tester.channelState?.addNewMessage(message3); - - // Verify initial state - expect(tester.channelState?.messages.length, equals(3)); - - // Create user.messages.deleted event (hard delete) - final userMessagesDeletedEvent = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: true, - ); - - // Dispatch event and wait for it to be processed - await tester.emitEvent(userMessagesDeletedEvent); - - // Verify user1's messages are removed - expect(tester.channelState?.messages.length, equals(1)); - expect( - tester.channelState?.messages.any((m) => m.user?.id == 'user-1'), - isFalse, - ); - - // Verify user2's message still exists - final user2Message = tester.channelState?.messages.firstWhere((m) => m.id == 'msg-3'); - expect(user2Message, isNotNull); - expect(user2Message?.user?.id, equals('user-2')); - }, - ); - - final threadMessagesPersistence = _createPersistenceClient(); - channelTest( - 'should handle thread messages from user', - 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'); - - final parentMessage = Message( - id: 'parent-msg', - text: 'Parent message', - user: user2, - ); - final threadMessage1 = Message( - id: 'thread-msg-1', - text: 'Thread message from user 1', - user: user1, - parentId: 'parent-msg', - ); - final threadMessage2 = Message( - id: 'thread-msg-2', - text: 'Another thread message from user 1', - user: user1, - parentId: 'parent-msg', - ); - - tester.channelState?.addNewMessage(parentMessage); - tester.channelState?.addNewMessage(threadMessage1); - tester.channelState?.addNewMessage(threadMessage2); - - // Verify initial state - 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 = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: false, - ); - - // Dispatch event and wait for it to be processed - await tester.emitEvent(userMessagesDeletedEvent); - - // Verify thread messages are soft deleted - final threadMessages = tester.channelState?.threads['parent-msg']; - expect(threadMessages?.length, equals(2)); - for (final message in threadMessages!) { - expect(message.type, equals(MessageType.deleted)); - expect(message.state.isDeleted, isTrue); - } - - // Verify parent message is unaffected - final parent = tester.channelState?.messages.first; - expect(parent?.type, isNot(MessageType.deleted)); - }, - ); - - final nullUserPersistence = _createPersistenceClient(); - channelTest( - 'should do nothing when user is null', - 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( - id: 'msg-1', - text: 'Message from user 1', - user: user1, - ); - - tester.channelState?.addNewMessage(message1); - - // Verify initial state - expect(tester.channelState?.messages.length, equals(1)); - - // Create user.messages.deleted event without user - final userMessagesDeletedEvent = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.userMessagesDeleted, - hardDelete: false, - ); - - // Dispatch event and wait for it to be processed - await tester.emitEvent(userMessagesDeletedEvent); - - // Verify messages are unaffected - expect(tester.channelState?.messages.length, equals(1)); - expect( - tester.channelState?.messages.first.type, - isNot(MessageType.deleted), - ); - }, - ); - - final emptyChannelPersistence = _createPersistenceClient(); - channelTest( - 'should handle empty message list', - channelType: _channelType, - channelId: _channelId, - chatPersistenceClient: emptyChannelPersistence, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel), - body: (tester) async { - // Setup: Empty channel - expect(tester.channelState?.messages.length, equals(0)); - - // Create user.messages.deleted event - final userMessagesDeletedEvent = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.userMessagesDeleted, - user: User(id: 'user-1'), - hardDelete: false, - ); - - // Dispatch event - should not throw - await tester.emitEvent(userMessagesDeletedEvent); - - // Verify state is still empty - expect(tester.channelState?.messages.length, equals(0)); - }, - ); - - final hardDeleteStoragePersistence = _createPersistenceClient(); - channelTest( - 'should delete messages from persistence when hardDelete is true', - 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'); - - final message1 = Message( - id: 'msg-1', - text: 'Message from user 1', - user: user1, - ); - final message2 = Message( - id: 'msg-2', - text: 'Another message from user 1', - user: user1, - ); - final message3 = Message( - id: 'msg-3', - text: 'Message from user 2', - user: user2, - ); - - tester.channelState?.addNewMessage(message1); - tester.channelState?.addNewMessage(message2); - tester.channelState?.addNewMessage(message3); - - // Verify initial state - expect(tester.channelState?.messages.length, equals(3)); - - // Create user.messages.deleted event (hard delete) - final userMessagesDeletedEvent = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: true, - ); - - // Dispatch event and wait for it to be processed - await tester.emitEvent(userMessagesDeletedEvent); - - // Verify messages are removed from persistence - verify( - () => hardDeleteStoragePersistence.deleteMessageByIds(['msg-1', 'msg-2']), - ).called(1); - verify( - () => hardDeleteStoragePersistence.deletePinnedMessageByIds(['msg-1', 'msg-2']), - ).called(1); - - // Verify user1's messages are removed from state - expect(tester.channelState?.messages.length, equals(1)); - expect( - tester.channelState?.messages.any((m) => m.user?.id == 'user-1'), - isFalse, - ); - }, - ); - - final softDeleteStoragePersistence = _createPersistenceClient(); - channelTest( - 'should not delete from persistence when hardDelete is false', - 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( - id: 'msg-1', - text: 'Message from user 1', - user: user1, - ); - - tester.channelState?.addNewMessage(message1); - - // Create user.messages.deleted event (soft delete) - final userMessagesDeletedEvent = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: false, - ); - - // Dispatch event and wait for it to be processed - await tester.emitEvent(userMessagesDeletedEvent); - - // Verify persistence deletion methods were NOT called - verifyNever(() => softDeleteStoragePersistence.deleteMessageByIds(any())); - verifyNever(() => softDeleteStoragePersistence.deletePinnedMessageByIds(any())); - - // Verify message is soft deleted (still in state) - expect(tester.channelState?.messages.length, equals(1)); - expect(tester.channelState?.messages.first.type, equals(MessageType.deleted)); - }, - ); - - final storageWidePersistence = _createPersistenceClient(); - channelTest( - 'should delete all user messages including those only in storage', - 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'); - - final stateMessage1 = Message( - id: 'msg-1', - text: 'Message from user 1 in state', - user: user1, - pinned: true, - ); - final stateMessage2 = Message( - id: 'msg-2', - text: 'Message from user 2 in state', - user: user2, - ); - final stateThreadMessage1 = Message( - id: 'thread-msg-1', - text: 'Thread message from user 1 in state', - user: user1, - parentId: 'msg-1', - ); - final stateThreadMessage2 = Message( - id: 'thread-msg-2', - text: 'Another thread message from user 2 in state', - user: user2, - parentId: 'msg-1', - ); - - // Load the state with only 2 messages and 1 thread with 2 replies. - // 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. - 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(tester.channelState?.messages.length, equals(2)); - expect(tester.channelState?.threads['msg-1']?.length, equals(2)); - - // Create user.messages.deleted event (hard delete) - final userMessagesDeletedEvent = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: true, - ); - - // Dispatch event and wait for it to be processed - await tester.emitEvent(userMessagesDeletedEvent); - - // Verify user1's messages are removed from state - expect(tester.channelState?.messages.length, equals(1)); - expect(tester.channelState?.threads['msg-1']?.length, equals(1)); - - expect( - tester.channelState?.messages.any((m) => m.user?.id == 'user-1'), - isFalse, - ); - - expect( - 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( - () => storageWidePersistence.deleteMessagesFromUser( - cid: tester.channel.cid, - userId: user1.id, - hardDelete: true, - deletedAt: any(named: 'deletedAt'), - ), - ).called(1); - - // Verify in-state messages were also removed from state's persistence - final capturedIds = - verify( - () => storageWidePersistence.deleteMessageByIds(captureAny()), - ).captured.first - as List; - - expect( - capturedIds, - containsAll([ - 'msg-1', // state message - 'thread-msg-1', // state thread message - ]), - ); - }, - ); - - final crossThreadPersistence = _createPersistenceClient(); - channelTest( - 'should delete every authored message across threads without ' - 'cross-thread leakage (regression: _updateThreadMessages)', - 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 - // routes it through a single _updateMessages batch — historically - // this batch was passed unfiltered to every affected thread's - // merge, so replies to thread A leaked into thread B and v.v. - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - - final parentA = Message(id: 'parent-A', text: 'Thread A', user: user2); - final parentB = Message(id: 'parent-B', text: 'Thread B', user: user2); - - final topLevelFromUser1 = Message( - id: 'top-1', - text: 'user-1 top-level message', - user: user1, - ); - final replyA = Message( - id: 'reply-A', - text: 'user-1 reply in thread A', - user: user1, - parentId: 'parent-A', - ); - final replyB = Message( - id: 'reply-B', - text: 'user-1 reply in thread B', - user: user1, - parentId: 'parent-B', - ); - - 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( - tester.channelState?.threads['parent-A']?.map((m) => m.id), - equals(['reply-A']), - ); - expect( - tester.channelState?.threads['parent-B']?.map((m) => m.id), - equals(['reply-B']), - ); - - // Trigger the multi-thread batch via user.messages.deleted. - final userMessagesDeletedEvent = createDefaultEvent( - cid: tester.channel.cid, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: false, - ); - 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( - tester.channelState?.threads['parent-A']?.map((m) => m.id), - equals(['reply-A']), - reason: 'thread A must not contain replies from thread B', - ); - expect( - tester.channelState?.threads['parent-B']?.map((m) => m.id), - equals(['reply-B']), - reason: 'thread B must not contain replies from thread A', - ); - - // 2) Every message authored by user-1 is soft-deleted — top-level - // AND in both threads. The fix must not narrow this scope. - expect( - tester.channelState?.messages.firstWhere((m) => m.id == 'top-1').type, - equals(MessageType.deleted), - reason: 'top-level user-1 message must be deleted', - ); - expect( - tester.channelState?.threads['parent-A']?.first.type, - equals(MessageType.deleted), - reason: 'thread A reply from user-1 must be deleted', - ); - expect( - 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( - tester.channelState?.messages.firstWhere((m) => m.id == 'parent-A').type, - isNot(MessageType.deleted), - ); - expect( - tester.channelState?.messages.firstWhere((m) => m.id == 'parent-B').type, - isNot(MessageType.deleted), - ); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/channel/state/watching_events_test.dart b/packages/stream_chat/test/src/client/channel/state/watching_events_test.dart deleted file mode 100644 index 5fc63e1ee7..0000000000 --- a/packages/stream_chat/test/src/client/channel/state/watching_events_test.dart +++ /dev/null @@ -1,234 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; - -final _messageCreatedAt = DateTime.utc(2021, 3); - -ChannelState Function(ChannelState) _seedChannel() { - return (_) => createDefaultChannelState( - channel: createDefaultChannelModel( - cid: _channelCid, - config: createDefaultChannelConfig(readEvents: true, typingEvents: true), - ownCapabilities: const [ChannelCapability.readEvents], - ), - ); -} - -void main() { - group('Watching Events', () { - channelTest( - '${EventType.userWatchingStart} adds the watcher and updates watcherCount', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - final watcher = User(id: 'watcher-1'); - - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.userWatchingStart, - user: watcher, - watcherCount: 3, - ), - ); - - expect(tester.channelState!.watcherCount, 3); - expect( - tester.channelState!.channelState.watchers?.map((it) => it.id), - contains('watcher-1'), - ); - }, - ); - - channelTest( - '${EventType.userWatchingStop} removes the watcher and updates watcherCount', - 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). - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.userWatchingStart, - user: watcher, - watcherCount: 2, - ), - ); - expect(tester.channelState!.watcherCount, 2); - expect( - tester.channelState!.channelState.watchers?.map((it) => it.id), - contains('watcher-1'), - ); - - // Then stops watching (count = 1). - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.userWatchingStop, - user: watcher, - watcherCount: 1, - ), - ); - - expect(tester.channelState!.watcherCount, 1); - expect( - tester.channelState!.channelState.watchers?.map((it) => it.id), - isNot(contains('watcher-1')), - ); - }, - ); - - channelTest( - 'watching event without watcherCount preserves the existing count', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - // Seed an initial watcher count. - tester.channelState!.updateChannelState( - tester.channelState!.channelState.copyWith(watcherCount: 5), - ); - expect(tester.channelState!.watcherCount, 5); - - // A watching event that omits watcher_count must not wipe the count. - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.userWatchingStart, - user: User(id: 'watcher-2'), - ), - ); - - expect(tester.channelState!.watcherCount, 5); - expect( - tester.channelState!.channelState.watchers?.map((it) => it.id), - contains('watcher-2'), - ); - }, - ); - - channelTest( - '${EventType.messageNew} updates watcherCount from the event', - 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: tester.currentUser, - createdAt: _messageCreatedAt, - ); - - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.messageNew, - message: message, - watcherCount: 7, - ), - ); - - expect(tester.channelState!.watcherCount, 7); - }, - ); - - channelTest( - '${EventType.messageNew} without watcherCount preserves the existing count', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - // Seed an initial watcher count. - tester.channelState!.updateChannelState( - tester.channelState!.channelState.copyWith(watcherCount: 4), - ); - expect(tester.channelState!.watcherCount, 4); - - // A local/optimistic message.new without watcher_count must not - // reset the count. - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.messageNew, - message: Message( - id: 'test-message-id-2', - user: tester.currentUser, - createdAt: _messageCreatedAt, - ), - ), - ); - - expect(tester.channelState!.watcherCount, 4); - }, - ); - - channelTest( - '${EventType.notificationMessageNew} does not overwrite watcherCount', - channelType: _channelType, - channelId: _channelId, - setUp: (tester) => tester.watch(modifyResponse: _seedChannel()), - body: (tester) async { - // Seed a known watcher count. - tester.channelState!.updateChannelState( - tester.channelState!.channelState.copyWith(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. - await tester.emitEvent( - createDefaultEvent( - cid: tester.channel.cid, - type: EventType.notificationMessageNew, - message: Message( - id: 'notif-message-id', - user: User(id: 'other-user'), - createdAt: _messageCreatedAt, - ), - watcherCount: 0, - ), - ); - - expect(tester.channelState!.watcherCount, 5); - }, - ); - - 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); - - 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); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/client_api_test.dart b/packages/stream_chat/test/src/client/client_api_test.dart deleted file mode 100644 index 2ff30c6e5b..0000000000 --- a/packages/stream_chat/test/src/client/client_api_test.dart +++ /dev/null @@ -1,1481 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -void main() { - chatClientTest( - '`.queryUsers`', - body: (tester) async { - final users = List.generate( - 3, - (index) => User(id: 'test-user-id-$index'), - ); - - tester.mockApi( - (api) => api.user.queryUsers(presence: true), - result: QueryUsersResponse()..users = users, - ); - - 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 tester.client.queryUsers(); - expect(res, isNotNull); - expect(res.users.length, users.length); - - await usersEmitted; - - 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 = Filter.equal('channel_cid', cid); - - tester.mockApi( - (api) => api.moderation.queryBannedUsers(filter: filter), - result: QueryBannedUsersResponse()..bans = bans, - ); - - final res = await tester.client.queryBannedUsers(filter: filter); - expect(res, isNotNull); - expect(res.bans.length, bans.length); - - tester - ..verifyApi((api) => api.moderation.queryBannedUsers(filter: filter)) - ..verifyNoMoreApiInteractions((api) => api.moderation); - }, - ); - - chatClientTest( - '`.search`', - body: (tester) async { - const cid = 'test-type:test-id'; - final filter = Filter.in_('cid', const [cid]); - - final messages = List.generate( - 3, - (index) => createDefaultGetMessageResponse( - channel: ChannelModel(cid: cid), - message: Message(id: 'test-message-id-$index'), - ), - ); - - tester.mockApi( - (api) => api.general.searchMessages(filter), - result: createDefaultSearchMessagesResponse(results: messages), - ); - - final res = await tester.client.search(filter); - expect(res, isNotNull); - expect(res.results.length, messages.length); - - tester - ..verifyApi((api) => api.general.searchMessages(filter)) - ..verifyApi((api) => api.general.getAppSettings()) - ..verifyNoMoreApiInteractions((api) => api.general); - }, - ); - - 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'; - - tester.mockApi( - (api) => api.fileUploader.sendFile(file, channelId, channelType), - result: SendFileResponse()..file = fileUrl, - ); - - final res = await tester.client.sendFile(file, channelId, channelType); - expect(res, isNotNull); - expect(res.file, fileUrl); - - tester - ..verifyApi((api) => api.fileUploader.sendFile(file, channelId, channelType)) - ..verifyNoMoreApiInteractions((api) => api.fileUploader); - }, - ); - - 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'; - - tester.mockApi( - (api) => api.fileUploader.sendImage(image, channelId, channelType), - result: SendImageResponse()..file = fileUrl, - ); - - final res = await tester.client.sendImage(image, channelId, channelType); - expect(res, isNotNull); - expect(res.file, fileUrl); - - tester - ..verifyApi((api) => api.fileUploader.sendImage(image, channelId, channelType)) - ..verifyNoMoreApiInteractions((api) => api.fileUploader); - }, - ); - - chatClientTest( - '`.deleteFile`', - body: (tester) async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - const fileUrl = 'test-file-url'; - - tester.mockApi( - (api) => api.fileUploader.deleteFile(fileUrl, channelId, channelType), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.deleteFile(fileUrl, channelId, channelType); - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.fileUploader.deleteFile(fileUrl, channelId, channelType)) - ..verifyNoMoreApiInteractions((api) => api.fileUploader); - }, - ); - - chatClientTest( - '`.deleteImage`', - body: (tester) async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - const imageUrl = 'test-image-url'; - - tester.mockApi( - (api) => api.fileUploader.deleteImage(imageUrl, channelId, channelType), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.deleteImage(imageUrl, channelId, channelType); - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.fileUploader.deleteImage(imageUrl, channelId, channelType)) - ..verifyNoMoreApiInteractions((api) => api.fileUploader); - }, - ); - - chatClientTest( - '`.uploadImage`', - body: (tester) async { - final image = AttachmentFile(size: 33, path: 'test-image-path'); - const fileUrl = 'test-image-url'; - - tester.mockApi( - (api) => api.fileUploader.uploadImage(image), - result: UploadImageResponse()..file = fileUrl, - ); - - final res = await tester.client.uploadImage(image); - expect(res, isNotNull); - expect(res.file, fileUrl); - - tester - ..verifyApi((api) => api.fileUploader.uploadImage(image)) - ..verifyNoMoreApiInteractions((api) => api.fileUploader); - }, - ); - - chatClientTest( - '`.uploadFile`', - body: (tester) async { - final file = AttachmentFile(size: 33, path: 'test-file-path'); - const fileUrl = 'test-file-url'; - - tester.mockApi( - (api) => api.fileUploader.uploadFile(file), - result: UploadFileResponse()..file = fileUrl, - ); - - final res = await tester.client.uploadFile(file); - expect(res, isNotNull); - expect(res.file, fileUrl); - - tester - ..verifyApi((api) => api.fileUploader.uploadFile(file)) - ..verifyNoMoreApiInteractions((api) => api.fileUploader); - }, - ); - - chatClientTest( - '`.removeImage`', - body: (tester) async { - const imageUrl = 'test-image-url'; - - tester.mockApi( - (api) => api.fileUploader.removeImage(imageUrl), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.removeImage(imageUrl); - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.fileUploader.removeImage(imageUrl)) - ..verifyNoMoreApiInteractions((api) => api.fileUploader); - }, - ); - - chatClientTest( - '`.removeFile`', - body: (tester) async { - const fileUrl = 'test-file-url'; - - tester.mockApi( - (api) => api.fileUploader.removeFile(fileUrl), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.removeFile(fileUrl); - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.fileUploader.removeFile(fileUrl)) - ..verifyNoMoreApiInteractions((api) => api.fileUploader); - }, - ); - - 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 tester.client.updateChannel(channelId, channelType, data); - expect(res, isNotNull); - expect(res.channel.cid, '$channelType:$channelId'); - expect(res.channel.extraData['name'], 'test-channel'); - - tester - ..verifyApi((api) => api.channel.updateChannel(channelId, channelType, data)) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - 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 tester.client.updateChannelPartial( - channelId, - channelType, - set: set, - unset: unset, - ); - expect(res, isNotNull); - expect(res.channel.cid, '$channelType:$channelId'); - expect(res.channel.extraData, set); - - tester - ..verifyApi((api) => api.channel.updateChannelPartial(channelId, channelType, set: set, unset: unset)) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - chatClientTest( - '`.addDevice should work`', - body: (tester) async { - const id = 'test-device-id'; - const provider = PushProvider.firebase; - - tester.mockApi( - (api) => api.device.addDevice(id, provider), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.addDevice(id, provider); - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.device.addDevice(id, provider)) - ..verifyNoMoreApiInteractions((api) => api.device); - }, - ); - - 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(), - ); - - final res = await tester.client.addDevice( - id, - provider, - pushProviderName: pushProviderName, - ); - expect(res, isNotNull); - - tester - ..verifyApi( - (api) => api.device.addDevice( - id, - provider, - pushProviderName: pushProviderName, - ), - ) - ..verifyNoMoreApiInteractions((api) => api.device); - }, - ); - - chatClientTest( - '`.getDevices`', - body: (tester) async { - final devices = List.generate( - 3, - (index) => Device( - id: 'test-device-id-$index', - pushProvider: PushProvider.firebase.name, - ), - ); - - tester.mockApi( - (api) => api.device.getDevices(), - result: ListDevicesResponse()..devices = devices, - ); - - final res = await tester.client.getDevices(); - expect(res, isNotNull); - expect(res.devices.length, devices.length); - - tester - ..verifyApi((api) => api.device.getDevices()) - ..verifyNoMoreApiInteractions((api) => api.device); - }, - ); - - chatClientTest( - '`.removeDevice`', - body: (tester) async { - const deviceId = 'test-device-id'; - - tester.mockApi( - (api) => api.device.removeDevice(deviceId), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.removeDevice(deviceId); - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.device.removeDevice(deviceId)) - ..verifyNoMoreApiInteractions((api) => api.device); - }, - ); - - 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 preferences = [pushPreferenceInput, channelPreferenceInput]; - - 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( - 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, - ), - ]), - ); - - final res = await tester.client.setPushPreferences(preferences); - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.device.setPushPreferences(preferences)) - ..verifyNoMoreApiInteractions((api) => api.device); - }, - ); - - chatClientTest( - 'should handle push_preference.updated event', - body: (tester) async { - final pushPreference = PushPreference( - chatLevel: ChatLevel.mentions, - callLevel: CallLevel.all, - disabledUntil: DateTime.utc(2021, 3), - ); - - final event = createDefaultEvent( - type: EventType.pushPreferenceUpdated, - pushPreference: pushPreference, - ); - - // Initially null - expect(tester.currentUser?.pushPreferences, isNull); - - // 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); - }, - ); - - 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 [], - ); - - final res = await tester.client.listUserGroups( - limit: limit, - idGt: idGt, - createdAtGt: createdAtGt, - teamId: teamId, - ); - expect(res, isNotNull); - - tester - ..verifyApi( - (api) => api.userGroups.listUserGroups( - limit: limit, - idGt: idGt, - createdAtGt: createdAtGt, - teamId: teamId, - ), - ) - ..verifyNoMoreApiInteractions((api) => api.userGroups); - }, - ); - - 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 [], - ); - - final res = await tester.client.searchUserGroups( - query, - limit: limit, - nameGt: nameGt, - idGt: idGt, - teamId: teamId, - ); - expect(res, isNotNull); - - tester - ..verifyApi( - (api) => api.userGroups.searchUserGroups( - query, - limit: limit, - nameGt: nameGt, - idGt: idGt, - teamId: teamId, - ), - ) - ..verifyNoMoreApiInteractions((api) => api.userGroups); - }, - ); - - 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), - ), - ); - - final res = await tester.client.getUserGroup(id, teamId: teamId); - expect(res, isNotNull); - expect(res.userGroup.id, id); - - tester - ..verifyApi((api) => api.userGroups.getUserGroup(id, teamId: teamId)) - ..verifyNoMoreApiInteractions((api) => api.userGroups); - }, - ); - - 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, - description: description, - teamId: teamId, - memberIds: memberIds, - ), - result: CreateUserGroupResponse() - ..userGroup = UserGroup( - id: id, - name: name, - description: description, - teamId: teamId, - createdAt: DateTime.utc(2024, 1, 1), - updatedAt: DateTime.utc(2024, 1, 2), - ), - ); - - final res = await tester.client.createUserGroup( - name, - id: id, - description: description, - teamId: teamId, - memberIds: memberIds, - ); - 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); - }, - ); - - 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, - ), - result: UpdateUserGroupResponse() - ..userGroup = UserGroup( - id: id, - name: name, - description: description, - teamId: teamId, - createdAt: DateTime.utc(2024, 1, 1), - updatedAt: DateTime.utc(2024, 1, 2), - ), - ); - - final res = await tester.client.updateUserGroup( - id, - name: name, - description: description, - teamId: teamId, - ); - 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); - }, - ); - - chatClientTest( - '`.deleteUserGroup`', - body: (tester) async { - const id = 'test-group-id'; - const teamId = 'test-team-id'; - - tester.mockApi( - (api) => api.userGroups.deleteUserGroup(id, teamId: teamId), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.deleteUserGroup(id, teamId: teamId); - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.userGroups.deleteUserGroup(id, teamId: teamId)) - ..verifyNoMoreApiInteractions((api) => api.userGroups); - }, - ); - - 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, - ), - result: AddUserGroupMembersResponse() - ..userGroup = UserGroup( - id: id, - name: 'test-group-name', - createdAt: DateTime.utc(2024, 1, 1), - updatedAt: DateTime.utc(2024, 1, 2), - ), - ); - - final res = await tester.client.addUserGroupMembers( - id, - memberIds, - asAdmin: asAdmin, - teamId: teamId, - ); - expect(res, isNotNull); - - tester - ..verifyApi( - (api) => api.userGroups.addUserGroupMembers( - id, - memberIds, - asAdmin: asAdmin, - teamId: teamId, - ), - ) - ..verifyNoMoreApiInteractions((api) => api.userGroups); - }, - ); - - 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, - ), - result: RemoveUserGroupMembersResponse() - ..userGroup = UserGroup( - id: id, - name: 'test-group-name', - createdAt: DateTime.utc(2024, 1, 1), - updatedAt: DateTime.utc(2024, 1, 2), - ), - ); - - final res = await tester.client.removeUserGroupMembers( - id, - memberIds, - teamId: teamId, - ); - expect(res, isNotNull); - - tester - ..verifyApi( - (api) => api.userGroups.removeUserGroupMembers( - id, - memberIds, - teamId: teamId, - ), - ) - ..verifyNoMoreApiInteractions((api) => api.userGroups); - }, - ); - - 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 [], - ); - - final res = await tester.client.searchRoles( - query, - limit: limit, - nameGt: nameGt, - roleType: roleType, - includeGlobalRoles: includeGlobalRoles, - ); - expect(res, isNotNull); - - tester - ..verifyApi( - (api) => api.roles.searchRoles( - query, - limit: limit, - nameGt: nameGt, - roleType: roleType, - includeGlobalRoles: includeGlobalRoles, - ), - ) - ..verifyNoMoreApiInteractions((api) => api.roles); - }, - ); - - chatClientTest( - '`.devToken`', - body: (tester) async { - const userId = 'test-user-id'; - - final token = tester.client.devToken(userId); - - expect(token, isNotNull); - expect(token.userId, userId); - expect(token.authType, AuthType.jwt); - }, - ); - - chatClientTest( - '`.markAllRead`', - body: (tester) async { - tester.mockApi( - (api) => api.channel.markAllRead(), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.markAllRead(); - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.channel.markAllRead()) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - 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', - ), - ]; - - tester.mockApi( - (api) => api.channel.markChannelsDelivered(deliveries), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.markChannelsDelivered(deliveries); - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.channel.markChannelsDelivered(deliveries)) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - chatClientTest( - '`.sendEvent`', - body: (tester) async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - final event = Event(type: EventType.any); - - tester.mockApi( - (api) => api.channel.sendEvent(channelId, channelType, event), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.sendEvent(channelId, channelType, event); - expect(res, isNotNull); - - tester - ..verifyApi( - (api) => api.channel.sendEvent(channelId, channelType, event), - ) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - chatClientTest( - '`.sendReaction`', - body: (tester) 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, - ); - - tester.mockApi( - (api) => api.message.sendReaction(messageId, reaction), - result: createDefaultSendReactionResponse( - message: Message(id: messageId), - reaction: reaction, - ), - ); - - 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); - }, - ); - - chatClientTest( - '`.deleteReaction`', - body: (tester) async { - const messageId = 'test-message-id'; - const reactionType = 'like'; - - tester.mockApi( - (api) => api.message.deleteReaction(messageId, reactionType), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.deleteReaction(messageId, reactionType); - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.message.deleteReaction(messageId, reactionType)) - ..verifyNoMoreApiInteractions((api) => api.message); - }, - ); - - chatClientTest( - '`.sendMessage`', - body: (tester) async { - final message = Message(id: 'test-message-id'); - 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), - ); - - 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); - }, - ); - - chatClientTest( - '`.createDraft`', - body: (tester) 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.createDraft( - channelId, - channelType, - any(that: isSameDraftMessageAs(message)), - ), - result: createDefaultCreateDraftResponse( - draft: createDefaultDraft( - channelCid: '$channelType:$channelId', - message: message, - ), - ), - ); - - final res = await tester.client.createDraft( - message, - channelId, - channelType, - ); - - 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); - }, - ); - - chatClientTest( - '`.deleteDraft`', - body: (tester) async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - tester.mockApi( - (api) => api.message.deleteDraft(channelId, channelType), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.deleteDraft(channelId, channelType); - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.message.deleteDraft(channelId, channelType)) - ..verifyNoMoreApiInteractions((api) => api.message); - }, - ); - - chatClientTest( - '`.getDraft`', - body: (tester) async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - final message = DraftMessage(id: 'test-message-id', text: 'Hello!'); - - tester.mockApi( - (api) => api.message.getDraft(channelId, channelType), - result: createDefaultGetDraftResponse( - draft: createDefaultDraft( - channelCid: '$channelType:$channelId', - message: message, - ), - ), - ); - - final res = await tester.client.getDraft(channelId, channelType); - - expect(res, isNotNull); - expect(res.draft.message, isSameDraftMessageAs(message)); - - tester - ..verifyApi((api) => api.message.getDraft(channelId, channelType)) - ..verifyNoMoreApiInteractions((api) => api.message); - }, - ); - - chatClientTest( - '`.queryDrafts`', - body: (tester) async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - final filter = Filter.equal('channel_cid', '$channelType:$channelId'); - final sort = [const SortOption.desc('created_at')]; - const pagination = PaginationParams(limit: 20); - - final drafts = [ - createDefaultDraft( - channelCid: '$channelType:$channelId', - message: DraftMessage(id: 'test-message-id', text: 'Hello!'), - ), - ]; - - 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, - ); - - 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); - }, - ); - - chatClientTest( - '`.getReplies`', - body: (tester) async { - const parentId = 'test-parent-id'; - - final messages = List.generate( - 3, - (index) => Message(id: 'test-message-id-$index'), - ); - - tester.mockApi( - (api) => api.message.getReplies(parentId), - result: createDefaultQueryRepliesResponse(messages: messages), - ); - - final res = await tester.client.getReplies(parentId); - expect(res, isNotNull); - expect(res.messages.length, messages.length); - - tester - ..verifyApi((api) => api.message.getReplies(parentId)) - ..verifyNoMoreApiInteractions((api) => api.message); - }, - ); - - chatClientTest( - '`.getReactions`', - body: (tester) async { - const messageId = 'test-parent-id'; - - 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), - ); - - 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); - - tester - ..verifyApi((api) => api.message.getReactions(messageId)) - ..verifyNoMoreApiInteractions((api) => api.message); - }, - ); - - chatClientTest( - '`.queryReactions`', - body: (tester) async { - const messageId = 'test-message-id'; - - 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), - ); - - 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); - - tester - ..verifyApi((api) => api.message.queryReactions(messageId)) - ..verifyNoMoreApiInteractions((api) => api.message); - }, - ); - - chatClientTest( - '`.updateMessage`', - body: (tester) async { - final message = Message(id: 'test-message-id', text: 'Hello!'); - - tester.mockApi( - (api) => api.message.updateMessage(any(that: isSameMessageAs(message))), - result: createDefaultUpdateMessageResponse(message: message), - ); - - final res = await tester.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); - }, - ); - - chatClientTest( - '`.deleteMessage`', - body: (tester) async { - const messageId = 'test-message-id'; - - tester.mockApi( - (api) => api.message.deleteMessage(messageId, hard: false), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.deleteMessage(messageId); - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.message.deleteMessage(messageId, hard: false)) - ..verifyNoMoreApiInteractions((api) => api.message); - }, - ); - - chatClientTest( - '`.deleteMessageForMe`', - body: (tester) async { - const messageId = 'test-message-id'; - - tester.mockApi( - (api) => api.message.deleteMessage(messageId, deleteForMe: true), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.deleteMessageForMe(messageId); - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.message.deleteMessage(messageId, deleteForMe: true)) - ..verifyNoMoreApiInteractions((api) => api.message); - }, - ); - - chatClientTest( - '`.getMessage`', - body: (tester) async { - const messageId = 'test-message-id'; - final message = Message(id: messageId); - - tester.mockApi( - (api) => api.message.getMessage(messageId), - result: createDefaultGetMessageResponse(message: message), - ); - - final res = await tester.client.getMessage(messageId); - expect(res, isNotNull); - expect(res.message.id, messageId); - - tester - ..verifyApi((api) => api.message.getMessage(messageId)) - ..verifyNoMoreApiInteractions((api) => api.message); - }, - ); - - chatClientTest( - '`.getMessagesById`', - body: (tester) 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(); - - tester.mockApi( - (api) => api.message.getMessagesById(channelId, channelType, messageIds), - result: createDefaultGetMessagesByIdResponse(messages: messages), - ); - - final res = await tester.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); - }, - ); - - chatClientTest( - '`.translateMessage`', - body: (tester) 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), - ); - - final res = await tester.client.translateMessage(messageId, language); - - expect(res, isNotNull); - expect(res.message.i18n, translatedMessage.i18n); - - tester - ..verifyApi((api) => api.message.translateMessage(messageId, language)) - ..verifyNoMoreApiInteractions((api) => api.message); - }, - ); - - chatClientTest( - '`.partialUpdateMessage`', - body: (tester) async { - const messageId = 'test-message-id'; - final message = Message(id: messageId); - - const set = {'text': 'Update Message text'}; - const unset = ['pinExpires']; - - final updateMessageResponse = createDefaultUpdateMessageResponse( - message: message.copyWith(text: set['text'], pinExpires: null), - ); - - tester.mockApi( - (api) => api.message.partialUpdateMessage( - message.id, - set: set, - unset: unset, - ), - result: updateMessageResponse, - ); - - final res = await tester.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); - - tester - ..verifyApi( - (api) => api.message.partialUpdateMessage( - message.id, - set: set, - unset: unset, - ), - ) - ..verifyNoMoreApiInteractions((api) => api.message); - }, - ); - - 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 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', - ); - - tester - ..verifyApi((api) => api.general.enrichUrl(url)) - ..verifyApi((api) => api.general.getAppSettings()) - ..verifyNoMoreApiInteractions((api) => api.general); - }, - ); -} diff --git a/packages/stream_chat/test/src/client/client_block_user_test.dart b/packages/stream_chat/test/src/client/client_block_user_test.dart deleted file mode 100644 index f0d3960ea3..0000000000 --- a/packages/stream_chat/test/src/client/client_block_user_test.dart +++ /dev/null @@ -1,181 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -void main() { - group('Block user state management', () { - 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(tester.currentUser?.blockedUserIds, isEmpty); - - tester.mockApi( - (api) => api.user.blockUser(userId), - result: createDefaultUserBlockResponse( - blockedUserId: userId, - blockedByUserId: testUser.id, - ), - ); - - await tester.client.blockUser(userId); - - // 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); - }, - ); - - chatClientTest( - 'blockUser should not duplicate existing blocked user IDs', - body: (tester) async { - const userId = 'blocked-user-id'; - tester.clientState.blockedUserIds = const [userId]; - - // Verify the user is already in the blocked list - expect(tester.currentUser?.blockedUserIds, contains(userId)); - - tester.mockApi( - (api) => api.user.blockUser(userId), - result: createDefaultUserBlockResponse( - blockedUserId: userId, - blockedByUserId: tester.currentUser!.id, - ), - ); - - await tester.client.blockUser(userId); - - // Verify - should still have only one entry - expect(tester.currentUser?.blockedUserIds, contains(userId)); - expect(tester.currentUser?.blockedUserIds.length, 1); - tester - ..verifyApi((api) => api.user.blockUser(userId)) - ..verifyNoMoreApiInteractions((api) => api.user); - }, - ); - - 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( - tester.currentUser?.blockedUserIds, - containsAll([blockedUserId, otherBlockedId]), - ); - - tester.mockApi( - (api) => api.user.unblockUser(blockedUserId), - result: createDefaultEmptyResponse(), - ); - - await tester.client.unblockUser(blockedUserId); - - // Verify - blockedUserId should be removed - expect( - tester.currentUser?.blockedUserIds, - contains(otherBlockedId), - ); - - expect( - tester.currentUser?.blockedUserIds, - isNot(contains(blockedUserId)), - ); - - tester - ..verifyApi((api) => api.user.unblockUser(blockedUserId)) - ..verifyNoMoreApiInteractions((api) => api.user); - }, - ); - - chatClientTest( - 'unblockUser should be resilient if user ID not in blocked list', - body: (tester) async { - const nonBlockedUserId = 'not-in-list'; - const otherBlockedId = 'other-blocked-id'; - tester.clientState.blockedUserIds = const [otherBlockedId]; - - // Verify initial state - expect( - tester.currentUser?.blockedUserIds, - contains(otherBlockedId), - ); - - expect( - tester.currentUser?.blockedUserIds, - isNot(contains(nonBlockedUserId)), - ); - - tester.mockApi( - (api) => api.user.unblockUser(nonBlockedUserId), - result: createDefaultEmptyResponse(), - ); - - await tester.client.unblockUser(nonBlockedUserId); - - // Verify - should remain unchanged - expect(tester.currentUser?.blockedUserIds, contains(otherBlockedId)); - expect(tester.currentUser?.blockedUserIds, isNot(contains(nonBlockedUserId))); - tester - ..verifyApi((api) => api.user.unblockUser(nonBlockedUserId)) - ..verifyNoMoreApiInteractions((api) => api.user); - }, - ); - - chatClientTest( - 'queryBlockedUsers should update client state with blockedUserIds', - body: (tester) async { - const blockedId1 = 'blocked-1'; - const blockedId2 = 'blocked-2'; - - // Verify initial state - 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 - tester.mockApi( - (api) => api.user.queryBlockedUsers(), - result: createDefaultBlockedUsersResponse( - blocks: [ - UserBlock( - user: user, - userId: user.id, - blockedUser: blockedUser1, - blockedUserId: blockedId1, - ), - UserBlock( - user: user, - userId: user.id, - blockedUser: blockedUser2, - blockedUserId: blockedId2, - ), - ], - ), - ); - - await tester.client.queryBlockedUsers(); - - // Verify - should now include both blocked IDs - expect( - tester.currentUser?.blockedUserIds, - containsAll([blockedId1, blockedId2]), - ); - - tester - ..verifyApi((api) => api.user.queryBlockedUsers()) - ..verifyNoMoreApiInteractions((api) => api.user); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/client_channel_api_test.dart b/packages/stream_chat/test/src/client/client_channel_api_test.dart deleted file mode 100644 index 9d7be3a076..0000000000 --- a/packages/stream_chat/test/src/client/client_channel_api_test.dart +++ /dev/null @@ -1,864 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -const _channelId = 'test-channel-id'; -const _channelType = 'test-channel-type'; -const _channelCid = '$_channelType:$_channelId'; -const _channelData = {'name': 'test-channel-name'}; - -void main() { - group('`.channel`', () { - chatClientTest( - 'should return back a new channel instance', - body: (tester) { - final channel = tester.client.channel( - _channelType, - id: _channelId, - extraData: _channelData, - ); - - expect(channel, isNotNull); - expect(channel.type, _channelType); - expect(channel.id, _channelId); - expect(channel.cid, _channelCid); - expect(channel.extraData, _channelData); - }, - ); - - chatClientTest( - 'should return back in memory channel instance if available', - body: (tester) async { - final channel = tester.client.channel( - _channelType, - id: _channelId, - extraData: _channelData, - ); - - final channelState = createDefaultChannelState( - channel: createDefaultChannelModel(cid: _channelCid), - ); - - tester.mockApi( - (api) => api.channel.queryChannel( - _channelType, - channelId: _channelId, - channelData: _channelData, - state: true, - watch: true, - presence: false, - ), - result: channelState, - ); - - final channelsEmission = expectLater( - tester.clientState.channelsStream.skip(1), - emitsInOrder([ - {_channelCid: isCorrectChannelFor(channelState)}, - ]), - ); - - await channel.watch(); - await channelsEmission; - - final newChannel = tester.client.channel(_channelType, id: _channelId); - expect(newChannel, channel); - - tester.verifyApi( - (api) => api.channel.queryChannel( - _channelType, - channelId: _channelId, - channelData: _channelData, - state: true, - watch: true, - presence: false, - ), - ); - }, - ); - }); - - chatClientTest( - '`.createChannel`', - body: (tester) async { - final channelState = createDefaultChannelState( - channel: createDefaultChannelModel(cid: _channelCid, extraData: _channelData), - ); - - tester.mockApi( - (api) => api.channel.queryChannel( - _channelType, - channelId: _channelId, - channelData: _channelData, - state: false, - ), - result: channelState, - ); - - final res = await tester.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, _channelCid); - expect(channel.extraData, _channelData); - - tester - ..verifyApi( - (api) => api.channel.queryChannel( - _channelType, - channelId: _channelId, - channelData: _channelData, - state: false, - ), - ) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - chatClientTest( - '`.watchChannel`', - body: (tester) async { - final channelState = createDefaultChannelState( - channel: createDefaultChannelModel(cid: _channelCid, extraData: _channelData), - ); - - tester.mockApi( - (api) => api.channel.queryChannel( - _channelType, - channelId: _channelId, - channelData: _channelData, - watch: true, - ), - result: channelState, - ); - - final res = await tester.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, _channelCid); - expect(channel.extraData, _channelData); - - tester - ..verifyApi( - (api) => api.channel.queryChannel( - _channelType, - channelId: _channelId, - channelData: _channelData, - watch: true, - ), - ) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - chatClientTest( - '`.queryChannel`', - body: (tester) async { - final channelState = createDefaultChannelState( - channel: createDefaultChannelModel(cid: _channelCid, extraData: _channelData), - ); - - tester.mockApi( - (api) => api.channel.queryChannel( - _channelType, - channelId: _channelId, - channelData: _channelData, - ), - result: channelState, - ); - - final res = await tester.client.queryChannel( - _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, _channelCid); - expect(channel.extraData, _channelData); - - tester - ..verifyApi( - (api) => api.channel.queryChannel( - _channelType, - channelId: _channelId, - channelData: _channelData, - ), - ) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - chatClientTest( - '`.queryMembers`', - body: (tester) async { - final members = List.generate( - 3, - (index) => Member(userId: 'test-user-id-$index'), - ); - - tester.mockApi( - (api) => api.general.queryMembers(_channelType), - result: QueryMembersResponse()..members = members, - ); - - final res = await tester.client.queryMembers(_channelType); - expect(res, isNotNull); - expect(res.members.length, members.length); - - tester - ..verifyApi((api) => api.general.queryMembers(_channelType)) - ..verifyApi((api) => api.general.getAppSettings()) - ..verifyNoMoreApiInteractions((api) => api.general); - }, - ); - - chatClientTest( - '`.hideChannel`', - body: (tester) async { - tester.mockApi( - (api) => api.channel.hideChannel(_channelId, _channelType), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.hideChannel(_channelId, _channelType); - - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.channel.hideChannel(_channelId, _channelType)) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - chatClientTest( - '`.showChannel`', - body: (tester) async { - tester.mockApi( - (api) => api.channel.showChannel(_channelId, _channelType), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.showChannel(_channelId, _channelType); - - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.channel.showChannel(_channelId, _channelType)) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - chatClientTest( - '`.deleteChannel`', - body: (tester) async { - tester.mockApi( - (api) => api.channel.deleteChannel(_channelId, _channelType), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.deleteChannel(_channelId, _channelType); - - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.channel.deleteChannel(_channelId, _channelType)) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - chatClientTest( - '`.truncateChannel`', - body: (tester) async { - tester.mockApi( - (api) => api.channel.truncateChannel(_channelId, _channelType), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.truncateChannel(_channelId, _channelType); - - expect(res, isNotNull); - - tester - ..verifyApi( - (api) => api.channel.truncateChannel(_channelId, _channelType), - ) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - chatClientTest( - '`.muteChannel`', - body: (tester) async { - tester.mockApi( - (api) => api.moderation.muteChannel(_channelCid), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.muteChannel(_channelCid); - - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.moderation.muteChannel(_channelCid)) - ..verifyNoMoreApiInteractions((api) => api.moderation); - }, - ); - - chatClientTest( - '`.unmuteChannel`', - body: (tester) async { - tester.mockApi( - (api) => api.moderation.unmuteChannel(_channelCid), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.unmuteChannel(_channelCid); - - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.moderation.unmuteChannel(_channelCid)) - ..verifyNoMoreApiInteractions((api) => api.moderation); - }, - ); - - 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), - ), - ); - - final res = await tester.client.partialMemberUpdate( - channelId: _channelId, - channelType: _channelType, - set: set, - unset: unset, - ); - - 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); - }, - ); - - 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), - ), - ); - - final res = await tester.client.partialMemberUpdate( - channelId: _channelId, - channelType: _channelType, - set: set, - unset: unset, - ); - - 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); - }, - ); - - 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 tester.client.pinChannel( - channelId: _channelId, - channelType: _channelType, - ); - - expect(res, isNotNull); - - tester - ..verifyApi( - (api) => api.channel.updateMemberPartial( - channelId: _channelId, - channelType: _channelType, - set: const MemberUpdatePayload(pinned: true).toJson(), - ), - ) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - 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)), - ), - ); - - final res = await tester.client.unpinChannel( - channelId: _channelId, - channelType: _channelType, - ); - - expect(res, isNotNull); - - tester - ..verifyApi( - (api) => api.channel.updateMemberPartial( - channelId: _channelId, - channelType: _channelType, - unset: [MemberUpdateType.pinned.name], - ), - ) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - 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)), - ), - ); - - final res = await tester.client.archiveChannel( - channelId: _channelId, - channelType: _channelType, - ); - - expect(res, isNotNull); - - tester - ..verifyApi( - (api) => api.channel.updateMemberPartial( - channelId: _channelId, - channelType: _channelType, - set: const MemberUpdatePayload(archived: true).toJson(), - ), - ) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - 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 res = await tester.client.unarchiveChannel( - channelId: _channelId, - channelType: _channelType, - ); - - expect(res, isNotNull); - - tester - ..verifyApi( - (api) => api.channel.updateMemberPartial( - channelId: _channelId, - channelType: _channelType, - unset: [MemberUpdateType.archived.name], - ), - ) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - chatClientTest( - '`.acceptChannelInvite`', - body: (tester) async { - tester.mockApi( - (api) => api.channel.acceptChannelInvite(_channelId, _channelType), - result: AcceptInviteResponse()..channel = createDefaultChannelModel(cid: _channelCid), - ); - - final res = await tester.client.acceptChannelInvite(_channelId, _channelType); - expect(res, isNotNull); - expect(res.channel.cid, _channelCid); - - tester - ..verifyApi((api) => api.channel.acceptChannelInvite(_channelId, _channelType)) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - chatClientTest( - '`.rejectChannelInvite`', - body: (tester) async { - tester.mockApi( - (api) => api.channel.rejectChannelInvite(_channelId, _channelType), - result: RejectInviteResponse()..channel = createDefaultChannelModel(cid: _channelCid), - ); - - final res = await tester.client.rejectChannelInvite(_channelId, _channelType); - expect(res, isNotNull); - expect(res.channel.cid, _channelCid); - - tester - ..verifyApi((api) => api.channel.rejectChannelInvite(_channelId, _channelType)) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - chatClientTest( - '`.addChannelMembers`', - 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); - - tester.mockApi( - (api) => api.channel.addMembers(_channelId, _channelType, memberIds), - result: createDefaultAddMembersResponse( - channel: createDefaultChannelModel(cid: _channelCid), - members: members, - ), - ); - - final res = await tester.client.addChannelMembers( - _channelId, - _channelType, - memberIds, - ); - - 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); - }, - ); - - 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 hideHistoryBefore = DateTime.parse('2024-01-01T00:00:00Z'); - - tester.mockApi( - (api) => api.channel.addMembers( - _channelId, - _channelType, - memberIds, - hideHistoryBefore: hideHistoryBefore, - ), - result: createDefaultAddMembersResponse( - channel: createDefaultChannelModel(cid: _channelCid), - members: members, - ), - ); - - 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); - - tester - ..verifyApi( - (api) => api.channel.addMembers( - _channelId, - _channelType, - memberIds, - hideHistoryBefore: hideHistoryBefore, - ), - ) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - chatClientTest( - '`.removeChannelMembers`', - 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); - - tester.mockApi( - (api) => api.channel.removeMembers(_channelId, _channelType, memberIds), - result: RemoveMembersResponse() - ..channel = createDefaultChannelModel(cid: _channelCid) - ..members = members, - ); - - final res = await tester.client.removeChannelMembers( - _channelId, - _channelType, - memberIds, - ); - - 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); - }, - ); - - chatClientTest( - '`.inviteChannelMembers`', - 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); - - tester.mockApi( - (api) => api.channel.inviteChannelMembers(_channelId, _channelType, memberIds), - result: InviteMembersResponse() - ..channel = createDefaultChannelModel(cid: _channelCid) - ..members = members, - ); - - final res = await tester.client.inviteChannelMembers( - _channelId, - _channelType, - memberIds, - ); - - expect(res, isNotNull); - expect(res.channel.cid, _channelCid); - expect(res.members.length, memberIds.length); - - tester - ..verifyApi((api) => api.channel.inviteChannelMembers(_channelId, _channelType, memberIds)) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - chatClientTest( - '`.stopChannelWatching`', - body: (tester) async { - tester.mockApi( - (api) => api.channel.stopWatching(_channelId, _channelType), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.stopChannelWatching(_channelId, _channelType); - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.channel.stopWatching(_channelId, _channelType)) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - chatClientTest( - '`.sendAction`', - body: (tester) async { - const messageId = 'test-message-id'; - const formData = {'key': 'value'}; - - tester.mockApi( - (api) => api.message.sendAction(_channelId, _channelType, messageId, formData), - result: createDefaultSendActionResponse(), - ); - - final res = await tester.client.sendAction( - _channelId, - _channelType, - messageId, - formData, - ); - - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.message.sendAction(_channelId, _channelType, messageId, formData)) - ..verifyNoMoreApiInteractions((api) => api.message); - }, - ); - - chatClientTest( - '`.markChannelRead`', - body: (tester) async { - tester.mockApi( - (api) => api.channel.markRead(_channelId, _channelType), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.markChannelRead(_channelId, _channelType); - - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.channel.markRead(_channelId, _channelType)) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - chatClientTest( - '`.markChannelUnread`', - body: (tester) async { - const messageId = 'test-message-id'; - - tester.mockApi( - (api) => api.channel.markUnread(_channelId, _channelType, messageId), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.markChannelUnread( - _channelId, - _channelType, - messageId, - ); - - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.channel.markUnread(_channelId, _channelType, messageId)) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); - - chatClientTest( - '`.markChannelUnreadByTimestamp`', - body: (tester) async { - final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); - - tester.mockApi( - (api) => api.channel.markUnreadByTimestamp( - _channelId, - _channelType, - timestamp, - ), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.markChannelUnreadByTimestamp( - _channelId, - _channelType, - timestamp, - ); - - expect(res, isNotNull); - - tester - ..verifyApi( - (api) => api.channel.markUnreadByTimestamp( - _channelId, - _channelType, - timestamp, - ), - ) - ..verifyNoMoreApiInteractions((api) => api.channel); - }, - ); -} diff --git a/packages/stream_chat/test/src/client/client_connection_test.dart b/packages/stream_chat/test/src/client/client_connection_test.dart deleted file mode 100644 index 41a2525ff2..0000000000 --- a/packages/stream_chat/test/src/client/client_connection_test.dart +++ /dev/null @@ -1,319 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -// NOTE(migration): the `.connectAnonymousUser` tests are not here — the -// anonymous token's user id cannot pass the fake server's connect-URI -// validation without the TokenManager seam. They live in -// client_reconnection_test.dart, on a hand-rolled fake socket, until the seam -// lands. -void main() { - group('Fake web-socket connection functions', () { - chatClientTest( - '`.connectUser` should work fine', - connect: (tester) => tester.mockSuccessfulAuth(), - body: (tester) async { - final user = tester.user; - final token = createTestToken(user.id).rawValue; - - final statusEmitted = expectLater( - // skipping first seed status -> ConnectionStatus.disconnected - tester.client.wsConnectionStatusStream.skip(1), - emitsInOrder([ - ConnectionStatus.connecting, - ConnectionStatus.connected, - ]), - ); - - final res = await tester.client.connectUser(user, token); - expect(res, isNotNull); - expect(res, isSameUserAs(user)); - - await statusEmitted; - }, - ); - - 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).rawValue; - } - - final statusEmitted = expectLater( - // skipping first seed status -> ConnectionStatus.disconnected - tester.client.wsConnectionStatusStream.skip(1), - emitsInOrder([ - ConnectionStatus.connecting, - ConnectionStatus.connected, - ]), - ); - - final res = await tester.client.connectUserWithProvider(user, tokenProvider); - expect(res, isNotNull); - expect(res, isSameUserAs(user)); - - await statusEmitted; - }, - ); - - 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), - ); - - final statusEmitted = expectLater( - // skipping first seed status -> ConnectionStatus.disconnected - tester.client.wsConnectionStatusStream.skip(1), - emitsInOrder([ - ConnectionStatus.connecting, - ConnectionStatus.connected, - ]), - ); - - final res = await tester.client.connectGuestUser(user); - expect(res, isNotNull); - expect(res, isSameUserAs(user)); - - tester.verifyApi( - (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), - ); - - 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(errorCode: ChatErrorCode.inputError), - ); - - 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; - }, - ); - }); - - group('`.openConnection`', () { - chatClientTest( - 'should throw if state does not contain user', - connect: (_) {}, - body: (tester) async { - expect(tester.currentUser, isNull); - await expectLater( - tester.client.openConnection(), - throwsA(isA()), - ); - }, - ); - - chatClientTest( - 'should throw if connection is already available', - connect: (tester) => tester.mockSuccessfulAuth(), - body: (tester) async { - expect(tester.currentUser, isNull); - - // 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); - - await expectLater( - tester.client.openConnection(), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('Connection 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, - ]), - ); - - // 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); - - tester.client.closeConnection(); - - await tester.client.openConnection(); - - await statusEmitted; - }, - ); - }); - }); - - group('Fake web-socket connection functions failure', () { - 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; - - await expectLater( - tester.client.connectUser(user, token), - throwsA(isA()), - ); - }, - ); - - chatClientTest( - '`.connectUserWithProvider` should throw if `ws.connect` fails', - connect: (tester) => tester.mockConnectionError(), - body: (tester) async { - final user = tester.user; - Future tokenProvider(String userId) async { - expect(userId, user.id); - return createTestToken(userId).rawValue; - } - - await expectLater( - tester.client.connectUserWithProvider(user, tokenProvider), - throwsA(isA()), - ); - }, - ); - - 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; - - tester.mockApi( - (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), - result: createDefaultConnectGuestUserResponse(user: user, accessToken: token), - ); - - await expectLater( - tester.client.connectGuestUser(user), - throwsA(isA()), - ); - - tester.verifyApi( - (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), - ); - }, - ); - }); - - group('Connect user calls with `connectWebSocket`: false', () { - chatClientTest( - '`.connectUser` should succeed without connecting', - connect: (_) {}, - body: (tester) async { - final user = tester.user; - final token = createTestToken(user.id).rawValue; - - final res = await tester.client.connectUser( - user, - token, - connectWebSocket: false, - ); - expect(res, isSameUserAs(user)); - expect(tester.connectionStatus, ConnectionStatus.disconnected); - }, - ); - - chatClientTest( - '`.connectUserWithProvider` should succeed without connecting', - connect: (_) {}, - body: (tester) async { - final user = tester.user; - Future tokenProvider(String userId) async { - expect(userId, user.id); - return createTestToken(userId).rawValue; - } - - final res = await tester.client.connectUserWithProvider( - user, - tokenProvider, - connectWebSocket: false, - ); - expect(res, isSameUserAs(user)); - expect(tester.connectionStatus, ConnectionStatus.disconnected); - }, - ); - - chatClientTest( - '`.connectGuestUser` should succeed without connecting', - connect: (_) {}, - 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), - ); - - final res = await tester.client.connectGuestUser( - user, - connectWebSocket: false, - ); - - expect(res, isSameUserAs(user)); - expect(tester.connectionStatus, ConnectionStatus.disconnected); - tester.verifyApi( - (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), - ); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/client_default_wiring_test.dart b/packages/stream_chat/test/src/client/client_default_wiring_test.dart deleted file mode 100644 index 06e93822e2..0000000000 --- a/packages/stream_chat/test/src/client/client_default_wiring_test.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -// Every other client test injects a `chatApi`, so nothing exercises the -// constructor's default REST stack. This pins it: a client built without one -// has to reach the network through a real `StreamChatApi` / `StreamHttpClient` -// carrying the api key and the connected user's credentials. -void main() { - setUpAll(() => registerFallbackValue(RequestOptions())); - - test('builds a REST stack carrying the api key and user credentials', () async { - const apiKey = 'test-api-key'; - final adapter = _MockHttpClientAdapter(); - - when(() => adapter.fetch(any(), any(), any())).thenAnswer( - (_) async => ResponseBody.fromString( - '{"app":{}}', - 200, - headers: { - Headers.contentTypeHeader: [Headers.jsonContentType], - }, - ), - ); - - final client = StreamChatClient(apiKey, httpClientAdapter: adapter); - addTearDown(client.dispose); - - final user = User(id: 'test-user-id'); - final token = Token.development(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 {} diff --git a/packages/stream_chat/test/src/client/client_events_test.dart b/packages/stream_chat/test/src/client/client_events_test.dart deleted file mode 100644 index 26c868d847..0000000000 --- a/packages/stream_chat/test/src/client/client_events_test.dart +++ /dev/null @@ -1,138 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -void main() { - group('WS events', () { - group('User messages deleted event', () { - chatClientTest( - 'should broadcast global user.messages.deleted event to all channels', - body: (tester) async { - // Add messages from the user to be deleted - final bannedUser = User(id: 'banned-user', name: 'Banned User'); - final message1 = Message( - id: 'msg-1', - text: 'Message in channel 1', - user: bannedUser, - ); - final message2 = Message( - id: 'msg-2', - text: 'Message in channel 2', - user: bannedUser, - ); - - // Setup: Create multiple channels with state - final channelState1 = createDefaultChannelState( - channel: createDefaultChannelModel(cid: 'messaging:channel-1'), - messages: [message1], - ); - final channelState2 = createDefaultChannelState( - channel: createDefaultChannelModel(cid: 'messaging:channel-2'), - messages: [message2], - ); - - final channel1 = Channel.fromState(tester.client, channelState1); - final channel2 = Channel.fromState(tester.client, channelState2); - - // Register channels in client state - tester.clientState.addChannels({ - 'messaging:channel-1': channel1, - 'messaging:channel-2': channel2, - }); - - // Verify initial state - expect(channel1.state?.messages.length, equals(1)); - expect(channel2.state?.messages.length, equals(1)); - - // 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, - ); - - await tester.emitEvent(event); - - // Verify messages are soft deleted in all channels - final channel1Message = channel1.state?.messages.first; - expect(channel1Message?.type, equals(MessageType.deleted)); - expect(channel1Message?.state.isDeleted, isTrue); - - final channel2Message = channel2.state?.messages.first; - expect(channel2Message?.type, equals(MessageType.deleted)); - expect(channel2Message?.state.isDeleted, isTrue); - }, - ); - - chatClientTest( - 'should broadcast global hard delete to all channels', - 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'); - - final message1 = Message( - id: 'msg-1', - text: 'Message in channel 1', - user: bannedUser, - ); - final message2 = Message( - id: 'msg-2', - text: 'Message in channel 2', - user: bannedUser, - ); - final message3 = Message( - id: 'msg-3', - text: 'Safe message', - user: otherUser, - ); - - // Setup: Create multiple channels with state - final channelState1 = createDefaultChannelState( - channel: createDefaultChannelModel(cid: 'messaging:channel-1'), - messages: [message1, message3], - ); - final channelState2 = createDefaultChannelState( - channel: createDefaultChannelModel(cid: 'messaging:channel-2'), - messages: [message2], - ); - - final channel1 = Channel.fromState(tester.client, channelState1); - final channel2 = Channel.fromState(tester.client, channelState2); - - // Register channels in client state - tester.clientState.addChannels({ - 'messaging:channel-1': channel1, - 'messaging:channel-2': channel2, - }); - - // Verify initial state - expect(channel1.state?.messages.length, equals(2)); - expect(channel2.state?.messages.length, equals(1)); - - // 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, - ); - - await tester.emitEvent(event); - - // Verify banned user's messages are removed from all channels - expect(channel1.state?.messages.length, equals(1)); - expect( - channel1.state?.messages.any((m) => m.user?.id == 'banned-user'), - isFalse, - ); - expect(channel2.state?.messages.length, equals(0)); - - // Verify other user's message is unaffected - final safeMessage = channel1.state?.messages.firstWhere((m) => m.id == 'msg-3'); - expect(safeMessage?.user?.id, equals('other-user')); - }, - ); - }); - }); -} diff --git a/packages/stream_chat/test/src/client/client_live_location_test.dart b/packages/stream_chat/test/src/client/client_live_location_test.dart deleted file mode 100644 index 467698a613..0000000000 --- a/packages/stream_chat/test/src/client/client_live_location_test.dart +++ /dev/null @@ -1,279 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -void main() { - 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)), - ); - - final event = createDefaultEvent( - type: EventType.locationShared, - cid: 'test-channel:123', - message: Message( - id: 'message-123', - sharedLocation: location, - ), - ); - - // Initially empty - expect(tester.clientState.activeLiveLocations, isEmpty); - - // Trigger the event - await tester.emitEvent(event); - - // Should add location to active live locations - final activeLiveLocations = tester.clientState.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations.first.messageId, equals('message-123')); - }, - ); - - 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)), - ); - - // Set initial location - tester.clientState.activeLiveLocations = [initialLocation]; - - 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)), - ); - - final event = createDefaultEvent( - type: EventType.locationUpdated, - cid: 'test-channel:123', - message: Message( - id: 'message-123', - sharedLocation: updatedLocation, - ), - ); - - // Trigger the event - await tester.emitEvent(event); - - // 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: tester.currentUser!.id, - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device-1', - endAt: DateTime.timestamp().add(const Duration(hours: 1)), - ); - - // Set initial location - tester.clientState.activeLiveLocations = [location]; - expect(tester.clientState.activeLiveLocations, hasLength(1)); - - final expiredLocation = location.copyWith( - endAt: DateTime.timestamp().subtract(const Duration(hours: 1)), - ); - - final event = createDefaultEvent( - type: EventType.locationExpired, - cid: 'test-channel:123', - message: Message( - id: 'message-123', - sharedLocation: expiredLocation, - ), - ); - - // Trigger the event - await tester.emitEvent(event); - - // Should remove the location - expect(tester.clientState.activeLiveLocations, isEmpty); - }, - ); - - 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); - - // 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)); - }, - ); - - 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)), - ); - - final event = createDefaultEvent( - type: EventType.locationShared, - cid: 'test-channel:123', - message: Message( - id: 'message-123', - sharedLocation: location, - ), - ); - - // Trigger the event - await tester.emitEvent(event); - - // Should not add location from other user - expect(tester.clientState.activeLiveLocations, isEmpty); - }, - ); - - 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 - ); - - final event = createDefaultEvent( - type: EventType.locationShared, - cid: 'test-channel:123', - message: Message( - id: 'message-123', - sharedLocation: staticLocation, - ), - ); - - // Trigger the event - await tester.emitEvent(event); - - // Should not add static location - expect(tester.clientState.activeLiveLocations, isEmpty); - }, - ); - - 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 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)), - ); - - final event1 = createDefaultEvent( - type: EventType.locationShared, - cid: 'test-channel:123', - message: Message( - id: 'message-123', - sharedLocation: location1, - ), - ); - - final event2 = createDefaultEvent( - type: EventType.locationShared, - cid: 'test-channel:123', - message: Message( - id: 'message-456', - sharedLocation: location2, - ), - ); - - // Trigger first event - await tester.emitEvent(event1); - - 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); - - final activeLiveLocations2 = tester.clientState.activeLiveLocations; - expect(activeLiveLocations2, hasLength(1)); - expect(activeLiveLocations2.first.messageId, equals('message-456')); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/client_persistence_connection_test.dart b/packages/stream_chat/test/src/client/client_persistence_connection_test.dart deleted file mode 100644 index 920da8df2a..0000000000 --- a/packages/stream_chat/test/src/client/client_persistence_connection_test.dart +++ /dev/null @@ -1,310 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -// 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), - ); -} - -// 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); - }, - ); -} - -void main() { - group('Fake web-socket connection function with failure and persistence', () { - _failedConnectionWithPersistenceTest( - '`.connectUser` should connect successfully if persistence contains event', - body: (tester, persistence) async { - final user = tester.user; - - final event = Event( - type: EventType.healthCheck, - connectionId: 'test-connection-id', - me: OwnUser.fromUser(user), - ); - when(persistence.getConnectionInfo).thenAnswer((_) async => event); - - final res = await tester.client.connectUser(user, createTestToken(user.id).rawValue); - expect(res, isNotNull); - expect(res, isSameUserAs(user)); - - verify(persistence.getConnectionInfo).called(1); - verifyNoMoreInteractions(persistence); - }, - ); - - _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 createTestToken(userId).rawValue; - } - - final event = Event( - type: EventType.healthCheck, - connectionId: 'test-connection-id', - me: OwnUser.fromUser(user), - ); - when(persistence.getConnectionInfo).thenAnswer((_) async => event); - - final res = await tester.client.connectUserWithProvider(user, tokenProvider); - expect(res, isNotNull); - expect(res, isSameUserAs(user)); - - verify(persistence.getConnectionInfo).called(1); - verifyNoMoreInteractions(persistence); - }, - ); - - _failedConnectionWithPersistenceTest( - '`.connectGuestUser` should connect successfully if persistence contains event', - body: (tester, persistence) async { - final user = tester.user; - - final event = Event( - type: EventType.healthCheck, - connectionId: 'test-connection-id', - me: OwnUser.fromUser(user), - ); - when(persistence.getConnectionInfo).thenAnswer((_) async => event); - - tester.mockApi( - (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), - result: createDefaultConnectGuestUserResponse(user: user), - ); - - final res = await tester.client.connectGuestUser(user); - expect(res, isNotNull); - expect(res, isSameUserAs(user)); - - verify(persistence.getConnectionInfo).called(1); - verifyNoMoreInteractions(persistence); - tester - ..verifyApi((api) => api.guest.getGuestUser(any(that: isSameUserAs(user)))) - ..verifyNoMoreApiInteractions((api) => api.guest); - }, - ); - }); - - group('PersistenceConnectionTests', () { - _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); - }, - ); - - _persistenceConnectionTest( - '''multiple call to openPersistenceConnection does not throws an error if already connected to the same user''', - 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); - }, - ); - - _persistenceConnectionTest( - '''openPersistenceConnection throws an error if client is already connected to a different user''', - 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.copyWith(id: 'new-id')), - throwsA(const TypeMatcher()), - ); - }, - ); - - _persistenceConnectionTest( - '''openPersistenceConnection throws an error if chatPersistenceClient is not set''', - body: (tester) async { - await expectLater( - tester.client.openPersistenceConnection(tester.user), - throwsA(const TypeMatcher()), - ); - }, - ); - - _persistenceConnectionTest( - 'closePersistenceConnection disconnects the client', - body: (tester) async { - tester.client.chatPersistenceClient = MockPersistenceClient(); - await tester.client.openPersistenceConnection(tester.user); - expect(tester.client.persistenceEnabled, isTrue); - - await tester.client.closePersistenceConnection(); - expect(tester.client.persistenceEnabled, isFalse); - }, - ); - - _persistenceConnectionTest( - '''closePersistenceConnection compeletes normally if chatPersistenceClient is not connected''', - body: (tester) async { - tester.client.chatPersistenceClient = MockPersistenceClient(); - expect(tester.client.chatPersistenceClient!.isConnected, isFalse); - - await expectLater(tester.client.closePersistenceConnection(), completes); - }, - ); - - _persistenceConnectionTest( - '''closePersistenceConnection completes normally if chatPersistenceClient is not set''', - body: (tester) async { - expect(tester.client.persistenceEnabled, isFalse); - await expectLater(tester.client.closePersistenceConnection(), completes); - }, - ); - - _persistenceConnectionTest( - '''connectUser completes normally if the persistence connection is already connected to the same user''', - body: (tester) async { - tester.client.chatPersistenceClient = MockPersistenceClient(); - await tester.client.openPersistenceConnection(tester.user); - expect(tester.client.persistenceEnabled, isTrue); - - await expectLater( - tester.client.connectUser( - tester.user, - createTestToken(tester.user.id).rawValue, - connectWebSocket: false, - ), - completes, - ); - }, - ); - - _persistenceConnectionTest( - '''connectUser should throw if the persistence connection if already connected to a different user''', - body: (tester) async { - tester.client.chatPersistenceClient = MockPersistenceClient(); - await tester.client.openPersistenceConnection(tester.user.copyWith(id: 'new-id')); - expect(tester.client.persistenceEnabled, isTrue); - - await expectLater( - tester.client.connectUser( - tester.user, - createTestToken(tester.user.id).rawValue, - connectWebSocket: false, - ), - throwsA(const TypeMatcher()), - ); - }, - ); - - group('Sync Method Tests', () { - setUpAll(() { - // fallback values - registerFallbackValue([]); - registerFallbackValue(DateTime(0)); - }); - - _persistenceConnectionTest( - 'should retrieve data from persistence client and sync successfully', - body: (tester) async { - final cids = ['channel1', 'channel2']; - final lastSyncAt = DateTime.utc(2021, 3); - final fakeClient = FakePersistenceClient( - channelCids: cids, - lastSyncAt: lastSyncAt, - ); - - tester.client.chatPersistenceClient = fakeClient; - tester.mockApi( - (api) => api.general.sync(cids, lastSyncAt), - result: createDefaultSyncResponse(), - ); - - await tester.client.sync(); - - tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); - - final newLastSyncAt = await fakeClient.getLastSyncAt(); - expect(newLastSyncAt?.isAfter(lastSyncAt), isTrue); - }, - ); - - _persistenceConnectionTest( - 'should set lastSyncAt on first sync when null', - body: (tester) async { - final fakeClient = FakePersistenceClient( - channelCids: ['channel1'], - lastSyncAt: null, - ); - - tester.client.chatPersistenceClient = fakeClient; - - await tester.client.sync(); - - expectLater(fakeClient.getLastSyncAt(), completion(isNotNull)); - tester.verifyNeverCalled((api) => api.general.sync(any(), any())); - }, - ); - - _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, - ); - - tester.client.chatPersistenceClient = fakeClient; - tester.mockApiFailure( - (api) => api.general.sync(cids, lastSyncAt), - error: StreamChatNetworkError.raw( - code: 4, - statusCode: 400, - message: 'Too many events', - ), - ); - - await tester.client.sync(); - - expect(await fakeClient.getChannelCids(), isEmpty); // Should be flushed - - tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); - }, - ); - }); - }); -} diff --git a/packages/stream_chat/test/src/client/client_pin_message_test.dart b/packages/stream_chat/test/src/client/client_pin_message_test.dart deleted file mode 100644 index 310f9c4c6d..0000000000 --- a/packages/stream_chat/test/src/client/client_pin_message_test.dart +++ /dev/null @@ -1,188 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -void main() { - group('`.pinMessage`', () { - 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 tester.client.pinMessage(messageId); - - expect(res, isNotNull); - expect(res.message.pinned, isTrue); - expect(res.message.pinExpires, isNull); - - tester - ..verifyApi( - (api) => api.message.partialUpdateMessage( - messageId, - set: {'pinned': true, 'pin_expires': null}, - ), - ) - ..verifyNoMoreApiInteractions((api) => api.message); - }, - ); - - chatClientTest( - 'should work fine if passed timeoutOrExpirationDate as num(seconds)', - body: (tester) async { - const messageId = 'test-message-id'; - final message = Message(id: messageId); - const timeoutOrExpirationDate = 300; // 300 seconds - - // 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'), - ), - result: createDefaultUpdateMessageResponse( - message: message.copyWith( - pinned: true, - pinExpires: DateTime.utc(2021, 3).add( - const Duration(seconds: timeoutOrExpirationDate), - ), - state: MessageState.sent, - ), - ), - ); - - final res = await tester.client.pinMessage( - messageId, - timeoutOrExpirationDate: timeoutOrExpirationDate, - ); - - expect(res, isNotNull); - expect(res.message.pinned, isTrue); - expect(res.message.pinExpires, isNotNull); - - tester - ..verifyApi( - (api) => api.message.partialUpdateMessage( - messageId, - set: any(named: 'set'), - ), - ) - ..verifyNoMoreApiInteractions((api) => api.message); - }, - ); - - chatClientTest( - 'should work fine if passed timeoutOrExpirationDate as DateTime', - body: (tester) async { - const messageId = 'test-message-id'; - final message = Message(id: messageId); - final timeoutOrExpirationDate = DateTime.utc(2021, 3).add(const Duration(days: 3)); // 3 days - - tester.mockApi( - (api) => api.message.partialUpdateMessage( - messageId, - set: { - 'pinned': true, - 'pin_expires': timeoutOrExpirationDate.toUtc().toIso8601String(), - }, - ), - result: createDefaultUpdateMessageResponse( - message: message.copyWith( - pinned: true, - pinExpires: timeoutOrExpirationDate, - state: MessageState.sent, - ), - ), - ); - - final res = await tester.client.pinMessage( - messageId, - timeoutOrExpirationDate: timeoutOrExpirationDate, - ); - - expect(res, isNotNull); - expect(res.message.pinned, isTrue); - expect(res.message.pinExpires, isNotNull); - expect(res.message.pinExpires, timeoutOrExpirationDate.toUtc()); - - tester - ..verifyApi( - (api) => api.message.partialUpdateMessage( - messageId, - set: { - 'pinned': true, - 'pin_expires': timeoutOrExpirationDate.toUtc().toIso8601String(), - }, - ), - ) - ..verifyNoMoreApiInteractions((api) => api.message); - }, - ); - - chatClientTest( - 'should throw if invalid timeoutOrExpirationDate is passed', - body: (tester) async { - const messageId = 'test-message-id'; - const timeoutOrExpirationDate = 'invalid-value'; - - try { - await tester.client.pinMessage( - messageId, - timeoutOrExpirationDate: timeoutOrExpirationDate, - ); - } catch (e) { - expect(e, isA()); - } - }, - ); - }); - - chatClientTest( - '`.unpinMessage`', - body: (tester) async { - const messageId = 'test-message-id'; - final message = Message(id: messageId, pinned: true); - - tester.mockApi( - (api) => api.message.partialUpdateMessage( - messageId, - set: {'pinned': false}, - ), - result: createDefaultUpdateMessageResponse( - message: message.copyWith( - pinned: false, - state: MessageState.sent, - ), - ), - ); - - final res = await tester.client.unpinMessage(messageId); - - expect(res, isNotNull); - expect(res.message.pinned, isFalse); - - tester - ..verifyApi( - (api) => api.message.partialUpdateMessage( - messageId, - set: {'pinned': false}, - ), - ) - ..verifyNoMoreApiInteractions((api) => api.message); - }, - ); -} diff --git a/packages/stream_chat/test/src/client/client_polls_api_test.dart b/packages/stream_chat/test/src/client/client_polls_api_test.dart deleted file mode 100644 index ca32387b74..0000000000 --- a/packages/stream_chat/test/src/client/client_polls_api_test.dart +++ /dev/null @@ -1,377 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -void main() { - chatClientTest( - '`.createPoll`', - body: (tester) async { - final poll = createDefaultPoll(); - - tester.mockApi( - (api) => api.polls.createPoll(poll), - result: CreatePollResponse()..poll = poll, - ); - - final res = await tester.client.createPoll(poll); - expect(res, isNotNull); - expect(res.poll, poll); - - tester - ..verifyApi((api) => api.polls.createPoll(poll)) - ..verifyNoMoreApiInteractions((api) => api.polls); - }, - ); - - chatClientTest( - '`.getPoll`', - body: (tester) async { - const pollId = 'test-poll-id'; - final poll = createDefaultPoll(id: pollId); - - tester.mockApi( - (api) => api.polls.getPoll(pollId), - result: GetPollResponse()..poll = poll, - ); - - final res = await tester.client.getPoll(pollId); - expect(res, isNotNull); - expect(res.poll, poll); - - tester - ..verifyApi((api) => api.polls.getPoll(pollId)) - ..verifyNoMoreApiInteractions((api) => api.polls); - }, - ); - - chatClientTest( - '`.updatePoll`', - body: (tester) async { - final poll = createDefaultPoll(id: 'test-poll-id'); - - tester.mockApi( - (api) => api.polls.updatePoll(poll), - result: createDefaultUpdatePollResponse(poll: poll), - ); - - final res = await tester.client.updatePoll(poll); - expect(res, isNotNull); - expect(res.poll, poll); - - tester - ..verifyApi((api) => api.polls.updatePoll(poll)) - ..verifyNoMoreApiInteractions((api) => api.polls); - }, - ); - - chatClientTest( - '`.partialUpdatePoll`', - body: (tester) async { - const pollId = 'test-poll-id'; - final set = {'name': 'What is your favorite color?'}; - final unset = []; - - final poll = createDefaultPoll(id: pollId, name: set['name']!); - - tester.mockApi( - (api) => api.polls.partialUpdatePoll(pollId, set: set, unset: unset), - result: createDefaultUpdatePollResponse(poll: poll), - ); - - 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']); - - tester - ..verifyApi((api) => api.polls.partialUpdatePoll(pollId, set: set, unset: unset)) - ..verifyNoMoreApiInteractions((api) => api.polls); - }, - ); - - chatClientTest( - '`.deletePoll`', - body: (tester) async { - const pollId = 'test-poll-id'; - - tester.mockApi( - (api) => api.polls.deletePoll(pollId), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.deletePoll(pollId); - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.polls.deletePoll(pollId)) - ..verifyNoMoreApiInteractions((api) => api.polls); - }, - ); - - chatClientTest( - '`.closePoll`', - body: (tester) async { - const pollId = 'test-poll-id'; - - tester.mockApi( - (api) => api.polls.partialUpdatePoll(pollId, set: {'is_closed': true}), - result: createDefaultUpdatePollResponse(), - ); - - final res = await tester.client.closePoll(pollId); - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.polls.partialUpdatePoll(pollId, set: {'is_closed': true})) - ..verifyNoMoreApiInteractions((api) => api.polls); - }, - ); - - chatClientTest( - '`.createPollOption`', - body: (tester) async { - const pollId = 'test-poll-id'; - final option = createDefaultPollOption(); - - tester.mockApi( - (api) => api.polls.createPollOption(pollId, option), - result: CreatePollOptionResponse()..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); - }, - ); - - chatClientTest( - '`.getPollOption`', - body: (tester) async { - const pollId = 'test-poll-id'; - const optionId = 'test-option-id'; - final option = createDefaultPollOption(id: optionId); - - tester.mockApi( - (api) => api.polls.getPollOption(pollId, optionId), - result: GetPollOptionResponse()..pollOption = option, - ); - - final res = await tester.client.getPollOption(pollId, optionId); - expect(res, isNotNull); - expect(res.pollOption, option); - - tester - ..verifyApi((api) => api.polls.getPollOption(pollId, optionId)) - ..verifyNoMoreApiInteractions((api) => api.polls); - }, - ); - - chatClientTest( - '`.updatePollOption`', - body: (tester) async { - const pollId = 'test-poll-id'; - final option = createDefaultPollOption(id: 'test-option-id'); - - tester.mockApi( - (api) => api.polls.updatePollOption(pollId, option), - result: UpdatePollOptionResponse()..pollOption = option, - ); - - final res = await tester.client.updatePollOption(pollId, option); - expect(res, isNotNull); - expect(res.pollOption, option); - - tester - ..verifyApi((api) => api.polls.updatePollOption(pollId, option)) - ..verifyNoMoreApiInteractions((api) => api.polls); - }, - ); - - chatClientTest( - '`.deletePollOption`', - body: (tester) async { - const pollId = 'test-poll-id'; - const optionId = 'test-option-id'; - - tester.mockApi( - (api) => api.polls.deletePollOption(pollId, optionId), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.deletePollOption(pollId, optionId); - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.polls.deletePollOption(pollId, optionId)) - ..verifyNoMoreApiInteractions((api) => api.polls); - }, - ); - - 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', - ); - - tester.mockApi( - (api) => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteOption(optionId))), - result: createDefaultCastPollVoteResponse(vote: vote), - ); - - final res = await tester.client.castPollVote(messageId, pollId, optionId: optionId); - expect(res, isNotNull); - expect(res.vote, vote); - - tester - ..verifyApi((api) => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteOption(optionId)))) - ..verifyNoMoreApiInteractions((api) => api.polls); - }, - ); - - 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', - ); - - tester.mockApi( - (api) => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteAnswer(answerText))), - result: createDefaultCastPollVoteResponse(vote: vote), - ); - - final res = await tester.client.addPollAnswer(messageId, pollId, answerText: answerText); - expect(res, isNotNull); - expect(res.vote, vote); - - tester - ..verifyApi((api) => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteAnswer(answerText)))) - ..verifyNoMoreApiInteractions((api) => api.polls); - }, - ); - - chatClientTest( - '`.removePollVote`', - body: (tester) async { - const messageId = 'test-message-id'; - const pollId = 'test-poll-id'; - const voteId = 'test-vote-id'; - - tester.mockApi( - (api) => api.polls.removePollVote(messageId, pollId, voteId), - result: RemovePollVoteResponse(), - ); - - final res = await tester.client.removePollVote(messageId, pollId, voteId); - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.polls.removePollVote(messageId, pollId, voteId)) - ..verifyNoMoreApiInteractions((api) => api.polls); - }, - ); - - chatClientTest( - '`.queryPolls`', - body: (tester) async { - final filter = Filter.in_('id', const ['test-poll-id']); - final sort = [const SortOption.desc('created_at')]; - const pagination = PaginationParams(limit: 20); - - final polls = List.generate( - pagination.limit, - (index) => createDefaultPoll(id: 'test-poll-id-$index'), - ); - - tester.mockApi( - (api) => api.polls.queryPolls( - filter: filter, - sort: sort, - pagination: pagination, - ), - result: QueryPollsResponse()..polls = polls, - ); - - final res = await tester.client.queryPolls( - filter: filter, - sort: sort, - pagination: pagination, - ); - expect(res, isNotNull); - expect(res.polls.length, polls.length); - - tester - ..verifyApi( - (api) => api.polls.queryPolls( - filter: filter, - sort: sort, - pagination: pagination, - ), - ) - ..verifyNoMoreApiInteractions((api) => api.polls); - }, - ); - - chatClientTest( - '`.queryPollVotes`', - body: (tester) async { - const pollId = 'test-poll-id'; - final filter = Filter.in_('id', const ['test-vote-id']); - final sort = [const SortOption.desc('created_at')]; - const pagination = PaginationParams(limit: 20); - - final votes = List.generate( - pagination.limit, - (index) => createDefaultPollVote(id: 'test-vote-id-$index', answerText: 'Red'), - ); - - tester.mockApi( - (api) => api.polls.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - ), - result: QueryPollVotesResponse()..votes = votes, - ); - - final res = await tester.client.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - ); - expect(res, isNotNull); - expect(res.votes.length, votes.length); - - tester - ..verifyApi( - (api) => api.polls.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - ), - ) - ..verifyNoMoreApiInteractions((api) => api.polls); - }, - ); -} diff --git a/packages/stream_chat/test/src/client/client_query_channels_test.dart b/packages/stream_chat/test/src/client/client_query_channels_test.dart deleted file mode 100644 index ac4fe1db95..0000000000 --- a/packages/stream_chat/test/src/client/client_query_channels_test.dart +++ /dev/null @@ -1,290 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -void main() { - group('`.queryChannels`', () { - 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'), - ), - ); - - 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), - ); - - 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'), - ), - ); - }, - ); - - chatClientTest( - '''should rethrow if `.queryChannelsOnline` throws and persistence channels are empty''', - body: (tester) async { - tester.mockApiFailure( - (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'), - ), - error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError), - ); - - await expectLater( - tester.client.queryChannels(), - emitsError(isA()), - ); - - 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'), - ), - ); - }, - ); - - chatClientTest( - 'should coalesce concurrent identical calls into a single HTTP request', - 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 - // cache and each fired its own queryChannels HTTP request. - // - // With the fix, the cache slot is reserved synchronously after the - // hash check, so concurrent callers find the in-flight future and - // share its result. - final channelStates = List.generate( - 3, - (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. - 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), - delay: const Duration(milliseconds: 100), - ); - - // Fire 5 identical calls back-to-back in the same tick. - final results = await Future.wait( - List.generate(5, (_) => tester.client.queryChannels().toList()), - ); - - // All callers should receive the same channels. - for (final emitted in results) { - expect(emitted, hasLength(1)); - expect(emitted.single, channelStates.map(isCorrectChannelFor)); - } - - // But only ONE HTTP request should have been issued. - 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'), - ), - ); - }, - ); - - chatClientTest( - 'should fire a fresh request once the cached future has settled', - 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) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: 'test-type-$index:test-id-$index'), - ), - ); - - 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), - ); - - await tester.client.queryChannels().toList(); - await tester.client.queryChannels().toList(); - - tester.verifyApiCalled( - (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'), - ), - times: 2, - ); - }, - ); - - chatClientTest( - 'concurrent calls with different filters do not share the cache', - 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. - 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(), - delay: const Duration(milliseconds: 100), - ); - - await Future.wait([ - tester.client.queryChannels(filter: Filter.in_('cid', const ['a'])).toList(), - tester.client.queryChannels(filter: Filter.in_('cid', const ['b'])).toList(), - ]); - - tester.verifyApiCalled( - (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'), - ), - times: 2, - ); - }, - ); - - chatClientTest( - 'concurrent calls share the same error when the request fails', - 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. - tester.mockApiFailure( - (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'), - ), - error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError), - delay: const Duration(milliseconds: 100), - ); - - final errors = await Future.wait( - List.generate(5, (_) async { - try { - await tester.client.queryChannels().toList(); - return null; - } catch (e) { - return e; - } - }), - ); - - // Every caller surfaces the same error type. - expect(errors, hasLength(5)); - for (final error in errors) { - expect(error, isA()); - } - - // But only ONE HTTP request was made — the rest piggybacked. - 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'), - ), - ); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/client_reconnection_test.dart b/packages/stream_chat/test/src/client/client_reconnection_test.dart deleted file mode 100644 index 225e61aea1..0000000000 --- a/packages/stream_chat/test/src/client/client_reconnection_test.dart +++ /dev/null @@ -1,553 +0,0 @@ -// 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:rxdart/rxdart.dart'; -import 'package:stream_chat/src/ws/websocket.dart'; -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -void main() { - group('Fake web-socket connection functions', () { - const apiKey = 'test-api-key'; - late final api = FakeChatApi(); - - late StreamChatClient client; - - setUpAll(() { - // fallback values - registerFallbackValue(_FakeUser()); - }); - - setUp(() { - final ws = _FakeWebSocket(); - client = StreamChatClient(apiKey, ws: ws, chatApi: api); - }); - - tearDown(() { - client.dispose(); - }); - - test('`.connectAnonymousUser` should work fine', () async { - expectLater( - // skipping first seed status -> ConnectionStatus.disconnected - client.wsConnectionStatusStream.skip(1), - emitsInOrder([ - ConnectionStatus.connecting, - ConnectionStatus.connected, - ]), - ); - - final res = await client.connectAnonymousUser(); - expect(res, isNotNull); - }); - }); - - group('Fake web-socket connection functions failure', () { - const apiKey = 'test-api-key'; - late final api = FakeChatApi(); - - late StreamChatClient client; - - setUpAll(() { - // fallback values - registerFallbackValue(_FakeUser()); - }); - - setUp(() { - final ws = _FakeWebSocketWithConnectionError(); - client = StreamChatClient(apiKey, chatApi: api, ws: ws); - }); - - tearDown(() { - client.dispose(); - }); - - test( - '`.connectAnonymousUser` should throw if `ws.connect` fails', - () async { - try { - await client.connectAnonymousUser(); - } catch (e) { - expect(e, isA()); - } - }, - ); - }); - - group('Connect user calls with `connectWebSocket`: false', () { - const apiKey = 'test-api-key'; - late final api = FakeChatApi(); - - late StreamChatClient client; - - setUpAll(() { - // fallback values - registerFallbackValue(_FakeUser()); - }); - - setUp(() { - client = StreamChatClient(apiKey, chatApi: api); - }); - - tearDown(() { - client.dispose(); - }); - - test( - '`.connectAnonymousUser` should succeed without connecting', - () async { - final res = await client.connectAnonymousUser( - connectWebSocket: false, - ); - - expect(res, isNotNull); - expect(client.wsConnectionStatus, ConnectionStatus.disconnected); - }, - ); - }); - - group('Fake web-socket connection function with failure and persistence', () { - const apiKey = 'test-api-key'; - late final api = FakeChatApi(); - late final persistence = MockPersistenceClient(); - - late StreamChatClient client; - - setUpAll(() { - // fallback values - registerFallbackValue(_FakeUser()); - }); - - setUp(() { - final ws = _FakeWebSocketWithConnectionError(); - client = StreamChatClient(apiKey, chatApi: api, ws: ws)..chatPersistenceClient = persistence; - }); - - tearDown(() { - client.dispose(); - }); - - test( - '''`.connectAnonymousUser` should connect successfully if persistence contains event''', - () async { - final user = User(id: 'test-user-id'); - - when(persistence.getConnectionInfo).thenAnswer( - (invocation) async => Event( - type: EventType.healthCheck, - connectionId: 'test-connection-id', - me: OwnUser.fromUser(user), - ), - ); - - final res = await client.connectAnonymousUser(); - expect(res, isNotNull); - - verify(persistence.getConnectionInfo).called(1); - verifyNoMoreInteractions(persistence); - }, - ); - }); - - group('recoverStateOnReconnect', () { - const apiKey = 'test-api-key'; - final user = User(id: 'test-user-id'); - final token = Token.development(user.id).rawValue; - - late FakeChatApi api; - late _FakeWebSocket ws; - late StreamChatClient client; - - setUpAll(() { - registerFallbackValue(const PaginationParams()); - registerFallbackValue(Filter.equal('cid', '')); - }); - - setUp(() { - api = FakeChatApi(); - 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 - // async error inside the connection-status listener. - 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 = []); - }); - - tearDown(() async { - await client.dispose(); - }); - - // 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; - await delay(100); - ws.connectionStatus = ConnectionStatus.connected; - await delay(300); - } - - test('should re-query active channels on reconnect when enabled (default)', () async { - // Setup: connect with default flag, register two channels. - client = StreamChatClient(apiKey, chatApi: api, ws: ws); - await client.connectUser(user, token); - await delay(300); - - final channel1 = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); - final channel2 = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c2'))); - client.state.addChannels({'messaging:c1': channel1, 'messaging:c2': channel2}); - - // Drop interactions from the initial connect's (empty-channel) recovery - // so we only count the reconnect call. - clearInteractions(api.channel); - - await simulateReconnect(); - - verify( - () => api.channel.queryChannels( - filter: Filter.in_('cid', const ['messaging:c1', 'messaging:c2']), - 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: const PaginationParams(limit: 30), - ), - ).called(1); - }); - - test('should skip the re-query on reconnect when disabled', () async { - client = StreamChatClient(apiKey, chatApi: api, ws: ws, recoverStateOnReconnect: false); - await client.connectUser(user, token); - await delay(300); - - final channel = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); - client.state.addChannels({'messaging:c1': channel}); - clearInteractions(api.channel); - - await simulateReconnect(); - - verifyNever( - () => 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('should still emit `connectionRecovered` when disabled', () async { - client = StreamChatClient(apiKey, chatApi: api, ws: ws, recoverStateOnReconnect: false); - await client.connectUser(user, token); - await delay(300); - - final channel = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); - client.state.addChannels({'messaging:c1': channel}); - - // Subscribe AFTER the initial connect so the captured event is the - // one fired by the manual reconnect. - final recoveredEvents = []; - final sub = client.on(EventType.connectionRecovered).listen(recoveredEvents.add); - - await simulateReconnect(); - await sub.cancel(); - - expect(recoveredEvents, hasLength(1)); - }); - - // 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. - test('should not surface an error when the re-query fails', () async { - 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'), - ), - ).thenThrow(const StreamChatError('You cannot use queryChannels without an active connection.')); - - client = StreamChatClient(apiKey, chatApi: api, ws: ws); - await client.connectUser(user, token); - await delay(300); - - final channel = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); - client.state.addChannels({'messaging:c1': channel}); - - // Subscribe AFTER the initial connect so the captured event is the - // one fired by the manual reconnect. - final recoveredEvents = []; - final sub = client.on(EventType.connectionRecovered).listen(recoveredEvents.add); - - await simulateReconnect(); - await sub.cancel(); - - expect(recoveredEvents, hasLength(1)); - }); - - test('should skip the re-query when no active channels are tracked', () async { - client = StreamChatClient(apiKey, chatApi: api, ws: ws); - await client.connectUser(user, token); - await delay(300); - - // No channels added — the cids.isNotEmpty guard should short-circuit. - clearInteractions(api.channel); - - await simulateReconnect(); - - verifyNever( - () => 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('should respect runtime toggling via the setter', () async { - client = StreamChatClient(apiKey, chatApi: api, ws: ws); - await client.connectUser(user, token); - await delay(300); - - final channel = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); - client.state.addChannels({'messaging:c1': channel}); - clearInteractions(api.channel); - - // Disable mid-flight → no re-query on reconnect. - client.recoverStateOnReconnect = false; - await simulateReconnect(); - verifyNever( - () => 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'), - ), - ); - - // Re-enable → re-query on the next reconnect. - client.recoverStateOnReconnect = true; - await simulateReconnect(); - 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); - }); - }); - - group('dispose during reconnect recovery', () { - const apiKey = 'test-api-key'; - final user = User(id: 'test-user-id'); - final token = Token.development(user.id).rawValue; - - late FakeChatApi api; - late _FakeWebSocket ws; - late StreamChatClient client; - var disposed = false; - - setUpAll(() { - registerFallbackValue(const PaginationParams()); - registerFallbackValue(Filter.equal('cid', '')); - }); - - setUp(() { - api = FakeChatApi(); - ws = _FakeWebSocket(); - disposed = false; - }); - - // The test disposes the client itself; avoid disposing it a second time. - tearDown(() async { - 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. - test('disposing mid-recovery does not surface a late recovery event', () async { - // Keep the recovery's channel query pending so the client is still - // mid-recovery at the moment it is disposed. - final pendingQuery = Completer(); - 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((_) => pendingQuery.future); - - client = StreamChatClient(apiKey, chatApi: api, ws: ws); - await client.connectUser(user, token); - await delay(300); - - // Track a channel so reconnecting triggers channel recovery, which then - // blocks on the pending query above. - final channel = Channel.fromState( - client, - ChannelState(channel: ChannelModel(cid: 'messaging:c1')), - ); - client.state.addChannels({'messaging:c1': channel}); - - // Drop then restore the connection to start a reconnect recovery. - ws.connectionStatus = ConnectionStatus.disconnected; - await delay(100); - ws.connectionStatus = ConnectionStatus.connected; - await delay(100); - - // Dispose while the recovery is still in flight. - await client.dispose(); - disposed = true; - - // Let the now-orphaned recovery finish. Its trailing work must be - // discarded silently instead of thrown as an unhandled async error. - pendingQuery.complete(QueryChannelsResponse()..channels = []); - await delay(300); - - expect(client.wsConnectionStatus, ConnectionStatus.disconnected); - }); - }); -} - -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 = StreamWebSocketError('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/client_state_guards_test.dart b/packages/stream_chat/test/src/client/client_state_guards_test.dart deleted file mode 100644 index df863a566a..0000000000 --- a/packages/stream_chat/test/src/client/client_state_guards_test.dart +++ /dev/null @@ -1,169 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -void main() { - group('ClientState mutation guards', () { - // 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}); - - 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); - }, - ); - - chatClientTest( - '`state.users` returns an unmodifiable view', - connect: (_) {}, - body: (tester) async { - tester.clientState.updateUser(User(id: 'u1')); - - expect(tester.clientState.users.containsKey('u1'), isTrue); - expect(() => tester.clientState.users.remove('u1'), throwsUnsupportedError); - expect(() => tester.clientState.users.clear(), throwsUnsupportedError); - }, - ); - - chatClientTest( - '`state.activeLiveLocations` returns an unmodifiable view', - connect: (_) {}, - body: (tester) async { - expect(() => tester.clientState.activeLiveLocations.clear(), throwsUnsupportedError); - }, - ); - - 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}); - - 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); - - tester.clientState.removeChannel('messaging:c1'); - await Future.delayed(Duration.zero); - - expect(received, hasLength(1)); - expect(received.single, isEmpty); - - await sub.cancel(); - }, - ); - - 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); - }, - ); - - chatClientTest( - '`channelsStream` emits unmodifiable maps', - connect: (_) {}, - body: (tester) async { - final received = >[]; - final sub = tester.clientState.channelsStream.listen(received.add); - - final channel = Channel.fromState( - tester.client, - createDefaultChannelState(channel: createDefaultChannelModel(cid: 'messaging:c1')), - ); - tester.clientState.addChannels({'messaging:c1': channel}); - await Future.delayed(Duration.zero); - - // 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); - } - - await sub.cancel(); - }, - ); - - chatClientTest( - '`usersStream` emits unmodifiable maps', - connect: (_) {}, - body: (tester) async { - final received = >[]; - final sub = tester.clientState.usersStream.listen(received.add); - - tester.clientState.updateUser(User(id: 'u1')); - await Future.delayed(Duration.zero); - - expect(received, hasLength(greaterThanOrEqualTo(2))); - for (final emitted in received) { - expect(emitted.clear, throwsUnsupportedError); - } - - await sub.cancel(); - }, - ); - - chatClientTest( - '`activeLiveLocationsStream` emits unmodifiable lists', - connect: (_) {}, - body: (tester) async { - final received = >[]; - final sub = tester.clientState.activeLiveLocationsStream.listen(received.add); - - tester.clientState.activeLiveLocations = const []; - await Future.delayed(Duration.zero); - - expect(received, isNotEmpty); - for (final emitted in received) { - expect(emitted.clear, throwsUnsupportedError); - } - - await sub.cancel(); - }, - ); - }); - - // 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', - 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); - expect(state.unreadChannels, 0); - - final updateUser = initialUser.copyWith( - totalUnreadCount: 33, - unreadChannels: 33, - ); - state.currentUser = updateUser; - - expect(state.currentUser, updateUser); - expect(state.totalUnreadCount, 33); - expect(state.unreadChannels, 33); - }, - ); -} diff --git a/packages/stream_chat/test/src/client/client_sync_test.dart b/packages/stream_chat/test/src/client/client_sync_test.dart deleted file mode 100644 index e1d101ee58..0000000000 --- a/packages/stream_chat/test/src/client/client_sync_test.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -void main() { - 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); - - 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 tester.client.sync(cids: cids, lastSyncAt: lastSyncAt); - - tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); - }, - ); - - 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())); - }, - ); - - 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())); - }, - ); - }); -} diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart new file mode 100644 index 0000000000..8fce6b5192 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -0,0 +1,6501 @@ +// 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: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:stream_chat_test/stream_chat_test.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', () { + const apiKey = 'test-api-key'; + late final api = FakeChatApi(); + + late StreamChatClient client; + + setUpAll(() { + // fallback values + registerFallbackValue(_FakeUser()); + }); + + setUp(() { + final ws = _FakeWebSocket(); + client = StreamChatClient(apiKey, ws: ws, chatApi: api); + }); + + tearDown(() { + client.dispose(); + }); + + chatClientTest( + '`.connectUser` should work fine', + connect: (tester) => tester.mockSuccessfulAuth(), + body: (tester) async { + final user = tester.user; + final token = createTestToken(user.id).rawValue; + + final statusEmitted = expectLater( + // skipping first seed status -> ConnectionStatus.disconnected + tester.client.wsConnectionStatusStream.skip(1), + emitsInOrder([ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + final res = await tester.client.connectUser(user, token); + expect(res, isNotNull); + expect(res, isSameUserAs(user)); + + await statusEmitted; + }, + ); + + 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).rawValue; + } + + final statusEmitted = expectLater( + // skipping first seed status -> ConnectionStatus.disconnected + tester.client.wsConnectionStatusStream.skip(1), + emitsInOrder([ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + final res = await tester.client.connectUserWithProvider(user, tokenProvider); + expect(res, isNotNull); + expect(res, isSameUserAs(user)); + + await statusEmitted; + }, + ); + + 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), + ); + + final statusEmitted = expectLater( + // skipping first seed status -> ConnectionStatus.disconnected + tester.client.wsConnectionStatusStream.skip(1), + emitsInOrder([ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + final res = await tester.client.connectGuestUser(user); + expect(res, isNotNull); + expect(res, isSameUserAs(user)); + + tester.verifyApi( + (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), + ); + + 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(errorCode: ChatErrorCode.inputError), + ); + + 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 { + expectLater( + // skipping first seed status -> ConnectionStatus.disconnected + client.wsConnectionStatusStream.skip(1), + emitsInOrder([ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + final res = await client.connectAnonymousUser(); + expect(res, isNotNull); + }); + + group('`.openConnection`', () { + chatClientTest( + 'should throw if state does not contain user', + connect: (_) {}, + body: (tester) async { + expect(tester.currentUser, isNull); + await expectLater( + tester.client.openConnection(), + throwsA(isA()), + ); + }, + ); + + chatClientTest( + 'should throw if connection is already available', + connect: (tester) => tester.mockSuccessfulAuth(), + body: (tester) async { + expect(tester.currentUser, isNull); + + // 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); + + await expectLater( + tester.client.openConnection(), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Connection 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, + ]), + ); + + // 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); + + tester.client.closeConnection(); + + await tester.client.openConnection(); + + await statusEmitted; + }, + ); + }); + }); + + group('Fake web-socket connection functions failure', () { + const apiKey = 'test-api-key'; + late final api = FakeChatApi(); + + late StreamChatClient client; + + setUpAll(() { + // fallback values + registerFallbackValue(_FakeUser()); + }); + + setUp(() { + final ws = _FakeWebSocketWithConnectionError(); + client = StreamChatClient(apiKey, chatApi: api, ws: ws); + }); + + tearDown(() { + client.dispose(); + }); + + 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; + + await expectLater( + tester.client.connectUser(user, token), + throwsA(isA()), + ); + }, + ); + + chatClientTest( + '`.connectUserWithProvider` should throw if `ws.connect` fails', + connect: (tester) => tester.mockConnectionError(), + body: (tester) async { + final user = tester.user; + Future tokenProvider(String userId) async { + expect(userId, user.id); + return createTestToken(userId).rawValue; + } + + await expectLater( + tester.client.connectUserWithProvider(user, tokenProvider), + throwsA(isA()), + ); + }, + ); + + 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; + + tester.mockApi( + (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), + result: createDefaultConnectGuestUserResponse(user: user, accessToken: token), + ); + + 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', + () async { + try { + await client.connectAnonymousUser(); + } catch (e) { + expect(e, isA()); + } + }, + ); + }); + + group('Connect user calls with `connectWebSocket`: false', () { + const apiKey = 'test-api-key'; + late final api = FakeChatApi(); + + late StreamChatClient client; + + setUpAll(() { + // fallback values + registerFallbackValue(_FakeUser()); + }); + + setUp(() { + client = StreamChatClient(apiKey, chatApi: api); + }); + + tearDown(() { + client.dispose(); + }); + + chatClientTest( + '`.connectUser` should succeed without connecting', + connect: (_) {}, + body: (tester) async { + final user = tester.user; + final token = createTestToken(user.id).rawValue; + + final res = await tester.client.connectUser( + user, + token, + connectWebSocket: false, + ); + expect(res, isSameUserAs(user)); + expect(tester.connectionStatus, ConnectionStatus.disconnected); + }, + ); + + chatClientTest( + '`.connectUserWithProvider` should succeed without connecting', + connect: (_) {}, + body: (tester) async { + final user = tester.user; + Future tokenProvider(String userId) async { + expect(userId, user.id); + return createTestToken(userId).rawValue; + } + + final res = await tester.client.connectUserWithProvider( + user, + tokenProvider, + connectWebSocket: false, + ); + expect(res, isSameUserAs(user)); + expect(tester.connectionStatus, ConnectionStatus.disconnected); + }, + ); + + chatClientTest( + '`.connectGuestUser` should succeed without connecting', + connect: (_) {}, + 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), + ); + + final res = await tester.client.connectGuestUser( + user, + connectWebSocket: false, + ); + + 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', + () async { + final res = await client.connectAnonymousUser( + connectWebSocket: false, + ); + + expect(res, isNotNull); + expect(client.wsConnectionStatus, ConnectionStatus.disconnected); + }, + ); + }); + + 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(); + + late StreamChatClient client; + + setUpAll(() { + // fallback values + registerFallbackValue(_FakeUser()); + }); + + setUp(() { + final ws = _FakeWebSocketWithConnectionError(); + client = StreamChatClient(apiKey, chatApi: api, ws: ws)..chatPersistenceClient = persistence; + }); + + tearDown(() { + client.dispose(); + }); + + _failedConnectionWithPersistenceTest( + '`.connectUser` should connect successfully if persistence contains event', + body: (tester, persistence) async { + final user = tester.user; + + final event = Event( + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ); + when(persistence.getConnectionInfo).thenAnswer((_) async => event); + + final res = await tester.client.connectUser(user, createTestToken(user.id).rawValue); + expect(res, isNotNull); + expect(res, isSameUserAs(user)); + + verify(persistence.getConnectionInfo).called(1); + verifyNoMoreInteractions(persistence); + }, + ); + + _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 createTestToken(userId).rawValue; + } + + final event = Event( + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ); + when(persistence.getConnectionInfo).thenAnswer((_) async => event); + + final res = await tester.client.connectUserWithProvider(user, tokenProvider); + expect(res, isNotNull); + expect(res, isSameUserAs(user)); + + verify(persistence.getConnectionInfo).called(1); + verifyNoMoreInteractions(persistence); + }, + ); + + _failedConnectionWithPersistenceTest( + '`.connectGuestUser` should connect successfully if persistence contains event', + body: (tester, persistence) async { + final user = tester.user; + + final event = Event( + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ); + when(persistence.getConnectionInfo).thenAnswer((_) async => event); + + tester.mockApi( + (api) => api.guest.getGuestUser(any(that: isSameUserAs(user))), + result: createDefaultConnectGuestUserResponse(user: user), + ); + + final res = await tester.client.connectGuestUser(user); + expect(res, isNotNull); + expect(res, isSameUserAs(user)); + + verify(persistence.getConnectionInfo).called(1); + verifyNoMoreInteractions(persistence); + tester + ..verifyApi((api) => api.guest.getGuestUser(any(that: isSameUserAs(user)))) + ..verifyNoMoreApiInteractions((api) => api.guest); + }, + ); + + test( + '''`.connectAnonymousUser` should connect successfully if persistence contains event''', + () async { + final user = User(id: 'test-user-id'); + + when(persistence.getConnectionInfo).thenAnswer( + (invocation) async => Event( + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ), + ); + + final res = await client.connectAnonymousUser(); + expect(res, isNotNull); + + verify(persistence.getConnectionInfo).called(1); + verifyNoMoreInteractions(persistence); + }, + ); + }); + + group('Client with connected user with persistence', () { + // 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); + + tester.mockSuccessfulAuth(); + await tester.client.connectUser(tester.user, createTestToken(tester.user.id).rawValue); + + expect(tester.client.persistenceEnabled, isTrue); + expect(tester.connectionStatus, ConnectionStatus.connected); + }, + body: (tester) => body(tester, persistence), + ); + } + + group('`.sync`', () { + _clientWithPersistenceTest( + '''should update persistence connectionInfo and lastSync when sync succeeds''', + 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.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(tester.user), + ), + Event( + isLocal: false, + type: EventType.messageDeleted, + message: Message(id: 'test-message-id'), + ), + ], + ), + ); + + when(() => persistence.updateConnectionInfo(any())).thenAnswer((_) => Future.value()); + when(() => persistence.updateLastSyncAt(any())).thenAnswer((_) => Future.value()); + + await tester.client.sync(cids: cids, lastSyncAt: lastSyncAt); + + verify(() => persistence.updateConnectionInfo(any())).called(1); + verify(() => persistence.updateLastSyncAt(any())).called(1); + tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); + }, + ); + + _clientWithPersistenceTest( + 'should work fine if persistence contains sync params', + 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.utc(2021, 3); + + when(persistence.getChannelCids).thenAnswer((_) async => cids); + when(persistence.getLastSyncAt).thenAnswer((_) async => lastSyncAt); + + 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', text: 'Hey!'), + ), + ], + ), + ); + + when(() => persistence.updateConnectionInfo(any())).thenAnswer((_) => Future.value()); + when(() => persistence.updateLastSyncAt(any())).thenAnswer((_) => Future.value()); + + await tester.client.sync(); + + verify(() => persistence.updateConnectionInfo(any())).called(1); + verify(() => persistence.updateLastSyncAt(any())).called(1); + tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); + verify(persistence.getChannelCids).called(1); + verify(persistence.getLastSyncAt).called(1); + }, + ); + }); + + group('`.queryChannels`', () { + _clientWithPersistenceTest( + 'should emit channels twice if persistence contains some channels', + body: (tester, persistence) async { + final persistentChannelStates = List.generate( + 3, + (index) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$index:test-id-$index'), + ), + ); + + when( + () => persistence.queryChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) async => createDefaultQueryChannelsResponse(channels: persistentChannelStates)); + + final channelStates = List.generate( + 3, + (index) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$index:test-id-$index'), + ), + ); + + 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), + ); + + when(() => persistence.getChannelThreads(any())).thenAnswer( + (_) async => >{ + for (final channelState in channelStates) + channelState.channel!.cid: [createDefaultMessage(id: 'test-message-id', text: 'Test message')], + }, + ); + + when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); + when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); + when( + () => persistence.saveChannelQueries( + cids: any(named: 'cids'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + resolvedFilter: any(named: 'resolvedFilter'), + resolvedSort: any(named: 'resolvedSort'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + clearQueryCache: any(named: 'clearQueryCache'), + ), + ).thenAnswer((_) => Future.value()); + + // 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( + tester.client.queryChannels(), + emitsInOrder([ + // emits persistent channels first + persistentChannelStates.map(isCorrectChannelFor), + // makes api call and emits network fetched channels + channelStates.map(isCorrectChannelFor), + ]), + ); + + // Wait safely past the 1s debounce on persistence writes + // (updateChannelState, updateChannelThreads) so all trailing + // invocations have fired before we verify counts. + await Future.delayed(const Duration(milliseconds: 1500)); + + verify( + () => persistence.queryChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).called(1); + + 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'), + ), + ); + + verify(() => persistence.getChannelThreads(any())).called(channelStates.length); + verify(() => persistence.updateChannelState(any())).called(channelStates.length); + verify(() => persistence.updateChannelThreads(any(), any())).called(channelStates.length); + verify( + () => persistence.saveChannelQueries( + cids: any(named: 'cids'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + resolvedFilter: any(named: 'resolvedFilter'), + resolvedSort: any(named: 'resolvedSort'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + clearQueryCache: any(named: 'clearQueryCache'), + ), + ).called(1); + }, + ); + + _clientWithPersistenceTest( + '''should never rethrow network call if persistence already emitted some channels''', + body: (tester, persistence) async { + final persistentChannelStates = List.generate( + 3, + (index) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$index:test-id-$index'), + ), + ); + + when( + () => persistence.queryChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) async => createDefaultQueryChannelsResponse(channels: persistentChannelStates)); + + tester.mockApiFailure( + (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'), + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError), + ); + + when(() => persistence.getChannelThreads(any())).thenAnswer( + (_) async => >{ + for (final channelState in persistentChannelStates) + channelState.channel!.cid: [createDefaultMessage(id: 'test-message-id', text: 'Test message')], + }, + ); + + when(() => persistence.updateChannelState(any())).thenAnswer((_) async => {}); + when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async => {}); + + // 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( + tester.client.queryChannels(), + emitsInOrder([ + // emits persistent channels + persistentChannelStates.map(isCorrectChannelFor), + ]), + ); + + // Wait safely past the 1s debounce on persistence writes + // (updateChannelState, updateChannelThreads) so all trailing + // invocations have fired before we verify counts. + await Future.delayed(const Duration(milliseconds: 1500)); + + verify( + () => persistence.queryChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).called(1); + + 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'), + ), + ); + + verify(() => persistence.getChannelThreads(any())).called(persistentChannelStates.length); + verify(() => persistence.updateChannelState(any())).called(persistentChannelStates.length); + verify(() => persistence.updateChannelThreads(any(), any())).called(persistentChannelStates.length); + }, + ); + + _clientWithPersistenceTest( + 'queryChannelsOnline with inline filter persists via saveChannelQueries', + body: (tester, persistence) async { + final filter = Filter.in_('members', const ['test-user-id']); + + final channelStates = List.generate( + 3, + (i) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$i:test-id-$i'), + ), + ); + + tester.mockApi( + (api) => api.channel.queryChannels( + filter: filter, + 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), + ); + + when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); + when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); + when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); + when( + () => persistence.saveChannelQueries( + cids: any(named: 'cids'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + resolvedFilter: any(named: 'resolvedFilter'), + resolvedSort: any(named: 'resolvedSort'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + clearQueryCache: any(named: 'clearQueryCache'), + ), + ).thenAnswer((_) => Future.value()); + + await Future.delayed(const Duration(milliseconds: 1100)); + clearInteractions(persistence); + + await tester.client.queryChannelsOnline(filter: filter); + + // The standard path passes filter (the inline filter) and a null + // predefinedFilter. resolvedFilter / resolvedSort stay null — + // they're only meaningful for the predefined-filter path. + verify( + () => persistence.saveChannelQueries( + cids: channelStates.map((s) => s.channel!.cid).toList(), + filter: filter, + sort: null, + predefinedFilter: null, + resolvedFilter: null, + resolvedSort: null, + filterValues: null, + sortValues: null, + clearQueryCache: true, + ), + ).called(1); + }, + ); + + _clientWithPersistenceTest( + 'queryChannelsOnline with predefined filter persists via saveChannelQueries with resolved sort', + 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) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$i:test-id-$i'), + ), + ); + + tester.mockApi( + (api) => api.channel.queryChannels( + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + 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, + predefinedFilter: const PredefinedFilter( + name: filterName, + filter: Filter.empty(), + sort: [SortOption.desc('last_message_at')], + ), + ), + ); + + when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); + when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); + when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); + when( + () => persistence.saveChannelQueries( + cids: any(named: 'cids'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + resolvedFilter: any(named: 'resolvedFilter'), + resolvedSort: any(named: 'resolvedSort'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + clearQueryCache: any(named: 'clearQueryCache'), + ), + ).thenAnswer((_) => Future.value()); + + await Future.delayed(const Duration(milliseconds: 1100)); + clearInteractions(persistence); + + await tester.client.queryChannelsOnline( + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + ); + + verify( + () => persistence.saveChannelQueries( + cids: channelStates.map((s) => s.channel!.cid).toList(), + filter: null, + sort: null, + predefinedFilter: filterName, + resolvedFilter: const Filter.empty(), + resolvedSort: const [SortOption.desc('last_message_at')], + filterValues: filterValues, + sortValues: sortValues, + clearQueryCache: true, + ), + ).called(1); + }, + ); + + _clientWithPersistenceTest( + 'queryChannelsOffline with predefined filter reads via queryChannelStates', + 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) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$i:test-id-$i'), + ), + ); + + when( + () => persistence.queryChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).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 Future.delayed(const Duration(milliseconds: 1100)); + clearInteractions(persistence); + + final channels = await tester.client.queryChannelsOffline( + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + ); + + expect(channels, hasLength(channelStates.length)); + + verify( + () => persistence.queryChannelStates( + filter: null, + sort: null, + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + messageLimit: 25, + paginationParams: const PaginationParams(), + ), + ).called(1); + }, + ); + + _clientWithPersistenceTest( + 'queryChannelsWithResult yields QueryChannelsResult with predefinedFilter=null for inline filter', + body: (tester, persistence) async { + final channelStates = List.generate( + 2, + (i) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$i:test-id-$i'), + ), + ); + + when( + () => persistence.queryChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) async => createDefaultQueryChannelsResponse()); + + 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), + ); + + when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); + when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); + when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); + when( + () => persistence.saveChannelQueries( + cids: any(named: 'cids'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + resolvedFilter: any(named: 'resolvedFilter'), + resolvedSort: any(named: 'resolvedSort'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + clearQueryCache: any(named: 'clearQueryCache'), + ), + ).thenAnswer((_) => Future.value()); + + await Future.delayed(const Duration(milliseconds: 1100)); + clearInteractions(persistence); + + final results = await tester.client.queryChannelsWithResult().toList(); + + // Persistence returned empty, so only the online emission is yielded. + expect(results, hasLength(1)); + expect(results.single.channels, hasLength(channelStates.length)); + expect(results.single.predefinedFilter, isNull); + }, + ); + + _clientWithPersistenceTest( + 'queryChannelsWithResult yields QueryChannelsResult with predefinedFilter populated for predefined query', + 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) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$i:test-id-$i'), + ), + ); + + const resolvedSort = [SortOption.desc('last_message_at')]; + const resolvedFilter = Filter.empty(); + const expectedPredefinedFilter = PredefinedFilter( + name: filterName, + filter: resolvedFilter, + sort: resolvedSort, + ); + + when( + () => persistence.queryChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) async => createDefaultQueryChannelsResponse()); + + tester.mockApi( + (api) => api.channel.queryChannels( + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + 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, + predefinedFilter: expectedPredefinedFilter, + ), + ); + + when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); + when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); + when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); + when( + () => persistence.saveChannelQueries( + cids: any(named: 'cids'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + resolvedFilter: any(named: 'resolvedFilter'), + resolvedSort: any(named: 'resolvedSort'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + clearQueryCache: any(named: 'clearQueryCache'), + ), + ).thenAnswer((_) => Future.value()); + + await Future.delayed(const Duration(milliseconds: 1100)); + clearInteractions(persistence); + + final results = await tester.client + .queryChannelsWithResult( + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + ) + .toList(); + + // Persistence returned empty, so only the online emission is yielded. + expect(results, hasLength(1)); + expect(results.single.channels, hasLength(channelStates.length)); + expect(results.single.predefinedFilter, isNotNull); + expect(results.single.predefinedFilter!.name, equals(filterName)); + expect(results.single.predefinedFilter!.sort, equals(resolvedSort)); + }, + ); + }); + + _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 + tester.client.wsConnectionStatusStream.skip(1), + emits(ConnectionStatus.disconnected), + ); + + await tester.client.disconnectUser(flushChatPersistence: true); + + expect(tester.clientState.currentUser, isNull); + expect(tester.connectionStatus, ConnectionStatus.disconnected); + }, + ); + }); + + group('Client with connected user without persistence', () { + const _channelData = {'name': 'test-channel-name'}; + + 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); + + 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 tester.client.sync(cids: cids, lastSyncAt: lastSyncAt); + + tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); + }, + ); + + 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())); + }, + ); + + 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`', () { + 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'), + ), + ); + + 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), + ); + + 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'), + ), + ); + }, + ); + + chatClientTest( + '''should rethrow if `.queryChannelsOnline` throws and persistence channels are empty''', + body: (tester) async { + tester.mockApiFailure( + (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'), + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError), + ); + + await expectLater( + tester.client.queryChannels(), + emitsError(isA()), + ); + + 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'), + ), + ); + }, + ); + + chatClientTest( + 'should coalesce concurrent identical calls into a single HTTP request', + 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 + // cache and each fired its own queryChannels HTTP request. + // + // With the fix, the cache slot is reserved synchronously after the + // hash check, so concurrent callers find the in-flight future and + // share its result. + final channelStates = List.generate( + 3, + (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. + 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), + delay: const Duration(milliseconds: 100), + ); + + // Fire 5 identical calls back-to-back in the same tick. + final results = await Future.wait( + List.generate(5, (_) => tester.client.queryChannels().toList()), + ); + + // All callers should receive the same channels. + for (final emitted in results) { + expect(emitted, hasLength(1)); + expect(emitted.single, channelStates.map(isCorrectChannelFor)); + } + + // But only ONE HTTP request should have been issued. + 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'), + ), + ); + }, + ); + + chatClientTest( + 'should fire a fresh request once the cached future has settled', + 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) => createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'test-type-$index:test-id-$index'), + ), + ); + + 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), + ); + + await tester.client.queryChannels().toList(); + await tester.client.queryChannels().toList(); + + tester.verifyApiCalled( + (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'), + ), + times: 2, + ); + }, + ); + + chatClientTest( + 'concurrent calls with different filters do not share the cache', + 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. + 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(), + delay: const Duration(milliseconds: 100), + ); + + await Future.wait([ + tester.client.queryChannels(filter: Filter.in_('cid', const ['a'])).toList(), + tester.client.queryChannels(filter: Filter.in_('cid', const ['b'])).toList(), + ]); + + tester.verifyApiCalled( + (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'), + ), + times: 2, + ); + }, + ); + + chatClientTest( + 'concurrent calls share the same error when the request fails', + 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. + tester.mockApiFailure( + (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'), + ), + error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError), + delay: const Duration(milliseconds: 100), + ); + + final errors = await Future.wait( + List.generate(5, (_) async { + try { + await tester.client.queryChannels().toList(); + return null; + } catch (e) { + return e; + } + }), + ); + + // Every caller surfaces the same error type. + expect(errors, hasLength(5)); + for (final error in errors) { + expect(error, isA()); + } + + // But only ONE HTTP request was made — the rest piggybacked. + 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'), + ), + ); + }, + ); + }); + + chatClientTest( + '`.queryUsers`', + body: (tester) async { + final users = List.generate( + 3, + (index) => User(id: 'test-user-id-$index'), + ); + + tester.mockApi( + (api) => api.user.queryUsers(presence: true), + result: QueryUsersResponse()..users = users, + ); + + 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 tester.client.queryUsers(); + expect(res, isNotNull); + expect(res.users.length, users.length); + + await usersEmitted; + + 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 = Filter.equal('channel_cid', cid); + + tester.mockApi( + (api) => api.moderation.queryBannedUsers(filter: filter), + result: QueryBannedUsersResponse()..bans = bans, + ); + + final res = await tester.client.queryBannedUsers(filter: filter); + expect(res, isNotNull); + expect(res.bans.length, bans.length); + + tester + ..verifyApi((api) => api.moderation.queryBannedUsers(filter: filter)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + chatClientTest( + '`.search`', + body: (tester) async { + const cid = 'test-type:test-id'; + final filter = Filter.in_('cid', const [cid]); + + final messages = List.generate( + 3, + (index) => createDefaultGetMessageResponse( + channel: ChannelModel(cid: cid), + message: Message(id: 'test-message-id-$index'), + ), + ); + + tester.mockApi( + (api) => api.general.searchMessages(filter), + result: createDefaultSearchMessagesResponse(results: messages), + ); + + final res = await tester.client.search(filter); + expect(res, isNotNull); + expect(res.results.length, messages.length); + + tester + ..verifyApi((api) => api.general.searchMessages(filter)) + ..verifyApi((api) => api.general.getAppSettings()) + ..verifyNoMoreApiInteractions((api) => api.general); + }, + ); + + 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'; + + tester.mockApi( + (api) => api.fileUploader.sendFile(file, channelId, channelType), + result: SendFileResponse()..file = fileUrl, + ); + + final res = await tester.client.sendFile(file, channelId, channelType); + expect(res, isNotNull); + expect(res.file, fileUrl); + + tester + ..verifyApi((api) => api.fileUploader.sendFile(file, channelId, channelType)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); + + 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'; + + tester.mockApi( + (api) => api.fileUploader.sendImage(image, channelId, channelType), + result: SendImageResponse()..file = fileUrl, + ); + + final res = await tester.client.sendImage(image, channelId, channelType); + expect(res, isNotNull); + expect(res.file, fileUrl); + + tester + ..verifyApi((api) => api.fileUploader.sendImage(image, channelId, channelType)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); + + chatClientTest( + '`.deleteFile`', + body: (tester) async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const fileUrl = 'test-file-url'; + + tester.mockApi( + (api) => api.fileUploader.deleteFile(fileUrl, channelId, channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.deleteFile(fileUrl, channelId, channelType); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.fileUploader.deleteFile(fileUrl, channelId, channelType)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); + + chatClientTest( + '`.deleteImage`', + body: (tester) async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const imageUrl = 'test-image-url'; + + tester.mockApi( + (api) => api.fileUploader.deleteImage(imageUrl, channelId, channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.deleteImage(imageUrl, channelId, channelType); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.fileUploader.deleteImage(imageUrl, channelId, channelType)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); + + chatClientTest( + '`.uploadImage`', + body: (tester) async { + final image = AttachmentFile(size: 33, path: 'test-image-path'); + const fileUrl = 'test-image-url'; + + tester.mockApi( + (api) => api.fileUploader.uploadImage(image), + result: UploadImageResponse()..file = fileUrl, + ); + + final res = await tester.client.uploadImage(image); + expect(res, isNotNull); + expect(res.file, fileUrl); + + tester + ..verifyApi((api) => api.fileUploader.uploadImage(image)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); + + chatClientTest( + '`.uploadFile`', + body: (tester) async { + final file = AttachmentFile(size: 33, path: 'test-file-path'); + const fileUrl = 'test-file-url'; + + tester.mockApi( + (api) => api.fileUploader.uploadFile(file), + result: UploadFileResponse()..file = fileUrl, + ); + + final res = await tester.client.uploadFile(file); + expect(res, isNotNull); + expect(res.file, fileUrl); + + tester + ..verifyApi((api) => api.fileUploader.uploadFile(file)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); + + chatClientTest( + '`.removeImage`', + body: (tester) async { + const imageUrl = 'test-image-url'; + + tester.mockApi( + (api) => api.fileUploader.removeImage(imageUrl), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.removeImage(imageUrl); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.fileUploader.removeImage(imageUrl)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); + + chatClientTest( + '`.removeFile`', + body: (tester) async { + const fileUrl = 'test-file-url'; + + tester.mockApi( + (api) => api.fileUploader.removeFile(fileUrl), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.removeFile(fileUrl); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.fileUploader.removeFile(fileUrl)) + ..verifyNoMoreApiInteractions((api) => api.fileUploader); + }, + ); + + 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 tester.client.updateChannel(channelId, channelType, data); + expect(res, isNotNull); + expect(res.channel.cid, '$channelType:$channelId'); + expect(res.channel.extraData['name'], 'test-channel'); + + tester + ..verifyApi((api) => api.channel.updateChannel(channelId, channelType, data)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + 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 tester.client.updateChannelPartial( + channelId, + channelType, + set: set, + unset: unset, + ); + expect(res, isNotNull); + expect(res.channel.cid, '$channelType:$channelId'); + expect(res.channel.extraData, set); + + tester + ..verifyApi((api) => api.channel.updateChannelPartial(channelId, channelType, set: set, unset: unset)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.addDevice should work`', + body: (tester) async { + const id = 'test-device-id'; + const provider = PushProvider.firebase; + + tester.mockApi( + (api) => api.device.addDevice(id, provider), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.addDevice(id, provider); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.device.addDevice(id, provider)) + ..verifyNoMoreApiInteractions((api) => api.device); + }, + ); + + 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(), + ); + + final res = await tester.client.addDevice( + id, + provider, + pushProviderName: pushProviderName, + ); + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.device.addDevice( + id, + provider, + pushProviderName: pushProviderName, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.device); + }, + ); + + chatClientTest( + '`.getDevices`', + body: (tester) async { + final devices = List.generate( + 3, + (index) => Device( + id: 'test-device-id-$index', + pushProvider: PushProvider.firebase.name, + ), + ); + + tester.mockApi( + (api) => api.device.getDevices(), + result: ListDevicesResponse()..devices = devices, + ); + + final res = await tester.client.getDevices(); + expect(res, isNotNull); + expect(res.devices.length, devices.length); + + tester + ..verifyApi((api) => api.device.getDevices()) + ..verifyNoMoreApiInteractions((api) => api.device); + }, + ); + + chatClientTest( + '`.removeDevice`', + body: (tester) async { + const deviceId = 'test-device-id'; + + tester.mockApi( + (api) => api.device.removeDevice(deviceId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.removeDevice(deviceId); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.device.removeDevice(deviceId)) + ..verifyNoMoreApiInteractions((api) => api.device); + }, + ); + + 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 preferences = [pushPreferenceInput, channelPreferenceInput]; + + 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( + 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, + ), + ]), + ); + + final res = await tester.client.setPushPreferences(preferences); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.device.setPushPreferences(preferences)) + ..verifyNoMoreApiInteractions((api) => api.device); + }, + ); + + chatClientTest( + 'should handle push_preference.updated event', + body: (tester) async { + final pushPreference = PushPreference( + chatLevel: ChatLevel.mentions, + callLevel: CallLevel.all, + disabledUntil: DateTime.utc(2021, 3), + ); + + final event = createDefaultEvent( + type: EventType.pushPreferenceUpdated, + pushPreference: pushPreference, + ); + + // Initially null + expect(tester.currentUser?.pushPreferences, isNull); + + // 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); + }, + ); + + 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 [], + ); + + final res = await tester.client.listUserGroups( + limit: limit, + idGt: idGt, + createdAtGt: createdAtGt, + teamId: teamId, + ); + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.userGroups.listUserGroups( + limit: limit, + idGt: idGt, + createdAtGt: createdAtGt, + teamId: teamId, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.userGroups); + }, + ); + + 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 [], + ); + + final res = await tester.client.searchUserGroups( + query, + limit: limit, + nameGt: nameGt, + idGt: idGt, + teamId: teamId, + ); + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.userGroups.searchUserGroups( + query, + limit: limit, + nameGt: nameGt, + idGt: idGt, + teamId: teamId, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.userGroups); + }, + ); + + 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), + ), + ); + + final res = await tester.client.getUserGroup(id, teamId: teamId); + expect(res, isNotNull); + expect(res.userGroup.id, id); + + tester + ..verifyApi((api) => api.userGroups.getUserGroup(id, teamId: teamId)) + ..verifyNoMoreApiInteractions((api) => api.userGroups); + }, + ); + + 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, + description: description, + teamId: teamId, + memberIds: memberIds, + ), + result: CreateUserGroupResponse() + ..userGroup = UserGroup( + id: id, + name: name, + description: description, + teamId: teamId, + createdAt: DateTime.utc(2024, 1, 1), + updatedAt: DateTime.utc(2024, 1, 2), + ), + ); + + final res = await tester.client.createUserGroup( + name, + id: id, + description: description, + teamId: teamId, + memberIds: memberIds, + ); + 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); + }, + ); + + 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, + ), + result: UpdateUserGroupResponse() + ..userGroup = UserGroup( + id: id, + name: name, + description: description, + teamId: teamId, + createdAt: DateTime.utc(2024, 1, 1), + updatedAt: DateTime.utc(2024, 1, 2), + ), + ); + + final res = await tester.client.updateUserGroup( + id, + name: name, + description: description, + teamId: teamId, + ); + 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); + }, + ); + + chatClientTest( + '`.deleteUserGroup`', + body: (tester) async { + const id = 'test-group-id'; + const teamId = 'test-team-id'; + + tester.mockApi( + (api) => api.userGroups.deleteUserGroup(id, teamId: teamId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.deleteUserGroup(id, teamId: teamId); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.userGroups.deleteUserGroup(id, teamId: teamId)) + ..verifyNoMoreApiInteractions((api) => api.userGroups); + }, + ); + + 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, + ), + result: AddUserGroupMembersResponse() + ..userGroup = UserGroup( + id: id, + name: 'test-group-name', + createdAt: DateTime.utc(2024, 1, 1), + updatedAt: DateTime.utc(2024, 1, 2), + ), + ); + + final res = await tester.client.addUserGroupMembers( + id, + memberIds, + asAdmin: asAdmin, + teamId: teamId, + ); + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.userGroups.addUserGroupMembers( + id, + memberIds, + asAdmin: asAdmin, + teamId: teamId, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.userGroups); + }, + ); + + 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, + ), + result: RemoveUserGroupMembersResponse() + ..userGroup = UserGroup( + id: id, + name: 'test-group-name', + createdAt: DateTime.utc(2024, 1, 1), + updatedAt: DateTime.utc(2024, 1, 2), + ), + ); + + final res = await tester.client.removeUserGroupMembers( + id, + memberIds, + teamId: teamId, + ); + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.userGroups.removeUserGroupMembers( + id, + memberIds, + teamId: teamId, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.userGroups); + }, + ); + + 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 [], + ); + + final res = await tester.client.searchRoles( + query, + limit: limit, + nameGt: nameGt, + roleType: roleType, + includeGlobalRoles: includeGlobalRoles, + ); + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.roles.searchRoles( + query, + limit: limit, + nameGt: nameGt, + roleType: roleType, + includeGlobalRoles: includeGlobalRoles, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.roles); + }, + ); + + chatClientTest( + '`.devToken`', + body: (tester) async { + const userId = 'test-user-id'; + + final token = tester.client.devToken(userId); + + expect(token, isNotNull); + expect(token.userId, userId); + expect(token.authType, AuthType.jwt); + }, + ); + + group('`.channel`', () { + chatClientTest( + 'should return back a new channel instance', + body: (tester) { + final channel = tester.client.channel( + _channelType, + id: _channelId, + extraData: _channelData, + ); + + expect(channel, isNotNull); + expect(channel.type, _channelType); + expect(channel.id, _channelId); + expect(channel.cid, _channelCid); + expect(channel.extraData, _channelData); + }, + ); + + chatClientTest( + 'should return back in memory channel instance if available', + body: (tester) async { + final channel = tester.client.channel( + _channelType, + id: _channelId, + extraData: _channelData, + ); + + final channelState = createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid), + ); + + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + state: true, + watch: true, + presence: false, + ), + result: channelState, + ); + + final channelsEmission = expectLater( + tester.clientState.channelsStream.skip(1), + emitsInOrder([ + {_channelCid: isCorrectChannelFor(channelState)}, + ]), + ); + + await channel.watch(); + await channelsEmission; + + final newChannel = tester.client.channel(_channelType, id: _channelId); + expect(newChannel, channel); + + tester.verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + state: true, + watch: true, + presence: false, + ), + ); + }, + ); + }); + + chatClientTest( + '`.createChannel`', + body: (tester) async { + final channelState = createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid, extraData: _channelData), + ); + + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + state: false, + ), + result: channelState, + ); + + final res = await tester.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, _channelCid); + expect(channel.extraData, _channelData); + + tester + ..verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + state: false, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.watchChannel`', + body: (tester) async { + final channelState = createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid, extraData: _channelData), + ); + + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + watch: true, + ), + result: channelState, + ); + + final res = await tester.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, _channelCid); + expect(channel.extraData, _channelData); + + tester + ..verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + watch: true, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.queryChannel`', + body: (tester) async { + final channelState = createDefaultChannelState( + channel: createDefaultChannelModel(cid: _channelCid, extraData: _channelData), + ); + + tester.mockApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + ), + result: channelState, + ); + + final res = await tester.client.queryChannel( + _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, _channelCid); + expect(channel.extraData, _channelData); + + tester + ..verifyApi( + (api) => api.channel.queryChannel( + _channelType, + channelId: _channelId, + channelData: _channelData, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.queryMembers`', + body: (tester) async { + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); + + tester.mockApi( + (api) => api.general.queryMembers(_channelType), + result: QueryMembersResponse()..members = members, + ); + + final res = await tester.client.queryMembers(_channelType); + expect(res, isNotNull); + expect(res.members.length, members.length); + + tester + ..verifyApi((api) => api.general.queryMembers(_channelType)) + ..verifyApi((api) => api.general.getAppSettings()) + ..verifyNoMoreApiInteractions((api) => api.general); + }, + ); + + chatClientTest( + '`.hideChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.hideChannel(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.hideChannel(_channelId, _channelType); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.channel.hideChannel(_channelId, _channelType)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.showChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.showChannel(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.showChannel(_channelId, _channelType); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.channel.showChannel(_channelId, _channelType)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.deleteChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.deleteChannel(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.deleteChannel(_channelId, _channelType); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.channel.deleteChannel(_channelId, _channelType)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.truncateChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.truncateChannel(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.truncateChannel(_channelId, _channelType); + + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.channel.truncateChannel(_channelId, _channelType), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.muteChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.moderation.muteChannel(_channelCid), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.muteChannel(_channelCid); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.muteChannel(_channelCid)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + chatClientTest( + '`.unmuteChannel`', + body: (tester) async { + tester.mockApi( + (api) => api.moderation.unmuteChannel(_channelCid), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.unmuteChannel(_channelCid); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.unmuteChannel(_channelCid)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + 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), + ), + ); + + final res = await tester.client.partialMemberUpdate( + channelId: _channelId, + channelType: _channelType, + set: set, + unset: unset, + ); + + 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); + }, + ); + + 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), + ), + ); + + final res = await tester.client.partialMemberUpdate( + channelId: _channelId, + channelType: _channelType, + set: set, + unset: unset, + ); + + 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); + }, + ); + + 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 tester.client.pinChannel( + channelId: _channelId, + channelType: _channelType, + ); + + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: const MemberUpdatePayload(pinned: true).toJson(), + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + 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)), + ), + ); + + final res = await tester.client.unpinChannel( + channelId: _channelId, + channelType: _channelType, + ); + + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + unset: [MemberUpdateType.pinned.name], + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + 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)), + ), + ); + + final res = await tester.client.archiveChannel( + channelId: _channelId, + channelType: _channelType, + ); + + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + set: const MemberUpdatePayload(archived: true).toJson(), + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + 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 res = await tester.client.unarchiveChannel( + channelId: _channelId, + channelType: _channelType, + ); + + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.channel.updateMemberPartial( + channelId: _channelId, + channelType: _channelType, + unset: [MemberUpdateType.archived.name], + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.acceptChannelInvite`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.acceptChannelInvite(_channelId, _channelType), + result: AcceptInviteResponse()..channel = createDefaultChannelModel(cid: _channelCid), + ); + + final res = await tester.client.acceptChannelInvite(_channelId, _channelType); + expect(res, isNotNull); + expect(res.channel.cid, _channelCid); + + tester + ..verifyApi((api) => api.channel.acceptChannelInvite(_channelId, _channelType)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.rejectChannelInvite`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.rejectChannelInvite(_channelId, _channelType), + result: RejectInviteResponse()..channel = createDefaultChannelModel(cid: _channelCid), + ); + + final res = await tester.client.rejectChannelInvite(_channelId, _channelType); + expect(res, isNotNull); + expect(res.channel.cid, _channelCid); + + tester + ..verifyApi((api) => api.channel.rejectChannelInvite(_channelId, _channelType)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.addChannelMembers`', + 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); + + tester.mockApi( + (api) => api.channel.addMembers(_channelId, _channelType, memberIds), + result: createDefaultAddMembersResponse( + channel: createDefaultChannelModel(cid: _channelCid), + members: members, + ), + ); + + final res = await tester.client.addChannelMembers( + _channelId, + _channelType, + memberIds, + ); + + 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); + }, + ); + + 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 hideHistoryBefore = DateTime.parse('2024-01-01T00:00:00Z'); + + tester.mockApi( + (api) => api.channel.addMembers( + _channelId, + _channelType, + memberIds, + hideHistoryBefore: hideHistoryBefore, + ), + result: createDefaultAddMembersResponse( + channel: createDefaultChannelModel(cid: _channelCid), + members: members, + ), + ); + + 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); + + tester + ..verifyApi( + (api) => api.channel.addMembers( + _channelId, + _channelType, + memberIds, + hideHistoryBefore: hideHistoryBefore, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.removeChannelMembers`', + 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); + + tester.mockApi( + (api) => api.channel.removeMembers(_channelId, _channelType, memberIds), + result: RemoveMembersResponse() + ..channel = createDefaultChannelModel(cid: _channelCid) + ..members = members, + ); + + final res = await tester.client.removeChannelMembers( + _channelId, + _channelType, + memberIds, + ); + + 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); + }, + ); + + chatClientTest( + '`.inviteChannelMembers`', + 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); + + tester.mockApi( + (api) => api.channel.inviteChannelMembers(_channelId, _channelType, memberIds), + result: InviteMembersResponse() + ..channel = createDefaultChannelModel(cid: _channelCid) + ..members = members, + ); + + final res = await tester.client.inviteChannelMembers( + _channelId, + _channelType, + memberIds, + ); + + expect(res, isNotNull); + expect(res.channel.cid, _channelCid); + expect(res.members.length, memberIds.length); + + tester + ..verifyApi((api) => api.channel.inviteChannelMembers(_channelId, _channelType, memberIds)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.stopChannelWatching`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.stopWatching(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.stopChannelWatching(_channelId, _channelType); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.channel.stopWatching(_channelId, _channelType)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.sendAction`', + body: (tester) async { + const messageId = 'test-message-id'; + const formData = {'key': 'value'}; + + tester.mockApi( + (api) => api.message.sendAction(_channelId, _channelType, messageId, formData), + result: createDefaultSendActionResponse(), + ); + + final res = await tester.client.sendAction( + _channelId, + _channelType, + messageId, + formData, + ); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.message.sendAction(_channelId, _channelType, messageId, formData)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.markChannelRead`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.markRead(_channelId, _channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.markChannelRead(_channelId, _channelType); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.channel.markRead(_channelId, _channelType)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.markChannelUnread`', + body: (tester) async { + const messageId = 'test-message-id'; + + tester.mockApi( + (api) => api.channel.markUnread(_channelId, _channelType, messageId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.markChannelUnread( + _channelId, + _channelType, + messageId, + ); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.channel.markUnread(_channelId, _channelType, messageId)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.markChannelUnreadByTimestamp`', + body: (tester) async { + final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); + + tester.mockApi( + (api) => api.channel.markUnreadByTimestamp( + _channelId, + _channelType, + timestamp, + ), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.markChannelUnreadByTimestamp( + _channelId, + _channelType, + timestamp, + ); + + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.channel.markUnreadByTimestamp( + _channelId, + _channelType, + timestamp, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.createPoll`', + body: (tester) async { + final poll = createDefaultPoll(); + + tester.mockApi( + (api) => api.polls.createPoll(poll), + result: CreatePollResponse()..poll = poll, + ); + + final res = await tester.client.createPoll(poll); + expect(res, isNotNull); + expect(res.poll, poll); + + tester + ..verifyApi((api) => api.polls.createPoll(poll)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.getPoll`', + body: (tester) async { + const pollId = 'test-poll-id'; + final poll = createDefaultPoll(id: pollId); + + tester.mockApi( + (api) => api.polls.getPoll(pollId), + result: GetPollResponse()..poll = poll, + ); + + final res = await tester.client.getPoll(pollId); + expect(res, isNotNull); + expect(res.poll, poll); + + tester + ..verifyApi((api) => api.polls.getPoll(pollId)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.updatePoll`', + body: (tester) async { + final poll = createDefaultPoll(id: 'test-poll-id'); + + tester.mockApi( + (api) => api.polls.updatePoll(poll), + result: createDefaultUpdatePollResponse(poll: poll), + ); + + final res = await tester.client.updatePoll(poll); + expect(res, isNotNull); + expect(res.poll, poll); + + tester + ..verifyApi((api) => api.polls.updatePoll(poll)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.partialUpdatePoll`', + body: (tester) async { + const pollId = 'test-poll-id'; + final set = {'name': 'What is your favorite color?'}; + final unset = []; + + final poll = createDefaultPoll(id: pollId, name: set['name']!); + + tester.mockApi( + (api) => api.polls.partialUpdatePoll(pollId, set: set, unset: unset), + result: createDefaultUpdatePollResponse(poll: poll), + ); + + 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']); + + tester + ..verifyApi((api) => api.polls.partialUpdatePoll(pollId, set: set, unset: unset)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.deletePoll`', + body: (tester) async { + const pollId = 'test-poll-id'; + + tester.mockApi( + (api) => api.polls.deletePoll(pollId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.deletePoll(pollId); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.polls.deletePoll(pollId)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.closePoll`', + body: (tester) async { + const pollId = 'test-poll-id'; + + tester.mockApi( + (api) => api.polls.partialUpdatePoll(pollId, set: {'is_closed': true}), + result: createDefaultUpdatePollResponse(), + ); + + final res = await tester.client.closePoll(pollId); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.polls.partialUpdatePoll(pollId, set: {'is_closed': true})) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.createPollOption`', + body: (tester) async { + const pollId = 'test-poll-id'; + final option = createDefaultPollOption(); + + tester.mockApi( + (api) => api.polls.createPollOption(pollId, option), + result: CreatePollOptionResponse()..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); + }, + ); + + chatClientTest( + '`.getPollOption`', + body: (tester) async { + const pollId = 'test-poll-id'; + const optionId = 'test-option-id'; + final option = createDefaultPollOption(id: optionId); + + tester.mockApi( + (api) => api.polls.getPollOption(pollId, optionId), + result: GetPollOptionResponse()..pollOption = option, + ); + + final res = await tester.client.getPollOption(pollId, optionId); + expect(res, isNotNull); + expect(res.pollOption, option); + + tester + ..verifyApi((api) => api.polls.getPollOption(pollId, optionId)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.updatePollOption`', + body: (tester) async { + const pollId = 'test-poll-id'; + final option = createDefaultPollOption(id: 'test-option-id'); + + tester.mockApi( + (api) => api.polls.updatePollOption(pollId, option), + result: UpdatePollOptionResponse()..pollOption = option, + ); + + final res = await tester.client.updatePollOption(pollId, option); + expect(res, isNotNull); + expect(res.pollOption, option); + + tester + ..verifyApi((api) => api.polls.updatePollOption(pollId, option)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.deletePollOption`', + body: (tester) async { + const pollId = 'test-poll-id'; + const optionId = 'test-option-id'; + + tester.mockApi( + (api) => api.polls.deletePollOption(pollId, optionId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.deletePollOption(pollId, optionId); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.polls.deletePollOption(pollId, optionId)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + 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', + ); + + tester.mockApi( + (api) => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteOption(optionId))), + result: createDefaultCastPollVoteResponse(vote: vote), + ); + + final res = await tester.client.castPollVote(messageId, pollId, optionId: optionId); + expect(res, isNotNull); + expect(res.vote, vote); + + tester + ..verifyApi((api) => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteOption(optionId)))) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + 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', + ); + + tester.mockApi( + (api) => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteAnswer(answerText))), + result: createDefaultCastPollVoteResponse(vote: vote), + ); + + final res = await tester.client.addPollAnswer(messageId, pollId, answerText: answerText); + expect(res, isNotNull); + expect(res.vote, vote); + + tester + ..verifyApi((api) => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteAnswer(answerText)))) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.removePollVote`', + body: (tester) async { + const messageId = 'test-message-id'; + const pollId = 'test-poll-id'; + const voteId = 'test-vote-id'; + + tester.mockApi( + (api) => api.polls.removePollVote(messageId, pollId, voteId), + result: RemovePollVoteResponse(), + ); + + final res = await tester.client.removePollVote(messageId, pollId, voteId); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.polls.removePollVote(messageId, pollId, voteId)) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.queryPolls`', + body: (tester) async { + final filter = Filter.in_('id', const ['test-poll-id']); + final sort = [const SortOption.desc('created_at')]; + const pagination = PaginationParams(limit: 20); + + final polls = List.generate( + pagination.limit, + (index) => createDefaultPoll(id: 'test-poll-id-$index'), + ); + + tester.mockApi( + (api) => api.polls.queryPolls( + filter: filter, + sort: sort, + pagination: pagination, + ), + result: QueryPollsResponse()..polls = polls, + ); + + final res = await tester.client.queryPolls( + filter: filter, + sort: sort, + pagination: pagination, + ); + expect(res, isNotNull); + expect(res.polls.length, polls.length); + + tester + ..verifyApi( + (api) => api.polls.queryPolls( + filter: filter, + sort: sort, + pagination: pagination, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.queryPollVotes`', + body: (tester) async { + const pollId = 'test-poll-id'; + final filter = Filter.in_('id', const ['test-vote-id']); + final sort = [const SortOption.desc('created_at')]; + const pagination = PaginationParams(limit: 20); + + final votes = List.generate( + pagination.limit, + (index) => createDefaultPollVote(id: 'test-vote-id-$index', answerText: 'Red'), + ); + + tester.mockApi( + (api) => api.polls.queryPollVotes( + pollId, + filter: filter, + sort: sort, + pagination: pagination, + ), + result: QueryPollVotesResponse()..votes = votes, + ); + + final res = await tester.client.queryPollVotes( + pollId, + filter: filter, + sort: sort, + pagination: pagination, + ); + expect(res, isNotNull); + expect(res.votes.length, votes.length); + + tester + ..verifyApi( + (api) => api.polls.queryPollVotes( + pollId, + filter: filter, + sort: sort, + pagination: pagination, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.polls); + }, + ); + + chatClientTest( + '`.updateUser`', + body: (tester) async { + final user = User( + id: 'test-user-id', + extraData: const {'name': 'test-user'}, + ); + + tester.mockApi( + (api) => api.user.updateUsers([user]), + result: createDefaultUpdateUsersResponse(users: {user.id: user}), + ); + + final res = await tester.client.updateUser(user); + + expect(res, isNotNull); + expect(res.users, {user.id: user}); + + tester + ..verifyApi((api) => api.user.updateUsers([user])) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + chatClientTest( + '`.partialUpdateUser`', + body: (tester) async { + const userId = 'test-user-id'; + + final set = {'color': 'yellow'}; + final unset = []; + + final partialUpdateRequest = PartialUpdateUserRequest( + id: userId, + set: set, + unset: unset, + ); + + final updatedUser = User( + id: userId, + extraData: {'color': set['color']}, + ); + + tester.mockApi( + (api) => api.user.partialUpdateUsers([partialUpdateRequest]), + result: createDefaultUpdateUsersResponse(users: {updatedUser.id: updatedUser}), + ); + + final res = await tester.client.partialUpdateUser( + userId, + set: set, + unset: unset, + ); + + expect(res, isNotNull); + expect(res.users, {updatedUser.id: updatedUser}); + + tester + ..verifyApi((api) => api.user.partialUpdateUsers([partialUpdateRequest])) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + chatClientTest( + '`.banUser`', + body: (tester) async { + const userId = 'test-user-id'; + + tester.mockApi( + (api) => api.moderation.banUser(userId, options: any(named: 'options')), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.banUser(userId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.banUser(userId, options: any(named: 'options'))) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + chatClientTest( + '`.unbanUser`', + body: (tester) async { + const userId = 'test-user-id'; + + tester.mockApi( + (api) => api.moderation.unbanUser(userId, options: any(named: 'options')), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.unbanUser(userId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.unbanUser(userId, options: any(named: 'options'))) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + 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 tester.client.blockUser(userId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.user.blockUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + chatClientTest( + '`.unblockUser`', + body: (tester) async { + const userId = 'test-user-id'; + + tester.mockApi( + (api) => api.user.unblockUser(userId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.unblockUser(userId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.user.unblockUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + chatClientTest( + '`.queryBlockedUsers`', + body: (tester) async { + final users = List.generate( + 3, + (index) => User(id: 'test-user-id-$index'), + ); + + 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 tester.client.queryBlockedUsers(); + expect(res, isNotNull); + expect(res.blocks.length, 2); + + tester + ..verifyApi((api) => api.user.queryBlockedUsers()) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + group('Block user state management', () { + 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(tester.currentUser?.blockedUserIds, isEmpty); + + tester.mockApi( + (api) => api.user.blockUser(userId), + result: createDefaultUserBlockResponse( + blockedUserId: userId, + blockedByUserId: testUser.id, + ), + ); + + await tester.client.blockUser(userId); + + // 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); + }, + ); + + chatClientTest( + 'blockUser should not duplicate existing blocked user IDs', + body: (tester) async { + const userId = 'blocked-user-id'; + tester.clientState.blockedUserIds = const [userId]; + + // Verify the user is already in the blocked list + expect(tester.currentUser?.blockedUserIds, contains(userId)); + + tester.mockApi( + (api) => api.user.blockUser(userId), + result: createDefaultUserBlockResponse( + blockedUserId: userId, + blockedByUserId: tester.currentUser!.id, + ), + ); + + await tester.client.blockUser(userId); + + // Verify - should still have only one entry + expect(tester.currentUser?.blockedUserIds, contains(userId)); + expect(tester.currentUser?.blockedUserIds.length, 1); + tester + ..verifyApi((api) => api.user.blockUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + 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( + tester.currentUser?.blockedUserIds, + containsAll([blockedUserId, otherBlockedId]), + ); + + tester.mockApi( + (api) => api.user.unblockUser(blockedUserId), + result: createDefaultEmptyResponse(), + ); + + await tester.client.unblockUser(blockedUserId); + + // Verify - blockedUserId should be removed + expect( + tester.currentUser?.blockedUserIds, + contains(otherBlockedId), + ); + + expect( + tester.currentUser?.blockedUserIds, + isNot(contains(blockedUserId)), + ); + + tester + ..verifyApi((api) => api.user.unblockUser(blockedUserId)) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + chatClientTest( + 'unblockUser should be resilient if user ID not in blocked list', + body: (tester) async { + const nonBlockedUserId = 'not-in-list'; + const otherBlockedId = 'other-blocked-id'; + tester.clientState.blockedUserIds = const [otherBlockedId]; + + // Verify initial state + expect( + tester.currentUser?.blockedUserIds, + contains(otherBlockedId), + ); + + expect( + tester.currentUser?.blockedUserIds, + isNot(contains(nonBlockedUserId)), + ); + + tester.mockApi( + (api) => api.user.unblockUser(nonBlockedUserId), + result: createDefaultEmptyResponse(), + ); + + await tester.client.unblockUser(nonBlockedUserId); + + // Verify - should remain unchanged + expect(tester.currentUser?.blockedUserIds, contains(otherBlockedId)); + expect(tester.currentUser?.blockedUserIds, isNot(contains(nonBlockedUserId))); + tester + ..verifyApi((api) => api.user.unblockUser(nonBlockedUserId)) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + chatClientTest( + 'queryBlockedUsers should update client state with blockedUserIds', + body: (tester) async { + const blockedId1 = 'blocked-1'; + const blockedId2 = 'blocked-2'; + + // Verify initial state + 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 + tester.mockApi( + (api) => api.user.queryBlockedUsers(), + result: createDefaultBlockedUsersResponse( + blocks: [ + UserBlock( + user: user, + userId: user.id, + blockedUser: blockedUser1, + blockedUserId: blockedId1, + ), + UserBlock( + user: user, + userId: user.id, + blockedUser: blockedUser2, + blockedUserId: blockedId2, + ), + ], + ), + ); + + await tester.client.queryBlockedUsers(); + + // Verify - should now include both blocked IDs + expect( + tester.currentUser?.blockedUserIds, + containsAll([blockedId1, blockedId2]), + ); + + tester + ..verifyApi((api) => api.user.queryBlockedUsers()) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + }); + + 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 tester.client.getUnreadCount(); + + expect(res, isNotNull); + expect(res.totalUnreadCount, 42); + expect(res.totalUnreadThreadsCount, 8); + + tester + ..verifyApi((api) => api.user.getUnreadCount()) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + chatClientTest( + '`.getUnreadCount` should also update user unread count as a side effect', + 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.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', + ), + ], + ), + ); + + tester.client.getUnreadCount().ignore(); + + // Wait for the local side effect event to be processed + await Future.delayed(Duration.zero); + + expect(tester.currentUser?.totalUnreadCount, 25); + expect(tester.currentUser?.unreadChannels, 2); // channels.length + expect(tester.currentUser?.unreadThreads, 2); // threads.length + + tester + ..verifyApi((api) => api.user.getUnreadCount()) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + chatClientTest( + '`.shadowBan`', + body: (tester) async { + const userId = 'test-user-id'; + + tester.mockApi( + (api) => api.moderation.banUser(userId, options: {'shadow': true}), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.shadowBan(userId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.banUser(userId, options: {'shadow': true})) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + chatClientTest( + '`.removeShadowBan`', + body: (tester) async { + const userId = 'test-user-id'; + + tester.mockApi( + (api) => api.moderation.unbanUser(userId, options: {'shadow': true}), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.removeShadowBan(userId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.unbanUser(userId, options: {'shadow': true})) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + chatClientTest( + '`.muteUser`', + body: (tester) async { + const userId = 'test-user-id'; + + tester.mockApi( + (api) => api.moderation.muteUser(userId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.muteUser(userId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.muteUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + chatClientTest( + '`.unmuteUser`', + body: (tester) async { + const userId = 'test-user-id'; + + tester.mockApi( + (api) => api.moderation.unmuteUser(userId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.unmuteUser(userId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.unmuteUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + chatClientTest( + '`.flagMessage`', + body: (tester) async { + const messageId = 'test-message-id'; + + tester.mockApi( + (api) => api.moderation.flagMessage(messageId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.flagMessage(messageId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.flagMessage(messageId)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + chatClientTest( + '`.unflagMessage`', + body: (tester) async { + const messageId = 'test-message-id'; + + tester.mockApi( + (api) => api.moderation.unflagMessage(messageId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.unflagMessage(messageId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.unflagMessage(messageId)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + chatClientTest( + '`.flagUser`', + body: (tester) async { + const userId = 'test-message-id'; + + tester.mockApi( + (api) => api.moderation.flagUser(userId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.flagUser(userId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.flagUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + chatClientTest( + '`.unflagUser`', + body: (tester) async { + const userId = 'test-message-id'; + + tester.mockApi( + (api) => api.moderation.unflagUser(userId), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.unflagUser(userId); + + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.moderation.unflagUser(userId)) + ..verifyNoMoreApiInteractions((api) => api.moderation); + }, + ); + + 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)), + ), + ]; + + tester.mockApi( + (api) => api.user.getActiveLiveLocations(), + result: GetActiveLiveLocationsResponse()..activeLiveLocations = locations, + ); + + // Initial state should be empty + expect(tester.clientState.activeLiveLocations, isEmpty); + + final res = await tester.client.getActiveLiveLocations(); + + expect(res, isNotNull); + expect(res.activeLiveLocations, hasLength(2)); + expect(res.activeLiveLocations, equals(locations)); + expect(tester.clientState.activeLiveLocations, equals(locations)); + + tester + ..verifyApi((api) => api.user.getActiveLiveLocations()) + ..verifyNoMoreApiInteractions((api) => api.user); + }, + ); + + 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 = LocationCoordinates( + latitude: 40.7128, + longitude: -74.0060, + ); + + final expectedLocation = Location( + latitude: location.latitude, + longitude: location.longitude, + createdByDeviceId: createdByDeviceId, + endAt: endAt, + ); + + tester.mockApi( + (api) => api.user.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + location: location, + endAt: endAt, + ), + result: expectedLocation, + ); + + final res = await tester.client.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + location: location, + endAt: endAt, + ); + + 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); + }, + ); + + chatClientTest( + '`.stopLiveLocation`', + body: (tester) async { + const messageId = 'test-message-id'; + const createdByDeviceId = 'test-device-id'; + + final expectedLocation = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: createdByDeviceId, + endAt: DateTime.timestamp(), // Should be expired + ); + + tester.mockApi( + (api) => api.user.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + endAt: any(named: 'endAt'), + ), + result: expectedLocation, + ); + + final res = await tester.client.stopLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + ); + + 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); + }, + ); + + 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)), + ); + + final event = createDefaultEvent( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: location, + ), + ); + + // Initially empty + expect(tester.clientState.activeLiveLocations, isEmpty); + + // Trigger the event + await tester.emitEvent(event); + + // Should add location to active live locations + final activeLiveLocations = tester.clientState.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations.first.messageId, equals('message-123')); + }, + ); + + 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)), + ); + + // Set initial location + tester.clientState.activeLiveLocations = [initialLocation]; + + 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)), + ); + + final event = createDefaultEvent( + type: EventType.locationUpdated, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: updatedLocation, + ), + ); + + // Trigger the event + await tester.emitEvent(event); + + // 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: tester.currentUser!.id, + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + endAt: DateTime.timestamp().add(const Duration(hours: 1)), + ); + + // Set initial location + tester.clientState.activeLiveLocations = [location]; + expect(tester.clientState.activeLiveLocations, hasLength(1)); + + final expiredLocation = location.copyWith( + endAt: DateTime.timestamp().subtract(const Duration(hours: 1)), + ); + + final event = createDefaultEvent( + type: EventType.locationExpired, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: expiredLocation, + ), + ); + + // Trigger the event + await tester.emitEvent(event); + + // Should remove the location + expect(tester.clientState.activeLiveLocations, isEmpty); + }, + ); + + 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); + + // 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)); + }, + ); + + 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)), + ); + + final event = createDefaultEvent( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: location, + ), + ); + + // Trigger the event + await tester.emitEvent(event); + + // Should not add location from other user + expect(tester.clientState.activeLiveLocations, isEmpty); + }, + ); + + 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 + ); + + final event = createDefaultEvent( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: staticLocation, + ), + ); + + // Trigger the event + await tester.emitEvent(event); + + // Should not add static location + expect(tester.clientState.activeLiveLocations, isEmpty); + }, + ); + + 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 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)), + ); + + final event1 = createDefaultEvent( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: location1, + ), + ); + + final event2 = createDefaultEvent( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-456', + sharedLocation: location2, + ), + ); + + // Trigger first event + await tester.emitEvent(event1); + + 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); + + final activeLiveLocations2 = tester.clientState.activeLiveLocations; + expect(activeLiveLocations2, hasLength(1)); + expect(activeLiveLocations2.first.messageId, equals('message-456')); + }, + ); + }); + + chatClientTest( + '`.markAllRead`', + body: (tester) async { + tester.mockApi( + (api) => api.channel.markAllRead(), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.markAllRead(); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.channel.markAllRead()) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + 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', + ), + ]; + + tester.mockApi( + (api) => api.channel.markChannelsDelivered(deliveries), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.markChannelsDelivered(deliveries); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.channel.markChannelsDelivered(deliveries)) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.sendEvent`', + body: (tester) async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + final event = Event(type: EventType.any); + + tester.mockApi( + (api) => api.channel.sendEvent(channelId, channelType, event), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.sendEvent(channelId, channelType, event); + expect(res, isNotNull); + + tester + ..verifyApi( + (api) => api.channel.sendEvent(channelId, channelType, event), + ) + ..verifyNoMoreApiInteractions((api) => api.channel); + }, + ); + + chatClientTest( + '`.sendReaction`', + body: (tester) 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, + ); + + tester.mockApi( + (api) => api.message.sendReaction(messageId, reaction), + result: createDefaultSendReactionResponse( + message: Message(id: messageId), + reaction: reaction, + ), + ); + + 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); + }, + ); + + chatClientTest( + '`.deleteReaction`', + body: (tester) async { + const messageId = 'test-message-id'; + const reactionType = 'like'; + + tester.mockApi( + (api) => api.message.deleteReaction(messageId, reactionType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.deleteReaction(messageId, reactionType); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.message.deleteReaction(messageId, reactionType)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.sendMessage`', + body: (tester) async { + final message = Message(id: 'test-message-id'); + 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), + ); + + 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); + }, + ); + + chatClientTest( + '`.createDraft`', + body: (tester) 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.createDraft( + channelId, + channelType, + any(that: isSameDraftMessageAs(message)), + ), + result: createDefaultCreateDraftResponse( + draft: createDefaultDraft( + channelCid: '$channelType:$channelId', + message: message, + ), + ), + ); + + final res = await tester.client.createDraft( + message, + channelId, + channelType, + ); + + 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); + }, + ); + + chatClientTest( + '`.deleteDraft`', + body: (tester) async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + tester.mockApi( + (api) => api.message.deleteDraft(channelId, channelType), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.deleteDraft(channelId, channelType); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.message.deleteDraft(channelId, channelType)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.getDraft`', + body: (tester) async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final message = DraftMessage(id: 'test-message-id', text: 'Hello!'); + + tester.mockApi( + (api) => api.message.getDraft(channelId, channelType), + result: createDefaultGetDraftResponse( + draft: createDefaultDraft( + channelCid: '$channelType:$channelId', + message: message, + ), + ), + ); + + final res = await tester.client.getDraft(channelId, channelType); + + expect(res, isNotNull); + expect(res.draft.message, isSameDraftMessageAs(message)); + + tester + ..verifyApi((api) => api.message.getDraft(channelId, channelType)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.queryDrafts`', + body: (tester) async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final filter = Filter.equal('channel_cid', '$channelType:$channelId'); + final sort = [const SortOption.desc('created_at')]; + const pagination = PaginationParams(limit: 20); + + final drafts = [ + createDefaultDraft( + channelCid: '$channelType:$channelId', + message: DraftMessage(id: 'test-message-id', text: 'Hello!'), + ), + ]; + + 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, + ); + + 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); + }, + ); + + chatClientTest( + '`.getReplies`', + body: (tester) async { + const parentId = 'test-parent-id'; + + final messages = List.generate( + 3, + (index) => Message(id: 'test-message-id-$index'), + ); + + tester.mockApi( + (api) => api.message.getReplies(parentId), + result: createDefaultQueryRepliesResponse(messages: messages), + ); + + final res = await tester.client.getReplies(parentId); + expect(res, isNotNull); + expect(res.messages.length, messages.length); + + tester + ..verifyApi((api) => api.message.getReplies(parentId)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.getReactions`', + body: (tester) async { + const messageId = 'test-parent-id'; + + 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), + ); + + 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); + + tester + ..verifyApi((api) => api.message.getReactions(messageId)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.queryReactions`', + body: (tester) async { + const messageId = 'test-message-id'; + + 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), + ); + + 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); + + tester + ..verifyApi((api) => api.message.queryReactions(messageId)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.updateMessage`', + body: (tester) async { + final message = Message(id: 'test-message-id', text: 'Hello!'); + + tester.mockApi( + (api) => api.message.updateMessage(any(that: isSameMessageAs(message))), + result: createDefaultUpdateMessageResponse(message: message), + ); + + final res = await tester.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); + }, + ); + + chatClientTest( + '`.deleteMessage`', + body: (tester) async { + const messageId = 'test-message-id'; + + tester.mockApi( + (api) => api.message.deleteMessage(messageId, hard: false), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.deleteMessage(messageId); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.message.deleteMessage(messageId, hard: false)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.deleteMessageForMe`', + body: (tester) async { + const messageId = 'test-message-id'; + + tester.mockApi( + (api) => api.message.deleteMessage(messageId, deleteForMe: true), + result: createDefaultEmptyResponse(), + ); + + final res = await tester.client.deleteMessageForMe(messageId); + expect(res, isNotNull); + + tester + ..verifyApi((api) => api.message.deleteMessage(messageId, deleteForMe: true)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.getMessage`', + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + + tester.mockApi( + (api) => api.message.getMessage(messageId), + result: createDefaultGetMessageResponse(message: message), + ); + + final res = await tester.client.getMessage(messageId); + expect(res, isNotNull); + expect(res.message.id, messageId); + + tester + ..verifyApi((api) => api.message.getMessage(messageId)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.getMessagesById`', + body: (tester) 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(); + + tester.mockApi( + (api) => api.message.getMessagesById(channelId, channelType, messageIds), + result: createDefaultGetMessagesByIdResponse(messages: messages), + ); + + final res = await tester.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); + }, + ); + + chatClientTest( + '`.translateMessage`', + body: (tester) 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), + ); + + final res = await tester.client.translateMessage(messageId, language); + + expect(res, isNotNull); + expect(res.message.i18n, translatedMessage.i18n); + + tester + ..verifyApi((api) => api.message.translateMessage(messageId, language)) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + '`.partialUpdateMessage`', + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + final updateMessageResponse = createDefaultUpdateMessageResponse( + message: message.copyWith(text: set['text'], pinExpires: null), + ); + + tester.mockApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + ), + result: updateMessageResponse, + ); + + final res = await tester.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); + + tester + ..verifyApi( + (api) => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + group('`.pinMessage`', () { + ChannelState Function(ChannelState) _seedChannel() { + return (_) => createDefaultChannelState( + channel: createDefaultChannelModel( + cid: _channelCid, + config: createDefaultChannelConfig(readEvents: true, typingEvents: true), + ownCapabilities: [ChannelCapability.readEvents], + ), + ); + } + + 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 tester.client.pinMessage(messageId); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNull); + + tester + ..verifyApi( + (api) => api.message.partialUpdateMessage( + messageId, + set: {'pinned': true, 'pin_expires': null}, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + 'should work fine if passed timeoutOrExpirationDate as num(seconds)', + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + const timeoutOrExpirationDate = 300; // 300 seconds + + // 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'), + ), + result: createDefaultUpdateMessageResponse( + message: message.copyWith( + pinned: true, + pinExpires: DateTime.utc(2021, 3).add( + const Duration(seconds: timeoutOrExpirationDate), + ), + state: MessageState.sent, + ), + ), + ); + + final res = await tester.client.pinMessage( + messageId, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNotNull); + + tester + ..verifyApi( + (api) => api.message.partialUpdateMessage( + messageId, + set: any(named: 'set'), + ), + ) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + chatClientTest( + 'should work fine if passed timeoutOrExpirationDate as DateTime', + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + final timeoutOrExpirationDate = DateTime.utc(2021, 3).add(const Duration(days: 3)); // 3 days + + tester.mockApi( + (api) => api.message.partialUpdateMessage( + messageId, + set: { + 'pinned': true, + 'pin_expires': timeoutOrExpirationDate.toUtc().toIso8601String(), + }, + ), + result: createDefaultUpdateMessageResponse( + message: message.copyWith( + pinned: true, + pinExpires: timeoutOrExpirationDate, + state: MessageState.sent, + ), + ), + ); + + final res = await tester.client.pinMessage( + messageId, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNotNull); + expect(res.message.pinExpires, timeoutOrExpirationDate.toUtc()); + + tester + ..verifyApi( + (api) => api.message.partialUpdateMessage( + messageId, + set: { + 'pinned': true, + 'pin_expires': timeoutOrExpirationDate.toUtc().toIso8601String(), + }, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + channelTest( + 'should throw if invalid timeoutOrExpirationDate is passed', + 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 tester.channel.pinMessage( + message, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + } catch (e) { + expect(e, isA()); + } + }, + ); + }); + + chatClientTest( + '`.unpinMessage`', + body: (tester) async { + const messageId = 'test-message-id'; + final message = Message(id: messageId, pinned: true); + + tester.mockApi( + (api) => api.message.partialUpdateMessage( + messageId, + set: {'pinned': false}, + ), + result: createDefaultUpdateMessageResponse( + message: message.copyWith( + pinned: false, + state: MessageState.sent, + ), + ), + ); + + final res = await tester.client.unpinMessage(messageId); + + expect(res, isNotNull); + expect(res.message.pinned, isFalse); + + tester + ..verifyApi( + (api) => api.message.partialUpdateMessage( + messageId, + set: {'pinned': false}, + ), + ) + ..verifyNoMoreApiInteractions((api) => api.message); + }, + ); + + 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 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', + ); + + tester + ..verifyApi((api) => api.general.enrichUrl(url)) + ..verifyApi((api) => api.general.getAppSettings()) + ..verifyNoMoreApiInteractions((api) => api.general); + }, + ); + + // 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''', + 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); + expect(state.unreadChannels, 0); + + final updateUser = initialUser.copyWith( + totalUnreadCount: 33, + unreadChannels: 33, + ); + state.currentUser = updateUser; + + expect(state.currentUser, updateUser); + expect(state.totalUnreadCount, 33); + expect(state.unreadChannels, 33); + }, + ); + }); + + group('PersistenceConnectionTests', () { + // 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); + }, + ); + } + + _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); + }, + ); + + _persistenceConnectionTest( + '''multiple call to openPersistenceConnection does not throws an error if already connected to the same user''', + 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); + }, + ); + + _persistenceConnectionTest( + '''openPersistenceConnection throws an error if client is already connected to a different user''', + 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.copyWith(id: 'new-id')), + throwsA(const TypeMatcher()), + ); + }, + ); + + _persistenceConnectionTest( + '''openPersistenceConnection throws an error if chatPersistenceClient is not set''', + body: (tester) async { + await expectLater( + tester.client.openPersistenceConnection(tester.user), + throwsA(const TypeMatcher()), + ); + }, + ); + + _persistenceConnectionTest( + 'closePersistenceConnection disconnects the client', + body: (tester) async { + tester.client.chatPersistenceClient = MockPersistenceClient(); + await tester.client.openPersistenceConnection(tester.user); + expect(tester.client.persistenceEnabled, isTrue); + + await tester.client.closePersistenceConnection(); + expect(tester.client.persistenceEnabled, isFalse); + }, + ); + + _persistenceConnectionTest( + '''closePersistenceConnection compeletes normally if chatPersistenceClient is not connected''', + body: (tester) async { + tester.client.chatPersistenceClient = MockPersistenceClient(); + expect(tester.client.chatPersistenceClient!.isConnected, isFalse); + + await expectLater(tester.client.closePersistenceConnection(), completes); + }, + ); + + _persistenceConnectionTest( + '''closePersistenceConnection completes normally if chatPersistenceClient is not set''', + body: (tester) async { + expect(tester.client.persistenceEnabled, isFalse); + await expectLater(tester.client.closePersistenceConnection(), completes); + }, + ); + + _persistenceConnectionTest( + '''connectUser completes normally if the persistence connection is already connected to the same user''', + body: (tester) async { + tester.client.chatPersistenceClient = MockPersistenceClient(); + await tester.client.openPersistenceConnection(tester.user); + expect(tester.client.persistenceEnabled, isTrue); + + await expectLater( + tester.client.connectUser( + tester.user, + createTestToken(tester.user.id).rawValue, + connectWebSocket: false, + ), + completes, + ); + }, + ); + + _persistenceConnectionTest( + '''connectUser should throw if the persistence connection if already connected to a different user''', + body: (tester) async { + tester.client.chatPersistenceClient = MockPersistenceClient(); + await tester.client.openPersistenceConnection(tester.user.copyWith(id: 'new-id')); + expect(tester.client.persistenceEnabled, isTrue); + + await expectLater( + tester.client.connectUser( + tester.user, + createTestToken(tester.user.id).rawValue, + connectWebSocket: false, + ), + throwsA(const TypeMatcher()), + ); + }, + ); + + group('Sync Method Tests', () { + setUpAll(() { + // fallback values + registerFallbackValue([]); + registerFallbackValue(DateTime(0)); + }); + + _persistenceConnectionTest( + 'should retrieve data from persistence client and sync successfully', + body: (tester) async { + final cids = ['channel1', 'channel2']; + final lastSyncAt = DateTime.utc(2021, 3); + final fakeClient = FakePersistenceClient( + channelCids: cids, + lastSyncAt: lastSyncAt, + ); + + tester.client.chatPersistenceClient = fakeClient; + tester.mockApi( + (api) => api.general.sync(cids, lastSyncAt), + result: createDefaultSyncResponse(), + ); + + await tester.client.sync(); + + tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); + + final newLastSyncAt = await fakeClient.getLastSyncAt(); + expect(newLastSyncAt?.isAfter(lastSyncAt), isTrue); + }, + ); + + _persistenceConnectionTest( + 'should set lastSyncAt on first sync when null', + body: (tester) async { + final fakeClient = FakePersistenceClient( + channelCids: ['channel1'], + lastSyncAt: null, + ); + + tester.client.chatPersistenceClient = fakeClient; + + await tester.client.sync(); + + expectLater(fakeClient.getLastSyncAt(), completion(isNotNull)); + tester.verifyNeverCalled((api) => api.general.sync(any(), any())); + }, + ); + + _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, + ); + + tester.client.chatPersistenceClient = fakeClient; + tester.mockApiFailure( + (api) => api.general.sync(cids, lastSyncAt), + error: StreamChatNetworkError.raw( + code: 4, + statusCode: 400, + message: 'Too many events', + ), + ); + + await tester.client.sync(); + + expect(await fakeClient.getChannelCids(), isEmpty); // Should be flushed + + tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); + }, + ); + }); + }); + + group('recoverStateOnReconnect', () { + const apiKey = 'test-api-key'; + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + late FakeChatApi api; + late _FakeWebSocket ws; + late StreamChatClient client; + + setUpAll(() { + registerFallbackValue(const PaginationParams()); + registerFallbackValue(Filter.equal('cid', '')); + }); + + setUp(() { + api = FakeChatApi(); + 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 + // async error inside the connection-status listener. + 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 = []); + }); + + tearDown(() async { + await client.dispose(); + }); + + // 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; + await delay(100); + ws.connectionStatus = ConnectionStatus.connected; + 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. + + test('should re-query active channels on reconnect when enabled (default)', () async { + // Setup: connect with default flag, register two channels. + client = StreamChatClient(apiKey, chatApi: api, ws: ws); + await client.connectUser(user, token); + await delay(300); + + final channel1 = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); + final channel2 = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c2'))); + client.state.addChannels({'messaging:c1': channel1, 'messaging:c2': channel2}); + + // Drop interactions from the initial connect's (empty-channel) recovery + // so we only count the reconnect call. + clearInteractions(api.channel); + + await simulateReconnect(); + + verify( + () => api.channel.queryChannels( + filter: Filter.in_('cid', const ['messaging:c1', 'messaging:c2']), + 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: const PaginationParams(limit: 30), + ), + ).called(1); + }); + + test('should skip the re-query on reconnect when disabled', () async { + client = StreamChatClient(apiKey, chatApi: api, ws: ws, recoverStateOnReconnect: false); + await client.connectUser(user, token); + await delay(300); + + final channel = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); + client.state.addChannels({'messaging:c1': channel}); + clearInteractions(api.channel); + + await simulateReconnect(); + + verifyNever( + () => 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('should still emit `connectionRecovered` when disabled', () async { + client = StreamChatClient(apiKey, chatApi: api, ws: ws, recoverStateOnReconnect: false); + await client.connectUser(user, token); + await delay(300); + + final channel = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); + client.state.addChannels({'messaging:c1': channel}); + + // Subscribe AFTER the initial connect so the captured event is the + // one fired by the manual reconnect. + final recoveredEvents = []; + final sub = client.on(EventType.connectionRecovered).listen(recoveredEvents.add); + + await simulateReconnect(); + await sub.cancel(); + + expect(recoveredEvents, hasLength(1)); + }); + + // 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. + test('should not surface an error when the re-query fails', () async { + 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'), + ), + ).thenThrow(const StreamChatError('You cannot use queryChannels without an active connection.')); + + client = StreamChatClient(apiKey, chatApi: api, ws: ws); + await client.connectUser(user, token); + await delay(300); + + final channel = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); + client.state.addChannels({'messaging:c1': channel}); + + // Subscribe AFTER the initial connect so the captured event is the + // one fired by the manual reconnect. + final recoveredEvents = []; + final sub = client.on(EventType.connectionRecovered).listen(recoveredEvents.add); + + await simulateReconnect(); + await sub.cancel(); + + expect(recoveredEvents, hasLength(1)); + }); + + test('should skip the re-query when no active channels are tracked', () async { + client = StreamChatClient(apiKey, chatApi: api, ws: ws); + await client.connectUser(user, token); + await delay(300); + + // No channels added — the cids.isNotEmpty guard should short-circuit. + clearInteractions(api.channel); + + await simulateReconnect(); + + verifyNever( + () => 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('should respect runtime toggling via the setter', () async { + client = StreamChatClient(apiKey, chatApi: api, ws: ws); + await client.connectUser(user, token); + await delay(300); + + final channel = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); + client.state.addChannels({'messaging:c1': channel}); + clearInteractions(api.channel); + + // Disable mid-flight → no re-query on reconnect. + client.recoverStateOnReconnect = false; + await simulateReconnect(); + verifyNever( + () => 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'), + ), + ); + + // Re-enable → re-query on the next reconnect. + client.recoverStateOnReconnect = true; + await simulateReconnect(); + 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); + }); + }); + + group('dispose during reconnect recovery', () { + const apiKey = 'test-api-key'; + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + late FakeChatApi api; + late _FakeWebSocket ws; + late StreamChatClient client; + var disposed = false; + + setUpAll(() { + registerFallbackValue(const PaginationParams()); + registerFallbackValue(Filter.equal('cid', '')); + }); + + setUp(() { + api = FakeChatApi(); + ws = _FakeWebSocket(); + disposed = false; + }); + + // The test disposes the client itself; avoid disposing it a second time. + tearDown(() async { + 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. + test('disposing mid-recovery does not surface a late recovery event', () async { + // Keep the recovery's channel query pending so the client is still + // mid-recovery at the moment it is disposed. + final pendingQuery = Completer(); + 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((_) => pendingQuery.future); + + client = StreamChatClient(apiKey, chatApi: api, ws: ws); + await client.connectUser(user, token); + await delay(300); + + // Track a channel so reconnecting triggers channel recovery, which then + // blocks on the pending query above. + final channel = Channel.fromState( + client, + ChannelState(channel: ChannelModel(cid: 'messaging:c1')), + ); + client.state.addChannels({'messaging:c1': channel}); + + // Drop then restore the connection to start a reconnect recovery. + ws.connectionStatus = ConnectionStatus.disconnected; + await delay(100); + ws.connectionStatus = ConnectionStatus.connected; + await delay(100); + + // Dispose while the recovery is still in flight. + await client.dispose(); + disposed = true; + + // Let the now-orphaned recovery finish. Its trailing work must be + // discarded silently instead of thrown as an unhandled async error. + pendingQuery.complete(QueryChannelsResponse()..channels = []); + await delay(300); + + expect(client.wsConnectionStatus, ConnectionStatus.disconnected); + }); + }); + + group('WS events', () { + group('User messages deleted event', () { + chatClientTest( + 'should broadcast global user.messages.deleted event to all channels', + body: (tester) async { + // Add messages from the user to be deleted + final bannedUser = User(id: 'banned-user', name: 'Banned User'); + final message1 = Message( + id: 'msg-1', + text: 'Message in channel 1', + user: bannedUser, + ); + final message2 = Message( + id: 'msg-2', + text: 'Message in channel 2', + user: bannedUser, + ); + + // Setup: Create multiple channels with state + final channelState1 = createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'messaging:channel-1'), + messages: [message1], + ); + final channelState2 = createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'messaging:channel-2'), + messages: [message2], + ); + + final channel1 = Channel.fromState(tester.client, channelState1); + final channel2 = Channel.fromState(tester.client, channelState2); + + // Register channels in client state + tester.clientState.addChannels({ + 'messaging:channel-1': channel1, + 'messaging:channel-2': channel2, + }); + + // Verify initial state + expect(channel1.state?.messages.length, equals(1)); + expect(channel2.state?.messages.length, equals(1)); + + // 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, + ); + + await tester.emitEvent(event); + + // Verify messages are soft deleted in all channels + final channel1Message = channel1.state?.messages.first; + expect(channel1Message?.type, equals(MessageType.deleted)); + expect(channel1Message?.state.isDeleted, isTrue); + + final channel2Message = channel2.state?.messages.first; + expect(channel2Message?.type, equals(MessageType.deleted)); + expect(channel2Message?.state.isDeleted, isTrue); + }, + ); + + chatClientTest( + 'should broadcast global hard delete to all channels', + 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'); + + final message1 = Message( + id: 'msg-1', + text: 'Message in channel 1', + user: bannedUser, + ); + final message2 = Message( + id: 'msg-2', + text: 'Message in channel 2', + user: bannedUser, + ); + final message3 = Message( + id: 'msg-3', + text: 'Safe message', + user: otherUser, + ); + + // Setup: Create multiple channels with state + final channelState1 = createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'messaging:channel-1'), + messages: [message1, message3], + ); + final channelState2 = createDefaultChannelState( + channel: createDefaultChannelModel(cid: 'messaging:channel-2'), + messages: [message2], + ); + + final channel1 = Channel.fromState(tester.client, channelState1); + final channel2 = Channel.fromState(tester.client, channelState2); + + // Register channels in client state + tester.clientState.addChannels({ + 'messaging:channel-1': channel1, + 'messaging:channel-2': channel2, + }); + + // Verify initial state + expect(channel1.state?.messages.length, equals(2)); + expect(channel2.state?.messages.length, equals(1)); + + // 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, + ); + + await tester.emitEvent(event); + + // Verify banned user's messages are removed from all channels + expect(channel1.state?.messages.length, equals(1)); + expect( + channel1.state?.messages.any((m) => m.user?.id == 'banned-user'), + isFalse, + ); + expect(channel2.state?.messages.length, equals(0)); + + // Verify other user's message is unaffected + final safeMessage = channel1.state?.messages.firstWhere((m) => m.id == 'msg-3'); + expect(safeMessage?.user?.id, equals('other-user')); + }, + ); + }); + }); + + group('ClientState mutation guards', () { + // 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}); + + 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); + }, + ); + + chatClientTest( + '`state.users` returns an unmodifiable view', + connect: (_) {}, + body: (tester) async { + tester.clientState.updateUser(User(id: 'u1')); + + expect(tester.clientState.users.containsKey('u1'), isTrue); + expect(() => tester.clientState.users.remove('u1'), throwsUnsupportedError); + expect(() => tester.clientState.users.clear(), throwsUnsupportedError); + }, + ); + + chatClientTest( + '`state.activeLiveLocations` returns an unmodifiable view', + connect: (_) {}, + body: (tester) async { + expect(() => tester.clientState.activeLiveLocations.clear(), throwsUnsupportedError); + }, + ); + + 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}); + + 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); + + tester.clientState.removeChannel('messaging:c1'); + await Future.delayed(Duration.zero); + + expect(received, hasLength(1)); + expect(received.single, isEmpty); + + await sub.cancel(); + }, + ); + + 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); + }, + ); + + chatClientTest( + '`channelsStream` emits unmodifiable maps', + connect: (_) {}, + body: (tester) async { + final received = >[]; + final sub = tester.clientState.channelsStream.listen(received.add); + + final channel = Channel.fromState( + tester.client, + createDefaultChannelState(channel: createDefaultChannelModel(cid: 'messaging:c1')), + ); + tester.clientState.addChannels({'messaging:c1': channel}); + await Future.delayed(Duration.zero); + + // 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); + } + + await sub.cancel(); + }, + ); + + chatClientTest( + '`usersStream` emits unmodifiable maps', + connect: (_) {}, + body: (tester) async { + final received = >[]; + final sub = tester.clientState.usersStream.listen(received.add); + + tester.clientState.updateUser(User(id: 'u1')); + await Future.delayed(Duration.zero); + + expect(received, hasLength(greaterThanOrEqualTo(2))); + for (final emitted in received) { + expect(emitted.clear, throwsUnsupportedError); + } + + await sub.cancel(); + }, + ); + + chatClientTest( + '`activeLiveLocationsStream` emits unmodifiable lists', + connect: (_) {}, + body: (tester) async { + final received = >[]; + final sub = tester.clientState.activeLiveLocationsStream.listen(received.add); + + tester.clientState.activeLiveLocations = const []; + await Future.delayed(Duration.zero); + + expect(received, isNotEmpty); + for (final emitted in received) { + expect(emitted.clear, throwsUnsupportedError); + } + + await sub.cancel(); + }, + ); + }); + + group('default REST wiring', () { + setUpAll(() => registerFallbackValue(RequestOptions())); + + test('builds a REST stack carrying the api key and user credentials', () async { + const apiKey = 'test-api-key'; + final adapter = _MockHttpClientAdapter(); + + when(() => adapter.fetch(any(), any(), any())).thenAnswer( + (_) async => ResponseBody.fromString( + '{"app":{}}', + 200, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ), + ); + + final client = StreamChatClient(apiKey, httpClientAdapter: adapter); + addTearDown(client.dispose); + + final user = User(id: 'test-user-id'); + final token = Token.development(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 = StreamWebSocketError('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/client_user_api_test.dart b/packages/stream_chat/test/src/client/client_user_api_test.dart deleted file mode 100644 index c2f41e851a..0000000000 --- a/packages/stream_chat/test/src/client/client_user_api_test.dart +++ /dev/null @@ -1,572 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -void main() { - chatClientTest( - '`.updateUser`', - body: (tester) async { - final user = User( - id: 'test-user-id', - extraData: const {'name': 'test-user'}, - ); - - tester.mockApi( - (api) => api.user.updateUsers([user]), - result: createDefaultUpdateUsersResponse(users: {user.id: user}), - ); - - final res = await tester.client.updateUser(user); - - expect(res, isNotNull); - expect(res.users, {user.id: user}); - - tester - ..verifyApi((api) => api.user.updateUsers([user])) - ..verifyNoMoreApiInteractions((api) => api.user); - }, - ); - - chatClientTest( - '`.partialUpdateUser`', - body: (tester) async { - const userId = 'test-user-id'; - - final set = {'color': 'yellow'}; - final unset = []; - - final partialUpdateRequest = PartialUpdateUserRequest( - id: userId, - set: set, - unset: unset, - ); - - final updatedUser = User( - id: userId, - extraData: {'color': set['color']}, - ); - - tester.mockApi( - (api) => api.user.partialUpdateUsers([partialUpdateRequest]), - result: createDefaultUpdateUsersResponse(users: {updatedUser.id: updatedUser}), - ); - - final res = await tester.client.partialUpdateUser( - userId, - set: set, - unset: unset, - ); - - expect(res, isNotNull); - expect(res.users, {updatedUser.id: updatedUser}); - - tester - ..verifyApi((api) => api.user.partialUpdateUsers([partialUpdateRequest])) - ..verifyNoMoreApiInteractions((api) => api.user); - }, - ); - - chatClientTest( - '`.banUser`', - body: (tester) async { - const userId = 'test-user-id'; - - tester.mockApi( - (api) => api.moderation.banUser(userId, options: any(named: 'options')), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.banUser(userId); - - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.moderation.banUser(userId, options: any(named: 'options'))) - ..verifyNoMoreApiInteractions((api) => api.moderation); - }, - ); - - chatClientTest( - '`.unbanUser`', - body: (tester) async { - const userId = 'test-user-id'; - - tester.mockApi( - (api) => api.moderation.unbanUser(userId, options: any(named: 'options')), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.unbanUser(userId); - - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.moderation.unbanUser(userId, options: any(named: 'options'))) - ..verifyNoMoreApiInteractions((api) => api.moderation); - }, - ); - - 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 tester.client.blockUser(userId); - - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.user.blockUser(userId)) - ..verifyNoMoreApiInteractions((api) => api.user); - }, - ); - - chatClientTest( - '`.unblockUser`', - body: (tester) async { - const userId = 'test-user-id'; - - tester.mockApi( - (api) => api.user.unblockUser(userId), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.unblockUser(userId); - - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.user.unblockUser(userId)) - ..verifyNoMoreApiInteractions((api) => api.user); - }, - ); - - chatClientTest( - '`.queryBlockedUsers`', - body: (tester) async { - final users = List.generate( - 3, - (index) => User(id: 'test-user-id-$index'), - ); - - 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 tester.client.queryBlockedUsers(); - expect(res, isNotNull); - expect(res.blocks.length, 2); - - tester - ..verifyApi((api) => api.user.queryBlockedUsers()) - ..verifyNoMoreApiInteractions((api) => api.user); - }, - ); - - 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 tester.client.getUnreadCount(); - - expect(res, isNotNull); - expect(res.totalUnreadCount, 42); - expect(res.totalUnreadThreadsCount, 8); - - tester - ..verifyApi((api) => api.user.getUnreadCount()) - ..verifyNoMoreApiInteractions((api) => api.user); - }, - ); - - chatClientTest( - '`.getUnreadCount` should also update user unread count as a side effect', - 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.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', - ), - ], - ), - ); - - tester.client.getUnreadCount().ignore(); - - // Wait for the local side effect event to be processed - await Future.delayed(Duration.zero); - - expect(tester.currentUser?.totalUnreadCount, 25); - expect(tester.currentUser?.unreadChannels, 2); // channels.length - expect(tester.currentUser?.unreadThreads, 2); // threads.length - - tester - ..verifyApi((api) => api.user.getUnreadCount()) - ..verifyNoMoreApiInteractions((api) => api.user); - }, - ); - - chatClientTest( - '`.shadowBan`', - body: (tester) async { - const userId = 'test-user-id'; - - tester.mockApi( - (api) => api.moderation.banUser(userId, options: {'shadow': true}), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.shadowBan(userId); - - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.moderation.banUser(userId, options: {'shadow': true})) - ..verifyNoMoreApiInteractions((api) => api.moderation); - }, - ); - - chatClientTest( - '`.removeShadowBan`', - body: (tester) async { - const userId = 'test-user-id'; - - tester.mockApi( - (api) => api.moderation.unbanUser(userId, options: {'shadow': true}), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.removeShadowBan(userId); - - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.moderation.unbanUser(userId, options: {'shadow': true})) - ..verifyNoMoreApiInteractions((api) => api.moderation); - }, - ); - - chatClientTest( - '`.muteUser`', - body: (tester) async { - const userId = 'test-user-id'; - - tester.mockApi( - (api) => api.moderation.muteUser(userId), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.muteUser(userId); - - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.moderation.muteUser(userId)) - ..verifyNoMoreApiInteractions((api) => api.moderation); - }, - ); - - chatClientTest( - '`.unmuteUser`', - body: (tester) async { - const userId = 'test-user-id'; - - tester.mockApi( - (api) => api.moderation.unmuteUser(userId), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.unmuteUser(userId); - - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.moderation.unmuteUser(userId)) - ..verifyNoMoreApiInteractions((api) => api.moderation); - }, - ); - - chatClientTest( - '`.flagMessage`', - body: (tester) async { - const messageId = 'test-message-id'; - - tester.mockApi( - (api) => api.moderation.flagMessage(messageId), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.flagMessage(messageId); - - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.moderation.flagMessage(messageId)) - ..verifyNoMoreApiInteractions((api) => api.moderation); - }, - ); - - chatClientTest( - '`.unflagMessage`', - body: (tester) async { - const messageId = 'test-message-id'; - - tester.mockApi( - (api) => api.moderation.unflagMessage(messageId), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.unflagMessage(messageId); - - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.moderation.unflagMessage(messageId)) - ..verifyNoMoreApiInteractions((api) => api.moderation); - }, - ); - - chatClientTest( - '`.flagUser`', - body: (tester) async { - const userId = 'test-message-id'; - - tester.mockApi( - (api) => api.moderation.flagUser(userId), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.flagUser(userId); - - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.moderation.flagUser(userId)) - ..verifyNoMoreApiInteractions((api) => api.moderation); - }, - ); - - chatClientTest( - '`.unflagUser`', - body: (tester) async { - const userId = 'test-message-id'; - - tester.mockApi( - (api) => api.moderation.unflagUser(userId), - result: createDefaultEmptyResponse(), - ); - - final res = await tester.client.unflagUser(userId); - - expect(res, isNotNull); - - tester - ..verifyApi((api) => api.moderation.unflagUser(userId)) - ..verifyNoMoreApiInteractions((api) => api.moderation); - }, - ); - - 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)), - ), - ]; - - tester.mockApi( - (api) => api.user.getActiveLiveLocations(), - result: GetActiveLiveLocationsResponse()..activeLiveLocations = locations, - ); - - // Initial state should be empty - expect(tester.clientState.activeLiveLocations, isEmpty); - - final res = await tester.client.getActiveLiveLocations(); - - expect(res, isNotNull); - expect(res.activeLiveLocations, hasLength(2)); - expect(res.activeLiveLocations, equals(locations)); - expect(tester.clientState.activeLiveLocations, equals(locations)); - - tester - ..verifyApi((api) => api.user.getActiveLiveLocations()) - ..verifyNoMoreApiInteractions((api) => api.user); - }, - ); - - 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 = LocationCoordinates( - latitude: 40.7128, - longitude: -74.0060, - ); - - final expectedLocation = Location( - latitude: location.latitude, - longitude: location.longitude, - createdByDeviceId: createdByDeviceId, - endAt: endAt, - ); - - tester.mockApi( - (api) => api.user.updateLiveLocation( - messageId: messageId, - createdByDeviceId: createdByDeviceId, - location: location, - endAt: endAt, - ), - result: expectedLocation, - ); - - final res = await tester.client.updateLiveLocation( - messageId: messageId, - createdByDeviceId: createdByDeviceId, - location: location, - endAt: endAt, - ); - - 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); - }, - ); - - chatClientTest( - '`.stopLiveLocation`', - body: (tester) async { - const messageId = 'test-message-id'; - const createdByDeviceId = 'test-device-id'; - - final expectedLocation = Location( - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: createdByDeviceId, - endAt: DateTime.timestamp(), // Should be expired - ); - - tester.mockApi( - (api) => api.user.updateLiveLocation( - messageId: messageId, - createdByDeviceId: createdByDeviceId, - endAt: any(named: 'endAt'), - ), - result: expectedLocation, - ); - - final res = await tester.client.stopLiveLocation( - messageId: messageId, - createdByDeviceId: createdByDeviceId, - ); - - 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); - }, - ); -} diff --git a/packages/stream_chat/test/src/client/client_with_persistence_test.dart b/packages/stream_chat/test/src/client/client_with_persistence_test.dart deleted file mode 100644 index 4e5f97e0d0..0000000000 --- a/packages/stream_chat/test/src/client/client_with_persistence_test.dart +++ /dev/null @@ -1,725 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_test/stream_chat_test.dart'; - -// 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); - - tester.mockSuccessfulAuth(); - await tester.client.connectUser(tester.user, createTestToken(tester.user.id).rawValue); - - expect(tester.client.persistenceEnabled, isTrue); - expect(tester.connectionStatus, ConnectionStatus.connected); - }, - body: (tester) => body(tester, persistence), - ); -} - -void main() { - group('Client with connected user with persistence', () { - group('`.sync`', () { - _clientWithPersistenceTest( - '''should update persistence connectionInfo and lastSync when sync succeeds''', - 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.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(tester.user), - ), - Event( - isLocal: false, - type: EventType.messageDeleted, - message: Message(id: 'test-message-id'), - ), - ], - ), - ); - - when(() => persistence.updateConnectionInfo(any())).thenAnswer((_) => Future.value()); - when(() => persistence.updateLastSyncAt(any())).thenAnswer((_) => Future.value()); - - await tester.client.sync(cids: cids, lastSyncAt: lastSyncAt); - - verify(() => persistence.updateConnectionInfo(any())).called(1); - verify(() => persistence.updateLastSyncAt(any())).called(1); - tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); - }, - ); - - _clientWithPersistenceTest( - 'should work fine if persistence contains sync params', - 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.utc(2021, 3); - - when(persistence.getChannelCids).thenAnswer((_) async => cids); - when(persistence.getLastSyncAt).thenAnswer((_) async => lastSyncAt); - - 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', text: 'Hey!'), - ), - ], - ), - ); - - when(() => persistence.updateConnectionInfo(any())).thenAnswer((_) => Future.value()); - when(() => persistence.updateLastSyncAt(any())).thenAnswer((_) => Future.value()); - - await tester.client.sync(); - - verify(() => persistence.updateConnectionInfo(any())).called(1); - verify(() => persistence.updateLastSyncAt(any())).called(1); - tester.verifyApi((api) => api.general.sync(cids, lastSyncAt)); - verify(persistence.getChannelCids).called(1); - verify(persistence.getLastSyncAt).called(1); - }, - ); - }); - - group('`.queryChannels`', () { - _clientWithPersistenceTest( - 'should emit channels twice if persistence contains some channels', - body: (tester, persistence) async { - final persistentChannelStates = List.generate( - 3, - (index) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: 'test-type-$index:test-id-$index'), - ), - ); - - when( - () => persistence.queryChannelStates( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - ), - ).thenAnswer((_) async => createDefaultQueryChannelsResponse(channels: persistentChannelStates)); - - final channelStates = List.generate( - 3, - (index) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: 'test-type-$index:test-id-$index'), - ), - ); - - 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), - ); - - when(() => persistence.getChannelThreads(any())).thenAnswer( - (_) async => >{ - for (final channelState in channelStates) - channelState.channel!.cid: [createDefaultMessage(id: 'test-message-id', text: 'Test message')], - }, - ); - - when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); - when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); - when( - () => persistence.saveChannelQueries( - cids: any(named: 'cids'), - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - resolvedFilter: any(named: 'resolvedFilter'), - resolvedSort: any(named: 'resolvedSort'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - clearQueryCache: any(named: 'clearQueryCache'), - ), - ).thenAnswer((_) => Future.value()); - - // 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( - tester.client.queryChannels(), - emitsInOrder([ - // emits persistent channels first - persistentChannelStates.map(isCorrectChannelFor), - // makes api call and emits network fetched channels - channelStates.map(isCorrectChannelFor), - ]), - ); - - // Wait safely past the 1s debounce on persistence writes - // (updateChannelState, updateChannelThreads) so all trailing - // invocations have fired before we verify counts. - await Future.delayed(const Duration(milliseconds: 1500)); - - verify( - () => persistence.queryChannelStates( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - ), - ).called(1); - - 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'), - ), - ); - - verify(() => persistence.getChannelThreads(any())).called(channelStates.length); - verify(() => persistence.updateChannelState(any())).called(channelStates.length); - verify(() => persistence.updateChannelThreads(any(), any())).called(channelStates.length); - verify( - () => persistence.saveChannelQueries( - cids: any(named: 'cids'), - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - resolvedFilter: any(named: 'resolvedFilter'), - resolvedSort: any(named: 'resolvedSort'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - clearQueryCache: any(named: 'clearQueryCache'), - ), - ).called(1); - }, - ); - - _clientWithPersistenceTest( - '''should never rethrow network call if persistence already emitted some channels''', - body: (tester, persistence) async { - final persistentChannelStates = List.generate( - 3, - (index) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: 'test-type-$index:test-id-$index'), - ), - ); - - when( - () => persistence.queryChannelStates( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - ), - ).thenAnswer((_) async => createDefaultQueryChannelsResponse(channels: persistentChannelStates)); - - tester.mockApiFailure( - (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'), - ), - error: createDefaultNetworkError(errorCode: ChatErrorCode.inputError), - ); - - when(() => persistence.getChannelThreads(any())).thenAnswer( - (_) async => >{ - for (final channelState in persistentChannelStates) - channelState.channel!.cid: [createDefaultMessage(id: 'test-message-id', text: 'Test message')], - }, - ); - - when(() => persistence.updateChannelState(any())).thenAnswer((_) async => {}); - when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async => {}); - - // 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( - tester.client.queryChannels(), - emitsInOrder([ - // emits persistent channels - persistentChannelStates.map(isCorrectChannelFor), - ]), - ); - - // Wait safely past the 1s debounce on persistence writes - // (updateChannelState, updateChannelThreads) so all trailing - // invocations have fired before we verify counts. - await Future.delayed(const Duration(milliseconds: 1500)); - - verify( - () => persistence.queryChannelStates( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - ), - ).called(1); - - 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'), - ), - ); - - verify(() => persistence.getChannelThreads(any())).called(persistentChannelStates.length); - verify(() => persistence.updateChannelState(any())).called(persistentChannelStates.length); - verify(() => persistence.updateChannelThreads(any(), any())).called(persistentChannelStates.length); - }, - ); - - _clientWithPersistenceTest( - 'queryChannelsOnline with inline filter persists via saveChannelQueries', - body: (tester, persistence) async { - final filter = Filter.in_('members', const ['test-user-id']); - - final channelStates = List.generate( - 3, - (i) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: 'test-type-$i:test-id-$i'), - ), - ); - - tester.mockApi( - (api) => api.channel.queryChannels( - filter: filter, - 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), - ); - - when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); - when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); - when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); - when( - () => persistence.saveChannelQueries( - cids: any(named: 'cids'), - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - resolvedFilter: any(named: 'resolvedFilter'), - resolvedSort: any(named: 'resolvedSort'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - clearQueryCache: any(named: 'clearQueryCache'), - ), - ).thenAnswer((_) => Future.value()); - - await Future.delayed(const Duration(milliseconds: 1100)); - clearInteractions(persistence); - - await tester.client.queryChannelsOnline(filter: filter); - - // The standard path passes filter (the inline filter) and a null - // predefinedFilter. resolvedFilter / resolvedSort stay null — - // they're only meaningful for the predefined-filter path. - verify( - () => persistence.saveChannelQueries( - cids: channelStates.map((s) => s.channel!.cid).toList(), - filter: filter, - sort: null, - predefinedFilter: null, - resolvedFilter: null, - resolvedSort: null, - filterValues: null, - sortValues: null, - clearQueryCache: true, - ), - ).called(1); - }, - ); - - _clientWithPersistenceTest( - 'queryChannelsOnline with predefined filter persists via saveChannelQueries with resolved sort', - 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) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: 'test-type-$i:test-id-$i'), - ), - ); - - tester.mockApi( - (api) => api.channel.queryChannels( - predefinedFilter: filterName, - filterValues: filterValues, - sortValues: sortValues, - 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, - predefinedFilter: const PredefinedFilter( - name: filterName, - filter: Filter.empty(), - sort: [SortOption.desc('last_message_at')], - ), - ), - ); - - when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); - when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); - when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); - when( - () => persistence.saveChannelQueries( - cids: any(named: 'cids'), - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - resolvedFilter: any(named: 'resolvedFilter'), - resolvedSort: any(named: 'resolvedSort'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - clearQueryCache: any(named: 'clearQueryCache'), - ), - ).thenAnswer((_) => Future.value()); - - await Future.delayed(const Duration(milliseconds: 1100)); - clearInteractions(persistence); - - await tester.client.queryChannelsOnline( - predefinedFilter: filterName, - filterValues: filterValues, - sortValues: sortValues, - ); - - verify( - () => persistence.saveChannelQueries( - cids: channelStates.map((s) => s.channel!.cid).toList(), - filter: null, - sort: null, - predefinedFilter: filterName, - resolvedFilter: const Filter.empty(), - resolvedSort: const [SortOption.desc('last_message_at')], - filterValues: filterValues, - sortValues: sortValues, - clearQueryCache: true, - ), - ).called(1); - }, - ); - - _clientWithPersistenceTest( - 'queryChannelsOffline with predefined filter reads via queryChannelStates', - 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) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: 'test-type-$i:test-id-$i'), - ), - ); - - when( - () => persistence.queryChannelStates( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: filterName, - filterValues: filterValues, - sortValues: sortValues, - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - ), - ).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 Future.delayed(const Duration(milliseconds: 1100)); - clearInteractions(persistence); - - final channels = await tester.client.queryChannelsOffline( - predefinedFilter: filterName, - filterValues: filterValues, - sortValues: sortValues, - ); - - expect(channels, hasLength(channelStates.length)); - - verify( - () => persistence.queryChannelStates( - filter: null, - sort: null, - predefinedFilter: filterName, - filterValues: filterValues, - sortValues: sortValues, - messageLimit: 25, - paginationParams: const PaginationParams(), - ), - ).called(1); - }, - ); - - _clientWithPersistenceTest( - 'queryChannelsWithResult yields QueryChannelsResult with predefinedFilter=null for inline filter', - body: (tester, persistence) async { - final channelStates = List.generate( - 2, - (i) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: 'test-type-$i:test-id-$i'), - ), - ); - - when( - () => persistence.queryChannelStates( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - ), - ).thenAnswer((_) async => createDefaultQueryChannelsResponse()); - - 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), - ); - - when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); - when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); - when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); - when( - () => persistence.saveChannelQueries( - cids: any(named: 'cids'), - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - resolvedFilter: any(named: 'resolvedFilter'), - resolvedSort: any(named: 'resolvedSort'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - clearQueryCache: any(named: 'clearQueryCache'), - ), - ).thenAnswer((_) => Future.value()); - - await Future.delayed(const Duration(milliseconds: 1100)); - clearInteractions(persistence); - - final results = await tester.client.queryChannelsWithResult().toList(); - - // Persistence returned empty, so only the online emission is yielded. - expect(results, hasLength(1)); - expect(results.single.channels, hasLength(channelStates.length)); - expect(results.single.predefinedFilter, isNull); - }, - ); - - _clientWithPersistenceTest( - 'queryChannelsWithResult yields QueryChannelsResult with predefinedFilter populated for predefined query', - 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) => createDefaultChannelState( - channel: createDefaultChannelModel(cid: 'test-type-$i:test-id-$i'), - ), - ); - - const resolvedSort = [SortOption.desc('last_message_at')]; - const resolvedFilter = Filter.empty(); - const expectedPredefinedFilter = PredefinedFilter( - name: filterName, - filter: resolvedFilter, - sort: resolvedSort, - ); - - when( - () => persistence.queryChannelStates( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - ), - ).thenAnswer((_) async => createDefaultQueryChannelsResponse()); - - tester.mockApi( - (api) => api.channel.queryChannels( - predefinedFilter: filterName, - filterValues: filterValues, - sortValues: sortValues, - 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, - predefinedFilter: expectedPredefinedFilter, - ), - ); - - when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); - when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); - when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); - when( - () => persistence.saveChannelQueries( - cids: any(named: 'cids'), - filter: any(named: 'filter'), - sort: any(named: 'sort'), - predefinedFilter: any(named: 'predefinedFilter'), - resolvedFilter: any(named: 'resolvedFilter'), - resolvedSort: any(named: 'resolvedSort'), - filterValues: any(named: 'filterValues'), - sortValues: any(named: 'sortValues'), - clearQueryCache: any(named: 'clearQueryCache'), - ), - ).thenAnswer((_) => Future.value()); - - await Future.delayed(const Duration(milliseconds: 1100)); - clearInteractions(persistence); - - final results = await tester.client - .queryChannelsWithResult( - predefinedFilter: filterName, - filterValues: filterValues, - sortValues: sortValues, - ) - .toList(); - - // Persistence returned empty, so only the online emission is yielded. - expect(results, hasLength(1)); - expect(results.single.channels, hasLength(channelStates.length)); - expect(results.single.predefinedFilter, isNotNull); - expect(results.single.predefinedFilter!.name, equals(filterName)); - expect(results.single.predefinedFilter!.sort, equals(resolvedSort)); - }, - ); - }); - - _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 - tester.client.wsConnectionStatusStream.skip(1), - emits(ConnectionStatus.disconnected), - ); - - await tester.client.disconnectUser(flushChatPersistence: true); - - expect(tester.clientState.currentUser, isNull); - expect(tester.connectionStatus, ConnectionStatus.disconnected); - }, - ); - }); -} From 8a4145c8b83efce2e600141f1b38cb2937e094b8 Mon Sep 17 00:00:00 2001 From: Petar Velikov Date: Wed, 16 Sep 2026 20:38:41 +0200 Subject: [PATCH 100/103] fix(llc): drop the read state of a member removed from a channel (#2966) Co-authored-by: Claude --- packages/stream_chat/CHANGELOG.md | 1 + .../client/channel/channel_client_state.dart | 13 +++++++ .../channel/channel_state_mutations.dart | 14 ++----- .../channel/channel_client_state_test.dart | 39 ++++++++++++++++++- .../channel/channel_state_mutations_test.dart | 24 +++--------- 5 files changed, 60 insertions(+), 31 deletions(-) diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index a5ef64b7ae..7bd3b886e9 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -15,6 +15,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 dc2e822b2e..9470d7f27f 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 @@ -37,6 +37,7 @@ class ChannelClientState { upsertTypingEvent: _upsertTypingEvent, removeTypingEvent: _removeTypingEvent, removeWatcher: _removeWatcher, + removeMember: _removeMember, updateMember: _updateMember, deleteMessagesFromUser: _deleteMessagesFromUser, ); @@ -75,6 +76,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/test/src/client/channel/channel_client_state_test.dart b/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart index be6c042cb1..858a34ddd5 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 @@ -2159,13 +2159,23 @@ void main() { expect(channel.state!.channelState.members?.map((m) => m.userId), ['new-member']); }); - test('${EventType.memberRemoved} removes the member', () async { + test('${EventType.memberRemoved} removes the member and its read state', () async { channel.state!.updateChannelState( channel.state!.channelState.copyWith( members: [ Member(userId: 'member-1'), Member(userId: 'member-2'), ], + read: [ + Read( + user: User(id: 'member-1'), + lastRead: DateTime(2020), + ), + Read( + user: User(id: 'member-2'), + lastRead: DateTime(2020), + ), + ], ), ); @@ -2179,6 +2189,33 @@ void main() { await Future.delayed(Duration.zero); expect(channel.state!.channelState.members?.map((m) => m.userId), ['member-2']); + expect(channel.state!.channelState.read?.map((r) => r.user.id), ['member-2']); + }); + + test('${EventType.memberRemoved} clears the read state of the only member', () async { + channel.state!.updateChannelState( + channel.state!.channelState.copyWith( + members: [Member(userId: 'member-1')], + read: [ + Read( + user: User(id: 'member-1'), + lastRead: DateTime(2020), + ), + ], + ), + ); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.memberRemoved, + user: User(id: 'member-1'), + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.channelState.members, isEmpty); + expect(channel.state!.channelState.read, isEmpty); }); test('${EventType.memberUpdated} replaces the member entry', () async { 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 7b279af40e..bd0061247c 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 @@ -19,6 +19,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; @@ -46,6 +47,7 @@ void main() { upsertTypingEventCalls = []; removeTypingEventCalls = []; removeWatcherCalls = []; + removeMemberCalls = []; updateMemberCalls = []; deleteMessagesFromUserCalls = []; @@ -55,6 +57,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)); @@ -581,27 +584,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', () { From 266adf0cdd52ea04acf18b36fccf6e6a32e30a9e Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 17 Sep 2026 08:49:57 +0200 Subject: [PATCH 101/103] test(llc): retire test/src/fakes.dart in favour of the shared harness doubles Co-Authored-By: Claude Opus 5 --- .../test/src/client/sync_manager_test.dart | 8 +- packages/stream_chat/test/src/fakes.dart | 266 ------------------ .../lib/src/helpers/mocks.dart | 4 + 3 files changed, 6 insertions(+), 272 deletions(-) delete mode 100644 packages/stream_chat/test/src/fakes.dart 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 8fb145b554..d97e1ff5e3 100644 --- a/packages/stream_chat/test/src/client/sync_manager_test.dart +++ b/packages/stream_chat/test/src/client/sync_manager_test.dart @@ -1,10 +1,8 @@ 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'; // Records what `/sync` was asked for, and answers with events or an error. @@ -78,9 +76,7 @@ typedef _Harness = ({ }); void main() { - registerFallbackValue(FakeEvent()); - registerFallbackValue(const PaginationParams()); - registerFallbackValue(const Filter.empty()); + 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/fakes.dart b/packages/stream_chat/test/src/fakes.dart deleted file mode 100644 index ccc1cdfa10..0000000000 --- a/packages/stream_chat/test/src/fakes.dart +++ /dev/null @@ -1,266 +0,0 @@ -import 'dart:async'; - -import 'package:mocktail/mocktail.dart'; -import 'package:rxdart/rxdart.dart'; -import 'package:stream_chat/src/core/http/token.dart'; -import 'package:stream_chat/src/core/http/token_manager.dart'; -import 'package:stream_chat/src/ws/websocket.dart'; -import 'package:stream_chat/stream_chat.dart'; - -class FakeTokenManager extends Fake implements TokenManager { - final token = Token.development('test-user-id'); - - @override - bool get isStatic => true; - - @override - String? get userId => token.userId; - - @override - Future loadToken({bool refresh = false}) async => token; - - @override - Future setTokenOrProvider( - String userId, { - Token? token, - TokenProvider? provider, - }) async => this.token; - - @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, - Filter? filter, - SortOrder? sort, - String? predefinedFilter, - Filter? resolvedFilter, - SortOrder? resolvedSort, - Map? filterValues, - Map? sortValues, - bool clearQueryCache = false, - }) async {} - - @override - Future updateChannelStates(List channelStates) async {} -} - -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 = StreamWebSocketError('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/lib/src/helpers/mocks.dart b/packages/stream_chat_test/lib/src/helpers/mocks.dart index a4616fa37b..8ffa21749e 100644 --- a/packages/stream_chat_test/lib/src/helpers/mocks.dart +++ b/packages/stream_chat_test/lib/src/helpers/mocks.dart @@ -113,6 +113,9 @@ class FakePersistenceClient extends Fake implements ChatPersistenceClient { /// Number of times [disconnect] was called. int disconnectCallCount = 0; + /// Number of times [flush] was called. + int flushCallCount = 0; + @override bool get isConnected => _isConnected; @@ -139,6 +142,7 @@ class FakePersistenceClient extends Fake implements ChatPersistenceClient { Future flush() async { _lastSyncAt = null; _channelCids = []; + flushCallCount++; } @override From 30dd3ad39e6a1053eea7ac498ff3fdfa16df2180 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 17 Sep 2026 09:00:40 +0200 Subject: [PATCH 102/103] test(llc): build the delivery reporter's channels on the shared harness client Co-Authored-By: Claude Opus 5 --- .../channel_delivery_reporter_test.dart | 978 +++++++++--------- 1 file changed, 478 insertions(+), 500 deletions(-) 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 64ef01adca..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 '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'; 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,386 +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]); - await delay(150); - - expect(capturedDeliveries, isEmpty); - }); + 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, ); @@ -429,113 +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 -Logger _createLogger(String name) { - final logger = Logger.detached(name)..level = Level.ALL; - logger.onRecord.listen(print); - return logger; -} - -StreamChatClient _createMockClient() { - final client = _MockStreamChatClient(); - final clientState = _FakeClientState( - currentUser: OwnUser(id: 'current-user-id'), - ); - - when(() => client.state).thenReturn(clientState); - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - return _createLogger(invocation.positionalArguments.first as String); - }); - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - when(() => client.retryPolicy).thenReturn( - RetryPolicy(shouldRetry: (_, __, ___) => false), - ); - - return client; -} - Message _createMessage([Message Function(Message)? builder]) { final baseMessage = Message( id: 'default-id', @@ -627,50 +652,3 @@ Read _createCurrentUserRead( } // endregion - -// region Test Doubles - -class _MockStreamChatClient extends Mock implements StreamChatClient { - @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, - }); - - final OwnUser? _currentUser; - - @override - OwnUser? get currentUser => _currentUser; - - @override - Map get channels => _channels; - final _channels = {}; - - @override - void addChannels(Map channelMap) { - _channels.addAll(channelMap); - } -} - -// endregion From 4d1c9c4df31bc8ccfeb8cba39f05d0515b0a032d Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 17 Sep 2026 09:32:08 +0200 Subject: [PATCH 103/103] test(llc): scope the shared mock imports that shadow the harness doubles Co-Authored-By: Claude Opus 5 --- .../test/src/client/channel/channel_event_handler_test.dart | 2 +- .../test/src/client/channel/channel_state_mutations_test.dart | 2 +- packages/stream_chat/test/src/client/sync_manager_test.dart | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 95feb13fed..63414b7fc9 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 @@ -5,7 +5,7 @@ import 'package:stream_chat/src/client/channel/channel_state_mutations.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_test/stream_chat_test.dart'; -import '../../mocks.dart'; +import '../../mocks.dart' show FakeClientState, MockStreamChatClient, MockStreamChatClientWithPersistence; class MockChannel extends Mock implements Channel {} 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 ac1b5fd5b7..86cb18bbda 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 @@ -4,7 +4,7 @@ import 'package:stream_chat/src/client/channel/channel_state_mutations.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_test/stream_chat_test.dart'; -import '../../mocks.dart'; +import '../../mocks.dart' show FakeClientState, MockLogger, MockStreamChatClient; class MockChannelClientState extends Mock implements ChannelClientState {} 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 d97e1ff5e3..2f2e5aab7b 100644 --- a/packages/stream_chat/test/src/client/sync_manager_test.dart +++ b/packages/stream_chat/test/src/client/sync_manager_test.dart @@ -3,7 +3,7 @@ import 'package:stream_chat/src/client/sync_manager.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_test/stream_chat_test.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].