diff --git a/lib/model/store.dart b/lib/model/store.dart index bc5c752174..05a9faabf3 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -147,13 +147,17 @@ abstract class GlobalStore extends ChangeNotifier { // It's up to us. Start loading. future = loadPerAccount(accountId); _perAccountStoresLoading[accountId] = future; - store = await future; - _setPerAccount(accountId, store); - unawaited(_perAccountStoresLoading.remove(accountId)); - return store; + try { + store = await future; + _setPerAccount(accountId, store); + return store; + } finally { + unawaited(_perAccountStoresLoading.remove(accountId)); + } } Future _reloadPerAccount(int accountId) async { + assert(_accounts.containsKey(accountId)); assert(_perAccountStores.containsKey(accountId)); assert(!_perAccountStoresLoading.containsKey(accountId)); final store = await loadPerAccount(accountId); @@ -169,6 +173,11 @@ abstract class GlobalStore extends ChangeNotifier { /// Load per-account data for the given account, unconditionally. /// + /// The account for `accountId` must exist. + /// + /// Throws [AccountNotFoundException] if after any async gap + /// the account has been removed. + /// /// This method should be called only by the implementation of [perAccount]. /// Other callers interested in per-account data should use [perAccount] /// and/or [perAccountSync]. @@ -183,36 +192,30 @@ abstract class GlobalStore extends ChangeNotifier { // The API key is invalid and the store can never be loaded // unless the user retries manually. final account = getAccount(accountId); - if (account == null) { - // The account was logged out during `await doLoadPerAccount`. - // Here, that seems possible only by the user's own action; - // the logout can't have been done programmatically. - // Even if it were, it would have come with its own UI feedback. - // Anyway, skip showing feedback, to not be confusing or repetitive. - throw AccountNotFoundException(); - } + assert(account != null); // doLoadPerAccount would have thrown AccountNotFoundException final zulipLocalizations = GlobalLocalizations.zulipLocalizations; reportErrorToUserModally( zulipLocalizations.errorCouldNotConnectTitle, message: zulipLocalizations.errorInvalidApiKeyMessage( - account.realmUrl.toString())); + account!.realmUrl.toString())); await logOutAccount(this, accountId); throw AccountNotFoundException(); default: rethrow; } } - if (!_accounts.containsKey(accountId)) { - // TODO(#1354): handle this earlier - // [removeAccount] was called during [doLoadPerAccount]. - store.dispose(); - throw AccountNotFoundException(); - } + // doLoadPerAccount would have thrown AccountNotFoundException + assert(_accounts.containsKey(accountId)); return store; } /// Load per-account data for the given account, unconditionally. /// + /// The account for `accountId` must exist. + /// + /// Throws [AccountNotFoundException] if after any async gap + /// the account has been removed. + /// /// This method should be called only by [loadPerAccount]. Future doLoadPerAccount(int accountId); @@ -263,6 +266,8 @@ abstract class GlobalStore extends ChangeNotifier { Future doUpdateAccount(int accountId, AccountsCompanion data); /// Remove an account from the store. + /// + /// The account for `accountId` must exist. Future removeAccount(int accountId) async { assert(_accounts.containsKey(accountId)); await doRemoveAccount(accountId); @@ -941,15 +946,31 @@ class UpdateMachine { store.updateMachine = this; } - /// Load the user's data from the server, and start an event queue going. + /// Load data for the given account from the server, + /// and start an event queue going. + /// + /// The account for `accountId` must exist. + /// + /// Throws [AccountNotFoundException] if after any async gap + /// the account has been removed. /// /// In the future this might load an old snapshot from local storage first. static Future load(GlobalStore globalStore, int accountId) async { Account account = globalStore.getAccount(accountId)!; final connection = globalStore.apiConnectionFromAccount(account); + void stopAndThrowIfNoAccount() { + final account = globalStore.getAccount(accountId); + if (account == null) { + assert(debugLog('Account logged out during UpdateMachine.load')); + connection.close(); + throw AccountNotFoundException(); + } + } + final stopwatch = Stopwatch()..start(); - final initialSnapshot = await _registerQueueWithRetry(connection); + final initialSnapshot = await _registerQueueWithRetry(connection, + stopAndThrowIfNoAccount: stopAndThrowIfNoAccount); final t = (stopwatch..stop()).elapsed; assert(debugLog("initial fetch time: ${t.inMilliseconds}ms")); @@ -991,13 +1012,20 @@ class UpdateMachine { bool _disposed = false; + /// Make the register-queue request, with retries. + /// + /// After each async gap, calls [stopAndThrowIfNoAccount]. static Future _registerQueueWithRetry( - ApiConnection connection) async { + ApiConnection connection, { + required void Function() stopAndThrowIfNoAccount, + }) async { BackoffMachine? backoffMachine; while (true) { + InitialSnapshot? result; try { - return await registerQueue(connection); + result = await registerQueue(connection); } catch (e, s) { + stopAndThrowIfNoAccount(); // TODO(#890): tell user if initial-fetch errors persist, or look non-transient switch (e) { case HttpException(httpStatus: 401): @@ -1012,8 +1040,13 @@ class UpdateMachine { } assert(debugLog('Backing off, then will retry…')); await (backoffMachine ??= BackoffMachine()).wait(); + stopAndThrowIfNoAccount(); assert(debugLog('… Backoff wait complete, retrying initial fetch.')); } + if (result != null) { + stopAndThrowIfNoAccount(); + return result; + } } } diff --git a/test/api/fake_api.dart b/test/api/fake_api.dart index 6953515c6b..2b230376ac 100644 --- a/test/api/fake_api.dart +++ b/test/api/fake_api.dart @@ -1,6 +1,7 @@ import 'dart:collection'; import 'dart:convert'; +import 'package:checks/checks.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:zulip/api/core.dart'; @@ -278,3 +279,7 @@ class FakeApiConnection extends ApiConnection { ); } } + +extension FakeApiConnectionChecks on Subject { + Subject get isOpen => has((x) => x.isOpen, 'isOpen'); +} diff --git a/test/example_data.dart b/test/example_data.dart index 97527f42db..03cabbda97 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -981,9 +981,14 @@ PerAccountStore store({ } const _store = store; -UpdateMachine updateMachine({Account? account, InitialSnapshot? initialSnapshot}) { +UpdateMachine updateMachine({ + GlobalStore? globalStore, + Account? account, + InitialSnapshot? initialSnapshot, +}) { initialSnapshot ??= _initialSnapshot(); - final store = _store(account: account, initialSnapshot: initialSnapshot); + final store = _store(globalStore: globalStore, + account: account, initialSnapshot: initialSnapshot); return UpdateMachine.fromInitialSnapshot( store: store, initialSnapshot: initialSnapshot); } diff --git a/test/model/store_test.dart b/test/model/store_test.dart index 61b4ece5cf..4bcb272e9e 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -6,6 +6,7 @@ import 'package:fake_async/fake_async.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; +import 'package:zulip/api/backoff.dart'; import 'package:zulip/api/core.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; @@ -157,18 +158,85 @@ void main() { await check(future).throws(); })); + test('GlobalStore.perAccount loading succeeds', () => awaitFakeAsync((async) async { + NotificationService.instance.token = ValueNotifier('asdf'); + addTearDown(NotificationService.debugReset); + + final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); + final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; + final future = globalStore.perAccount(eg.selfAccount.id); + check(connection.takeRequests()).length.equals(1); // register request + + await future; + // poll, server-emoji-data, register-token requests + check(connection.takeRequests()).length.equals(3); + check(connection).isOpen.isTrue(); + })); + + test('GlobalStore.perAccount account is logged out while loading; then succeeds', () => awaitFakeAsync((async) async { + final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); + globalStore.prepareRegisterQueueResponse = (connection) => + connection.prepare( + delay: TestGlobalStore.removeAccountDuration + Duration(seconds: 1), + json: eg.initialSnapshot().toJson()); + final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; + final future = globalStore.perAccount(eg.selfAccount.id); + check(connection.takeRequests()).length.equals(1); // register request + + await logOutAccount(globalStore, eg.selfAccount.id); + check(globalStore.takeDoRemoveAccountCalls()) + .single.equals(eg.selfAccount.id); + + await check(future).throws(); + check(globalStore.takeDoRemoveAccountCalls()).isEmpty(); + // no poll, server-emoji-data, or register-token requests + check(connection.takeRequests()).isEmpty(); + check(connection).isOpen.isFalse(); + })); + test('GlobalStore.perAccount account is logged out while loading; then fails with HTTP status code 401', () => awaitFakeAsync((async) async { - final globalStore = LoadingTestGlobalStore(accounts: [eg.selfAccount]); + final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); + globalStore.prepareRegisterQueueResponse = (connection) => + connection.prepare( + delay: TestGlobalStore.removeAccountDuration + Duration(seconds: 1), + apiException: eg.apiExceptionUnauthorized()); + final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; final future = globalStore.perAccount(eg.selfAccount.id); + check(connection.takeRequests()).length.equals(1); // register request await logOutAccount(globalStore, eg.selfAccount.id); check(globalStore.takeDoRemoveAccountCalls()) .single.equals(eg.selfAccount.id); - globalStore.completers[eg.selfAccount.id]! - .single.completeError(eg.apiExceptionUnauthorized()); await check(future).throws(); check(globalStore.takeDoRemoveAccountCalls()).isEmpty(); + // no poll, server-emoji-data, or register-token requests + check(connection.takeRequests()).isEmpty(); + check(connection).isOpen.isFalse(); + })); + + test('GlobalStore.perAccount account is logged out during transient-error backoff', () => awaitFakeAsync((async) async { + final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); + globalStore.prepareRegisterQueueResponse = (connection) => + connection.prepare( + delay: Duration(seconds: 1), + httpException: http.ClientException('Oops')); + final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; + final future = globalStore.perAccount(eg.selfAccount.id); + BackoffMachine.debugDuration = Duration(seconds: 1); + async.elapse(Duration(milliseconds: 1500)); + check(connection.takeRequests()).length.equals(1); // register request + + assert(TestGlobalStore.removeAccountDuration < Duration(milliseconds: 500)); + await logOutAccount(globalStore, eg.selfAccount.id); + check(globalStore.takeDoRemoveAccountCalls()) + .single.equals(eg.selfAccount.id); + + await check(future).throws(); + check(globalStore.takeDoRemoveAccountCalls()).isEmpty(); + // no retry-register, poll, server-emoji-data, or register-token requests + check(connection.takeRequests()).isEmpty(); + check(connection).isOpen.isFalse(); })); // TODO test insertAccount @@ -266,11 +334,20 @@ void main() { }); test('when store loading', () async { - final globalStore = LoadingTestGlobalStore(accounts: [eg.selfAccount]); + final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); checkGlobalStore(globalStore, eg.selfAccount.id, expectAccount: true, expectStore: false); - // don't await; we'll complete/await it manually after removeAccount + assert(globalStore.useCachedApiConnections); + // Cache a connection and get this reference to it, + // so we can check later that it gets closed. + final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; + + globalStore.prepareRegisterQueueResponse = (connection) { + connection.prepare( + delay: TestGlobalStore.removeAccountDuration + Duration(seconds: 1), + json: eg.initialSnapshot().toJson()); + }; final loadingFuture = globalStore.perAccount(eg.selfAccount.id); checkGlobalStore(globalStore, eg.selfAccount.id, @@ -284,13 +361,11 @@ void main() { expectAccount: false, expectStore: false); check(notifyCount).equals(1); - globalStore.completers[eg.selfAccount.id]!.single - .complete(eg.store(account: eg.selfAccount, initialSnapshot: eg.initialSnapshot())); - // TODO test that the never-used store got disposed and its connection closed await check(loadingFuture).throws(); checkGlobalStore(globalStore, eg.selfAccount.id, expectAccount: false, expectStore: false); check(notifyCount).equals(1); // no extra notify + check(connection).isOpen.isFalse(); check(globalStore.debugNumPerAccountStoresLoading).equals(0); }); @@ -590,14 +665,13 @@ void main() { group('UpdateMachine.poll', () { late TestGlobalStore globalStore; - late UpdateMachine updateMachine; late PerAccountStore store; + late UpdateMachine updateMachine; late FakeApiConnection connection; void updateFromGlobalStore() { - updateMachine = globalStore.updateMachines[eg.selfAccount.id]!; - store = updateMachine.store; - assert(identical(store, globalStore.perAccountSync(eg.selfAccount.id))); + store = globalStore.perAccountSync(eg.selfAccount.id)!; + updateMachine = store.updateMachine!; connection = store.connection as FakeApiConnection; } @@ -993,69 +1067,65 @@ void main() { }); group('UpdateMachine.poll reload failure', () { - late LoadingTestGlobalStore globalStore; + late UpdateMachineTestGlobalStore globalStore; - List> completers() => - globalStore.completers[eg.selfAccount.id]!; + Future prepareReload(FakeAsync async, { + required void Function(FakeApiConnection) prepareRegisterQueueResponse, + }) async { + globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); - Future prepareReload(FakeAsync async) async { - globalStore = LoadingTestGlobalStore(accounts: [eg.selfAccount]); - final future = globalStore.perAccount(eg.selfAccount.id); - final store = eg.store(globalStore: globalStore, account: eg.selfAccount); - completers().single.complete(store); - await future; - completers().clear(); - final updateMachine = globalStore.updateMachines[eg.selfAccount.id] = - UpdateMachine.fromInitialSnapshot( - store: store, initialSnapshot: eg.initialSnapshot()); - updateMachine.debugPauseLoop(); - updateMachine.poll(); + final store = await globalStore.perAccount(eg.selfAccount.id); + final updateMachine = store.updateMachine!; - (store.connection as FakeApiConnection).prepare( + final connection = store.connection as FakeApiConnection; + connection.prepare( apiException: eg.apiExceptionBadEventQueueId()); + globalStore.prepareRegisterQueueResponse = prepareRegisterQueueResponse; + // When we reload, we should get a new connection, + // just like when the app runs live. This is more realistic, + // and we don't want a glitch where we try to double-close a connection + // just because of the test infrastructure. (One of the tests + // logs out the account, and the connection shouldn't be used after that.) + globalStore.clearCachedApiConnections(); updateMachine.debugAdvanceLoop(); - async.elapse(Duration.zero); + async.elapse(Duration.zero); // the bad-event-queue error arrives check(store).isLoading.isTrue(); } - void checkReloadFailure({ - required FutureOr Function() completeLoading, - }) { - awaitFakeAsync((async) async { - await prepareReload(async); - check(completers()).single.isCompleted.isFalse(); - - await completeLoading(); - check(completers()).single.isCompleted.isTrue(); - check(globalStore.takeDoRemoveAccountCalls()).single.equals(eg.selfAccount.id); - - async.elapse(TestGlobalStore.removeAccountDuration); - check(globalStore.perAccountSync(eg.selfAccount.id)).isNull(); - - async.flushTimers(); - // Reload never succeeds and there are no unhandled errors. - check(globalStore.perAccountSync(eg.selfAccount.id)).isNull(); + test('user logged out before new store is loaded', () => awaitFakeAsync((async) async { + await prepareReload(async, prepareRegisterQueueResponse: (connection) { + connection.prepare( + delay: TestGlobalStore.removeAccountDuration + Duration(seconds: 1), + json: eg.initialSnapshot().toJson()); }); - } - Future logOutAndCompleteWithNewStore() async { - // [PerAccountStore.fromInitialSnapshot] requires the account - // to be in the global store when called; do so before logging out. - final newStore = eg.store(globalStore: globalStore, account: eg.selfAccount); await logOutAccount(globalStore, eg.selfAccount.id); - completers().single.complete(newStore); - } + check(globalStore.takeDoRemoveAccountCalls()).single.equals(eg.selfAccount.id); - test('user logged out before new store is loaded', () => awaitFakeAsync((async) async { - checkReloadFailure(completeLoading: logOutAndCompleteWithNewStore); - })); + async.elapse(TestGlobalStore.removeAccountDuration); + check(globalStore.perAccountSync(eg.selfAccount.id)).isNull(); - void completeWithApiExceptionUnauthorized() { - completers().single.completeError(eg.apiExceptionUnauthorized()); - } + async.flushTimers(); + // Reload never succeeds and there are no unhandled errors. + check(globalStore.perAccountSync(eg.selfAccount.id)).isNull(); + })); test('new store is not loaded, gets HTTP 401 error instead', () => awaitFakeAsync((async) async { - checkReloadFailure(completeLoading: completeWithApiExceptionUnauthorized); + await prepareReload(async, prepareRegisterQueueResponse: (connection) { + connection.prepare( + delay: Duration(seconds: 1), + apiException: eg.apiExceptionUnauthorized()); + }); + + async.elapse(const Duration(seconds: 1)); + check(globalStore.takeDoRemoveAccountCalls()).single.equals(eg.selfAccount.id); + + async.elapse(TestGlobalStore.removeAccountDuration); + check(globalStore.perAccountSync(eg.selfAccount.id)).isNull(); + + async.flushTimers(); + // Reload never succeeds and there are no unhandled errors. + check(globalStore.perAccountSync(eg.selfAccount.id)).isNull(); })); }); diff --git a/test/model/test_store.dart b/test/model/test_store.dart index ebf6e66f1b..7e11c62ee1 100644 --- a/test/model/test_store.dart +++ b/test/model/test_store.dart @@ -1,38 +1,17 @@ import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/events.dart'; +import 'package:zulip/api/route/realm.dart'; import 'package:zulip/model/database.dart'; import 'package:zulip/model/store.dart'; +import 'package:zulip/notifications/receive.dart'; import 'package:zulip/widgets/store.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; -/// A [GlobalStore] containing data provided by callers, -/// and that causes no database queries or network requests. -/// -/// Tests can provide data to the store by calling [add]. -/// -/// The per-account stores will use [FakeApiConnection]. -/// -/// Unlike with [LiveGlobalStore] and the associated [UpdateMachine.load], -/// there is no automatic event-polling loop or other automated requests. -/// For each account loaded, there is a corresponding [UpdateMachine] -/// in [updateMachines], which tests can use for invoking that logic -/// explicitly when desired. -/// -/// See also [TestZulipBinding.globalStore], which provides one of these. -class TestGlobalStore extends GlobalStore { - TestGlobalStore({ - GlobalSettingsData? globalSettings, - required super.accounts, - }) : super(globalSettings: globalSettings ?? eg.globalSettings()); - - @override - Future doUpdateGlobalSettings(GlobalSettingsCompanion data) async { - // Nothing to do. - } - +mixin _ApiConnectionsMixin on GlobalStore { final Map< ({Uri realmUrl, int? zulipFeatureLevel, String? email, String? apiKey}), FakeApiConnection @@ -81,25 +60,12 @@ class TestGlobalStore extends GlobalStore { realmUrl: realmUrl, zulipFeatureLevel: zulipFeatureLevel, email: email, apiKey: apiKey)); } +} - /// A corresponding [UpdateMachine] for each loaded account. - final Map updateMachines = {}; - - final Map _initialSnapshots = {}; - - /// Add an account and corresponding server data to the test data. - /// - /// The given account will be added to the store. - /// The given initial snapshot will be used to initialize a corresponding - /// [PerAccountStore] when [perAccount] is subsequently called for this - /// account, in particular when a [PerAccountStoreWidget] is mounted. - Future add(Account account, InitialSnapshot initialSnapshot) async { - assert(initialSnapshot.zulipVersion == account.zulipVersion); - assert(initialSnapshot.zulipMergeBase == account.zulipMergeBase); - assert(initialSnapshot.zulipFeatureLevel == account.zulipFeatureLevel); - await insertAccount(account.toCompanion(false)); - assert(!_initialSnapshots.containsKey(account.id)); - _initialSnapshots[account.id] = initialSnapshot; +mixin _DatabaseMixin on GlobalStore { + @override + Future doUpdateGlobalSettings(GlobalSettingsCompanion data) async { + // Nothing to do. } int _nextAccountId = 1; @@ -136,10 +102,6 @@ class TestGlobalStore extends GlobalStore { // Nothing to do. } - static const Duration removeAccountDuration = Duration(milliseconds: 1); - Duration? loadPerAccountDuration; - Object? loadPerAccountException; - /// Consume the log of calls made to [doRemoveAccount]. List takeDoRemoveAccountCalls() { final result = _doRemoveAccountCalls; @@ -151,9 +113,54 @@ class TestGlobalStore extends GlobalStore { @override Future doRemoveAccount(int accountId) async { (_doRemoveAccountCalls ??= []).add(accountId); - await Future.delayed(removeAccountDuration); + await Future.delayed(TestGlobalStore.removeAccountDuration); // Nothing else to do. } +} + +/// A [GlobalStore] containing data provided by callers, +/// and that causes no database queries or network requests. +/// +/// Tests can provide data to the store by calling [add]. +/// +/// The per-account stores will use [FakeApiConnection]. +/// +/// Unlike with [LiveGlobalStore] and the associated [UpdateMachine.load], +/// there is no automatic event-polling loop or other automated requests. +/// Tests can use [PerAccountStore.updateMachine] in order to invoke that logic +/// explicitly when desired. +/// +/// See also: +/// * [TestZulipBinding.globalStore], which provides one of these. +/// * [UpdateMachineTestGlobalStore], which prepares per-account data +/// using [UpdateMachine.load] (like [LiveGlobalStore] does). +class TestGlobalStore extends GlobalStore with _ApiConnectionsMixin, _DatabaseMixin { + TestGlobalStore({ + GlobalSettingsData? globalSettings, + required super.accounts, + }) : super(globalSettings: globalSettings ?? eg.globalSettings()); + + final Map _initialSnapshots = {}; + + static const Duration removeAccountDuration = Duration(milliseconds: 1); + + /// Add an account and corresponding server data to the test data. + /// + /// The given account will be added to the store. + /// The given initial snapshot will be used to initialize a corresponding + /// [PerAccountStore] when [perAccount] is subsequently called for this + /// account, in particular when a [PerAccountStoreWidget] is mounted. + Future add(Account account, InitialSnapshot initialSnapshot) async { + assert(initialSnapshot.zulipVersion == account.zulipVersion); + assert(initialSnapshot.zulipMergeBase == account.zulipMergeBase); + assert(initialSnapshot.zulipFeatureLevel == account.zulipFeatureLevel); + await insertAccount(account.toCompanion(false)); + assert(!_initialSnapshots.containsKey(account.id)); + _initialSnapshots[account.id] = initialSnapshot; + } + + Duration? loadPerAccountDuration; + Object? loadPerAccountException; @override Future doLoadPerAccount(int accountId) async { @@ -169,12 +176,70 @@ class TestGlobalStore extends GlobalStore { accountId: accountId, initialSnapshot: initialSnapshot, ); - updateMachines[accountId] = UpdateMachine.fromInitialSnapshot( + UpdateMachine.fromInitialSnapshot( store: store, initialSnapshot: initialSnapshot); return Future.value(store); } } +/// A [GlobalStore] that causes no database queries, +/// and loads per-account data from API responses prepared by callers. +/// +/// The per-account stores will use [FakeApiConnection]. +/// +/// Like [LiveGlobalStore] and unlike [TestGlobalStore], +/// account data is loaded via [UpdateMachine.load]. +/// Callers can set [prepareRegisterQueueResponse] +/// to prepare a register-queue payload or an exception. +/// The implementation pauses the event-polling loop +/// to avoid being a nuisance and does a boring +/// [FakeApiConnection.prepare] for the register-token request. +/// +/// See also: +/// * [TestGlobalStore], which prepares per-account data +/// without using [UpdateMachine.load]. +class UpdateMachineTestGlobalStore extends GlobalStore with _ApiConnectionsMixin, _DatabaseMixin { + UpdateMachineTestGlobalStore({ + GlobalSettingsData? globalSettings, + required super.accounts, + }) : super(globalSettings: globalSettings ?? eg.globalSettings()); + + // [doLoadPerAccount] depends on the cache to prepare the API responses. + // Calling [clearCachedApiConnections] is permitted, though. + @override bool get useCachedApiConnections => true; + @override set useCachedApiConnections(bool value) => + throw UnsupportedError( + 'Setting UpdateMachineTestGlobalStore.useCachedApiConnections ' + 'is not supported.'); + + void Function(FakeApiConnection)? prepareRegisterQueueResponse; + + void _prepareRegisterQueueSuccess(FakeApiConnection connection) { + connection.prepare(json: eg.initialSnapshot().toJson()); + } + + @override + Future doLoadPerAccount(int accountId) async { + final account = getAccount(accountId); + + // UpdateMachine.load should pick up the connection + // with the network-request responses that we've prepared. + assert(useCachedApiConnections); + + final connection = apiConnectionFromAccount(account!) as FakeApiConnection; + (prepareRegisterQueueResponse ?? _prepareRegisterQueueSuccess)(connection); + connection + ..prepare(json: GetEventsResult(events: [HeartbeatEvent(id: 2)], queueId: null).toJson()) + ..prepare(json: ServerEmojiData(codeToNames: {}).toJson()); + if (NotificationService.instance.token.value != null) { + connection.prepare(json: {}); // register-token + } + final updateMachine = await UpdateMachine.load(this, accountId); + updateMachine.debugPauseLoop(); + return updateMachine.store; + } +} + extension PerAccountStoreTestExtension on PerAccountStore { Future addUser(User user) async { await handleEvent(RealmUserAddEvent(id: 1, person: user));