Files
noo/test/providers/session_controller_test.dart
T
Ayushya Amitabh 0a4608e6c3
Build iOS / build (push) Successful in 4m45s
Build App Bundle / build (push) Successful in 7m7s
Release 1.0.3: refine tablet layouts, pointer selection and account sync cleanup
2026-10-09 00:36:42 -04:00

184 lines
6.8 KiB
Dart

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();
});
for (final fails in [false, true]) {
test('account removal awaits native cleanup (failure: $fails)', () 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,
'acct_${accountId}_ui_synced_folders': ['/Photos'],
});
final connectivity = ConnectivityController();
await pumpUntil(() => connectivity.isOffline);
final session = SessionController(connectivity);
await pumpUntil(() => session.isLoggedIn);
const channel = MethodChannel('dev.ayushya.noo/sync_service');
var cleanupCalled = false;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'removeAccountData') {
expect(call.arguments['accountId'], accountId);
expect(session.accounts, isNotEmpty);
cleanupCalled = true;
if (fails) throw PlatformException(code: 'cleanup_failed');
}
return null;
});
try {
if (fails) {
await expectLater(
session.removeAccount(accountId),
throwsA(isA<PlatformException>()),
);
expect(session.accounts, isNotEmpty);
expect(
(await SharedPreferences.getInstance()).getStringList(
'acct_${accountId}_ui_synced_folders',
),
['/Photos'],
);
} else {
await session.removeAccount(accountId);
expect(session.accounts, isEmpty);
expect(session.isLoggedIn, false);
expect(
(await SharedPreferences.getInstance()).containsKey(
'acct_${accountId}_ui_synced_folders',
),
false,
);
}
expect(cleanupCalled, true);
} finally {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
session.dispose();
connectivity.dispose();
}
});
}
}