From f220b1cc1cfaffa43ed224e71aeca05557a9c961 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 17 Jun 2026 14:53:30 +0200 Subject: [PATCH 1/7] feat(llc): add queryUsers to StreamFeedsClient Expose the generated DefaultApi.queryUsers endpoint via a public queryUsers() method on StreamFeedsClient. Adds FullUserResponseMapper to map FullUserResponse to the domain UserData model. Closes FLU-370 Co-Authored-By: Claude Opus 4.8 --- docs/code_snippets/02_01_querying_users.dart | 63 +++++++++++++++++++ packages/stream_feeds/CHANGELOG.md | 3 + .../lib/src/client/feeds_client_impl.dart | 46 ++++++++++---- .../stream_feeds/lib/src/feeds_client.dart | 34 ++++++++++ .../lib/src/models/user_data.dart | 25 ++++++++ 5 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 docs/code_snippets/02_01_querying_users.dart diff --git a/docs/code_snippets/02_01_querying_users.dart b/docs/code_snippets/02_01_querying_users.dart new file mode 100644 index 00000000..1f211d1e --- /dev/null +++ b/docs/code_snippets/02_01_querying_users.dart @@ -0,0 +1,63 @@ +import 'package:stream_feeds/stream_feeds.dart'; + +late StreamFeedsClient client; + +Future queryUsers() async { + // Search users by name prefix + final result = await client.queryUsers( + filterConditions: { + 'name': {r'$autocomplete': 'Al'}, + }, + sort: [const SortParamRequest(field: 'name', direction: 1)], + limit: 25, + ); + + switch (result) { + case Success(data: final users): + for (final user in users) { + print('${user.id}: ${user.name}'); + } + case Failure(error: final error): + print('Failed to query users: $error'); + } +} + +Future queryUsersWithFilter() async { + // Query users by exact ID match + final result = await client.queryUsers( + filterConditions: { + 'id': { + r'$in': ['alice', 'bob', 'carol'], + }, + }, + ); + + switch (result) { + case Success(data: final users): + print('Found ${users.length} users'); + case Failure(error: final error): + print('Failed to query users: $error'); + } +} + +Future queryUsersWithPresence() async { + // Query users and include online presence information + final result = await client.queryUsers( + filterConditions: { + 'teams': {r'$contains': 'support'}, + }, + sort: [const SortParamRequest(field: 'last_active', direction: -1)], + limit: 10, + presence: true, + ); + + switch (result) { + case Success(data: final users): + for (final user in users) { + final status = user.online ? 'online' : 'offline'; + print('${user.name ?? user.id} is $status'); + } + case Failure(error: final error): + print('Failed to query users: $error'); + } +} diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index 1b00fdc3..23022da7 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -1,5 +1,8 @@ ## Upcoming +### New methods +- Added `queryUsers` to `StreamFeedsClient` for searching users by filter conditions, sort, and pagination. + ### New fields - Added `isRead` and `isSeen` fields to `ActivityData` and `AggregatedActivityData` for notification-feed read/seen state. - Added `friendReactionCount` and `friendReactions` fields to `ActivityData` to expose reactions from friends. diff --git a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart index 34c8c176..d9da23ae 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -15,6 +15,7 @@ import '../models/feeds_config.dart'; import '../models/follow_data.dart'; import '../models/model_updates.dart'; import '../models/push_notifications_config.dart'; +import '../models/user_data.dart'; import '../repository/activities_repository.dart'; import '../repository/app_repository.dart'; import '../repository/bookmarks_repository.dart'; @@ -158,18 +159,18 @@ class StreamFeedsClientImpl implements StreamFeedsClient { _cdnClient = config.cdnClient ?? FeedsCdnClient(CdnApi(httpClient)); attachmentUploader = StreamAttachmentUploader(cdn: _cdnClient); - final feedsApi = feedsRestApi ?? api.DefaultApi(httpClient); + _feedsApi = feedsRestApi ?? api.DefaultApi(httpClient); - _activitiesRepository = ActivitiesRepository(feedsApi, attachmentUploader); - _appRepository = AppRepository(feedsApi); - _bookmarksRepository = BookmarksRepository(feedsApi); - _collectionsRepository = CollectionsRepository(feedsApi); - _commentsRepository = CommentsRepository(feedsApi, attachmentUploader); - _devicesRepository = DevicesRepository(feedsApi); - _feedsRepository = FeedsRepository(feedsApi); - _moderationRepository = ModerationRepository(feedsApi); - _pollsRepository = PollsRepository(feedsApi); - _capabilitiesRepository = CapabilitiesRepository(feedsApi); + _activitiesRepository = ActivitiesRepository(_feedsApi, attachmentUploader); + _appRepository = AppRepository(_feedsApi); + _bookmarksRepository = BookmarksRepository(_feedsApi); + _collectionsRepository = CollectionsRepository(_feedsApi); + _commentsRepository = CommentsRepository(_feedsApi, attachmentUploader); + _devicesRepository = DevicesRepository(_feedsApi); + _feedsRepository = FeedsRepository(_feedsApi); + _moderationRepository = ModerationRepository(_feedsApi); + _pollsRepository = PollsRepository(_feedsApi); + _capabilitiesRepository = CapabilitiesRepository(_feedsApi); moderation = ModerationClient(_moderationRepository); @@ -198,6 +199,7 @@ class StreamFeedsClientImpl implements StreamFeedsClient { @override late final StreamAttachmentUploader attachmentUploader; + late final api.DefaultApi _feedsApi; late final ActivitiesRepository _activitiesRepository; late final AppRepository _appRepository; late final BookmarksRepository _bookmarksRepository; @@ -573,6 +575,28 @@ class StreamFeedsClientImpl implements StreamFeedsClient { return _collectionsRepository.deleteCollections(refs: refs); } + @override + Future>> queryUsers({ + required Map filterConditions, + List? sort, + int? limit, + int? offset, + bool? presence, + bool? includeDeactivatedUsers, + }) async { + final payload = api.QueryUsersPayload( + filterConditions: filterConditions, + sort: sort, + limit: limit, + offset: offset, + presence: presence, + includeDeactivatedUsers: includeDeactivatedUsers, + ); + + final result = await _feedsApi.queryUsers(payload: payload); + return result.map((response) => response.users.map((u) => u.toModel()).toList()); + } + Stream get onReconnectEmitter { return connectionState .scan( diff --git a/packages/stream_feeds/lib/src/feeds_client.dart b/packages/stream_feeds/lib/src/feeds_client.dart index b2a73054..cf611f92 100644 --- a/packages/stream_feeds/lib/src/feeds_client.dart +++ b/packages/stream_feeds/lib/src/feeds_client.dart @@ -11,6 +11,7 @@ import 'models/feed_id.dart'; import 'models/feeds_config.dart'; import 'models/follow_data.dart'; import 'models/push_notifications_config.dart'; +import 'models/user_data.dart'; import 'state/activity.dart'; import 'state/activity_comment_list.dart'; import 'state/activity_list.dart'; @@ -961,6 +962,39 @@ abstract interface class StreamFeedsClient { required List refs, }); + /// Queries users matching the provided filter conditions. + /// + /// Searches for users using the specified [filterConditions] map and optional + /// [sort], [limit], [offset], [presence], and [includeDeactivatedUsers] parameters. + /// + /// Example: + /// ```dart + /// final result = await client.queryUsers( + /// filterConditions: {'name': {'$autocomplete': 'Al'}}, + /// sort: [api.SortParamRequest(field: 'name', direction: 1)], + /// limit: 25, + /// ); + /// + /// switch (result) { + /// case Success(value: final users): + /// for (final user in users) { + /// print('${user.id}: ${user.name}'); + /// } + /// case Failure(error: final error): + /// print('Failed to query users: $error'); + /// } + /// ``` + /// + /// Returns a [Result] containing a list of [UserData] or an error. + Future>> queryUsers({ + required Map filterConditions, + List? sort, + int? limit, + int? offset, + bool? presence, + bool? includeDeactivatedUsers, + }); + /// The moderation client for managing moderation-related operations. /// /// Provides access to moderation configurations, content moderation, and moderation-related diff --git a/packages/stream_feeds/lib/src/models/user_data.dart b/packages/stream_feeds/lib/src/models/user_data.dart index 153e16b3..71275f2e 100644 --- a/packages/stream_feeds/lib/src/models/user_data.dart +++ b/packages/stream_feeds/lib/src/models/user_data.dart @@ -123,3 +123,28 @@ extension UserResponseMapper on UserResponse { ); } } + +/// Extension function to convert a [FullUserResponse] to a [UserData] model. +extension FullUserResponseMapper on FullUserResponse { + /// Converts this API full user response to a domain [UserData] instance. + UserData toModel() { + return UserData( + banned: banned, + blockedUserIds: blockedUserIds, + createdAt: createdAt, + custom: custom, + deactivatedAt: deactivatedAt, + deletedAt: deletedAt, + id: id, + image: image, + language: language, + lastActive: lastActive, + name: name, + online: online, + revokeTokensIssuedBefore: revokeTokensIssuedBefore, + role: role, + teams: teams, + updatedAt: updatedAt, + ); + } +} From 0d393aa5380a54fa1c06d9b3897127c1fbc6f2a4 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 18 Jun 2026 10:21:00 +0200 Subject: [PATCH 2/7] test(llc): add coverage for queryUsers and FullUserResponseMapper Add unit tests for queryUsers() client method and FullUserResponseMapper.toModel(). Also adds createDefaultFullUserResponse and createDefaultQueryUsersResponse test helpers. Co-Authored-By: Claude Sonnet 4.6 --- .../test/client/feeds_client_test.dart | 59 +++++++++++++++++++ .../lib/src/helpers/test_data.dart | 40 +++++++++++++ 2 files changed, 99 insertions(+) diff --git a/packages/stream_feeds/test/client/feeds_client_test.dart b/packages/stream_feeds/test/client/feeds_client_test.dart index f1c877e4..f2adcf3a 100644 --- a/packages/stream_feeds/test/client/feeds_client_test.dart +++ b/packages/stream_feeds/test/client/feeds_client_test.dart @@ -935,4 +935,63 @@ void main() { }, ); }); + + // ============================================================ + // FEATURE: User Querying + // ============================================================ + + group('queryUsers', () { + feedsClientTest( + 'should query users successfully', + body: (tester) async { + final filterConditions = { + 'name': {r'$autocomplete': 'Al'}, + }; + final payload = QueryUsersPayload(filterConditions: filterConditions); + + tester.mockApi( + (api) => api.queryUsers(payload: payload), + result: createDefaultQueryUsersResponse( + users: [ + createDefaultFullUserResponse(id: 'user-1', name: 'Alice'), + createDefaultFullUserResponse(id: 'user-2', name: 'Alan'), + ], + ), + ); + + final result = await tester.client.queryUsers( + filterConditions: filterConditions, + ); + + expect(result.isSuccess, isTrue); + final users = result.getOrThrow(); + expect(users.length, equals(2)); + expect(users[0].id, equals('user-1')); + expect(users[1].id, equals('user-2')); + + tester.verifyApi((api) => api.queryUsers(payload: payload)); + }, + ); + + feedsClientTest( + 'should handle queryUsers failure', + body: (tester) async { + final filterConditions = {'id': 'bad'}; + final payload = QueryUsersPayload(filterConditions: filterConditions); + + tester.mockApiFailure( + (api) => api.queryUsers(payload: payload), + error: Exception('Failed to query users'), + ); + + final result = await tester.client.queryUsers( + filterConditions: filterConditions, + ); + + expect(result.isFailure, isTrue); + + tester.verifyApi((api) => api.queryUsers(payload: payload)); + }, + ); + }); } diff --git a/packages/stream_feeds_test/lib/src/helpers/test_data.dart b/packages/stream_feeds_test/lib/src/helpers/test_data.dart index b095ceb5..e8dcc75d 100644 --- a/packages/stream_feeds_test/lib/src/helpers/test_data.dart +++ b/packages/stream_feeds_test/lib/src/helpers/test_data.dart @@ -1210,3 +1210,43 @@ DurationResponse createDefaultCreateDeviceResponse() { DurationResponse createDefaultDeleteDeviceResponse() { return const DurationResponse(duration: '10ms'); } + +FullUserResponse createDefaultFullUserResponse({ + String id = 'user-1', + String? name, + String? image, +}) { + final now = DateTime(2021, 1, 1); + return FullUserResponse( + id: id, + banned: false, + blockedUserIds: const [], + channelMutes: const [], + createdAt: now, + custom: const {}, + devices: const [], + invisible: false, + language: 'en', + mutes: const [], + name: name, + image: image, + online: false, + role: 'user', + shadowBanned: false, + teams: const [], + totalUnreadCount: 0, + unreadChannels: 0, + unreadCount: 0, + unreadThreads: 0, + updatedAt: now, + ); +} + +QueryUsersResponse createDefaultQueryUsersResponse({ + List? users, +}) { + return QueryUsersResponse( + duration: '10ms', + users: users ?? [createDefaultFullUserResponse()], + ); +} From ea68b483681902dbb37d6e14d307601a3e326ded Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 18 Jun 2026 10:28:27 +0200 Subject: [PATCH 3/7] test(llc): fix redundant argument in queryUsers test Co-Authored-By: Claude Sonnet 4.6 --- packages/stream_feeds/test/client/feeds_client_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stream_feeds/test/client/feeds_client_test.dart b/packages/stream_feeds/test/client/feeds_client_test.dart index f2adcf3a..4471238d 100644 --- a/packages/stream_feeds/test/client/feeds_client_test.dart +++ b/packages/stream_feeds/test/client/feeds_client_test.dart @@ -953,7 +953,7 @@ void main() { (api) => api.queryUsers(payload: payload), result: createDefaultQueryUsersResponse( users: [ - createDefaultFullUserResponse(id: 'user-1', name: 'Alice'), + createDefaultFullUserResponse(name: 'Alice'), createDefaultFullUserResponse(id: 'user-2', name: 'Alan'), ], ), From 5a70cf75a872d9b879f6ad97efd92faf1670ca2f Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 9 Jul 2026 13:17:56 +0200 Subject: [PATCH 4/7] Add UserRepo and fix docs --- .cursor/rules/patterns/repository-pattern.mdc | 2 +- .cursor/rules/stream-feeds-sdk.mdc | 2 +- .../lib/src/client/feeds_client_impl.dart | 33 ++++++------ .../lib/src/client/moderation_client.dart | 4 +- .../stream_feeds/lib/src/feeds_client.dart | 54 ++++++++++--------- .../lib/src/repository/users_repository.dart | 47 ++++++++++++++++ 6 files changed, 95 insertions(+), 47 deletions(-) create mode 100644 packages/stream_feeds/lib/src/repository/users_repository.dart diff --git a/.cursor/rules/patterns/repository-pattern.mdc b/.cursor/rules/patterns/repository-pattern.mdc index bbddeb6a..59e12488 100644 --- a/.cursor/rules/patterns/repository-pattern.mdc +++ b/.cursor/rules/patterns/repository-pattern.mdc @@ -383,7 +383,7 @@ void main() { final result = await client.feed(FeedId(group: 'user', id: 'test')); switch (result) { - case Success(value: final feed): + case Success(data: final feed): expect(feed.id, equals('feed-1')); case Failure(error: final error): fail('Expected success, got error: $error'); diff --git a/.cursor/rules/stream-feeds-sdk.mdc b/.cursor/rules/stream-feeds-sdk.mdc index da1ee99d..bb28e7ca 100644 --- a/.cursor/rules/stream-feeds-sdk.mdc +++ b/.cursor/rules/stream-feeds-sdk.mdc @@ -132,7 +132,7 @@ class FeedStateNotifier extends StateNotifier { final result = await repository.getOrCreateFeed(state.feedQuery); switch (result) { - case Success(value: final feedData): + case Success(data: final feedData): state = state.copyWith(activities: feedData.activities.items); case Failure(error: final error): state = state.copyWith(error: error.toString()); diff --git a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart index d9da23ae..387b627e 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -26,6 +26,7 @@ import '../repository/devices_repository.dart'; import '../repository/feeds_repository.dart'; import '../repository/moderation_repository.dart'; import '../repository/polls_repository.dart'; +import '../repository/users_repository.dart'; import '../state/activity.dart'; import '../state/activity_comment_list.dart'; import '../state/activity_list.dart'; @@ -159,18 +160,19 @@ class StreamFeedsClientImpl implements StreamFeedsClient { _cdnClient = config.cdnClient ?? FeedsCdnClient(CdnApi(httpClient)); attachmentUploader = StreamAttachmentUploader(cdn: _cdnClient); - _feedsApi = feedsRestApi ?? api.DefaultApi(httpClient); + final feedsApi = feedsRestApi ?? api.DefaultApi(httpClient); - _activitiesRepository = ActivitiesRepository(_feedsApi, attachmentUploader); - _appRepository = AppRepository(_feedsApi); - _bookmarksRepository = BookmarksRepository(_feedsApi); - _collectionsRepository = CollectionsRepository(_feedsApi); - _commentsRepository = CommentsRepository(_feedsApi, attachmentUploader); - _devicesRepository = DevicesRepository(_feedsApi); - _feedsRepository = FeedsRepository(_feedsApi); - _moderationRepository = ModerationRepository(_feedsApi); - _pollsRepository = PollsRepository(_feedsApi); - _capabilitiesRepository = CapabilitiesRepository(_feedsApi); + _activitiesRepository = ActivitiesRepository(feedsApi, attachmentUploader); + _appRepository = AppRepository(feedsApi); + _bookmarksRepository = BookmarksRepository(feedsApi); + _collectionsRepository = CollectionsRepository(feedsApi); + _commentsRepository = CommentsRepository(feedsApi, attachmentUploader); + _devicesRepository = DevicesRepository(feedsApi); + _feedsRepository = FeedsRepository(feedsApi); + _moderationRepository = ModerationRepository(feedsApi); + _pollsRepository = PollsRepository(feedsApi); + _capabilitiesRepository = CapabilitiesRepository(feedsApi); + _usersRepository = UsersRepository(feedsApi); moderation = ModerationClient(_moderationRepository); @@ -199,7 +201,6 @@ class StreamFeedsClientImpl implements StreamFeedsClient { @override late final StreamAttachmentUploader attachmentUploader; - late final api.DefaultApi _feedsApi; late final ActivitiesRepository _activitiesRepository; late final AppRepository _appRepository; late final BookmarksRepository _bookmarksRepository; @@ -210,6 +211,7 @@ class StreamFeedsClientImpl implements StreamFeedsClient { late final ModerationRepository _moderationRepository; late final PollsRepository _pollsRepository; late final CapabilitiesRepository _capabilitiesRepository; + late final UsersRepository _usersRepository; // TODO: Fill this with correct values late final _systemEnvironmentManager = SystemEnvironmentManager( @@ -583,8 +585,8 @@ class StreamFeedsClientImpl implements StreamFeedsClient { int? offset, bool? presence, bool? includeDeactivatedUsers, - }) async { - final payload = api.QueryUsersPayload( + }) { + return _usersRepository.queryUsers( filterConditions: filterConditions, sort: sort, limit: limit, @@ -592,9 +594,6 @@ class StreamFeedsClientImpl implements StreamFeedsClient { presence: presence, includeDeactivatedUsers: includeDeactivatedUsers, ); - - final result = await _feedsApi.queryUsers(payload: payload); - return result.map((response) => response.users.map((u) => u.toModel()).toList()); } Stream get onReconnectEmitter { diff --git a/packages/stream_feeds/lib/src/client/moderation_client.dart b/packages/stream_feeds/lib/src/client/moderation_client.dart index 076fcf16..fcbd6e93 100644 --- a/packages/stream_feeds/lib/src/client/moderation_client.dart +++ b/packages/stream_feeds/lib/src/client/moderation_client.dart @@ -17,7 +17,7 @@ import '../state.dart' show ModerationConfigsQuery; /// /// // Ban a user /// final banResult = await client.moderation.ban( -/// api.BanRequest( +/// BanRequest( /// targetUserId: 'user-123', /// reason: 'Violation of community guidelines', /// ), @@ -25,7 +25,7 @@ import '../state.dart' show ModerationConfigsQuery; /// /// // Flag content for review /// final flagResult = await client.moderation.flag( -/// api.FlagRequest( +/// FlagRequest( /// targetId: 'activity-456', /// reason: 'inappropriate content', /// ), diff --git a/packages/stream_feeds/lib/src/feeds_client.dart b/packages/stream_feeds/lib/src/feeds_client.dart index cf611f92..b4f61fa8 100644 --- a/packages/stream_feeds/lib/src/feeds_client.dart +++ b/packages/stream_feeds/lib/src/feeds_client.dart @@ -395,7 +395,7 @@ abstract interface class StreamFeedsClient { /// ), /// ], /// ); - ///``` + /// ``` /// /// Returns a [Result] containing the list of upserted [ActivityData] or an error. Future>> upsertActivities({ @@ -406,11 +406,13 @@ abstract interface class StreamFeedsClient { /// /// Deletes the provided [ids] in a single batch operation. /// - ///```dart - ///await client.deleteActivities( - /// ids: ['123', '456'], - /// hardDelete: false, - ///); + /// Example: + /// ```dart + /// await client.deleteActivities( + /// ids: ['123', '456'], + /// hardDelete: false, + /// ); + /// ``` /// /// Returns a [Result] containing the list of deleted activity ids or an error. Future> deleteActivities({ @@ -657,7 +659,7 @@ abstract interface class StreamFeedsClient { /// ```dart /// final result = await client.getApp(); /// switch (result) { - /// case Success(value: final appData): + /// case Success(data: final appData): /// print('App name: ${appData.name}'); /// print('File upload size limit: ${appData.fileUploadConfig.sizeLimit}'); /// case Failure(error: final error): @@ -679,7 +681,7 @@ abstract interface class StreamFeedsClient { /// final result = await client.queryDevices(); /// /// switch (result) { - /// case Success(value: final devicesResponse): + /// case Success(data: final devicesResponse): /// print('Found ${devicesResponse.devices.length} devices'); /// case Failure(error: final error): /// print('Failed to query devices: $error'); @@ -790,13 +792,13 @@ abstract interface class StreamFeedsClient { /// Example: /// ```dart /// final result = await client.getOrCreateFollows( - /// api.FollowBatchRequest( + /// FollowBatchRequest( /// follows: [ - /// api.FollowRequest( + /// FollowRequest( /// source: FeedId.user('john').rawValue, /// target: FeedId.user('jane').rawValue, /// ), - /// api.FollowRequest( + /// FollowRequest( /// source: FeedId.user('john').rawValue, /// target: FeedId.user('bob').rawValue, /// ), @@ -805,7 +807,7 @@ abstract interface class StreamFeedsClient { /// ); /// /// switch (result) { - /// case Success(value: final batchFollowData): + /// case Success(data: final batchFollowData): /// print('Created ${batchFollowData.created.length} new follows'); /// print('Total follows: ${batchFollowData.follows.length}'); /// case Failure(error: final error): @@ -826,13 +828,13 @@ abstract interface class StreamFeedsClient { /// Example: /// ```dart /// final result = await client.getOrCreateUnfollows( - /// api.UnfollowBatchRequest( + /// UnfollowBatchRequest( /// follows: [ - /// api.UnfollowPair( + /// UnfollowPair( /// source: FeedId.user('john').rawValue, /// target: FeedId.user('jane').rawValue, /// ), - /// api.UnfollowPair( + /// UnfollowPair( /// source: FeedId.user('john').rawValue, /// target: FeedId.user('bob').rawValue, /// ), @@ -841,7 +843,7 @@ abstract interface class StreamFeedsClient { /// ); /// /// switch (result) { - /// case Success(value: final unfollowedFollows): + /// case Success(data: final unfollowedFollows): /// print('Unfollowed ${unfollowedFollows.length} feeds'); /// case Failure(error: final error): /// print('Failed to unfollow feeds: $error'); @@ -864,7 +866,7 @@ abstract interface class StreamFeedsClient { /// ); /// /// switch (result) { - /// case Success(value: final response): + /// case Success(data: final response): /// print('Found ${response.collections.length} collections'); /// case Failure(error: final error): /// print('Failed to read collections: $error'); @@ -884,9 +886,9 @@ abstract interface class StreamFeedsClient { /// Example: /// ```dart /// final result = await client.createCollections( - /// request: api.CreateCollectionsRequest( + /// request: CreateCollectionsRequest( /// collections: [ - /// api.CollectionRequest( + /// CollectionRequest( /// id: '123', /// name: 'my_collection', /// custom: const {'key': 'value'}, @@ -896,7 +898,7 @@ abstract interface class StreamFeedsClient { /// ); /// /// switch (result) { - /// case Success(value: final response): + /// case Success(data: final response): /// print('Created ${response.collections.length} collections'); /// case Failure(error: final error): /// print('Failed to create collections: $error'); @@ -915,9 +917,9 @@ abstract interface class StreamFeedsClient { /// Example: /// ```dart /// final result = await client.updateCollections( - /// request: api.UpdateCollectionsRequest( + /// request: UpdateCollectionsRequest( /// collections: [ - /// api.UpdateCollectionRequest( + /// UpdateCollectionRequest( /// id: '123', /// name: 'my_collection', /// custom: const {'updated_key': 'updated_value'}, @@ -927,7 +929,7 @@ abstract interface class StreamFeedsClient { /// ); /// /// switch (result) { - /// case Success(value: final response): + /// case Success(data: final response): /// print('Updated ${response.collections.length} collections'); /// case Failure(error: final error): /// print('Failed to update collections: $error'); @@ -950,7 +952,7 @@ abstract interface class StreamFeedsClient { /// ); /// /// switch (result) { - /// case Success(value: final response): + /// case Success(data: final response): /// print('Deleted collections successfully'); /// case Failure(error: final error): /// print('Failed to delete collections: $error'); @@ -971,12 +973,12 @@ abstract interface class StreamFeedsClient { /// ```dart /// final result = await client.queryUsers( /// filterConditions: {'name': {'$autocomplete': 'Al'}}, - /// sort: [api.SortParamRequest(field: 'name', direction: 1)], + /// sort: [SortParamRequest(field: 'name', direction: 1)], /// limit: 25, /// ); /// /// switch (result) { - /// case Success(value: final users): + /// case Success(data: final users): /// for (final user in users) { /// print('${user.id}: ${user.name}'); /// } diff --git a/packages/stream_feeds/lib/src/repository/users_repository.dart b/packages/stream_feeds/lib/src/repository/users_repository.dart new file mode 100644 index 00000000..1e490f25 --- /dev/null +++ b/packages/stream_feeds/lib/src/repository/users_repository.dart @@ -0,0 +1,47 @@ +import 'package:stream_core/stream_core.dart'; + +import '../generated/api/api.dart' as api; +import '../models/user_data.dart'; + +/// Repository for querying user data. +/// +/// Provides methods for searching users by filter conditions, sort, and pagination. +/// +/// All methods return [Result] objects for explicit error handling. +class UsersRepository { + /// Creates a new [UsersRepository] instance. + /// + /// The [api] parameter is required for making API calls to the Stream Feeds service. + const UsersRepository(this._api); + + // The API client used for making requests to the Stream Feeds service. + final api.DefaultApi _api; + + /// Queries users matching the provided filter conditions. + /// + /// Searches for users using the specified [filterConditions] map and optional + /// [sort], [limit], [offset], [presence], and [includeDeactivatedUsers] parameters. + /// + /// Returns a [Result] containing a list of [UserData] or an error. + Future>> queryUsers({ + required Map filterConditions, + List? sort, + int? limit, + int? offset, + bool? presence, + bool? includeDeactivatedUsers, + }) async { + final payload = api.QueryUsersPayload( + filterConditions: filterConditions, + sort: sort, + limit: limit, + offset: offset, + presence: presence, + includeDeactivatedUsers: includeDeactivatedUsers, + ); + + final result = await _api.queryUsers(payload: payload); + + return result.map((response) => response.users.map((u) => u.toModel()).toList()); + } +} From d27be60665aabf1fee0361a747afa52d66077069 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 18 Aug 2026 13:18:18 +0200 Subject: [PATCH 5/7] Improve on user query --- docs/code_snippets/02_01_querying_users.dart | 30 +-- .../lib/src/client/feeds_client_impl.dart | 19 +- .../stream_feeds/lib/src/feeds_client.dart | 25 +- .../lib/src/repository/users_repository.dart | 29 +-- packages/stream_feeds/lib/src/state.dart | 1 + .../lib/src/state/query/users_query.dart | 242 ++++++++++++++++++ .../src/state/query/users_query.freezed.dart | 128 +++++++++ .../test/client/feeds_client_test.dart | 95 ++++++- 8 files changed, 488 insertions(+), 81 deletions(-) create mode 100644 packages/stream_feeds/lib/src/state/query/users_query.dart create mode 100644 packages/stream_feeds/lib/src/state/query/users_query.freezed.dart diff --git a/docs/code_snippets/02_01_querying_users.dart b/docs/code_snippets/02_01_querying_users.dart index 1f211d1e..1265fa9d 100644 --- a/docs/code_snippets/02_01_querying_users.dart +++ b/docs/code_snippets/02_01_querying_users.dart @@ -5,11 +5,11 @@ late StreamFeedsClient client; Future queryUsers() async { // Search users by name prefix final result = await client.queryUsers( - filterConditions: { - 'name': {r'$autocomplete': 'Al'}, - }, - sort: [const SortParamRequest(field: 'name', direction: 1)], - limit: 25, + UsersQuery( + filter: Filter.autoComplete(UsersFilterField.name, 'Al'), + sort: [UsersSort.asc(UsersSortField.name)], + limit: 25, + ), ); switch (result) { @@ -25,11 +25,9 @@ Future queryUsers() async { Future queryUsersWithFilter() async { // Query users by exact ID match final result = await client.queryUsers( - filterConditions: { - 'id': { - r'$in': ['alice', 'bob', 'carol'], - }, - }, + UsersQuery( + filter: Filter.in_(UsersFilterField.id, ['alice', 'bob', 'carol']), + ), ); switch (result) { @@ -43,12 +41,12 @@ Future queryUsersWithFilter() async { Future queryUsersWithPresence() async { // Query users and include online presence information final result = await client.queryUsers( - filterConditions: { - 'teams': {r'$contains': 'support'}, - }, - sort: [const SortParamRequest(field: 'last_active', direction: -1)], - limit: 10, - presence: true, + UsersQuery( + filter: Filter.contains(UsersFilterField.teams, 'support'), + sort: [UsersSort.desc(UsersSortField.lastActive)], + limit: 10, + presence: true, + ), ); switch (result) { diff --git a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart index 387b627e..10e0d8e7 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -60,6 +60,7 @@ import '../state/query/members_query.dart'; import '../state/query/moderation_configs_query.dart'; import '../state/query/poll_votes_query.dart'; import '../state/query/polls_query.dart'; +import '../state/query/users_query.dart'; import '../ws/feeds_ws_event.dart'; import 'endpoint_config.dart'; @@ -578,22 +579,8 @@ class StreamFeedsClientImpl implements StreamFeedsClient { } @override - Future>> queryUsers({ - required Map filterConditions, - List? sort, - int? limit, - int? offset, - bool? presence, - bool? includeDeactivatedUsers, - }) { - return _usersRepository.queryUsers( - filterConditions: filterConditions, - sort: sort, - limit: limit, - offset: offset, - presence: presence, - includeDeactivatedUsers: includeDeactivatedUsers, - ); + Future>> queryUsers(UsersQuery query) { + return _usersRepository.queryUsers(query.toRequest()); } Stream get onReconnectEmitter { diff --git a/packages/stream_feeds/lib/src/feeds_client.dart b/packages/stream_feeds/lib/src/feeds_client.dart index b4f61fa8..57bdef68 100644 --- a/packages/stream_feeds/lib/src/feeds_client.dart +++ b/packages/stream_feeds/lib/src/feeds_client.dart @@ -45,6 +45,7 @@ import 'state/query/members_query.dart'; import 'state/query/moderation_configs_query.dart'; import 'state/query/poll_votes_query.dart'; import 'state/query/polls_query.dart'; +import 'state/query/users_query.dart'; export 'client/moderation_client.dart'; @@ -964,18 +965,19 @@ abstract interface class StreamFeedsClient { required List refs, }); - /// Queries users matching the provided filter conditions. + /// Queries users matching the provided [query]. /// - /// Searches for users using the specified [filterConditions] map and optional - /// [sort], [limit], [offset], [presence], and [includeDeactivatedUsers] parameters. + /// Use [UsersQuery] to configure the filter, sorting and pagination of the + /// search. Users are paginated with `limit`/`offset` because the users + /// endpoint does not return page cursors. /// /// Example: /// ```dart - /// final result = await client.queryUsers( - /// filterConditions: {'name': {'$autocomplete': 'Al'}}, - /// sort: [SortParamRequest(field: 'name', direction: 1)], + /// final result = await client.queryUsers(UsersQuery( + /// filter: Filter.autoComplete(UsersFilterField.name, 'Al'), + /// sort: [UsersSort.asc(UsersSortField.name)], /// limit: 25, - /// ); + /// )); /// /// switch (result) { /// case Success(data: final users): @@ -988,14 +990,7 @@ abstract interface class StreamFeedsClient { /// ``` /// /// Returns a [Result] containing a list of [UserData] or an error. - Future>> queryUsers({ - required Map filterConditions, - List? sort, - int? limit, - int? offset, - bool? presence, - bool? includeDeactivatedUsers, - }); + Future>> queryUsers(UsersQuery query); /// The moderation client for managing moderation-related operations. /// diff --git a/packages/stream_feeds/lib/src/repository/users_repository.dart b/packages/stream_feeds/lib/src/repository/users_repository.dart index 1e490f25..bb094244 100644 --- a/packages/stream_feeds/lib/src/repository/users_repository.dart +++ b/packages/stream_feeds/lib/src/repository/users_repository.dart @@ -17,31 +17,16 @@ class UsersRepository { // The API client used for making requests to the Stream Feeds service. final api.DefaultApi _api; - /// Queries users matching the provided filter conditions. - /// - /// Searches for users using the specified [filterConditions] map and optional - /// [sort], [limit], [offset], [presence], and [includeDeactivatedUsers] parameters. + /// Queries users matching the given [payload]. /// /// Returns a [Result] containing a list of [UserData] or an error. - Future>> queryUsers({ - required Map filterConditions, - List? sort, - int? limit, - int? offset, - bool? presence, - bool? includeDeactivatedUsers, - }) async { - final payload = api.QueryUsersPayload( - filterConditions: filterConditions, - sort: sort, - limit: limit, - offset: offset, - presence: presence, - includeDeactivatedUsers: includeDeactivatedUsers, - ); - + Future>> queryUsers( + api.QueryUsersPayload payload, + ) async { final result = await _api.queryUsers(payload: payload); - return result.map((response) => response.users.map((u) => u.toModel()).toList()); + return result.map( + (response) => response.users.map((u) => u.toModel()).toList(), + ); } } diff --git a/packages/stream_feeds/lib/src/state.dart b/packages/stream_feeds/lib/src/state.dart index f97ec3db..51664bd8 100644 --- a/packages/stream_feeds/lib/src/state.dart +++ b/packages/stream_feeds/lib/src/state.dart @@ -48,3 +48,4 @@ export 'state/query/members_query.dart'; export 'state/query/moderation_configs_query.dart'; export 'state/query/poll_votes_query.dart'; export 'state/query/polls_query.dart'; +export 'state/query/users_query.dart'; diff --git a/packages/stream_feeds/lib/src/state/query/users_query.dart b/packages/stream_feeds/lib/src/state/query/users_query.dart new file mode 100644 index 00000000..d5c40226 --- /dev/null +++ b/packages/stream_feeds/lib/src/state/query/users_query.dart @@ -0,0 +1,242 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:stream_core/stream_core.dart'; + +import '../../generated/api/models.dart' as api; +import '../../models/user_data.dart'; +import '../../utils/filter.dart'; +import '../../utils/sort.dart'; + +part 'users_query.freezed.dart'; + +/// A query for retrieving users with filtering, sorting, and pagination. +/// +/// Configures how users should be fetched from the Stream Feeds API +/// including filters, sorting options and pagination parameters. +/// +/// Unlike most other queries in this SDK, users are paginated with +/// [limit]/[offset] instead of cursors, because the users endpoint does not +/// return page cursors. +/// +/// ## Example +/// ```dart +/// final query = UsersQuery( +/// filter: Filter.autoComplete(UsersFilterField.name, 'Al'), +/// sort: [UsersSort.asc(UsersSortField.name)], +/// limit: 25, +/// ); +/// ``` +@freezed +class UsersQuery with _$UsersQuery { + const UsersQuery({ + this.filter, + this.sort, + this.limit, + this.offset, + this.presence, + this.includeDeactivatedUsers, + }); + + /// Optional filter criteria for this query. + /// + /// This filter can be a simple single filter or a complex combination of multiple filters + /// using logical operators (`.and`, `.or`). The filter determines which users + /// are included in the query results based on field values and comparison operators. + /// + /// Use [UsersFilterField] for type-safe field references. + @override + final UsersFilter? filter; + + /// Array of sorting criteria for this query. + /// + /// Specifies how users should be ordered in the response. + /// If not provided, the API will use its default sorting. + /// Multiple sort fields can be specified. + @override + final List? sort; + + /// The maximum number of users to return. + /// If not specified, the API will use its default limit. + @override + final int? limit; + + /// The number of users to skip before returning results. + /// + /// Combine with [limit] to page through results, for example an [offset] of + /// 25 with a [limit] of 25 returns the second page. + @override + final int? offset; + + /// Whether to include online presence information for the returned users. + /// + /// When enabled, the `online` and `lastActive` fields of [UserData] reflect + /// the current presence of each user. + @override + final bool? presence; + + /// Whether deactivated users should be included in the results. + /// + /// Deactivated users are excluded by default. + @override + final bool? includeDeactivatedUsers; +} + +// region Filter + +/// Represents filtering options for users. +/// +/// See [UsersFilterField] for available fields. +typedef UsersFilter = Filter; + +/// Represents a field that can be used in users filtering. +/// +/// This extension type provides a type-safe way to specify which field should be used +/// when creating filters for users queries. +class UsersFilterField extends FilterField { + /// Creates a new users filter field. + UsersFilterField(super.remote, super.value); + + /// Filter by whether the user is banned. + /// + /// **Supported operators:** `.equal` + static final banned = UsersFilterField( + 'banned', + (data) => data.banned, + ); + + /// Filter by the creation timestamp of the user. + /// + /// **Supported operators:** `.equal`, `.greaterThan`, `.lessThan`, `.greaterThanOrEqual`, `.lessThanOrEqual` + static final createdAt = UsersFilterField( + 'created_at', + (data) => data.createdAt, + ); + + /// Filter by the unique identifier of the user. + /// + /// **Supported operators:** `.equal`, `.in`, `.autoComplete` + static final id = UsersFilterField( + 'id', + (data) => data.id, + ); + + /// Filter by the timestamp the user was last active at. + /// + /// **Supported operators:** `.equal`, `.greaterThan`, `.lessThan`, `.greaterThanOrEqual`, `.lessThanOrEqual` + static final lastActive = UsersFilterField( + 'last_active', + (data) => data.lastActive, + ); + + /// Filter by the name of the user. + /// + /// **Supported operators:** `.equal`, `.in`, `.autoComplete`, `.query` + static final name = UsersFilterField( + 'name', + (data) => data.name, + ); + + /// Filter by the role of the user. + /// + /// **Supported operators:** `.equal`, `.in` + static final role = UsersFilterField( + 'role', + (data) => data.role, + ); + + /// Filter by the teams the user belongs to. + /// + /// **Supported operators:** `.equal`, `.in`, `.contains` + static final teams = UsersFilterField( + 'teams', + (data) => data.teams, + ); + + /// Filter by the last update timestamp of the user. + /// + /// **Supported operators:** `.equal`, `.greaterThan`, `.lessThan`, `.greaterThanOrEqual`, `.lessThanOrEqual` + static final updatedAt = UsersFilterField( + 'updated_at', + (data) => data.updatedAt, + ); +} + +// endregion + +// region Sort + +/// Represents a sorting operation for users. +class UsersSort extends Sort { + /// Creates a new users sort with ascending direction. + const UsersSort.asc( + UsersSortField super.field, { + super.nullOrdering = NullOrdering.nullsLast, + }) : super.asc(); + + /// Creates a new users sort with descending direction. + const UsersSort.desc( + UsersSortField super.field, { + super.nullOrdering = NullOrdering.nullsFirst, + }) : super.desc(); +} + +/// Defines the fields by which users can be sorted. +/// +/// This extension type provides specific fields for sorting user data. +class UsersSortField extends SortField { + /// Creates a new users sort field. + UsersSortField(super.remote, super.localValue); + + /// Sort by the creation timestamp of the user. + /// This field allows sorting users by when they were created (newest/oldest first). + static final createdAt = UsersSortField( + 'created_at', + (data) => data.createdAt, + ); + + /// Sort by the unique identifier of the user. + /// This field allows sorting users alphabetically by id. + static final id = UsersSortField( + 'id', + (data) => data.id, + ); + + /// Sort by the timestamp the user was last active at. + /// This field allows sorting users by recency of activity. + static final lastActive = UsersSortField( + 'last_active', + (data) => data.lastActive, + ); + + /// Sort by the name of the user. + /// This field allows sorting users alphabetically by name. + static final name = UsersSortField( + 'name', + (data) => data.name, + ); + + /// Sort by the last update timestamp of the user. + /// This field allows sorting users by when they were last updated (newest/oldest first). + static final updatedAt = UsersSortField( + 'updated_at', + (data) => data.updatedAt, + ); +} + +// endregion + +/// Extension for converting a [UsersQuery] to a [api.QueryUsersPayload]. +extension UsersQueryRequest on UsersQuery { + /// Converts this query to an API request format. + /// + /// Returns a [api.QueryUsersPayload] suitable for making API calls to retrieve users. + api.QueryUsersPayload toRequest() { + return api.QueryUsersPayload( + filterConditions: filter?.toRequest() ?? const {}, + sort: sort?.map((s) => s.toRequest()).toList(), + limit: limit, + offset: offset, + presence: presence, + includeDeactivatedUsers: includeDeactivatedUsers, + ); + } +} diff --git a/packages/stream_feeds/lib/src/state/query/users_query.freezed.dart b/packages/stream_feeds/lib/src/state/query/users_query.freezed.dart new file mode 100644 index 00000000..679e1a92 --- /dev/null +++ b/packages/stream_feeds/lib/src/state/query/users_query.freezed.dart @@ -0,0 +1,128 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'users_query.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$UsersQuery { + UsersFilter? get filter; + List? get sort; + int? get limit; + int? get offset; + bool? get presence; + bool? get includeDeactivatedUsers; + + /// Create a copy of UsersQuery + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $UsersQueryCopyWith get copyWith => _$UsersQueryCopyWithImpl(this as UsersQuery, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is UsersQuery && + (identical(other.filter, filter) || other.filter == filter) && + const DeepCollectionEquality().equals(other.sort, sort) && + (identical(other.limit, limit) || other.limit == limit) && + (identical(other.offset, offset) || other.offset == offset) && + (identical(other.presence, presence) || other.presence == presence) && + (identical( + other.includeDeactivatedUsers, + includeDeactivatedUsers, + ) || + other.includeDeactivatedUsers == includeDeactivatedUsers)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + filter, + const DeepCollectionEquality().hash(sort), + limit, + offset, + presence, + includeDeactivatedUsers, + ); + + @override + String toString() { + return 'UsersQuery(filter: $filter, sort: $sort, limit: $limit, offset: $offset, presence: $presence, includeDeactivatedUsers: $includeDeactivatedUsers)'; + } +} + +/// @nodoc +abstract mixin class $UsersQueryCopyWith<$Res> { + factory $UsersQueryCopyWith( + UsersQuery value, + $Res Function(UsersQuery) _then, + ) = _$UsersQueryCopyWithImpl; + @useResult + $Res call({ + Filter? filter, + List? sort, + int? limit, + int? offset, + bool? presence, + bool? includeDeactivatedUsers, + }); +} + +/// @nodoc +class _$UsersQueryCopyWithImpl<$Res> implements $UsersQueryCopyWith<$Res> { + _$UsersQueryCopyWithImpl(this._self, this._then); + + final UsersQuery _self; + final $Res Function(UsersQuery) _then; + + /// Create a copy of UsersQuery + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? filter = freezed, + Object? sort = freezed, + Object? limit = freezed, + Object? offset = freezed, + Object? presence = freezed, + Object? includeDeactivatedUsers = freezed, + }) { + return _then( + UsersQuery( + filter: freezed == filter + ? _self.filter + : filter // ignore: cast_nullable_to_non_nullable + as Filter?, + sort: freezed == sort + ? _self.sort + : sort // ignore: cast_nullable_to_non_nullable + as List?, + limit: freezed == limit + ? _self.limit + : limit // ignore: cast_nullable_to_non_nullable + as int?, + offset: freezed == offset + ? _self.offset + : offset // ignore: cast_nullable_to_non_nullable + as int?, + presence: freezed == presence + ? _self.presence + : presence // ignore: cast_nullable_to_non_nullable + as bool?, + includeDeactivatedUsers: freezed == includeDeactivatedUsers + ? _self.includeDeactivatedUsers + : includeDeactivatedUsers // ignore: cast_nullable_to_non_nullable + as bool?, + ), + ); + } +} diff --git a/packages/stream_feeds/test/client/feeds_client_test.dart b/packages/stream_feeds/test/client/feeds_client_test.dart index 4471238d..6e55b0d5 100644 --- a/packages/stream_feeds/test/client/feeds_client_test.dart +++ b/packages/stream_feeds/test/client/feeds_client_test.dart @@ -944,10 +944,19 @@ void main() { feedsClientTest( 'should query users successfully', body: (tester) async { - final filterConditions = { - 'name': {r'$autocomplete': 'Al'}, - }; - final payload = QueryUsersPayload(filterConditions: filterConditions); + final query = UsersQuery( + filter: Filter.autoComplete(UsersFilterField.name, 'Al'), + sort: [UsersSort.asc(UsersSortField.name)], + limit: 25, + ); + + const payload = QueryUsersPayload( + filterConditions: { + 'name': {r'$autocomplete': 'Al'}, + }, + sort: [SortParamRequest(field: 'name', direction: 1)], + limit: 25, + ); tester.mockApi( (api) => api.queryUsers(payload: payload), @@ -959,9 +968,7 @@ void main() { ), ); - final result = await tester.client.queryUsers( - filterConditions: filterConditions, - ); + final result = await tester.client.queryUsers(query); expect(result.isSuccess, isTrue); final users = result.getOrThrow(); @@ -973,20 +980,84 @@ void main() { }, ); + feedsClientTest( + 'should query users without a filter', + body: (tester) async { + const payload = QueryUsersPayload(filterConditions: {}); + + tester.mockApi( + (api) => api.queryUsers(payload: payload), + result: createDefaultQueryUsersResponse( + users: [createDefaultFullUserResponse()], + ), + ); + + final result = await tester.client.queryUsers(const UsersQuery()); + + expect(result.isSuccess, isTrue); + expect(result.getOrThrow().single.id, equals('user-1')); + + tester.verifyApi((api) => api.queryUsers(payload: payload)); + }, + ); + + feedsClientTest( + 'should forward pagination and presence options', + body: (tester) async { + final query = UsersQuery( + filter: Filter.in_(UsersFilterField.teams, const ['support']), + limit: 10, + offset: 20, + presence: true, + includeDeactivatedUsers: true, + ); + + const payload = QueryUsersPayload( + filterConditions: { + 'teams': { + r'$in': ['support'], + }, + }, + limit: 10, + offset: 20, + presence: true, + includeDeactivatedUsers: true, + ); + + tester.mockApi( + (api) => api.queryUsers(payload: payload), + result: createDefaultQueryUsersResponse( + users: [createDefaultFullUserResponse()], + ), + ); + + final result = await tester.client.queryUsers(query); + + expect(result.isSuccess, isTrue); + + tester.verifyApi((api) => api.queryUsers(payload: payload)); + }, + ); + feedsClientTest( 'should handle queryUsers failure', body: (tester) async { - final filterConditions = {'id': 'bad'}; - final payload = QueryUsersPayload(filterConditions: filterConditions); + final query = UsersQuery( + filter: Filter.equal(UsersFilterField.id, 'bad'), + ); + + const payload = QueryUsersPayload( + filterConditions: { + 'id': {r'$eq': 'bad'}, + }, + ); tester.mockApiFailure( (api) => api.queryUsers(payload: payload), error: Exception('Failed to query users'), ); - final result = await tester.client.queryUsers( - filterConditions: filterConditions, - ); + final result = await tester.client.queryUsers(query); expect(result.isFailure, isTrue); From 32a31b8a3321b1a517185693d69a17e9af0e123c Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 18 Aug 2026 14:55:49 +0200 Subject: [PATCH 6/7] Change queryUsers into userList --- docs/code_snippets/02_01_querying_users.dart | 40 ++- packages/stream_feeds/CHANGELOG.md | 2 +- .../lib/src/client/feeds_client_impl.dart | 15 +- .../stream_feeds/lib/src/feeds_client.dart | 28 +- .../lib/src/repository/users_repository.dart | 10 +- packages/stream_feeds/lib/src/state.dart | 2 + .../lib/src/state/query/users_query.dart | 73 ++-- .../src/state/query/users_query.freezed.dart | 11 +- .../stream_feeds/lib/src/state/user_list.dart | 95 ++++++ .../lib/src/state/user_list_state.dart | 108 ++++++ .../src/state/user_list_state.freezed.dart | 85 +++++ .../test/client/feeds_client_test.dart | 130 ------- .../test/state/user_list_test.dart | 322 ++++++++++++++++++ .../src/testers/state/user_list_tester.dart | 146 ++++++++ .../lib/stream_feeds_test.dart | 1 + 15 files changed, 863 insertions(+), 205 deletions(-) create mode 100644 packages/stream_feeds/lib/src/state/user_list.dart create mode 100644 packages/stream_feeds/lib/src/state/user_list_state.dart create mode 100644 packages/stream_feeds/lib/src/state/user_list_state.freezed.dart create mode 100644 packages/stream_feeds/test/state/user_list_test.dart create mode 100644 packages/stream_feeds_test/lib/src/testers/state/user_list_tester.dart diff --git a/docs/code_snippets/02_01_querying_users.dart b/docs/code_snippets/02_01_querying_users.dart index 1265fa9d..3b5e4c17 100644 --- a/docs/code_snippets/02_01_querying_users.dart +++ b/docs/code_snippets/02_01_querying_users.dart @@ -4,7 +4,7 @@ late StreamFeedsClient client; Future queryUsers() async { // Search users by name prefix - final result = await client.queryUsers( + final userList = client.userList( UsersQuery( filter: Filter.autoComplete(UsersFilterField.name, 'Al'), sort: [UsersSort.asc(UsersSortField.name)], @@ -12,6 +12,8 @@ Future queryUsers() async { ), ); + final result = await userList.get(); + switch (result) { case Success(data: final users): for (final user in users) { @@ -20,16 +22,24 @@ Future queryUsers() async { case Failure(error: final error): print('Failed to query users: $error'); } + + // The loaded users are also kept in the observable state of the list + userList.stream.listen((state) => print('${state.users.length} users')); + + // Dispose the list when you no longer need it + userList.dispose(); } Future queryUsersWithFilter() async { // Query users by exact ID match - final result = await client.queryUsers( + final userList = client.userList( UsersQuery( filter: Filter.in_(UsersFilterField.id, ['alice', 'bob', 'carol']), ), ); + final result = await userList.get(); + switch (result) { case Success(data: final users): print('Found ${users.length} users'); @@ -38,24 +48,26 @@ Future queryUsersWithFilter() async { } } -Future queryUsersWithPresence() async { - // Query users and include online presence information - final result = await client.queryUsers( +Future queryMoreUsers() async { + // Users are paginated with limit/offset instead of cursors + final userList = client.userList( UsersQuery( filter: Filter.contains(UsersFilterField.teams, 'support'), sort: [UsersSort.desc(UsersSortField.lastActive)], limit: 10, - presence: true, ), ); - switch (result) { - case Success(data: final users): - for (final user in users) { - final status = user.online ? 'online' : 'offline'; - print('${user.name ?? user.id} is $status'); - } - case Failure(error: final error): - print('Failed to query users: $error'); + await userList.get(); + + // Keep loading while more users are available + while (userList.state.canLoadMore) { + final result = await userList.queryMoreUsers(); + if (result.isFailure) break; + } + + for (final user in userList.state.users) { + final status = user.online ? 'online' : 'offline'; + print('${user.name ?? user.id} is $status'); } } diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index 23022da7..316e47c8 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -1,7 +1,7 @@ ## Upcoming ### New methods -- Added `queryUsers` to `StreamFeedsClient` for searching users by filter conditions, sort, and pagination. +- Added `userList` to `StreamFeedsClient`, returning a `UserList` state object for querying users. Takes a `UsersQuery` with type-safe `UsersFilterField`/`UsersSortField` filtering and sorting, plus an `includeDeactivatedUsers` option. Users are paginated with `limit`/`offset` (`UserListState.nextOffset` / `canLoadMore`) because the users endpoint returns no page cursors. ### New fields - Added `isRead` and `isSeen` fields to `ActivityData` and `AggregatedActivityData` for notification-feed read/seen state. diff --git a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart index 10e0d8e7..9c5a0b1c 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -15,7 +15,6 @@ import '../models/feeds_config.dart'; import '../models/follow_data.dart'; import '../models/model_updates.dart'; import '../models/push_notifications_config.dart'; -import '../models/user_data.dart'; import '../repository/activities_repository.dart'; import '../repository/app_repository.dart'; import '../repository/bookmarks_repository.dart'; @@ -61,6 +60,7 @@ import '../state/query/moderation_configs_query.dart'; import '../state/query/poll_votes_query.dart'; import '../state/query/polls_query.dart'; import '../state/query/users_query.dart'; +import '../state/user_list.dart'; import '../ws/feeds_ws_event.dart'; import 'endpoint_config.dart'; @@ -478,6 +478,14 @@ class StreamFeedsClientImpl implements StreamFeedsClient { ); } + @override + UserList userList(UsersQuery query) { + return UserList( + query: query, + usersRepository: _usersRepository, + ); + } + @override Future> getApp() => _appRepository.getApp(); @@ -578,11 +586,6 @@ class StreamFeedsClientImpl implements StreamFeedsClient { return _collectionsRepository.deleteCollections(refs: refs); } - @override - Future>> queryUsers(UsersQuery query) { - return _usersRepository.queryUsers(query.toRequest()); - } - Stream get onReconnectEmitter { return connectionState .scan( diff --git a/packages/stream_feeds/lib/src/feeds_client.dart b/packages/stream_feeds/lib/src/feeds_client.dart index 57bdef68..7a5cb053 100644 --- a/packages/stream_feeds/lib/src/feeds_client.dart +++ b/packages/stream_feeds/lib/src/feeds_client.dart @@ -11,7 +11,6 @@ import 'models/feed_id.dart'; import 'models/feeds_config.dart'; import 'models/follow_data.dart'; import 'models/push_notifications_config.dart'; -import 'models/user_data.dart'; import 'state/activity.dart'; import 'state/activity_comment_list.dart'; import 'state/activity_list.dart'; @@ -46,6 +45,7 @@ import 'state/query/moderation_configs_query.dart'; import 'state/query/poll_votes_query.dart'; import 'state/query/polls_query.dart'; import 'state/query/users_query.dart'; +import 'state/user_list.dart'; export 'client/moderation_client.dart'; @@ -965,32 +965,28 @@ abstract interface class StreamFeedsClient { required List refs, }); - /// Queries users matching the provided [query]. - /// - /// Use [UsersQuery] to configure the filter, sorting and pagination of the - /// search. Users are paginated with `limit`/`offset` because the users - /// endpoint does not return page cursors. + /// Creates a [UserList] object that represents a collection of users matching + /// the provided query. /// /// Example: /// ```dart - /// final result = await client.queryUsers(UsersQuery( + /// final userList = client.userList(UsersQuery( /// filter: Filter.autoComplete(UsersFilterField.name, 'Al'), /// sort: [UsersSort.asc(UsersSortField.name)], /// limit: 25, /// )); /// - /// switch (result) { - /// case Success(data: final users): - /// for (final user in users) { - /// print('${user.id}: ${user.name}'); - /// } - /// case Failure(error: final error): - /// print('Failed to query users: $error'); + /// // Fetch the first page of users + /// final result = await userList.get(); + /// + /// // Load more users if available + /// if (userList.state.canLoadMore) { + /// await userList.queryMoreUsers(); /// } /// ``` /// - /// Returns a [Result] containing a list of [UserData] or an error. - Future>> queryUsers(UsersQuery query); + /// Returns a [UserList] instance that can be used to interact with the collection of users. + UserList userList(UsersQuery query); /// The moderation client for managing moderation-related operations. /// diff --git a/packages/stream_feeds/lib/src/repository/users_repository.dart b/packages/stream_feeds/lib/src/repository/users_repository.dart index bb094244..e2cdb229 100644 --- a/packages/stream_feeds/lib/src/repository/users_repository.dart +++ b/packages/stream_feeds/lib/src/repository/users_repository.dart @@ -2,6 +2,7 @@ import 'package:stream_core/stream_core.dart'; import '../generated/api/api.dart' as api; import '../models/user_data.dart'; +import '../state/query/users_query.dart'; /// Repository for querying user data. /// @@ -17,13 +18,12 @@ class UsersRepository { // The API client used for making requests to the Stream Feeds service. final api.DefaultApi _api; - /// Queries users matching the given [payload]. + /// Queries users matching the given [query]. /// /// Returns a [Result] containing a list of [UserData] or an error. - Future>> queryUsers( - api.QueryUsersPayload payload, - ) async { - final result = await _api.queryUsers(payload: payload); + Future>> queryUsers(UsersQuery query) async { + final request = query.toRequest(); + final result = await _api.queryUsers(payload: request); return result.map( (response) => response.users.map((u) => u.toModel()).toList(), diff --git a/packages/stream_feeds/lib/src/state.dart b/packages/stream_feeds/lib/src/state.dart index 51664bd8..62aa4d34 100644 --- a/packages/stream_feeds/lib/src/state.dart +++ b/packages/stream_feeds/lib/src/state.dart @@ -49,3 +49,5 @@ export 'state/query/moderation_configs_query.dart'; export 'state/query/poll_votes_query.dart'; export 'state/query/polls_query.dart'; export 'state/query/users_query.dart'; +export 'state/user_list.dart'; +export 'state/user_list_state.dart'; diff --git a/packages/stream_feeds/lib/src/state/query/users_query.dart b/packages/stream_feeds/lib/src/state/query/users_query.dart index d5c40226..e4be3357 100644 --- a/packages/stream_feeds/lib/src/state/query/users_query.dart +++ b/packages/stream_feeds/lib/src/state/query/users_query.dart @@ -19,11 +19,11 @@ part 'users_query.freezed.dart'; /// /// ## Example /// ```dart -/// final query = UsersQuery( +/// final userList = client.userList(UsersQuery( /// filter: Filter.autoComplete(UsersFilterField.name, 'Al'), /// sort: [UsersSort.asc(UsersSortField.name)], /// limit: 25, -/// ); +/// )); /// ``` @freezed class UsersQuery with _$UsersQuery { @@ -32,10 +32,20 @@ class UsersQuery with _$UsersQuery { this.sort, this.limit, this.offset, - this.presence, this.includeDeactivatedUsers, }); + /// The maximum number of users the API returns in a single page. + /// + /// Requests with a higher [limit] are rejected. + static const maxLimit = 100; + + /// The highest [offset] the API accepts. + /// + /// Requests with a higher offset are rejected, which means offset pagination + /// cannot reach past this point. + static const maxOffset = 1000; + /// Optional filter criteria for this query. /// /// This filter can be a simple single filter or a complex combination of multiple filters @@ -48,31 +58,26 @@ class UsersQuery with _$UsersQuery { /// Array of sorting criteria for this query. /// - /// Specifies how users should be ordered in the response. - /// If not provided, the API will use its default sorting. - /// Multiple sort fields can be specified. + /// Specifies how users should be ordered in the response. At most five sort + /// criteria can be provided. If not specified, the API sorts by creation time, + /// newest first, matching [UsersSort.defaultSort]. @override final List? sort; /// The maximum number of users to return. - /// If not specified, the API will use its default limit. + /// + /// Defaults to 30 when not specified. Values above [maxLimit] are rejected. @override final int? limit; /// The number of users to skip before returning results. /// /// Combine with [limit] to page through results, for example an [offset] of - /// 25 with a [limit] of 25 returns the second page. + /// 25 with a [limit] of 25 returns the second page. Values above [maxOffset] + /// are rejected. @override final int? offset; - /// Whether to include online presence information for the returned users. - /// - /// When enabled, the `online` and `lastActive` fields of [UserData] reflect - /// the current presence of each user. - @override - final bool? presence; - /// Whether deactivated users should be included in the results. /// /// Deactivated users are excluded by default. @@ -105,7 +110,7 @@ class UsersFilterField extends FilterField { /// Filter by the creation timestamp of the user. /// - /// **Supported operators:** `.equal`, `.greaterThan`, `.lessThan`, `.greaterThanOrEqual`, `.lessThanOrEqual` + /// **Supported operators:** `.equal`, `.greater`, `.greaterOrEqual`, `.less`, `.lessOrEqual`, `.exists` static final createdAt = UsersFilterField( 'created_at', (data) => data.createdAt, @@ -113,7 +118,7 @@ class UsersFilterField extends FilterField { /// Filter by the unique identifier of the user. /// - /// **Supported operators:** `.equal`, `.in`, `.autoComplete` + /// **Supported operators:** `.equal`, `.in_`, `.greater`, `.greaterOrEqual`, `.less`, `.lessOrEqual`, `.exists`, `.autoComplete` static final id = UsersFilterField( 'id', (data) => data.id, @@ -121,7 +126,7 @@ class UsersFilterField extends FilterField { /// Filter by the timestamp the user was last active at. /// - /// **Supported operators:** `.equal`, `.greaterThan`, `.lessThan`, `.greaterThanOrEqual`, `.lessThanOrEqual` + /// **Supported operators:** `.equal`, `.greater`, `.greaterOrEqual`, `.less`, `.lessOrEqual`, `.exists` static final lastActive = UsersFilterField( 'last_active', (data) => data.lastActive, @@ -129,7 +134,9 @@ class UsersFilterField extends FilterField { /// Filter by the name of the user. /// - /// **Supported operators:** `.equal`, `.in`, `.autoComplete`, `.query` + /// **Supported operators:** `.equal`, `.in_`, `.autoComplete` + /// + /// Range comparisons and `.exists` are not supported on this field. static final name = UsersFilterField( 'name', (data) => data.name, @@ -137,7 +144,7 @@ class UsersFilterField extends FilterField { /// Filter by the role of the user. /// - /// **Supported operators:** `.equal`, `.in` + /// **Supported operators:** `.equal`, `.in_`, `.exists` static final role = UsersFilterField( 'role', (data) => data.role, @@ -145,7 +152,10 @@ class UsersFilterField extends FilterField { /// Filter by the teams the user belongs to. /// - /// **Supported operators:** `.equal`, `.in`, `.contains` + /// **Supported operators:** `.contains`, `.in_` + /// + /// `.equal` is only accepted with a `null` value, which matches users that + /// belong to no team. static final teams = UsersFilterField( 'teams', (data) => data.teams, @@ -153,7 +163,7 @@ class UsersFilterField extends FilterField { /// Filter by the last update timestamp of the user. /// - /// **Supported operators:** `.equal`, `.greaterThan`, `.lessThan`, `.greaterThanOrEqual`, `.lessThanOrEqual` + /// **Supported operators:** `.equal`, `.greater`, `.greaterOrEqual`, `.less`, `.lessOrEqual`, `.exists` static final updatedAt = UsersFilterField( 'updated_at', (data) => data.updatedAt, @@ -177,6 +187,15 @@ class UsersSort extends Sort { UsersSortField super.field, { super.nullOrdering = NullOrdering.nullsFirst, }) : super.desc(); + + /// Default sorting configuration for users. + /// + /// Matches the ordering the API applies when no sort is provided: the most + /// recently created users first, with the user id as a tie-breaker. + static final List defaultSort = [ + UsersSort.desc(UsersSortField.createdAt), + UsersSort.desc(UsersSortField.id), + ]; } /// Defines the fields by which users can be sorted. @@ -214,6 +233,15 @@ class UsersSortField extends SortField { (data) => data.name, ); + /// Sort by the role of the user. + /// + /// Unlike the other sort fields this one is not backed by a database index, + /// so sorting by it is slower and counts against the API budget of your app. + static final role = UsersSortField( + 'role', + (data) => data.role, + ); + /// Sort by the last update timestamp of the user. /// This field allows sorting users by when they were last updated (newest/oldest first). static final updatedAt = UsersSortField( @@ -235,7 +263,6 @@ extension UsersQueryRequest on UsersQuery { sort: sort?.map((s) => s.toRequest()).toList(), limit: limit, offset: offset, - presence: presence, includeDeactivatedUsers: includeDeactivatedUsers, ); } diff --git a/packages/stream_feeds/lib/src/state/query/users_query.freezed.dart b/packages/stream_feeds/lib/src/state/query/users_query.freezed.dart index 679e1a92..bc20960b 100644 --- a/packages/stream_feeds/lib/src/state/query/users_query.freezed.dart +++ b/packages/stream_feeds/lib/src/state/query/users_query.freezed.dart @@ -17,7 +17,6 @@ mixin _$UsersQuery { List? get sort; int? get limit; int? get offset; - bool? get presence; bool? get includeDeactivatedUsers; /// Create a copy of UsersQuery @@ -35,7 +34,6 @@ mixin _$UsersQuery { const DeepCollectionEquality().equals(other.sort, sort) && (identical(other.limit, limit) || other.limit == limit) && (identical(other.offset, offset) || other.offset == offset) && - (identical(other.presence, presence) || other.presence == presence) && (identical( other.includeDeactivatedUsers, includeDeactivatedUsers, @@ -50,13 +48,12 @@ mixin _$UsersQuery { const DeepCollectionEquality().hash(sort), limit, offset, - presence, includeDeactivatedUsers, ); @override String toString() { - return 'UsersQuery(filter: $filter, sort: $sort, limit: $limit, offset: $offset, presence: $presence, includeDeactivatedUsers: $includeDeactivatedUsers)'; + return 'UsersQuery(filter: $filter, sort: $sort, limit: $limit, offset: $offset, includeDeactivatedUsers: $includeDeactivatedUsers)'; } } @@ -72,7 +69,6 @@ abstract mixin class $UsersQueryCopyWith<$Res> { List? sort, int? limit, int? offset, - bool? presence, bool? includeDeactivatedUsers, }); } @@ -93,7 +89,6 @@ class _$UsersQueryCopyWithImpl<$Res> implements $UsersQueryCopyWith<$Res> { Object? sort = freezed, Object? limit = freezed, Object? offset = freezed, - Object? presence = freezed, Object? includeDeactivatedUsers = freezed, }) { return _then( @@ -114,10 +109,6 @@ class _$UsersQueryCopyWithImpl<$Res> implements $UsersQueryCopyWith<$Res> { ? _self.offset : offset // ignore: cast_nullable_to_non_nullable as int?, - presence: freezed == presence - ? _self.presence - : presence // ignore: cast_nullable_to_non_nullable - as bool?, includeDeactivatedUsers: freezed == includeDeactivatedUsers ? _self.includeDeactivatedUsers : includeDeactivatedUsers // ignore: cast_nullable_to_non_nullable diff --git a/packages/stream_feeds/lib/src/state/user_list.dart b/packages/stream_feeds/lib/src/state/user_list.dart new file mode 100644 index 00000000..b85a653a --- /dev/null +++ b/packages/stream_feeds/lib/src/state/user_list.dart @@ -0,0 +1,95 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:state_notifier/state_notifier.dart'; +import 'package:stream_core/stream_core.dart'; + +import '../models/query_configuration.dart'; +import '../models/user_data.dart'; +import '../repository/users_repository.dart'; +import 'query/users_query.dart'; +import 'state_notifier_extension.dart'; +import 'user_list_state.dart'; + +/// Represents a list of users with a query and state. +/// +/// The primary interface for working with user lists in the Stream Feeds SDK that provides +/// functionality for querying and managing collections of users with pagination support. +/// +/// Each user list instance maintains its own state that can be observed for updates. +/// The user list state includes the users fetched so far and pagination information. +/// +/// Users are paginated by offset rather than by cursor, because the users +/// endpoint does not return page cursors. +class UserList extends Disposable { + UserList({ + required this.query, + required this.usersRepository, + }) { + _stateNotifier = UserListStateNotifier( + initialState: const UserListState(), + ); + } + + final UsersQuery query; + final UsersRepository usersRepository; + + UserListState get state => stateNotifier.value; + StateNotifier get notifier => stateNotifier; + Stream get stream => stateNotifier.stream; + + @internal + UserListStateNotifier get stateNotifier => _stateNotifier; + late final UserListStateNotifier _stateNotifier; + + /// Queries the initial list of users based on the provided [UsersQuery]. + /// + /// Returns a [Result] containing a list of [UserData] or an error. + Future>> get() => _queryUsers(query); + + /// Queries more users based on the current pagination state. + /// + /// If there are no more users available, it returns an empty list. + /// + /// Optionally accepts a [limit] parameter to specify the maximum number of + /// users to return. + Future>> queryMoreUsers({int? limit}) async { + // Build the query with the current pagination state (with next offset) + final nextOffset = _stateNotifier.value.nextOffset; + + // Early return if no more users available + if (nextOffset == null) return const Result.success([]); + + // Create a new query starting at the next page offset + final nextQuery = query.copyWith( + offset: nextOffset, + limit: limit ?? query.limit, + ); + + return _queryUsers(nextQuery); + } + + // Internal method to query users and update state. + Future>> _queryUsers(UsersQuery query) async { + final result = await usersRepository.queryUsers(query); + + result.onSuccess( + (users) { + _stateNotifier.onQueryMoreUsers( + users, + QueryConfiguration( + filter: query.filter, + sort: query.sort ?? UsersSort.defaultSort, + ), + offset: query.offset ?? 0, + ); + }, + ); + + return result; + } + + @override + void dispose() { + _stateNotifier.dispose(); + super.dispose(); + } +} diff --git a/packages/stream_feeds/lib/src/state/user_list_state.dart b/packages/stream_feeds/lib/src/state/user_list_state.dart new file mode 100644 index 00000000..eea7e766 --- /dev/null +++ b/packages/stream_feeds/lib/src/state/user_list_state.dart @@ -0,0 +1,108 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:state_notifier/state_notifier.dart'; +import 'package:stream_core/stream_core.dart'; + +import '../models/query_configuration.dart'; +import '../models/user_data.dart'; +import 'query/users_query.dart'; + +part 'user_list_state.freezed.dart'; + +/// Manages the state of a user list and handles state updates. +/// +/// Provides methods to update the user list state in response to data changes +/// from the Stream Feeds API. +class UserListStateNotifier extends StateNotifier { + UserListStateNotifier({ + required UserListState initialState, + }) : super(initialState); + + QueryConfiguration? _queryConfig; + List> get usersSort { + return _queryConfig?.sort ?? UsersSort.defaultSort; + } + + /// Handles the result of a query for more users. + /// + /// [offset] is the offset that was sent with the request which produced + /// [users]. The users endpoint returns no page cursors, so the offset of the + /// next page is derived from the number of users the server returned. + void onQueryMoreUsers( + List users, + QueryConfiguration queryConfig, { + required int offset, + }) { + _queryConfig = queryConfig; + + // Merge the new users with the existing ones (keeping the sort order) + final updatedUsers = state.users.merge( + users, + key: (it) => it.id, + compare: usersSort.compare, + ); + + state = state.copyWith( + users: updatedUsers, + nextOffset: _nextOffset(offset: offset, pageSize: users.length), + ); + } + + // Computes the offset of the next page, or null when there is nothing more + // to load. + // + // The offset advances by the number of users the server returned rather than + // by the length of the merged list, so that pages overlapping on a user id do + // not shift the position in the result set. + static int? _nextOffset({required int offset, required int pageSize}) { + // An empty page is the only signal that the end was reached. + if (pageSize == 0) return null; + + final nextOffset = offset + pageSize; + + // The API rejects offsets above the maximum, so stop instead of issuing a + // request that is guaranteed to fail. Deliberately not clamped: requesting + // the maximum again would return a non-empty page forever. + if (nextOffset > UsersQuery.maxOffset) return null; + + return nextOffset; + } +} + +/// An observable state object that manages the current state of a user list. +/// +/// Maintains the currently loaded users and the offset needed to load the next +/// page of results. +@freezed +class UserListState with _$UserListState { + /// Creates a new [UserListState] instance. + const UserListState({ + this.users = const [], + this.nextOffset, + }); + + /// All the paginated users currently loaded. + /// + /// Contains every user fetched across pagination requests, ordered by the + /// sorting configuration of the query. + @override + final List users; + + /// The offset to request for the next page of users, or `null` when there are + /// no more users to load. + /// + /// Unlike most other collections in this SDK, users are paginated with + /// `limit`/`offset` instead of cursors, because the users endpoint does not + /// return page cursors. + /// + /// This is `null` before the first query, once a page comes back empty, and + /// once the next page would exceed [UsersQuery.maxOffset]. + @override + final int? nextOffset; + + /// Whether there are more users available to load. + /// + /// Because the endpoint reports no total count, a full last page still counts + /// as loadable: the end is only known once a page comes back empty. Expect a + /// final request that returns no users. + bool get canLoadMore => nextOffset != null; +} diff --git a/packages/stream_feeds/lib/src/state/user_list_state.freezed.dart b/packages/stream_feeds/lib/src/state/user_list_state.freezed.dart new file mode 100644 index 00000000..434488ce --- /dev/null +++ b/packages/stream_feeds/lib/src/state/user_list_state.freezed.dart @@ -0,0 +1,85 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'user_list_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$UserListState { + List get users; + int? get nextOffset; + + /// Create a copy of UserListState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $UserListStateCopyWith get copyWith => _$UserListStateCopyWithImpl( + this as UserListState, + _$identity, + ); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is UserListState && + const DeepCollectionEquality().equals(other.users, users) && + (identical(other.nextOffset, nextOffset) || other.nextOffset == nextOffset)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(users), + nextOffset, + ); + + @override + String toString() { + return 'UserListState(users: $users, nextOffset: $nextOffset)'; + } +} + +/// @nodoc +abstract mixin class $UserListStateCopyWith<$Res> { + factory $UserListStateCopyWith( + UserListState value, + $Res Function(UserListState) _then, + ) = _$UserListStateCopyWithImpl; + @useResult + $Res call({List users, int? nextOffset}); +} + +/// @nodoc +class _$UserListStateCopyWithImpl<$Res> implements $UserListStateCopyWith<$Res> { + _$UserListStateCopyWithImpl(this._self, this._then); + + final UserListState _self; + final $Res Function(UserListState) _then; + + /// Create a copy of UserListState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? users = null, Object? nextOffset = freezed}) { + return _then( + UserListState( + users: null == users + ? _self.users + : users // ignore: cast_nullable_to_non_nullable + as List, + nextOffset: freezed == nextOffset + ? _self.nextOffset + : nextOffset // ignore: cast_nullable_to_non_nullable + as int?, + ), + ); + } +} diff --git a/packages/stream_feeds/test/client/feeds_client_test.dart b/packages/stream_feeds/test/client/feeds_client_test.dart index 6e55b0d5..f1c877e4 100644 --- a/packages/stream_feeds/test/client/feeds_client_test.dart +++ b/packages/stream_feeds/test/client/feeds_client_test.dart @@ -935,134 +935,4 @@ void main() { }, ); }); - - // ============================================================ - // FEATURE: User Querying - // ============================================================ - - group('queryUsers', () { - feedsClientTest( - 'should query users successfully', - body: (tester) async { - final query = UsersQuery( - filter: Filter.autoComplete(UsersFilterField.name, 'Al'), - sort: [UsersSort.asc(UsersSortField.name)], - limit: 25, - ); - - const payload = QueryUsersPayload( - filterConditions: { - 'name': {r'$autocomplete': 'Al'}, - }, - sort: [SortParamRequest(field: 'name', direction: 1)], - limit: 25, - ); - - tester.mockApi( - (api) => api.queryUsers(payload: payload), - result: createDefaultQueryUsersResponse( - users: [ - createDefaultFullUserResponse(name: 'Alice'), - createDefaultFullUserResponse(id: 'user-2', name: 'Alan'), - ], - ), - ); - - final result = await tester.client.queryUsers(query); - - expect(result.isSuccess, isTrue); - final users = result.getOrThrow(); - expect(users.length, equals(2)); - expect(users[0].id, equals('user-1')); - expect(users[1].id, equals('user-2')); - - tester.verifyApi((api) => api.queryUsers(payload: payload)); - }, - ); - - feedsClientTest( - 'should query users without a filter', - body: (tester) async { - const payload = QueryUsersPayload(filterConditions: {}); - - tester.mockApi( - (api) => api.queryUsers(payload: payload), - result: createDefaultQueryUsersResponse( - users: [createDefaultFullUserResponse()], - ), - ); - - final result = await tester.client.queryUsers(const UsersQuery()); - - expect(result.isSuccess, isTrue); - expect(result.getOrThrow().single.id, equals('user-1')); - - tester.verifyApi((api) => api.queryUsers(payload: payload)); - }, - ); - - feedsClientTest( - 'should forward pagination and presence options', - body: (tester) async { - final query = UsersQuery( - filter: Filter.in_(UsersFilterField.teams, const ['support']), - limit: 10, - offset: 20, - presence: true, - includeDeactivatedUsers: true, - ); - - const payload = QueryUsersPayload( - filterConditions: { - 'teams': { - r'$in': ['support'], - }, - }, - limit: 10, - offset: 20, - presence: true, - includeDeactivatedUsers: true, - ); - - tester.mockApi( - (api) => api.queryUsers(payload: payload), - result: createDefaultQueryUsersResponse( - users: [createDefaultFullUserResponse()], - ), - ); - - final result = await tester.client.queryUsers(query); - - expect(result.isSuccess, isTrue); - - tester.verifyApi((api) => api.queryUsers(payload: payload)); - }, - ); - - feedsClientTest( - 'should handle queryUsers failure', - body: (tester) async { - final query = UsersQuery( - filter: Filter.equal(UsersFilterField.id, 'bad'), - ); - - const payload = QueryUsersPayload( - filterConditions: { - 'id': {r'$eq': 'bad'}, - }, - ); - - tester.mockApiFailure( - (api) => api.queryUsers(payload: payload), - error: Exception('Failed to query users'), - ); - - final result = await tester.client.queryUsers(query); - - expect(result.isFailure, isTrue); - - tester.verifyApi((api) => api.queryUsers(payload: payload)); - }, - ); - }); } diff --git a/packages/stream_feeds/test/state/user_list_test.dart b/packages/stream_feeds/test/state/user_list_test.dart new file mode 100644 index 00000000..29eba2d6 --- /dev/null +++ b/packages/stream_feeds/test/state/user_list_test.dart @@ -0,0 +1,322 @@ +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:stream_feeds_test/stream_feeds_test.dart'; + +void main() { + // ============================================================ + // FEATURE: Query Operations + // ============================================================ + + group('User List - Query Operations', () { + const query = UsersQuery(); + + userListTest( + 'get - should query initial users via API', + build: (client) => client.userList(query), + body: (tester) async { + final result = await tester.get(); + + expect(result, isA>>()); + final users = result.getOrThrow(); + + expect(users, hasLength(3)); + + // All fixtures share a createdAt, so the default sort + // (createdAt desc, id desc) orders them by id descending. + expect( + tester.userListState.users.map((it) => it.id), + ['user-3', 'user-2', 'user-1'], + ); + expect(tester.userListState.nextOffset, 3); + expect(tester.userListState.canLoadMore, isTrue); + }, + ); + + userListTest( + 'canLoadMore - should be false before the first query', + build: (client) => client.userList(query), + body: (tester) async { + expect(tester.userListState.users, isEmpty); + expect(tester.userListState.nextOffset, isNull); + expect(tester.userListState.canLoadMore, isFalse); + + // Querying more without an initial query returns empty and hits no API + final result = await tester.userList.queryMoreUsers(); + + expect(result.isSuccess, isTrue); + expect(result.getOrThrow(), isEmpty); + + tester.verifyNeverCalled( + (api) => api.queryUsers(payload: query.toRequest()), + ); + }, + ); + + userListTest( + 'queryMoreUsers - should load more users via API', + build: (client) => client.userList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + users: [createDefaultFullUserResponse()], + ), + ), + body: (tester) async { + // Initial state - one user loaded, next page starts at offset 1 + expect(tester.userListState.users, hasLength(1)); + expect(tester.userListState.nextOffset, 1); + + final nextPageQuery = tester.userList.query.copyWith(offset: 1); + + tester.mockApi( + (api) => api.queryUsers(payload: nextPageQuery.toRequest()), + result: createDefaultQueryUsersResponse( + users: [createDefaultFullUserResponse(id: 'user-2')], + ), + ); + + final result = await tester.userList.queryMoreUsers(); + + expect(result.isSuccess, isTrue); + expect(result.getOrThrow(), hasLength(1)); + + // Verify state was updated with merged users + expect(tester.userListState.users, hasLength(2)); + expect(tester.userListState.nextOffset, 2); + expect(tester.userListState.canLoadMore, isTrue); + + tester.verifyApi( + (api) => api.queryUsers(payload: nextPageQuery.toRequest()), + ); + }, + ); + + userListTest( + 'queryMoreUsers - should continue from the offset of the query', + build: (client) => client.userList(const UsersQuery(offset: 10)), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + users: [ + createDefaultFullUserResponse(), + createDefaultFullUserResponse(id: 'user-2'), + ], + ), + ), + body: (tester) async { + // Starting offset of 10 plus the two returned users + expect(tester.userListState.nextOffset, 12); + + final nextPageQuery = tester.userList.query.copyWith(offset: 12); + + tester.mockApi( + (api) => api.queryUsers(payload: nextPageQuery.toRequest()), + result: createDefaultQueryUsersResponse( + users: [createDefaultFullUserResponse(id: 'user-3')], + ), + ); + + await tester.userList.queryMoreUsers(); + + tester.verifyApi( + (api) => api.queryUsers(payload: nextPageQuery.toRequest()), + ); + }, + ); + + userListTest( + 'queryMoreUsers - should advance the offset by the returned page size', + build: (client) => client.userList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + users: [createDefaultFullUserResponse(name: 'Luke')], + ), + ), + body: (tester) async { + final nextPageQuery = tester.userList.query.copyWith(offset: 1); + + // The next page repeats user-1 with updated data, so the merged list + // grows by one while the server returned two users. + tester.mockApi( + (api) => api.queryUsers(payload: nextPageQuery.toRequest()), + result: createDefaultQueryUsersResponse( + users: [ + createDefaultFullUserResponse(name: 'Luke Skywalker'), + createDefaultFullUserResponse(id: 'user-2'), + ], + ), + ); + + await tester.userList.queryMoreUsers(); + + expect(tester.userListState.users, hasLength(2)); + expect( + tester.userListState.users.firstWhere((it) => it.id == 'user-1').name, + 'Luke Skywalker', + ); + + // Offset follows the server page size (1 + 2), not the merged length (2) + expect(tester.userListState.nextOffset, 3); + }, + ); + + userListTest( + 'queryMoreUsers - should stop when a page comes back empty', + build: (client) => client.userList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + users: [createDefaultFullUserResponse()], + ), + ), + body: (tester) async { + final nextPageQuery = tester.userList.query.copyWith(offset: 1); + + tester.mockApi( + (api) => api.queryUsers(payload: nextPageQuery.toRequest()), + result: createDefaultQueryUsersResponse(users: const []), + ); + + final result = await tester.userList.queryMoreUsers(); + + expect(result.isSuccess, isTrue); + expect(result.getOrThrow(), isEmpty); + + // State is unchanged and pagination has stopped + expect(tester.userListState.users, hasLength(1)); + expect(tester.userListState.nextOffset, isNull); + expect(tester.userListState.canLoadMore, isFalse); + + // A second call short-circuits instead of issuing another request + final again = await tester.userList.queryMoreUsers(); + + expect(again.getOrThrow(), isEmpty); + tester.verifyApiCalled( + (api) => api.queryUsers(payload: nextPageQuery.toRequest()), + times: 1, + ); + }, + ); + + userListTest( + 'queryMoreUsers - should forward the limit override', + build: (client) => client.userList(const UsersQuery(limit: 2)), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + users: [createDefaultFullUserResponse()], + ), + ), + body: (tester) async { + final nextPageQuery = tester.userList.query.copyWith( + offset: 1, + limit: 5, + ); + + tester.mockApi( + (api) => api.queryUsers(payload: nextPageQuery.toRequest()), + result: createDefaultQueryUsersResponse( + users: [createDefaultFullUserResponse(id: 'user-2')], + ), + ); + + await tester.userList.queryMoreUsers(limit: 5); + + tester.verifyApi( + (api) => api.queryUsers(payload: nextPageQuery.toRequest()), + ); + }, + ); + + userListTest( + 'queryMoreUsers - should stop at the maximum offset the API accepts', + build: (client) => client.userList( + const UsersQuery(limit: 100, offset: 950), + ), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + users: List.generate( + 100, + (index) => createDefaultFullUserResponse(id: 'user-$index'), + ), + ), + ), + body: (tester) async { + expect(tester.userListState.users, hasLength(100)); + + // 950 + 100 exceeds UsersQuery.maxOffset, so pagination stops rather + // than issuing a request the API would reject. + expect(tester.userListState.nextOffset, isNull); + expect(tester.userListState.canLoadMore, isFalse); + + final result = await tester.userList.queryMoreUsers(); + + expect(result.getOrThrow(), isEmpty); + }, + ); + + userListTest( + 'queryMoreUsers - should still load the page at the maximum offset', + build: (client) => client.userList( + const UsersQuery(limit: 100, offset: 900), + ), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + users: List.generate( + 100, + (index) => createDefaultFullUserResponse(id: 'user-$index'), + ), + ), + ), + body: (tester) { + // Exactly at the cap, so the next page is still reachable + expect(tester.userListState.nextOffset, UsersQuery.maxOffset); + expect(tester.userListState.canLoadMore, isTrue); + }, + ); + + userListTest( + 'get - should keep merged users in the order requested by the sort', + build: (client) => client.userList( + UsersQuery(sort: [UsersSort.asc(UsersSortField.name)], limit: 1), + ), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + users: [createDefaultFullUserResponse(id: 'user-c', name: 'Charlie')], + ), + ), + body: (tester) async { + final nextPageQuery = tester.userList.query.copyWith(offset: 1); + + tester.mockApi( + (api) => api.queryUsers(payload: nextPageQuery.toRequest()), + result: createDefaultQueryUsersResponse( + users: [createDefaultFullUserResponse(id: 'user-a', name: 'Alice')], + ), + ); + + await tester.userList.queryMoreUsers(); + + expect( + tester.userListState.users.map((it) => it.name), + ['Alice', 'Charlie'], + ); + }, + ); + + userListTest( + 'get - should handle failure', + build: (client) => client.userList(query), + body: (tester) async { + tester.mockApiFailure( + (api) => api.queryUsers(payload: query.toRequest()), + error: Exception('Failed to query users'), + ); + + final result = await tester.userList.get(); + + expect(result.isFailure, isTrue); + + // State is untouched on failure + expect(tester.userListState.users, isEmpty); + expect(tester.userListState.nextOffset, isNull); + expect(tester.userListState.canLoadMore, isFalse); + }, + ); + }); +} diff --git a/packages/stream_feeds_test/lib/src/testers/state/user_list_tester.dart b/packages/stream_feeds_test/lib/src/testers/state/user_list_tester.dart new file mode 100644 index 00000000..7160207a --- /dev/null +++ b/packages/stream_feeds_test/lib/src/testers/state/user_list_tester.dart @@ -0,0 +1,146 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:test/test.dart' as test; + +import '../../helpers/mocks.dart'; +import '../../helpers/test_data.dart'; +import '../base_tester.dart'; + +/// Test helper for user list operations. +/// +/// Automatically sets up WebSocket connection, client, and test infrastructure. +/// Tests are tagged with 'user-list' by default for filtering. +/// +/// [user] is optional, the user for whom the client is configured (defaults to luke_skywalker). +/// [build] constructs the [UserList] under test using the provided [StreamFeedsClient]. +/// [connect] is optional, custom connection logic (defaults to successful auth + connect). +/// [setUp] is optional and runs before [body] for setting up mocks and test state. +/// [body] is the test callback that receives a [UserListTester] for interactions. +/// [verify] is optional and runs after [body] for verifying API calls and interactions. +/// [tearDown] is optional and runs after [verify] for cleanup operations. +/// [skip] is optional, skip this test. +/// [tags] is optional, tags for test filtering. Defaults to ['user-list']. +/// [timeout] is optional, custom timeout for this test. +/// +/// Example: +/// ```dart +/// userListTest( +/// 'should query initial users', +/// build: (client) => client.userList(const UsersQuery()), +/// setUp: (tester) => tester.get(), +/// body: (tester) async { +/// expect(tester.userListState.users, hasLength(3)); +/// }, +/// ); +/// ``` +@isTest +void userListTest( + String description, { + User user = const User(id: 'luke_skywalker'), + required UserList Function(StreamFeedsClient client) build, + FutureOr Function(UserListTester tester)? connect, + FutureOr Function(UserListTester tester)? setUp, + required FutureOr Function(UserListTester tester) body, + FutureOr Function(UserListTester tester)? verify, + FutureOr Function(UserListTester tester)? tearDown, + bool skip = false, + Iterable tags = const ['user-list'], + test.Timeout? timeout, +}) { + return testWithTester( + description, + user: user, + build: build, + createTesterFn: _createUserListTester, + connect: connect, + setUp: setUp, + body: body, + verify: verify, + tearDown: tearDown, + skip: skip, + tags: tags, + timeout: timeout, + ); +} + +/// A test utility for user list operations. +/// +/// Provides helper methods for querying users and verifying user list state. +/// +/// Resources are automatically cleaned up after the test completes. +final class UserListTester extends BaseTester { + const UserListTester._({ + required UserList userList, + required super.client, + required super.wsTester, + required super.feedsApi, + required super.cdnApi, + }) : super(subject: userList); + + /// The user list being tested. + UserList get userList => subject; + + /// Current state of the user list. + UserListState get userListState => userList.state; + + /// Stream of user list state updates. + Stream get userListStateStream => userList.stream; + + /// Gets the user list by fetching it from the API. + /// + /// Call this to set up initial state before querying more users. + /// + /// Parameters: + /// - [modifyResponse]: Optional function to customize the user list response + Future>> get({ + QueryUsersResponse Function(QueryUsersResponse)? modifyResponse, + }) { + final query = userList.query; + + final defaultUserListResponse = createDefaultQueryUsersResponse( + users: [ + createDefaultFullUserResponse(name: 'Luke'), + createDefaultFullUserResponse(id: 'user-2', name: 'Leia'), + createDefaultFullUserResponse(id: 'user-3', name: 'Han'), + ], + ); + + mockApi( + (api) => api.queryUsers(payload: query.toRequest()), + result: switch (modifyResponse) { + final modifier? => modifier(defaultUserListResponse), + _ => defaultUserListResponse, + }, + ); + + return userList.get(); + } +} + +// Creates a UserListTester for testing user list operations. +// +// Automatically sets up WebSocket connection and registers cleanup handlers. +// This function is for internal use by userListTest only. +Future _createUserListTester({ + required UserList subject, + required StreamFeedsClient client, + required MockCdnApi cdnApi, + required MockDefaultApi feedsApi, + required MockWebSocketChannel webSocketChannel, +}) { + // Dispose user list after test + test.addTearDown(subject.dispose); + + return createTester( + webSocketChannel: webSocketChannel, + create: (wsTester) => UserListTester._( + userList: subject, + client: client, + wsTester: wsTester, + cdnApi: cdnApi, + feedsApi: feedsApi, + ), + ); +} diff --git a/packages/stream_feeds_test/lib/stream_feeds_test.dart b/packages/stream_feeds_test/lib/stream_feeds_test.dart index 0c6cf6c6..c7c160e4 100644 --- a/packages/stream_feeds_test/lib/stream_feeds_test.dart +++ b/packages/stream_feeds_test/lib/stream_feeds_test.dart @@ -42,4 +42,5 @@ export 'src/testers/state/member_list_tester.dart'; export 'src/testers/state/moderation_config_list_tester.dart'; export 'src/testers/state/poll_list_tester.dart'; export 'src/testers/state/poll_vote_list_tester.dart'; +export 'src/testers/state/user_list_tester.dart'; export 'src/testers/websocket_tester.dart'; From 2a7f700383860fcf505d7b2a0ac9cf3d45761e28 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 18 Aug 2026 15:53:01 +0200 Subject: [PATCH 7/7] Add assertions and tests --- packages/stream_feeds/CHANGELOG.md | 2 +- .../lib/src/state/query/users_query.dart | 14 +- .../stream_feeds/lib/src/state/user_list.dart | 1 + .../lib/src/state/user_list_state.dart | 38 ++- .../test/state/query/users_query_test.dart | 238 ++++++++++++++++++ .../test/state/user_list_test.dart | 36 ++- 6 files changed, 311 insertions(+), 18 deletions(-) create mode 100644 packages/stream_feeds/test/state/query/users_query_test.dart diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index 316e47c8..acd98854 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -1,7 +1,7 @@ ## Upcoming ### New methods -- Added `userList` to `StreamFeedsClient`, returning a `UserList` state object for querying users. Takes a `UsersQuery` with type-safe `UsersFilterField`/`UsersSortField` filtering and sorting, plus an `includeDeactivatedUsers` option. Users are paginated with `limit`/`offset` (`UserListState.nextOffset` / `canLoadMore`) because the users endpoint returns no page cursors. +- Added `userList` to `StreamFeedsClient` for querying users. ### New fields - Added `isRead` and `isSeen` fields to `ActivityData` and `AggregatedActivityData` for notification-feed read/seen state. diff --git a/packages/stream_feeds/lib/src/state/query/users_query.dart b/packages/stream_feeds/lib/src/state/query/users_query.dart index e4be3357..2209439c 100644 --- a/packages/stream_feeds/lib/src/state/query/users_query.dart +++ b/packages/stream_feeds/lib/src/state/query/users_query.dart @@ -33,7 +33,14 @@ class UsersQuery with _$UsersQuery { this.limit, this.offset, this.includeDeactivatedUsers, - }); + }) : assert( + limit == null || (limit > 0 && limit <= maxLimit), + 'limit must be between 1 and $maxLimit', + ), + assert( + offset == null || (offset >= 0 && offset <= maxOffset), + 'offset must be between 0 and $maxOffset', + ); /// The maximum number of users the API returns in a single page. /// @@ -66,7 +73,8 @@ class UsersQuery with _$UsersQuery { /// The maximum number of users to return. /// - /// Defaults to 30 when not specified. Values above [maxLimit] are rejected. + /// Defaults to 30 when not specified. Values above [maxLimit] are rejected by + /// the API and trip an assertion in debug builds. @override final int? limit; @@ -74,7 +82,7 @@ class UsersQuery with _$UsersQuery { /// /// Combine with [limit] to page through results, for example an [offset] of /// 25 with a [limit] of 25 returns the second page. Values above [maxOffset] - /// are rejected. + /// are rejected by the API and trip an assertion in debug builds. @override final int? offset; diff --git a/packages/stream_feeds/lib/src/state/user_list.dart b/packages/stream_feeds/lib/src/state/user_list.dart index b85a653a..3a247b90 100644 --- a/packages/stream_feeds/lib/src/state/user_list.dart +++ b/packages/stream_feeds/lib/src/state/user_list.dart @@ -80,6 +80,7 @@ class UserList extends Disposable { sort: query.sort ?? UsersSort.defaultSort, ), offset: query.offset ?? 0, + limit: query.limit, ); }, ); diff --git a/packages/stream_feeds/lib/src/state/user_list_state.dart b/packages/stream_feeds/lib/src/state/user_list_state.dart index eea7e766..cf73a9e8 100644 --- a/packages/stream_feeds/lib/src/state/user_list_state.dart +++ b/packages/stream_feeds/lib/src/state/user_list_state.dart @@ -24,13 +24,15 @@ class UserListStateNotifier extends StateNotifier { /// Handles the result of a query for more users. /// - /// [offset] is the offset that was sent with the request which produced - /// [users]. The users endpoint returns no page cursors, so the offset of the - /// next page is derived from the number of users the server returned. + /// [offset] and [limit] are the pagination parameters that were sent with the + /// request which produced [users]. The users endpoint returns no page + /// cursors, so the offset of the next page is derived from the number of + /// users the server returned. void onQueryMoreUsers( List users, QueryConfiguration queryConfig, { required int offset, + int? limit, }) { _queryConfig = queryConfig; @@ -43,7 +45,11 @@ class UserListStateNotifier extends StateNotifier { state = state.copyWith( users: updatedUsers, - nextOffset: _nextOffset(offset: offset, pageSize: users.length), + nextOffset: _nextOffset( + offset: offset, + pageSize: users.length, + limit: limit, + ), ); } @@ -53,10 +59,19 @@ class UserListStateNotifier extends StateNotifier { // The offset advances by the number of users the server returned rather than // by the length of the merged list, so that pages overlapping on a user id do // not shift the position in the result set. - static int? _nextOffset({required int offset, required int pageSize}) { - // An empty page is the only signal that the end was reached. + static int? _nextOffset({ + required int offset, + required int pageSize, + int? limit, + }) { + // An empty page always means the end was reached. if (pageSize == 0) return null; + // So does a page shorter than the one that was requested, but only when the + // query specified a limit. Without one the server applies its own default, + // which the SDK does not know. + if (limit != null && pageSize < limit) return null; + final nextOffset = offset + pageSize; // The API rejects offsets above the maximum, so stop instead of issuing a @@ -94,15 +109,16 @@ class UserListState with _$UserListState { /// `limit`/`offset` instead of cursors, because the users endpoint does not /// return page cursors. /// - /// This is `null` before the first query, once a page comes back empty, and - /// once the next page would exceed [UsersQuery.maxOffset]. + /// This is `null` before the first query, once the last page has been + /// reached, and once the next page would exceed [UsersQuery.maxOffset]. @override final int? nextOffset; /// Whether there are more users available to load. /// - /// Because the endpoint reports no total count, a full last page still counts - /// as loadable: the end is only known once a page comes back empty. Expect a - /// final request that returns no users. + /// Because the endpoint reports no total count, the end is only known once a + /// page comes back shorter than the requested [UsersQuery.limit], or empty + /// when the query specified no limit. A query without a limit therefore ends + /// with a final request that returns no users. bool get canLoadMore => nextOffset != null; } diff --git a/packages/stream_feeds/test/state/query/users_query_test.dart b/packages/stream_feeds/test/state/query/users_query_test.dart new file mode 100644 index 00000000..c463c919 --- /dev/null +++ b/packages/stream_feeds/test/state/query/users_query_test.dart @@ -0,0 +1,238 @@ +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:stream_feeds_test/stream_feeds_test.dart'; + +void main() { + // Distinct values for every filterable and sortable property, so a field + // wired to the wrong property cannot accidentally still match. + final user = UserData( + banned: true, + createdAt: DateTime.utc(2024), + id: 'user-1', + lastActive: DateTime.utc(2024, 3, 3), + name: 'Luke', + online: false, + role: 'admin', + teams: const ['red'], + updatedAt: DateTime.utc(2024, 2, 2), + ); + + group('Users Query - Filter Fields', () { + // Each case pairs a filter that should match [user] with one that should + // not, which pins down both the remote field name sent to the API and the + // local property the field reads. + final cases = + < + ({ + String remote, + UsersFilter matching, + UsersFilter notMatching, + }) + >[ + ( + remote: 'banned', + matching: Filter.equal(UsersFilterField.banned, true), + notMatching: Filter.equal(UsersFilterField.banned, false), + ), + ( + remote: 'created_at', + matching: Filter.equal(UsersFilterField.createdAt, user.createdAt), + notMatching: Filter.equal(UsersFilterField.createdAt, user.updatedAt), + ), + ( + remote: 'id', + matching: Filter.in_(UsersFilterField.id, const ['user-1', 'user-2']), + notMatching: Filter.equal(UsersFilterField.id, 'Luke'), + ), + ( + remote: 'last_active', + matching: Filter.greater(UsersFilterField.lastActive, user.updatedAt), + notMatching: Filter.less(UsersFilterField.lastActive, user.updatedAt), + ), + ( + remote: 'name', + matching: Filter.autoComplete(UsersFilterField.name, 'Lu'), + notMatching: Filter.equal(UsersFilterField.name, 'user-1'), + ), + ( + remote: 'role', + matching: Filter.equal(UsersFilterField.role, 'admin'), + notMatching: Filter.equal(UsersFilterField.role, 'user'), + ), + ( + remote: 'teams', + matching: Filter.contains(UsersFilterField.teams, 'red'), + notMatching: Filter.contains(UsersFilterField.teams, 'blue'), + ), + ( + remote: 'updated_at', + matching: Filter.equal(UsersFilterField.updatedAt, user.updatedAt), + notMatching: Filter.equal(UsersFilterField.updatedAt, user.createdAt), + ), + ]; + + for (final testCase in cases) { + test('${testCase.remote} - should read the matching user property', () { + expect(user.matches(testCase.matching), isTrue); + expect(user.matches(testCase.notMatching), isFalse); + }); + + test('${testCase.remote} - should serialize to the API field name', () { + expect(testCase.matching.toJson().keys, [testCase.remote]); + }); + } + + test('should match a user against a combination of filters', () { + final filter = Filter.and([ + Filter.equal(UsersFilterField.role, 'admin'), + Filter.contains(UsersFilterField.teams, 'red'), + ]); + + expect(user.matches(filter), isTrue); + expect(user.copyWith(role: 'user').matches(filter), isFalse); + }); + }); + + group('Users Query - Sort Fields', () { + // Each case holds two users that differ only in the sorted property, so a + // field reading the wrong property compares them as equal. + final cases = <({String remote, UsersSortField field, UserData other})>[ + ( + remote: 'created_at', + field: UsersSortField.createdAt, + other: user.copyWith(createdAt: DateTime.utc(2025)), + ), + ( + remote: 'id', + field: UsersSortField.id, + other: user.copyWith(id: 'user-2'), + ), + ( + remote: 'last_active', + field: UsersSortField.lastActive, + other: user.copyWith(lastActive: DateTime.utc(2025, 3, 3)), + ), + ( + remote: 'name', + field: UsersSortField.name, + other: user.copyWith(name: 'Rey'), + ), + ( + remote: 'role', + field: UsersSortField.role, + other: user.copyWith(role: 'user'), + ), + ( + remote: 'updated_at', + field: UsersSortField.updatedAt, + other: user.copyWith(updatedAt: DateTime.utc(2025, 2, 2)), + ), + ]; + + for (final testCase in cases) { + test('${testCase.remote} - should order by the matching property', () { + // Every `other` holds the higher value of the pair. + expect( + UsersSort.asc(testCase.field).compare(user, testCase.other), + isNegative, + ); + expect( + UsersSort.desc(testCase.field).compare(user, testCase.other), + isPositive, + ); + }); + + test('${testCase.remote} - should serialize to the API field name', () { + expect( + UsersSort.asc(testCase.field).toJson(), + {'field': testCase.remote, 'direction': SortDirection.asc.value}, + ); + }); + } + + test('defaultSort - should order newest first, tie-breaking on id', () { + final older = DateTime.utc(2023); + final users = [ + user.copyWith(id: 'user-2', createdAt: older), + user.copyWith(id: 'user-1'), + user.copyWith(id: 'user-3', createdAt: older), + ]..sort(UsersSort.defaultSort.compare); + + expect(users.map((it) => it.id), ['user-1', 'user-3', 'user-2']); + }); + }); + + group('Users Query - Validation', () { + // A `const` query with an invalid limit or offset fails to compile, so the + // queries here are built through a function to assert at runtime instead. + UsersQuery buildQuery({int? limit, int? offset}) { + return UsersQuery(limit: limit, offset: offset); + } + + test('should reject a limit outside the range the API accepts', () { + expect( + () => buildQuery(limit: UsersQuery.maxLimit + 1), + throwsA(isA()), + ); + expect(() => buildQuery(limit: 0), throwsA(isA())); + + expect(buildQuery(limit: UsersQuery.maxLimit).limit, UsersQuery.maxLimit); + }); + + test('should reject an offset outside the range the API accepts', () { + expect( + () => buildQuery(offset: UsersQuery.maxOffset + 1), + throwsA(isA()), + ); + expect(() => buildQuery(offset: -1), throwsA(isA())); + + // The last page still starts at the maximum offset itself. + expect( + buildQuery(offset: UsersQuery.maxOffset).offset, + UsersQuery.maxOffset, + ); + }); + + test('should also validate a query built with copyWith', () { + expect( + () => const UsersQuery().copyWith(limit: UsersQuery.maxLimit + 1), + throwsA(isA()), + ); + }); + }); + + group('Users Query - Request', () { + test('toRequest - should map the query onto the API payload', () { + final query = UsersQuery( + filter: Filter.equal(UsersFilterField.role, 'admin'), + sort: [UsersSort.asc(UsersSortField.name)], + limit: 10, + offset: 20, + includeDeactivatedUsers: true, + ); + + final request = query.toRequest(); + + expect(request.filterConditions, { + 'role': {r'$eq': 'admin'}, + }); + expect(request.sort?.map((it) => (it.field, it.direction)), [ + ('name', SortDirection.asc.value), + ]); + expect(request.limit, 10); + expect(request.offset, 20); + expect(request.includeDeactivatedUsers, isTrue); + }); + + test('toRequest - should leave unset options out of the payload', () { + final request = const UsersQuery().toRequest(); + + // The API applies its own defaults for everything the query omits, so + // nothing but an empty filter is sent. + expect(request.filterConditions, isEmpty); + expect(request.sort, isNull); + expect(request.limit, isNull); + expect(request.offset, isNull); + expect(request.includeDeactivatedUsers, isNull); + }); + }); +} diff --git a/packages/stream_feeds/test/state/user_list_test.dart b/packages/stream_feeds/test/state/user_list_test.dart index 29eba2d6..b3883a9a 100644 --- a/packages/stream_feeds/test/state/user_list_test.dart +++ b/packages/stream_feeds/test/state/user_list_test.dart @@ -199,19 +199,25 @@ void main() { build: (client) => client.userList(const UsersQuery(limit: 2)), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( - users: [createDefaultFullUserResponse()], + users: [ + createDefaultFullUserResponse(), + createDefaultFullUserResponse(id: 'user-2'), + ], ), ), body: (tester) async { final nextPageQuery = tester.userList.query.copyWith( - offset: 1, + offset: 2, limit: 5, ); tester.mockApi( (api) => api.queryUsers(payload: nextPageQuery.toRequest()), result: createDefaultQueryUsersResponse( - users: [createDefaultFullUserResponse(id: 'user-2')], + users: List.generate( + 5, + (index) => createDefaultFullUserResponse(id: 'user-$index'), + ), ), ); @@ -220,6 +226,30 @@ void main() { tester.verifyApi( (api) => api.queryUsers(payload: nextPageQuery.toRequest()), ); + + // The override is also what decides whether the page was the last one + expect(tester.userListState.nextOffset, 7); + }, + ); + + userListTest( + 'queryMoreUsers - should stop when a page is shorter than the limit', + build: (client) => client.userList(const UsersQuery(limit: 2)), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + users: [createDefaultFullUserResponse()], + ), + ), + body: (tester) async { + // A single user for a limit of two means there is no next page, so no + // extra request is needed to discover the end. + expect(tester.userListState.users, hasLength(1)); + expect(tester.userListState.nextOffset, isNull); + expect(tester.userListState.canLoadMore, isFalse); + + final result = await tester.userList.queryMoreUsers(); + + expect(result.getOrThrow(), isEmpty); }, );