-
Notifications
You must be signed in to change notification settings - Fork 306
store: Ensure sole ownership of MessageListView #1340
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f6655b8
8b601be
d186ed0
a055486
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
import 'dart:async'; | ||
|
||
import '../notifications/receive.dart'; | ||
import 'store.dart'; | ||
|
||
// TODO: Make this a part of GlobalStore | ||
Future<void> logOutAccount(GlobalStore globalStore, int accountId) async { | ||
final account = globalStore.getAccount(accountId); | ||
if (account == null) return; // TODO(log) | ||
|
||
// Unawaited, to not block removing the account on this request. | ||
unawaited(unregisterToken(globalStore, accountId)); | ||
|
||
await globalStore.removeAccount(accountId); | ||
} | ||
|
||
Future<void> unregisterToken(GlobalStore globalStore, int accountId) async { | ||
final account = globalStore.getAccount(accountId); | ||
if (account == null) return; // TODO(log) | ||
|
||
// TODO(#322) use actual acked push token; until #322, this is just null. | ||
final token = account.ackedPushToken | ||
// Try the current token as a fallback; maybe the server has registered | ||
// it and we just haven't recorded that fact in the client. | ||
?? NotificationService.instance.token.value; | ||
if (token == null) return; | ||
|
||
final connection = globalStore.apiConnectionFromAccount(account); | ||
try { | ||
await NotificationService.unregisterToken(connection, token: token); | ||
} catch (e) { | ||
// TODO retry? handle failures? | ||
} finally { | ||
connection.close(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,188 @@ | ||
import 'package:checks/checks.dart'; | ||
import 'package:flutter/foundation.dart'; | ||
import 'package:flutter_test/flutter_test.dart'; | ||
import 'package:http/http.dart' as http; | ||
import 'package:zulip/api/exception.dart'; | ||
import 'package:zulip/model/actions.dart'; | ||
import 'package:zulip/model/store.dart'; | ||
import 'package:zulip/notifications/receive.dart'; | ||
|
||
import '../api/fake_api.dart'; | ||
import '../example_data.dart' as eg; | ||
import '../fake_async.dart'; | ||
import '../model/binding.dart'; | ||
import '../model/store_checks.dart'; | ||
import '../model/test_store.dart'; | ||
import '../stdlib_checks.dart'; | ||
import 'store_test.dart'; | ||
|
||
void main() { | ||
TestZulipBinding.ensureInitialized(); | ||
|
||
late PerAccountStore store; | ||
late FakeApiConnection connection; | ||
|
||
Future<void> prepare({String? ackedPushToken = '123'}) async { | ||
addTearDown(testBinding.reset); | ||
final selfAccount = eg.selfAccount.copyWith(ackedPushToken: Value(ackedPushToken)); | ||
await testBinding.globalStore.add(selfAccount, eg.initialSnapshot()); | ||
store = await testBinding.globalStore.perAccount(selfAccount.id); | ||
connection = store.connection as FakeApiConnection; | ||
} | ||
|
||
/// Creates and caches a new [FakeApiConnection] in [TestGlobalStore]. | ||
/// | ||
/// In live code, [unregisterToken] makes a new [ApiConnection] for the | ||
/// unregister-token request instead of reusing the store's connection. | ||
/// To enable callers to prepare responses for that request, this function | ||
/// creates a new [FakeApiConnection] and caches it in [TestGlobalStore] | ||
/// for [unregisterToken] to pick up. | ||
/// | ||
/// Call this instead of just turning on | ||
/// [TestGlobalStore.useCachedApiConnections] so that [unregisterToken] | ||
/// doesn't try to call `close` twice on the same connection instance, | ||
/// which isn't allowed. (Once by the unregister-token code | ||
/// and once as part of removing the account.) | ||
FakeApiConnection separateConnection() { | ||
testBinding.globalStore | ||
..clearCachedApiConnections() | ||
..useCachedApiConnections = true; | ||
return testBinding.globalStore | ||
.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; | ||
} | ||
|
||
String unregisterApiPathForPlatform(TargetPlatform platform) { | ||
return switch (platform) { | ||
TargetPlatform.android => '/api/v1/users/me/android_gcm_reg_id', | ||
TargetPlatform.iOS => '/api/v1/users/me/apns_device_token', | ||
_ => throw Error(), | ||
}; | ||
} | ||
|
||
void checkSingleUnregisterRequest( | ||
FakeApiConnection connection, { | ||
String? expectedToken, | ||
}) { | ||
final subject = check(connection.takeRequests()).single.isA<http.Request>() | ||
..method.equals('DELETE') | ||
..url.path.equals(unregisterApiPathForPlatform(defaultTargetPlatform)); | ||
if (expectedToken != null) { | ||
subject.bodyFields.deepEquals({'token': expectedToken}); | ||
} | ||
} | ||
|
||
group('logOutAccount', () { | ||
test('smoke', () => awaitFakeAsync((async) async { | ||
await prepare(); | ||
check(testBinding.globalStore).accountIds.single.equals(eg.selfAccount.id); | ||
const unregisterDelay = Duration(seconds: 5); | ||
assert(unregisterDelay > TestGlobalStore.removeAccountDuration); | ||
final newConnection = separateConnection() | ||
..prepare(delay: unregisterDelay, json: {'msg': '', 'result': 'success'}); | ||
|
||
final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); | ||
// Unregister-token request and account removal dispatched together | ||
checkSingleUnregisterRequest(newConnection); | ||
check(testBinding.globalStore.takeDoRemoveAccountCalls()) | ||
.single.equals(eg.selfAccount.id); | ||
|
||
async.elapse(TestGlobalStore.removeAccountDuration); | ||
await future; | ||
// Account removal not blocked on unregister-token response | ||
check(testBinding.globalStore).accountIds.isEmpty(); | ||
check(connection.isOpen).isFalse(); | ||
check(newConnection.isOpen).isTrue(); // still busy with unregister-token | ||
|
||
async.elapse(unregisterDelay - TestGlobalStore.removeAccountDuration); | ||
check(newConnection.isOpen).isFalse(); | ||
})); | ||
|
||
test('unregister request has an error', () => awaitFakeAsync((async) async { | ||
await prepare(); | ||
check(testBinding.globalStore).accountIds.single.equals(eg.selfAccount.id); | ||
const unregisterDelay = Duration(seconds: 5); | ||
assert(unregisterDelay > TestGlobalStore.removeAccountDuration); | ||
final exception = ZulipApiException( | ||
httpStatus: 401, | ||
code: 'UNAUTHORIZED', | ||
data: {"result": "error", "msg": "Invalid API key", "code": "UNAUTHORIZED"}, | ||
routeName: 'removeEtcEtcToken', | ||
message: 'Invalid API key', | ||
); | ||
final newConnection = separateConnection() | ||
..prepare(delay: unregisterDelay, exception: exception); | ||
|
||
final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); | ||
// Unregister-token request and account removal dispatched together | ||
checkSingleUnregisterRequest(newConnection); | ||
check(testBinding.globalStore.takeDoRemoveAccountCalls()) | ||
.single.equals(eg.selfAccount.id); | ||
|
||
async.elapse(TestGlobalStore.removeAccountDuration); | ||
await future; | ||
// Account removal not blocked on unregister-token response | ||
check(testBinding.globalStore).accountIds.isEmpty(); | ||
check(connection.isOpen).isFalse(); | ||
check(newConnection.isOpen).isTrue(); // for the unregister-token request | ||
|
||
async.elapse(unregisterDelay - TestGlobalStore.removeAccountDuration); | ||
check(newConnection.isOpen).isFalse(); | ||
})); | ||
}); | ||
|
||
group('unregisterToken', () { | ||
testAndroidIos('smoke, happy path', () => awaitFakeAsync((async) async { | ||
await prepare(ackedPushToken: '123'); | ||
|
||
final newConnection = separateConnection() | ||
..prepare(json: {'msg': '', 'result': 'success'}); | ||
final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); | ||
async.elapse(Duration.zero); | ||
await future; | ||
checkSingleUnregisterRequest(newConnection, expectedToken: '123'); | ||
check(newConnection.isOpen).isFalse(); | ||
})); | ||
|
||
test('fallback to current token if acked is missing', () => awaitFakeAsync((async) async { | ||
await prepare(ackedPushToken: null); | ||
NotificationService.instance.token = ValueNotifier('asdf'); | ||
|
||
final newConnection = separateConnection() | ||
..prepare(json: {'msg': '', 'result': 'success'}); | ||
final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); | ||
async.elapse(Duration.zero); | ||
await future; | ||
checkSingleUnregisterRequest(newConnection, expectedToken: 'asdf'); | ||
check(newConnection.isOpen).isFalse(); | ||
})); | ||
|
||
test('no error if acked token and current token both missing', () => awaitFakeAsync((async) async { | ||
await prepare(ackedPushToken: null); | ||
NotificationService.instance.token = ValueNotifier(null); | ||
|
||
final newConnection = separateConnection(); | ||
final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); | ||
async.flushTimers(); | ||
await future; | ||
check(newConnection.takeRequests()).isEmpty(); | ||
})); | ||
|
||
test('connection closed if request errors', () => awaitFakeAsync((async) async { | ||
await prepare(ackedPushToken: '123'); | ||
|
||
final newConnection = separateConnection() | ||
..prepare(exception: ZulipApiException( | ||
httpStatus: 401, | ||
code: 'UNAUTHORIZED', | ||
data: {"result": "error", "msg": "Invalid API key", "code": "UNAUTHORIZED"}, | ||
routeName: 'removeEtcEtcToken', | ||
message: 'Invalid API key', | ||
)); | ||
final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); | ||
async.elapse(Duration.zero); | ||
await future; | ||
checkSingleUnregisterRequest(newConnection, expectedToken: '123'); | ||
check(newConnection.isOpen).isFalse(); | ||
})); | ||
}); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -12,8 +12,6 @@ import 'package:zulip/api/model/model.dart'; | |
import 'package:zulip/api/route/events.dart'; | ||
import 'package:zulip/api/route/messages.dart'; | ||
import 'package:zulip/api/route/realm.dart'; | ||
import 'package:zulip/model/message_list.dart'; | ||
import 'package:zulip/model/narrow.dart'; | ||
import 'package:zulip/log.dart'; | ||
import 'package:zulip/model/store.dart'; | ||
import 'package:zulip/notifications/receive.dart'; | ||
|
@@ -824,25 +822,6 @@ void main() { | |
checkReload(prepareHandleEventError); | ||
}); | ||
|
||
test('expired queue disposes registered MessageListView instances', () => awaitFakeAsync((async) async { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This scenario is still highly relevant — see my discussion above of the potential for a leak. So we should still test it. The change to make is just that the test now needs appropriate widgets as part of the setup, because the MessageList widget is now part of what makes this behave correctly. |
||
// Regression test for: https://github.com/zulip/zulip-flutter/issues/810 | ||
await preparePoll(); | ||
|
||
// Make sure there are [MessageListView]s in the message store. | ||
MessageListView.init(store: store, narrow: const MentionsNarrow()); | ||
MessageListView.init(store: store, narrow: const StarredMessagesNarrow()); | ||
check(store.debugMessageListViews).length.equals(2); | ||
|
||
// Let the server expire the event queue. | ||
prepareExpiredEventQueue(); | ||
updateMachine.debugAdvanceLoop(); | ||
async.elapse(Duration.zero); | ||
|
||
// The old store's [MessageListView]s have been disposed. | ||
// (And no exception was thrown; that was #810.) | ||
check(store.debugMessageListViews).isEmpty(); | ||
})); | ||
|
||
group('report error', () { | ||
String? lastReportedError; | ||
String? takeLastReportedError() { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this line is removed, it looks like all tests still pass. There should be a test that would notice if we dropped this.
(I think this is exactly what the test discussed in my previous comment would do.)