Skip to content

Commit ef8664d

Browse files
committed
store test: Add and use UpdateMachineTestGlobalStore, for less mocking
1 parent bd8554a commit ef8664d

File tree

3 files changed

+131
-45
lines changed

3 files changed

+131
-45
lines changed

test/api/fake_api.dart

+5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:collection';
22
import 'dart:convert';
33

4+
import 'package:checks/checks.dart';
45
import 'package:flutter/foundation.dart';
56
import 'package:http/http.dart' as http;
67
import 'package:zulip/api/core.dart';
@@ -278,3 +279,7 @@ class FakeApiConnection extends ApiConnection {
278279
);
279280
}
280281
}
282+
283+
extension FakeApiConnectionChecks on Subject<FakeApiConnection> {
284+
Subject<bool> get isOpen => has((x) => x.isOpen, 'isOpen');
285+
}

test/model/store_test.dart

+61-44
Original file line numberDiff line numberDiff line change
@@ -158,17 +158,25 @@ void main() {
158158
}));
159159

160160
test('GlobalStore.perAccount account is logged out while loading; then fails with HTTP status code 401', () => awaitFakeAsync((async) async {
161-
final globalStore = LoadingTestGlobalStore(accounts: [eg.selfAccount]);
161+
final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]);
162+
globalStore.prepareRegisterQueueResponse = (connection) =>
163+
connection.prepare(
164+
delay: TestGlobalStore.removeAccountDuration + Duration(seconds: 1),
165+
apiException: eg.apiExceptionUnauthorized());
166+
final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection;
162167
final future = globalStore.perAccount(eg.selfAccount.id);
168+
check(connection.takeRequests()).length.equals(1); // register request
163169

164170
await logOutAccount(globalStore, eg.selfAccount.id);
165171
check(globalStore.takeDoRemoveAccountCalls())
166172
.single.equals(eg.selfAccount.id);
167173

168-
globalStore.completers[eg.selfAccount.id]!
169-
.single.completeError(eg.apiExceptionUnauthorized());
170174
await check(future).throws<AccountNotFoundException>();
171175
check(globalStore.takeDoRemoveAccountCalls()).isEmpty();
176+
// no poll, server-emoji-data, or register-token requests
177+
check(connection.takeRequests()).isEmpty();
178+
// TODO(#1354) uncomment
179+
// check(connection).isOpen.isFalse();
172180
}));
173181

174182
// TODO test insertAccount
@@ -266,11 +274,20 @@ void main() {
266274
});
267275

268276
test('when store loading', () async {
269-
final globalStore = LoadingTestGlobalStore(accounts: [eg.selfAccount]);
277+
final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]);
270278
checkGlobalStore(globalStore, eg.selfAccount.id,
271279
expectAccount: true, expectStore: false);
272280

273-
// don't await; we'll complete/await it manually after removeAccount
281+
// assert(globalStore.useCachedApiConnections);
282+
// Cache a connection and get this reference to it,
283+
// so we can check later that it gets closed.
284+
// final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection;
285+
286+
globalStore.prepareRegisterQueueResponse = (connection) {
287+
connection.prepare(
288+
delay: TestGlobalStore.removeAccountDuration + Duration(seconds: 1),
289+
json: eg.initialSnapshot().toJson());
290+
};
274291
final loadingFuture = globalStore.perAccount(eg.selfAccount.id);
275292

276293
checkGlobalStore(globalStore, eg.selfAccount.id,
@@ -284,13 +301,14 @@ void main() {
284301
expectAccount: false, expectStore: false);
285302
check(notifyCount).equals(1);
286303

287-
globalStore.completers[eg.selfAccount.id]!.single
288-
.complete(eg.store(account: eg.selfAccount, initialSnapshot: eg.initialSnapshot()));
289-
// TODO test that the never-used store got disposed and its connection closed
290-
await check(loadingFuture).throws<AccountNotFoundException>();
304+
// Actually throws a null-check error; that's the bug #1354.
305+
// TODO(#1354) should specifically throw AccountNotFoundException
306+
await check(loadingFuture).throws();
291307
checkGlobalStore(globalStore, eg.selfAccount.id,
292308
expectAccount: false, expectStore: false);
293309
check(notifyCount).equals(1); // no extra notify
310+
// TODO(#1354) uncomment
311+
// check(connection).isOpen.isFalse();
294312

295313
check(globalStore.debugNumPerAccountStoresLoading).equals(0);
296314
});
@@ -992,44 +1010,39 @@ void main() {
9921010
});
9931011

9941012
group('UpdateMachine.poll reload failure', () {
995-
late LoadingTestGlobalStore globalStore;
996-
997-
List<Completer<PerAccountStore>> completers() =>
998-
globalStore.completers[eg.selfAccount.id]!;
999-
1000-
Future<void> prepareReload(FakeAsync async) async {
1001-
globalStore = LoadingTestGlobalStore(accounts: [eg.selfAccount]);
1002-
1003-
// Simulate the setup that [TestGlobalStore.doLoadPerAccount] would do.
1004-
// (These tests use [LoadingTestGlobalStore] for greater control in
1005-
// later steps; that requires this setup step to be finer-grained too.)
1006-
final updateMachine = eg.updateMachine(
1007-
globalStore: globalStore, account: eg.selfAccount);
1008-
final store = updateMachine.store;
1009-
final future = globalStore.perAccount(eg.selfAccount.id);
1010-
completers().single.complete(store);
1011-
await future;
1012-
completers().clear();
1013+
late UpdateMachineTestGlobalStore globalStore;
10131014

1014-
updateMachine.debugPauseLoop();
1015-
updateMachine.poll();
1016-
(store.connection as FakeApiConnection).prepare(
1015+
Future<void> prepareReload(FakeAsync async, {
1016+
required void Function(FakeApiConnection) prepareRegisterQueueResponse,
1017+
}) async {
1018+
globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]);
1019+
1020+
final store = await globalStore.perAccount(eg.selfAccount.id);
1021+
final updateMachine = store.updateMachine!;
1022+
1023+
final connection = store.connection as FakeApiConnection;
1024+
connection.prepare(
10171025
apiException: eg.apiExceptionBadEventQueueId());
1026+
globalStore.prepareRegisterQueueResponse = prepareRegisterQueueResponse;
1027+
// When we reload, we should get a new connection,
1028+
// just like when the app runs live. This is more realistic,
1029+
// and we don't want a glitch where we try to double-close a connection
1030+
// just because of the test infrastructure. (One of the tests
1031+
// logs out the account, and the connection shouldn't be used after that.)
1032+
globalStore.clearCachedApiConnections();
10181033
updateMachine.debugAdvanceLoop();
1019-
async.elapse(Duration.zero);
1034+
async.elapse(Duration.zero); // the bad-event-queue error arrives
10201035
check(store).isLoading.isTrue();
10211036
}
10221037

10231038
test('user logged out before new store is loaded', () => awaitFakeAsync((async) async {
1024-
await prepareReload(async);
1025-
check(completers()).single.isCompleted.isFalse();
1039+
await prepareReload(async, prepareRegisterQueueResponse: (connection) {
1040+
connection.prepare(
1041+
delay: TestGlobalStore.removeAccountDuration + Duration(seconds: 1),
1042+
json: eg.initialSnapshot().toJson());
1043+
});
10261044

1027-
// [PerAccountStore.fromInitialSnapshot] requires the account
1028-
// to be in the global store when called; do so before logging out.
1029-
final newStore = eg.store(globalStore: globalStore, account: eg.selfAccount);
10301045
await logOutAccount(globalStore, eg.selfAccount.id);
1031-
completers().single.complete(newStore);
1032-
check(completers()).single.isCompleted.isTrue();
10331046
check(globalStore.takeDoRemoveAccountCalls()).single.equals(eg.selfAccount.id);
10341047

10351048
async.elapse(TestGlobalStore.removeAccountDuration);
@@ -1038,15 +1051,19 @@ void main() {
10381051
async.flushTimers();
10391052
// Reload never succeeds and there are no unhandled errors.
10401053
check(globalStore.perAccountSync(eg.selfAccount.id)).isNull();
1041-
}));
1054+
}),
1055+
// An unhandled error is actually the bug #1354, so skip for now
1056+
// TODO(#1354) unskip
1057+
skip: true);
10421058

10431059
test('new store is not loaded, gets HTTP 401 error instead', () => awaitFakeAsync((async) async {
1044-
await prepareReload(async);
1045-
check(completers()).single.isCompleted.isFalse();
1060+
await prepareReload(async, prepareRegisterQueueResponse: (connection) {
1061+
connection.prepare(
1062+
delay: Duration(seconds: 1),
1063+
apiException: eg.apiExceptionUnauthorized());
1064+
});
10461065

1047-
completers().single.completeError(eg.apiExceptionUnauthorized());
1048-
async.elapse(Duration.zero);
1049-
check(completers()).single.isCompleted.isTrue();
1066+
async.elapse(const Duration(seconds: 1));
10501067
check(globalStore.takeDoRemoveAccountCalls()).single.equals(eg.selfAccount.id);
10511068

10521069
async.elapse(TestGlobalStore.removeAccountDuration);

test/model/test_store.dart

+65-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import 'package:zulip/api/model/events.dart';
22
import 'package:zulip/api/model/initial_snapshot.dart';
33
import 'package:zulip/api/model/model.dart';
4+
import 'package:zulip/api/route/events.dart';
5+
import 'package:zulip/api/route/realm.dart';
46
import 'package:zulip/model/database.dart';
57
import 'package:zulip/model/store.dart';
8+
import 'package:zulip/notifications/receive.dart';
69
import 'package:zulip/widgets/store.dart';
710

811
import '../api/fake_api.dart';
@@ -127,7 +130,10 @@ mixin _DatabaseMixin on GlobalStore {
127130
/// Tests can use [PerAccountStore.updateMachine] in order to invoke that logic
128131
/// explicitly when desired.
129132
///
130-
/// See also [TestZulipBinding.globalStore], which provides one of these.
133+
/// See also:
134+
/// * [TestZulipBinding.globalStore], which provides one of these.
135+
/// * [UpdateMachineTestGlobalStore], which prepares per-account data
136+
/// using [UpdateMachine.load] (like [LiveGlobalStore] does).
131137
class TestGlobalStore extends GlobalStore with _ApiConnectionsMixin, _DatabaseMixin {
132138
TestGlobalStore({
133139
GlobalSettingsData? globalSettings,
@@ -176,6 +182,64 @@ class TestGlobalStore extends GlobalStore with _ApiConnectionsMixin, _DatabaseMi
176182
}
177183
}
178184

185+
/// A [GlobalStore] that causes no database queries,
186+
/// and loads per-account data from API responses prepared by callers.
187+
///
188+
/// The per-account stores will use [FakeApiConnection].
189+
///
190+
/// Like [LiveGlobalStore] and unlike [TestGlobalStore],
191+
/// account data is loaded via [UpdateMachine.load].
192+
/// Callers can set [prepareRegisterQueueResponse]
193+
/// to prepare a register-queue payload or an exception.
194+
/// The implementation pauses the event-polling loop
195+
/// to avoid being a nuisance and does a boring
196+
/// [FakeApiConnection.prepare] for the register-token request.
197+
///
198+
/// See also:
199+
/// * [TestGlobalStore], which prepares per-account data
200+
/// without using [UpdateMachine.load].
201+
class UpdateMachineTestGlobalStore extends GlobalStore with _ApiConnectionsMixin, _DatabaseMixin {
202+
UpdateMachineTestGlobalStore({
203+
GlobalSettingsData? globalSettings,
204+
required super.accounts,
205+
}) : super(globalSettings: globalSettings ?? eg.globalSettings());
206+
207+
// [doLoadPerAccount] depends on the cache to prepare the API responses.
208+
// Calling [clearCachedApiConnections] is permitted, though.
209+
@override bool get useCachedApiConnections => true;
210+
@override set useCachedApiConnections(bool value) =>
211+
throw UnsupportedError(
212+
'Setting UpdateMachineTestGlobalStore.useCachedApiConnections '
213+
'is not supported.');
214+
215+
void Function(FakeApiConnection)? prepareRegisterQueueResponse;
216+
217+
void _prepareRegisterQueueSuccess(FakeApiConnection connection) {
218+
connection.prepare(json: eg.initialSnapshot().toJson());
219+
}
220+
221+
@override
222+
Future<PerAccountStore> doLoadPerAccount(int accountId) async {
223+
final account = getAccount(accountId);
224+
225+
// UpdateMachine.load should pick up the connection
226+
// with the network-request responses that we've prepared.
227+
assert(useCachedApiConnections);
228+
229+
final connection = apiConnectionFromAccount(account!) as FakeApiConnection;
230+
(prepareRegisterQueueResponse ?? _prepareRegisterQueueSuccess)(connection);
231+
connection
232+
..prepare(json: GetEventsResult(events: [HeartbeatEvent(id: 2)], queueId: null).toJson())
233+
..prepare(json: ServerEmojiData(codeToNames: {}).toJson());
234+
if (NotificationService.instance.token.value != null) {
235+
connection.prepare(json: {}); // register-token
236+
}
237+
final updateMachine = await UpdateMachine.load(this, accountId);
238+
updateMachine.debugPauseLoop();
239+
return updateMachine.store;
240+
}
241+
}
242+
179243
extension PerAccountStoreTestExtension on PerAccountStore {
180244
Future<void> addUser(User user) async {
181245
await handleEvent(RealmUserAddEvent(id: 1, person: user));

0 commit comments

Comments
 (0)