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/docs/code_snippets/02_01_querying_users.dart b/docs/code_snippets/02_01_querying_users.dart new file mode 100644 index 00000000..3b5e4c17 --- /dev/null +++ b/docs/code_snippets/02_01_querying_users.dart @@ -0,0 +1,73 @@ +import 'package:stream_feeds/stream_feeds.dart'; + +late StreamFeedsClient client; + +Future queryUsers() async { + // Search users by name prefix + final userList = client.userList( + UsersQuery( + filter: Filter.autoComplete(UsersFilterField.name, 'Al'), + sort: [UsersSort.asc(UsersSortField.name)], + limit: 25, + ), + ); + + final result = await userList.get(); + + 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'); + } + + // 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 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'); + case Failure(error: final error): + print('Failed to query users: $error'); + } +} + +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, + ), + ); + + 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 1b00fdc3..acd98854 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -1,5 +1,8 @@ ## Upcoming +### New methods +- Added `userList` to `StreamFeedsClient` for querying users. + ### 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..9c5a0b1c 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -25,6 +25,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'; @@ -58,6 +59,8 @@ 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 '../state/user_list.dart'; import '../ws/feeds_ws_event.dart'; import 'endpoint_config.dart'; @@ -170,6 +173,7 @@ class StreamFeedsClientImpl implements StreamFeedsClient { _moderationRepository = ModerationRepository(feedsApi); _pollsRepository = PollsRepository(feedsApi); _capabilitiesRepository = CapabilitiesRepository(feedsApi); + _usersRepository = UsersRepository(feedsApi); moderation = ModerationClient(_moderationRepository); @@ -208,6 +212,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( @@ -473,6 +478,14 @@ class StreamFeedsClientImpl implements StreamFeedsClient { ); } + @override + UserList userList(UsersQuery query) { + return UserList( + query: query, + usersRepository: _usersRepository, + ); + } + @override Future> getApp() => _appRepository.getApp(); 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 b2a73054..7a5cb053 100644 --- a/packages/stream_feeds/lib/src/feeds_client.dart +++ b/packages/stream_feeds/lib/src/feeds_client.dart @@ -44,6 +44,8 @@ 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 'state/user_list.dart'; export 'client/moderation_client.dart'; @@ -394,7 +396,7 @@ abstract interface class StreamFeedsClient { /// ), /// ], /// ); - ///``` + /// ``` /// /// Returns a [Result] containing the list of upserted [ActivityData] or an error. Future>> upsertActivities({ @@ -405,11 +407,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({ @@ -656,7 +660,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): @@ -678,7 +682,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'); @@ -789,13 +793,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, /// ), @@ -804,7 +808,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): @@ -825,13 +829,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, /// ), @@ -840,7 +844,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'); @@ -863,7 +867,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'); @@ -883,9 +887,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'}, @@ -895,7 +899,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'); @@ -914,9 +918,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'}, @@ -926,7 +930,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'); @@ -949,7 +953,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'); @@ -961,6 +965,29 @@ abstract interface class StreamFeedsClient { required List refs, }); + /// Creates a [UserList] object that represents a collection of users matching + /// the provided query. + /// + /// Example: + /// ```dart + /// final userList = client.userList(UsersQuery( + /// filter: Filter.autoComplete(UsersFilterField.name, 'Al'), + /// sort: [UsersSort.asc(UsersSortField.name)], + /// limit: 25, + /// )); + /// + /// // 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 [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. /// /// 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, + ); + } +} 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..e2cdb229 --- /dev/null +++ b/packages/stream_feeds/lib/src/repository/users_repository.dart @@ -0,0 +1,32 @@ +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. +/// +/// 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 given [query]. + /// + /// Returns a [Result] containing a list of [UserData] or an error. + 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 f97ec3db..62aa4d34 100644 --- a/packages/stream_feeds/lib/src/state.dart +++ b/packages/stream_feeds/lib/src/state.dart @@ -48,3 +48,6 @@ 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'; +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 new file mode 100644 index 00000000..2209439c --- /dev/null +++ b/packages/stream_feeds/lib/src/state/query/users_query.dart @@ -0,0 +1,277 @@ +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 userList = client.userList(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.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. + /// + /// 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 + /// 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. 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. + /// + /// 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; + + /// 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. Values above [maxOffset] + /// are rejected by the API and trip an assertion in debug builds. + @override + final int? offset; + + /// 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`, `.greater`, `.greaterOrEqual`, `.less`, `.lessOrEqual`, `.exists` + static final createdAt = UsersFilterField( + 'created_at', + (data) => data.createdAt, + ); + + /// Filter by the unique identifier of the user. + /// + /// **Supported operators:** `.equal`, `.in_`, `.greater`, `.greaterOrEqual`, `.less`, `.lessOrEqual`, `.exists`, `.autoComplete` + static final id = UsersFilterField( + 'id', + (data) => data.id, + ); + + /// Filter by the timestamp the user was last active at. + /// + /// **Supported operators:** `.equal`, `.greater`, `.greaterOrEqual`, `.less`, `.lessOrEqual`, `.exists` + static final lastActive = UsersFilterField( + 'last_active', + (data) => data.lastActive, + ); + + /// Filter by the name of the user. + /// + /// **Supported operators:** `.equal`, `.in_`, `.autoComplete` + /// + /// Range comparisons and `.exists` are not supported on this field. + static final name = UsersFilterField( + 'name', + (data) => data.name, + ); + + /// Filter by the role of the user. + /// + /// **Supported operators:** `.equal`, `.in_`, `.exists` + static final role = UsersFilterField( + 'role', + (data) => data.role, + ); + + /// Filter by the teams the user belongs to. + /// + /// **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, + ); + + /// Filter by the last update timestamp of the user. + /// + /// **Supported operators:** `.equal`, `.greater`, `.greaterOrEqual`, `.less`, `.lessOrEqual`, `.exists` + 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(); + + /// 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. +/// +/// 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 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( + '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, + 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..bc20960b --- /dev/null +++ b/packages/stream_feeds/lib/src/state/query/users_query.freezed.dart @@ -0,0 +1,119 @@ +// 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 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.includeDeactivatedUsers, + includeDeactivatedUsers, + ) || + other.includeDeactivatedUsers == includeDeactivatedUsers)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + filter, + const DeepCollectionEquality().hash(sort), + limit, + offset, + includeDeactivatedUsers, + ); + + @override + String toString() { + return 'UsersQuery(filter: $filter, sort: $sort, limit: $limit, offset: $offset, 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? 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? 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?, + includeDeactivatedUsers: freezed == includeDeactivatedUsers + ? _self.includeDeactivatedUsers + : includeDeactivatedUsers // ignore: cast_nullable_to_non_nullable + as bool?, + ), + ); + } +} 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..3a247b90 --- /dev/null +++ b/packages/stream_feeds/lib/src/state/user_list.dart @@ -0,0 +1,96 @@ +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, + limit: query.limit, + ); + }, + ); + + 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..cf73a9e8 --- /dev/null +++ b/packages/stream_feeds/lib/src/state/user_list_state.dart @@ -0,0 +1,124 @@ +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] 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; + + // 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, + limit: limit, + ), + ); + } + + // 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, + 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 + // 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 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, 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/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/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 new file mode 100644 index 00000000..b3883a9a --- /dev/null +++ b/packages/stream_feeds/test/state/user_list_test.dart @@ -0,0 +1,352 @@ +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(), + createDefaultFullUserResponse(id: 'user-2'), + ], + ), + ), + body: (tester) async { + final nextPageQuery = tester.userList.query.copyWith( + offset: 2, + limit: 5, + ); + + tester.mockApi( + (api) => api.queryUsers(payload: nextPageQuery.toRequest()), + result: createDefaultQueryUsersResponse( + users: List.generate( + 5, + (index) => createDefaultFullUserResponse(id: 'user-$index'), + ), + ), + ); + + await tester.userList.queryMoreUsers(limit: 5); + + 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); + }, + ); + + 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/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()], + ); +} 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';