Fix recurring empty-list-on-login race at the root
addAccountActivatedListener/addAccountReadyListener fired once, to whichever listeners were registered at that instant. Most per-tab controllers are lazy providers, only constructed (and so only registering) whenever something first reads them - if that happened after the one-shot event already fired (e.g. a cold-start connectivity misdetection delaying a tab's construction until after login verified), that controller's initial fetch never ran, leaving its list permanently empty. Both listeners now call back immediately on registration if the account is already in the state being subscribed to, closing the race regardless of construction timing. Documents the failure class and the guardrail for future controllers in architecture.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:noo/models/saved_account.dart';
|
||||
import 'package:noo/providers/connectivity_controller.dart';
|
||||
import 'package:noo/providers/session_controller.dart';
|
||||
|
||||
/// Regression coverage for the "empty list on first load" bug class: a
|
||||
/// controller built by a *lazy* `ChangeNotifierProvider` (FilesController,
|
||||
/// PhotosController, ...) can be constructed after
|
||||
/// [SessionController]'s one-shot account-activated/ready event already
|
||||
/// fired - e.g. `ConnectivityController` misreporting offline for a couple
|
||||
/// of seconds right after a cold Android start hides the Files tab from
|
||||
/// `main.dart`'s bottom nav, delaying `FilesController`'s construction
|
||||
/// until after login already finished. `addAccountActivatedListener`/
|
||||
/// `addAccountReadyListener` must call back immediately for a listener
|
||||
/// that registers after the fact, not only fire for the *next* login.
|
||||
void main() {
|
||||
const secureStorageChannel = MethodChannel(
|
||||
'plugins.it_nomads.com/flutter_secure_storage',
|
||||
);
|
||||
const connectivityChannel = MethodChannel(
|
||||
'dev.fluttercommunity.plus/connectivity',
|
||||
);
|
||||
const connectivityStatusChannel = EventChannel(
|
||||
'dev.fluttercommunity.plus/connectivity_status',
|
||||
);
|
||||
|
||||
const accountId = 'server_example_com__alice';
|
||||
const passwordKey = 'nc_app_password_$accountId';
|
||||
const password = 'app-password';
|
||||
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(secureStorageChannel, (call) async {
|
||||
if (call.method == 'readAll') {
|
||||
return <String, String>{passwordKey: password};
|
||||
}
|
||||
if (call.method == 'read') {
|
||||
final key = (call.arguments as Map)['key'] as String?;
|
||||
return key == passwordKey ? password : null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
// Reports no network on every `checkConnectivity()` call, so
|
||||
// `ConnectivityController` deterministically settles into `isOffline`
|
||||
// and `SessionController` never attempts a real (and, in this
|
||||
// sandboxed test environment, unreachable) HTTP call.
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(connectivityChannel, (call) async {
|
||||
if (call.method == 'check') return <String>['none'];
|
||||
return null;
|
||||
});
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockStreamHandler(
|
||||
connectivityStatusChannel,
|
||||
MockStreamHandler.inline(onListen: (arguments, events) {}),
|
||||
);
|
||||
});
|
||||
|
||||
Future<void> pumpUntil(bool Function() condition) async {
|
||||
for (var i = 0; i < 50 && !condition(); i++) {
|
||||
await Future.delayed(Duration.zero);
|
||||
}
|
||||
}
|
||||
|
||||
test('addAccountReadyListener calls back immediately for a listener that '
|
||||
'registers after a provisional/offline login already happened', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'account_migration_v1_done': true,
|
||||
'accounts_list': jsonEncode([
|
||||
const SavedAccount(
|
||||
id: accountId,
|
||||
serverUrl: 'https://server.example.com',
|
||||
username: 'alice',
|
||||
).toJson(),
|
||||
]),
|
||||
'active_account_id': accountId,
|
||||
});
|
||||
|
||||
final connectivity = ConnectivityController();
|
||||
await pumpUntil(() => connectivity.isOffline);
|
||||
expect(connectivity.isOffline, true);
|
||||
|
||||
final session = SessionController(connectivity);
|
||||
await pumpUntil(() => session.isLoggedIn);
|
||||
expect(session.isLoggedIn, true);
|
||||
|
||||
// The fix under test: this mirrors what FilesController's/
|
||||
// OfflineController's own constructor does, but simulated as
|
||||
// happening *after* the event above already fired - exactly the
|
||||
// lazy-Provider race this test guards against.
|
||||
var readyCalls = 0;
|
||||
session.addAccountReadyListener(() => readyCalls++);
|
||||
expect(readyCalls, 1);
|
||||
|
||||
connectivity.dispose();
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
test('addAccountReadyListener does not call back before any account is '
|
||||
'active', () async {
|
||||
SharedPreferences.setMockInitialValues({'account_migration_v1_done': true});
|
||||
|
||||
final connectivity = ConnectivityController();
|
||||
final session = SessionController(connectivity);
|
||||
await pumpUntil(() => !session.isRestoringSession);
|
||||
expect(session.isLoggedIn, false);
|
||||
|
||||
var readyCalls = 0;
|
||||
session.addAccountReadyListener(() => readyCalls++);
|
||||
expect(readyCalls, 0);
|
||||
|
||||
connectivity.dispose();
|
||||
session.dispose();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user