Repository: zulip/zulip-flutter (language: dart)

Issue:
Store user groups
We should store the list of user groups in the realm, with their metadata ~~and their memberships~~, and keep the data up to date.

(Memberships will be a separate issue: #1687.)

This data originates as `realm_user_groups` in the initial snapshot at https://zulip.com/api/register-queue#response , and is updated by events.

As a preparatory refactor, we'll move `PerAccountStore.users` (which is a `Map<int, User>`, indexed by user ID) to live on a new type `UserStore`. This will closely follow the example of `streams` and `StreamStore`.

Then the user groups will be added as an additional data structure on `UserStore`.

This is a prerequisite for features involving user groups, in particular:
* #233 


Discussion hints:
We're likely about to add a [client capability](https://zulip.com/api/register-queue#parameter-client_capabilities) on the server related to user groups, with a name like `allow_deactivated`:
https://chat.zulip.org/#narrow/stream/378-api-design/topic/deactivated.20groups.20in.20.60GET.60.20endpoints/near/1940349

So when implementing this part of the API, we should start sending that client capability.

(In general, we should always be sending all [client capabilities](https://zulip.com/api/register-queue#parameter-client_capabilities) that have been defined so far. That way we get the latest version of the API, rather than an API that's been adapted for older clients.)

For current servers since https://github.com/zulip/zulip/pull/29498 (server-10, ZFL 303), correctly maintaining user groups will also involve acting on RealmUserUpdateEvent.isActive. See the API docs for that property.

Chat thread: https://chat.zulip.org/#narrow/stream/378-api-design/topic/Groups.20events.20for.20deactivated.20users/near/1958907


Pull request: Add user groups to store and API binding
Fixes #662, toward #233 user-group @-mentions.


Test command: flutter test test/model/user_group_test.dart --reporter expanded 2>&1

Fix patch:
diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart
index 6fd21e5c9a..b3f3844366 100644
--- a/lib/api/model/events.dart
+++ b/lib/api/model/events.dart
@@ -30,6 +30,14 @@ sealed class Event {
           default: return UnexpectedEvent.fromJson(json);
         }
       case 'custom_profile_fields': return CustomProfileFieldsEvent.fromJson(json);
+      case 'user_group':
+        switch (json['op'] as String) {
+          case 'add': return UserGroupAddEvent.fromJson(json);
+          case 'update': return UserGroupUpdateEvent.fromJson(json);
+          // TODO(#1687): add_members, remove_members, add_subgroups, remove_subgroups
+          case 'remove': return UserGroupRemoveEvent.fromJson(json);
+          default: return UnexpectedEvent.fromJson(json);
+        }
       case 'realm_user':
         switch (json['op'] as String) {
           case 'add': return RealmUserAddEvent.fromJson(json);
@@ -206,6 +214,85 @@ class CustomProfileFieldsEvent extends Event {
   Map<String, dynamic> toJson() => _$CustomProfileFieldsEventToJson(this);
 }
 
+/// A Zulip event of type `user_group`.
+///
+/// See API docs starting at:
+///   https://zulip.com/api/get-events#user_group-add
+sealed class UserGroupEvent extends Event {
+  @override
+  @JsonKey(includeToJson: true)
+  String get type => 'user_group';
+
+  String get op;
+
+  UserGroupEvent({required super.id});
+}
+
+/// A [UserGroupEvent] with op `add`: https://zulip.com/api/get-events#user_group-add
+@JsonSerializable(fieldRename: FieldRename.snake)
+class UserGroupAddEvent extends UserGroupEvent {
+  @override
+  @JsonKey(includeToJson: true)
+  String get op => 'add';
+
+  final UserGroup group;
+
+  UserGroupAddEvent({required super.id, required this.group});
+
+  factory UserGroupAddEvent.fromJson(Map<String, dynamic> json) => _$UserGroupAddEventFromJson(json);
+
+  @override
+  Map<String, dynamic> toJson() => _$UserGroupAddEventToJson(this);
+}
+
+/// A [UserGroupEvent] with op `update`: https://zulip.com/api/get-events#user_group-update
+@JsonSerializable(fieldRename: FieldRename.snake)
+class UserGroupUpdateEvent extends UserGroupEvent {
+  @override
+  @JsonKey(includeToJson: true)
+  String get op => 'update';
+
+  final int groupId;
+  final UserGroupUpdateData data;
+
+  UserGroupUpdateEvent({required super.id, required this.groupId, required this.data});
+
+  factory UserGroupUpdateEvent.fromJson(Map<String, dynamic> json) => _$UserGroupUpdateEventFromJson(json);
+
+  @override
+  Map<String, dynamic> toJson() => _$UserGroupUpdateEventToJson(this);
+}
+
+@JsonSerializable(fieldRename: FieldRename.snake)
+class UserGroupUpdateData {
+  final String? name;
+  final String? description;
+  final bool? deactivated;
+
+  UserGroupUpdateData({required this.name, required this.description, required this.deactivated});
+
+  factory UserGroupUpdateData.fromJson(Map<String, dynamic> json) => _$UserGroupUpdateDataFromJson(json);
+
+  Map<String, dynamic> toJson() => _$UserGroupUpdateDataToJson(this);
+}
+
+/// A [UserGroupEvent] with op `remove`: https://zulip.com/api/get-events#user_group-remove
+@JsonSerializable(fieldRename: FieldRename.snake)
+class UserGroupRemoveEvent extends UserGroupEvent {
+  @override
+  @JsonKey(includeToJson: true)
+  String get op => 'remove';
+
+  final int groupId;
+
+  UserGroupRemoveEvent({required super.id, required this.groupId});
+
+  factory UserGroupRemoveEvent.fromJson(Map<String, dynamic> json) => _$UserGroupRemoveEventFromJson(json);
+
+  @override
+  Map<String, dynamic> toJson() => _$UserGroupRemoveEventToJson(this);
+}
+
 /// A Zulip event of type `realm_user`.
 ///
 /// The corresponding API docs are in several places for
diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart
index 2203b2d9df..31016c67ce 100644
--- a/lib/api/model/events.g.dart
+++ b/lib/api/model/events.g.dart
@@ -86,6 +86,69 @@ Map<String, dynamic> _$CustomProfileFieldsEventToJson(
   'fields': instance.fields,
 };
 
+UserGroupAddEvent _$UserGroupAddEventFromJson(Map<String, dynamic> json) =>
+    UserGroupAddEvent(
+      id: (json['id'] as num).toInt(),
+      group: UserGroup.fromJson(json['group'] as Map<String, dynamic>),
+    );
+
+Map<String, dynamic> _$UserGroupAddEventToJson(UserGroupAddEvent instance) =>
+    <String, dynamic>{
+      'id': instance.id,
+      'type': instance.type,
+      'op': instance.op,
+      'group': instance.group,
+    };
+
+UserGroupUpdateEvent _$UserGroupUpdateEventFromJson(
+  Map<String, dynamic> json,
+) => UserGroupUpdateEvent(
+  id: (json['id'] as num).toInt(),
+  groupId: (json['group_id'] as num).toInt(),
+  data: UserGroupUpdateData.fromJson(json['data'] as Map<String, dynamic>),
+);
+
+Map<String, dynamic> _$UserGroupUpdateEventToJson(
+  UserGroupUpdateEvent instance,
+) => <String, dynamic>{
+  'id': instance.id,
+  'type': instance.type,
+  'op': instance.op,
+  'group_id': instance.groupId,
+  'data': instance.data,
+};
+
+UserGroupUpdateData _$UserGroupUpdateDataFromJson(Map<String, dynamic> json) =>
+    UserGroupUpdateData(
+      name: json['name'] as String?,
+      description: json['description'] as String?,
+      deactivated: json['deactivated'] as bool?,
+    );
+
+Map<String, dynamic> _$UserGroupUpdateDataToJson(
+  UserGroupUpdateData instance,
+) => <String, dynamic>{
+  'name': instance.name,
+  'description': instance.description,
+  'deactivated': instance.deactivated,
+};
+
+UserGroupRemoveEvent _$UserGroupRemoveEventFromJson(
+  Map<String, dynamic> json,
+) => UserGroupRemoveEvent(
+  id: (json['id'] as num).toInt(),
+  groupId: (json['group_id'] as num).toInt(),
+);
+
+Map<String, dynamic> _$UserGroupRemoveEventToJson(
+  UserGroupRemoveEvent instance,
+) => <String, dynamic>{
+  'id': instance.id,
+  'type': instance.type,
+  'op': instance.op,
+  'group_id': instance.groupId,
+};
+
 RealmUserAddEvent _$RealmUserAddEventFromJson(Map<String, dynamic> json) =>
     RealmUserAddEvent(
       id: (json['id'] as num).toInt(),
diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart
index a9efdabdd5..d56c25b599 100644
--- a/lib/api/model/initial_snapshot.dart
+++ b/lib/api/model/initial_snapshot.dart
@@ -56,6 +56,8 @@ class InitialSnapshot {
 
   final Map<String, RealmEmojiItem> realmEmoji;
 
+  final List<UserGroup> realmUserGroups;
+
   final List<RecentDmConversation> recentPrivateConversations;
 
   final List<SavedSnippet>? savedSnippets; // TODO(server-10)
@@ -149,6 +151,7 @@ class InitialSnapshot {
     required this.mutedUsers,
     required this.presences,
     required this.realmEmoji,
+    required this.realmUserGroups,
     required this.recentPrivateConversations,
     required this.savedSnippets,
     required this.subscriptions,
diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart
index c33c457226..fa3601bdd0 100644
--- a/lib/api/model/initial_snapshot.g.dart
+++ b/lib/api/model/initial_snapshot.g.dart
@@ -54,6 +54,9 @@ InitialSnapshot _$InitialSnapshotFromJson(
   realmEmoji: (json['realm_emoji'] as Map<String, dynamic>).map(
     (k, e) => MapEntry(k, RealmEmojiItem.fromJson(e as Map<String, dynamic>)),
   ),
+  realmUserGroups: (json['realm_user_groups'] as List<dynamic>)
+      .map((e) => UserGroup.fromJson(e as Map<String, dynamic>))
+      .toList(),
   recentPrivateConversations:
       (json['recent_private_conversations'] as List<dynamic>)
           .map((e) => RecentDmConversation.fromJson(e as Map<String, dynamic>))
@@ -143,6 +146,7 @@ Map<String, dynamic> _$InitialSnapshotToJson(InitialSnapshot instance) =>
       'muted_users': instance.mutedUsers,
       'presences': instance.presences.map((k, e) => MapEntry(k.toString(), e)),
       'realm_emoji': instance.realmEmoji,
+      'realm_user_groups': instance.realmUserGroups,
       'recent_private_conversations': instance.recentPrivateConversations,
       'saved_snippets': instance.savedSnippets,
       'subscriptions': instance.subscriptions,
diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart
index 6bb34c89f4..db490b0d34 100644
--- a/lib/api/model/model.dart
+++ b/lib/api/model/model.dart
@@ -199,6 +199,42 @@ enum Emojiset {
     .map((key, value) => MapEntry(value, key));
 }
 
+/// As in [InitialSnapshot.realmUserGroups] or [UserGroupAddEvent].
+@JsonSerializable(fieldRename: FieldRename.snake)
+class UserGroup {
+  final int id;
+
+  // TODO(#1687) to maintain members, also act on user deactivation: https://github.com/zulip/zulip-flutter/issues/662#issuecomment-2405845356
+  // List<int> members; // TODO(#1687) track group members
+  // List<int> directSubgroupIds; // TODO(#1687) track group members
+
+  String name;
+  String description;
+
+  // final int? dateCreated; // not using; ignore
+  // final int? creatorId; // not using; ignore
+
+  final bool isSystemGroup;
+
+  // TODO(server-10): [deactivated] new in FL 290; previously no groups were deactivated
+  @JsonKey(defaultValue: false)
+  bool deactivated;
+
+  // TODO(#814): GroupSettingValue canAddMembersGroup, etc.; add to update event too
+
+  UserGroup({
+    required this.id,
+    required this.name,
+    required this.description,
+    required this.isSystemGroup,
+    required this.deactivated,
+  });
+
+  factory UserGroup.fromJson(Map<String, dynamic> json) => _$UserGroupFromJson(json);
+
+  Map<String, dynamic> toJson() => _$UserGroupToJson(this);
+}
+
 /// As in [InitialSnapshot.realmUsers], [InitialSnapshot.realmNonActiveUsers], and [InitialSnapshot.crossRealmBots].
 ///
 /// In the Zulip API, the items in realm_users, realm_non_active_users, and
diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart
index eff1019cda..ff01aba57f 100644
--- a/lib/api/model/model.g.dart
+++ b/lib/api/model/model.g.dart
@@ -94,6 +94,22 @@ Map<String, dynamic> _$RealmEmojiItemToJson(RealmEmojiItem instance) =>
       'author_id': instance.authorId,
     };
 
+UserGroup _$UserGroupFromJson(Map<String, dynamic> json) => UserGroup(
+  id: (json['id'] as num).toInt(),
+  name: json['name'] as String,
+  description: json['description'] as String,
+  isSystemGroup: json['is_system_group'] as bool,
+  deactivated: json['deactivated'] as bool? ?? false,
+);
+
+Map<String, dynamic> _$UserGroupToJson(UserGroup instance) => <String, dynamic>{
+  'id': instance.id,
+  'name': instance.name,
+  'description': instance.description,
+  'is_system_group': instance.isSystemGroup,
+  'deactivated': instance.deactivated,
+};
+
 User _$UserFromJson(Map<String, dynamic> json) => User(
   userId: (json['user_id'] as num).toInt(),
   deliveryEmail: json['delivery_email'] as String?,
diff --git a/lib/api/route/events.dart b/lib/api/route/events.dart
index bd14521c74..589a9b8cbf 100644
--- a/lib/api/route/events.dart
+++ b/lib/api/route/events.dart
@@ -18,6 +18,7 @@ Future<InitialSnapshot> registerQueue(ApiConnection connection) {
       'user_avatar_url_field_optional': false, // TODO(#254): turn on
       'stream_typing_notifications': true,
       'user_settings_object': true,
+      'include_deactivated_groups': true,
       'empty_topic_name': true,
     },
   });
diff --git a/lib/model/store.dart b/lib/model/store.dart
index 483cbb1662..09729bad7b 100644
--- a/lib/model/store.dart
+++ b/lib/model/store.dart
@@ -35,6 +35,7 @@ import 'settings.dart';
 import 'typing_status.dart';
 import 'unreads.dart';
 import 'user.dart';
+import 'user_group.dart';
 
 export 'package:drift/drift.dart' show Value;
 export 'database.dart' show Account, AccountsCompanion, AccountAlreadyExistsException;
@@ -433,7 +434,14 @@ Uri? tryResolveUrl(Uri baseUrl, String reference) {
 /// This class does not attempt to poll an event queue
 /// to keep the data up to date.  For that behavior, see
 /// [UpdateMachine].
-class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStore, SavedSnippetStore, UserStore, ChannelStore, MessageStore {
+class PerAccountStore extends PerAccountStoreBase with
+    ChangeNotifier,
+    UserGroupStore, ProxyUserGroupStore,
+    EmojiStore,
+    SavedSnippetStore,
+    UserStore,
+    ChannelStore,
+    MessageStore {
   /// Construct a store for the user's data, starting from the given snapshot.
   ///
   /// The global store must already have been updated with
@@ -475,6 +483,8 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
     final channels = ChannelStoreImpl(initialSnapshot: initialSnapshot);
     return PerAccountStore._(
       core: core,
+      groups: UserGroupStoreImpl(core: core,
+        groups: initialSnapshot.realmUserGroups),
       serverPresencePingIntervalSeconds: initialSnapshot.serverPresencePingIntervalSeconds,
       serverPresenceOfflineThresholdSeconds: initialSnapshot.serverPresenceOfflineThresholdSeconds,
       realmWildcardMentionPolicy: initialSnapshot.realmWildcardMentionPolicy,
@@ -524,6 +534,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
 
   PerAccountStore._({
     required super.core,
+    required UserGroupStoreImpl groups,
     required this.serverPresencePingIntervalSeconds,
     required this.serverPresenceOfflineThresholdSeconds,
     required this.realmWildcardMentionPolicy,
@@ -549,7 +560,8 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
     required this.unreads,
     required this.recentDmConversationsView,
     required this.recentSenders,
-  }) : _realmEmptyTopicDisplayName = realmEmptyTopicDisplayName,
+  }) : _groups = groups,
+       _realmEmptyTopicDisplayName = realmEmptyTopicDisplayName,
        _emoji = emoji,
        _savedSnippets = savedSnippets,
        _users = users,
@@ -582,6 +594,13 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
   ////////////////////////////////
   // Data attached to the realm or the server.
 
+  // (User groups come before even realm settings,
+  // because they'll be used for interpreting many realm settings.)
+  @protected
+  @override
+  UserGroupStore get userGroupStore => _groups;
+  final UserGroupStoreImpl _groups;
+
   final int serverPresencePingIntervalSeconds;
   final int serverPresenceOfflineThresholdSeconds;
 
@@ -901,6 +920,11 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
         customProfileFields = _sortCustomProfileFields(event.fields);
         notifyListeners();
 
+      case UserGroupEvent():
+        assert(debugLog("server event: user_group/${event.op}"));
+        _groups.handleUserGroupEvent(event);
+        notifyListeners();
+
       case RealmUserAddEvent():
         assert(debugLog("server event: realm_user/add"));
         _users.handleRealmUserEvent(event);
diff --git a/lib/model/user_group.dart b/lib/model/user_group.dart
new file mode 100644
index 0000000000..2079b323ec
--- /dev/null
+++ b/lib/model/user_group.dart
@@ -0,0 +1,79 @@
+import 'package:flutter/foundation.dart';
+
+import '../api/model/events.dart';
+import '../api/model/model.dart';
+import 'store.dart';
+
+/// The portion of [PerAccountStore] describing user groups.
+mixin UserGroupStore on PerAccountStoreBase {
+  /// The user group with the given ID, if any.
+  UserGroup? getGroup(int userGroupId);
+
+  /// All non-deactivated user groups in the realm.
+  ///
+  /// For when deactivated groups are desired too, see [allGroups].
+  Iterable<UserGroup> get activeGroups;
+
+  /// All user groups in the realm, even those deactivated.
+  ///
+  /// Consider using [activeGroups] instead.
+  Iterable<UserGroup> get allGroups;
+}
+
+mixin ProxyUserGroupStore on UserGroupStore {
+  @protected
+  UserGroupStore get userGroupStore;
+
+  @override
+  UserGroup? getGroup(int userGroupId) => userGroupStore.getGroup(userGroupId);
+  @override
+  Iterable<UserGroup> get activeGroups => userGroupStore.activeGroups;
+  @override
+  Iterable<UserGroup> get allGroups => userGroupStore.allGroups;
+}
+
+/// The implementation of [UserGroupStore] that does the work.
+class UserGroupStoreImpl extends PerAccountStoreBase with UserGroupStore {
+  UserGroupStoreImpl({required super.core, required List<UserGroup> groups})
+    : _groups = {
+        for (final group in groups)
+          group.id: group,
+      };
+
+  @override
+  UserGroup? getGroup(int userGroupId) {
+    return _groups[userGroupId];
+  }
+
+  @override
+  Iterable<UserGroup> get activeGroups {
+    return _groups.values.where((group) => !group.deactivated);
+  }
+
+  @override
+  Iterable<UserGroup> get allGroups {
+    return _groups.values;
+  }
+
+  final Map<int, UserGroup> _groups;
+
+  void handleUserGroupEvent(UserGroupEvent event) {
+    switch (event) {
+      case UserGroupAddEvent():
+        _groups[event.group.id] = event.group;
+
+      case UserGroupRemoveEvent():
+        _groups.remove(event.groupId);
+
+      case UserGroupUpdateEvent():
+        final group = _groups[event.groupId];
+        if (group == null) {
+          return; // TODO log
+        }
+        final data = event.data;
+        if (data.name != null)        group.name        = data.name!;
+        if (data.description != null) group.description = data.description!;
+        if (data.deactivated != null) group.deactivated = data.deactivated!;
+    }
+  }
+}


Test patch:
diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart
index 262395d955..36b6396c97 100644
--- a/test/api/model/model_checks.dart
+++ b/test/api/model/model_checks.dart
@@ -2,6 +2,14 @@ import 'package:checks/checks.dart';
 import 'package:zulip/api/model/model.dart';
 import 'package:zulip/api/model/submessage.dart';
 
+extension UserGroupChecks on Subject<UserGroup> {
+  Subject<int> get id => has((x) => x.id, 'id');
+  Subject<String> get name => has((x) => x.name, 'name');
+  Subject<String> get description => has((x) => x.description, 'description');
+  Subject<bool> get isSystemGroup => has((x) => x.isSystemGroup, 'isSystemGroup');
+  Subject<bool> get deactivated => has((x) => x.deactivated, 'deactivated');
+}
+
 extension UserChecks on Subject<User> {
   Subject<int> get userId => has((x) => x.userId, 'userId');
   Subject<String?> get deliveryEmail => has((x) => x.deliveryEmail, 'deliveryEmail');
diff --git a/test/example_data.dart b/test/example_data.dart
index ad4b8bb6fe..d46652506a 100644
--- a/test/example_data.dart
+++ b/test/example_data.dart
@@ -166,6 +166,26 @@ ServerEmojiData serverEmojiDataPopularLegacy = ServerEmojiData(codeToNames: {
   '1f419': ['octopus'],
 });
 
+/// A fresh user-group ID, from a random but always strictly increasing sequence.
+int _nextUserGroupId() => (_lastUserGroupId += 1 + Random().nextInt(10));
+int _lastUserGroupId = 100;
+
+UserGroup userGroup({
+  int? id,
+  String? name,
+  String? description,
+  bool isSystemGroup = false,
+  bool deactivated = false,
+}) {
+  return UserGroup(
+    id: id ??= _nextUserGroupId(),
+    name: name ??= 'group-$id',
+    description: description ?? 'A group named $name',
+    isSystemGroup: isSystemGroup,
+    deactivated: deactivated,
+  );
+}
+
 RealmEmojiItem realmEmojiItem({
   required String emojiCode,
   required String emojiName,
@@ -1112,6 +1132,7 @@ InitialSnapshot initialSnapshot({
   List<MutedUserItem>? mutedUsers,
   Map<int, PerUserPresence>? presences,
   Map<String, RealmEmojiItem>? realmEmoji,
+  List<UserGroup>? realmUserGroups,
   List<RecentDmConversation>? recentPrivateConversations,
   List<SavedSnippet>? savedSnippets,
   List<Subscription>? subscriptions,
@@ -1153,6 +1174,7 @@ InitialSnapshot initialSnapshot({
     mutedUsers: mutedUsers ?? [],
     presences: presences ?? {},
     realmEmoji: realmEmoji ?? {},
+    realmUserGroups: realmUserGroups ?? [],
     recentPrivateConversations: recentPrivateConversations ?? [],
     savedSnippets: savedSnippets ?? [],
     subscriptions: subscriptions ?? [], // TODO add subscriptions to default
diff --git a/test/model/user_group_test.dart b/test/model/user_group_test.dart
new file mode 100644
index 0000000000..f05b3c3c32
--- /dev/null
+++ b/test/model/user_group_test.dart
@@ -0,0 +1,119 @@
+import 'package:checks/checks.dart';
+import 'package:test_api/scaffolding.dart';
+import 'package:zulip/api/model/events.dart';
+import 'package:zulip/api/model/model.dart';
+import 'package:zulip/model/user_group.dart';
+
+import '../api/model/model_checks.dart';
+import '../example_data.dart' as eg;
+import '../stdlib_checks.dart';
+
+void main() {
+  List<UserGroup> sorted(Iterable<UserGroup> groups) {
+    return groups.toList()..sort((a, b) => a.id.compareTo(b.id));
+  }
+
+  void checkGroupsEqual(UserGroupStore store, Iterable<Object?> expected) {
+    check(sorted(store.allGroups)).jsonEquals(expected);
+  }
+
+  test('initialize', () {
+    final groups = [eg.userGroup(), eg.userGroup()];
+    final store = eg.store(initialSnapshot: eg.initialSnapshot(
+      realmUserGroups: groups));
+    checkGroupsEqual(store, groups);
+  });
+
+  test('getGroup', () {
+    final group1 = eg.userGroup();
+    final group2 = eg.userGroup();
+    final store = eg.store(initialSnapshot: eg.initialSnapshot(
+      realmUserGroups: [group1, group2]));
+    check(store.getGroup(group1.id)).jsonEquals(group1);
+    check(store.getGroup(group2.id)).jsonEquals(group2);
+    check(store.getGroup(eg.userGroup().id)).isNull();
+  });
+
+  test('activeGroups, allGroups', () async {
+    final group1 = eg.userGroup(deactivated: false);
+    final group2 = eg.userGroup(deactivated: true);
+    final group3 = eg.userGroup(deactivated: false);
+    final store = eg.store(initialSnapshot: eg.initialSnapshot(
+      realmUserGroups: [group1, group2, group3]));
+    check(sorted(store.allGroups)).jsonEquals([group1, group2, group3]);
+    check(sorted(store.activeGroups)).jsonEquals([group1, group3]);
+
+    await store.handleEvent(UserGroupUpdateEvent(id: 1, groupId: group1.id,
+      data: UserGroupUpdateData(name: null, description: null, deactivated: true)));
+    check(sorted(store.activeGroups)).jsonEquals([group3]);
+  });
+
+  test('UserGroupAddEvent, UserGroupRemoveEvent', () async {
+    final group1 = eg.userGroup();
+    final store = eg.store(initialSnapshot: eg.initialSnapshot(
+      realmUserGroups: [group1]));
+    checkGroupsEqual(store, [group1]);
+
+    final group2 = eg.userGroup();
+    await store.handleEvent(UserGroupAddEvent(id: 1, group: group2));
+    checkGroupsEqual(store, [group1, group2]);
+
+    await store.handleEvent(UserGroupRemoveEvent(id: 2, groupId: group1.id));
+    checkGroupsEqual(store, [group2]);
+  });
+
+  test('UserGroupUpdateEvent', () async {
+    final store = eg.store();
+    final group = eg.userGroup(
+      name: 'a group', description: 'is a group', deactivated: false);
+    await store.handleEvent(UserGroupAddEvent(id: 1, group: group));
+    checkGroupsEqual(store, [group]);
+
+    // Handles all the properties being updated at once.
+    await store.handleEvent(UserGroupUpdateEvent(id: 2, groupId: group.id,
+      data: UserGroupUpdateData(name: 'revised group',
+        description: 'different description', deactivated: true)));
+    checkGroupsEqual(store, [{
+      ...group.toJson(),
+      'name': 'revised group',
+      'description': 'different description',
+      'deactivated': true,
+    }]);
+
+    // Handles some properties being null, still updating the one that's present.
+    await store.handleEvent(UserGroupUpdateEvent(id: 2, groupId: group.id,
+      data: UserGroupUpdateData(name: null,
+        description: null, deactivated: false)));
+    checkGroupsEqual(store, [{
+      ...group.toJson(),
+      'name': 'revised group',
+      'description': 'different description',
+      'deactivated': false,
+    }]);
+  });
+
+  test('various fields make it through', () async {
+    final store = eg.store(initialSnapshot: eg.initialSnapshot(
+      realmUserGroups: [
+        eg.userGroup(id: 3, name: 'some group', description: 'this is a group',
+          isSystemGroup: true, deactivated: false),
+      ]));
+    await store.handleEvent(UserGroupAddEvent(id: 1, group: eg.userGroup(
+      id: 5, name: 'a different group', description: 'also a group',
+      isSystemGroup: false, deactivated: true)));
+    check(sorted(store.allGroups)).deepEquals(<Condition<Object?>>[
+      (it) => it.isA<UserGroup>()
+        ..id.equals(3)
+        ..name.equals('some group')
+        ..description.equals('this is a group')
+        ..isSystemGroup.isTrue()
+        ..deactivated.isFalse(),
+      (it) => it.isA<UserGroup>()
+        ..id.equals(5)
+        ..name.equals('a different group')
+        ..description.equals('also a group')
+        ..isSystemGroup.isFalse()
+        ..deactivated.isTrue(),
+    ]);
+  });
+}