import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart' show AsyncData, ProviderContainer, ProviderScope; import 'package:flutter_test/flutter_test.dart'; import 'package:logging/logging.dart'; import 'package:whitenoise/main.dart' show WnApp, formatAppLogRecord, initializeAppContainer, initializeWhitenoiseWithRetry, kDataVersion, kDataVersionFile, refreshAfterNotificationRoute, resolveWhitenoiseBaseDirectory; import 'package:whitenoise/providers/auth_provider.dart'; import 'package:whitenoise/providers/notification_provider.dart' show localNotificationSuppressedUntilProvider; import 'package:whitenoise/providers/theme_provider.dart'; import 'package:whitenoise/src/rust/api.dart' as rust_api; import 'package:whitenoise/src/rust/api/accounts.dart' as accounts_api; import 'package:whitenoise/src/rust/api/error.dart'; import 'package:whitenoise/src/rust/frb_generated.dart'; import 'mocks/mock_secure_storage.dart'; import 'mocks/mock_wn_api.dart'; import 'test_helpers.dart'; ({Directory tempDir, void Function() reset}) _mockPathProvider() { final tempDir = Directory.systemTemp.createTempSync('whitenoise_test'); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( const MethodChannel('plugins.flutter.io/path_provider'), (call) async { if (call.method == 'getApplicationDocumentsDirectory') { return tempDir.path; } return null; }, ); return ( tempDir: tempDir, reset: () { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( const MethodChannel('plugins.flutter.io/path_provider'), null, ); if (tempDir.existsSync()) { tempDir.deleteSync(recursive: true); } }, ); } ({Map values, void Function() reset}) _mockSecureStorage([ Map initialValues = const {}, ]) { final values = Map.from(initialValues); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), (call) async { final args = Map.from(call.arguments as Map? ?? const {}); final key = args['key'] as String?; switch (call.method) { case 'read': return key == null ? null : values[key]; case 'readAll': return Map.from(values); case 'write': if (key != null) { values[key] = args['value'] as String; } return null; case 'delete': if (key != null) { values.remove(key); } return null; case 'deleteAll': values.clear(); return null; default: return null; } }, ); return ( values: values, reset: () { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), null, ); }, ); } void _mockAppGroupContainerPath(String? path) { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( const MethodChannel('org.parres.whitenoise/app_group'), (call) async { if (call.method == 'getAppGroupContainerPath') { return path; } return null; }, ); } void _resetAppGroupContainerPath() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( const MethodChannel('org.parres.whitenoise/app_group'), null, ); } class _MockAuthNotifier extends AuthNotifier { int ensureExternalSignersRegisteredCount = 0; Object? ensureExternalSignersRegisteredError; @override Future build() async { state = const AsyncData(testPubkeyA); return testPubkeyA; } @override Future ensureExternalSignersRegistered() async { ensureExternalSignersRegisteredCount++; final error = ensureExternalSignersRegisteredError; if (error != null) { throw error; } } } class _MockThemeNotifier extends ThemeNotifier { ThemeMode _mode = ThemeMode.system; @override Future build() async { state = AsyncData(_mode); return _mode; } void setMode(ThemeMode mode) { _mode = mode; state = AsyncData(mode); } } class _MockInitApi extends MockWnApi { String? createdConfigDataDir; String? createdConfigLogsDir; rust_api.WhitenoiseConfig? initializedConfig; int initCallCount = 0; final initializeWhitenoiseErrors = []; @override Future crateApiCreateWhitenoiseConfig({ required String dataDir, required String logsDir, List? defaultRelayUrls, }) async { createdConfigDataDir = dataDir; createdConfigLogsDir = logsDir; return rust_api.WhitenoiseConfig( dataDir: dataDir, logsDir: logsDir, defaultRelayUrls: defaultRelayUrls, ); } @override Future crateApiInitializeWhitenoise({ required rust_api.WhitenoiseConfig config, }) async { initCallCount++; initializedConfig = config; if (initializeWhitenoiseErrors.isNotEmpty) { throw initializeWhitenoiseErrors.removeAt(0); } } @override void reset() { super.reset(); createdConfigDataDir = null; createdConfigLogsDir = null; initializedConfig = null; initCallCount = 0; initializeWhitenoiseErrors.clear(); } } void main() { late _MockInitApi mockApi; setUpAll(() { mockApi = _MockInitApi(); RustLib.initMock(api: mockApi); }); setUp(() { mockApi.reset(); }); test('formatAppLogRecord includes error and stack details', () { final stackTrace = StackTrace.fromString('stack trace'); final record = LogRecord( Level.WARNING, 'Something happened', 'TestLogger', 'boom', stackTrace, ); expect( formatAppLogRecord(record), 'WARNING: TestLogger: Something happened\n' ' error: boom\n' ' stackTrace: stack trace', ); }); group('WnApp', () { late _MockAuthNotifier mockAuth; late _MockThemeNotifier mockTheme; Future pumpWnApp(WidgetTester tester) async { setUpTestView(tester); mockAuth = _MockAuthNotifier(); mockTheme = _MockThemeNotifier(); await tester.pumpWidget( ProviderScope( overrides: [ authProvider.overrideWith(() => mockAuth), themeProvider.overrideWith(() => mockTheme), secureStorageProvider.overrideWithValue(MockSecureStorage()), ], child: const WnApp(), ), ); await tester.pumpAndSettle(); } testWidgets('has app title', (tester) async { await pumpWnApp(tester); final app = tester.widget(find.byType(MaterialApp)); expect(app.title, 'White Noise'); }); testWidgets('defaults to system theme mode', (tester) async { await pumpWnApp(tester); final app = tester.widget(find.byType(MaterialApp)); expect(app.themeMode, ThemeMode.system); }); testWidgets('responds to theme mode changes', (tester) async { await pumpWnApp(tester); mockTheme.setMode(ThemeMode.dark); await tester.pumpAndSettle(); final app = tester.widget(find.byType(MaterialApp)); expect(app.themeMode, ThemeMode.dark); }); testWidgets('has routes configured', (tester) async { await pumpWnApp(tester); final app = tester.widget(find.byType(MaterialApp)); expect(app.routerConfig, isNotNull); }); testWidgets('forwards lifecycle state changes without error', (tester) async { await pumpWnApp(tester); // ForegroundService is disabled on the test host (Platform.isAndroid // is false), so the observer chain ultimately no-ops — we're // verifying that didChangeAppLifecycleState wires through without // throwing and that each branch of the switch is exercised. tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); await tester.pumpAndSettle(); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); await tester.pumpAndSettle(); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); await tester.pumpAndSettle(); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await tester.pumpAndSettle(); }); testWidgets('does not run resume refresh for the initial resumed event', (tester) async { await pumpWnApp(tester); mockAuth.ensureExternalSignersRegisteredCount = 0; mockApi.resumeAfterBackgroundCallCount = 0; tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await tester.pumpAndSettle(); expect(mockAuth.ensureExternalSignersRegisteredCount, 0); expect(mockApi.resumeAfterBackgroundCallCount, 0); }); testWidgets('reconciles external signer callbacks on foreground resume', (tester) async { await pumpWnApp(tester); mockAuth.ensureExternalSignersRegisteredCount = 0; tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); await tester.pumpAndSettle(); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await tester.pumpAndSettle(); expect(mockAuth.ensureExternalSignersRegisteredCount, 1); }); testWidgets('continues resume work when external signer reconciliation fails', (tester) async { await pumpWnApp(tester); mockAuth.ensureExternalSignersRegisteredError = Exception('signer failed'); mockApi.resumeAfterBackgroundCallCount = 0; tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); await tester.pumpAndSettle(); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await tester.pumpAndSettle(); expect(mockAuth.ensureExternalSignersRegisteredCount, 1); expect(mockApi.resumeAfterBackgroundCallCount, 1); }); testWidgets('resumes Whitenoise catch-up on foreground resume', (tester) async { await pumpWnApp(tester); mockApi.resumeAfterBackgroundCallCount = 0; tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); await tester.pumpAndSettle(); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await tester.pumpAndSettle(); expect(mockApi.resumeAfterBackgroundCallCount, 1); }); testWidgets('temporarily suppresses local notifications on app resume', (tester) async { await pumpWnApp(tester); final container = ProviderScope.containerOf( tester.element(find.byType(WnApp)), listen: false, ); container.read(localNotificationSuppressedUntilProvider.notifier).clear(); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); await tester.pumpAndSettle(); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await tester.pumpAndSettle(); final suppressedUntil = container.read(localNotificationSuppressedUntilProvider); expect(suppressedUntil, isNotNull); expect(suppressedUntil!.isAfter(DateTime.now().toUtc()), isTrue); }); testWidgets('does not suppress local notifications on cold start without a tap', ( tester, ) async { await pumpWnApp(tester); final container = ProviderScope.containerOf( tester.element(find.byType(WnApp)), listen: false, ); expect(container.read(localNotificationSuppressedUntilProvider), isNull); }); testWidgets('logs Whitenoise resume failures without throwing', (tester) async { await pumpWnApp(tester); mockApi.shouldFailResumeAfterBackground = true; tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); await tester.pumpAndSettle(); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await tester.pumpAndSettle(); expect(mockApi.resumeAfterBackgroundCallCount, 1); }); test('refreshAfterNotificationRoute relies on foreground catch-up only', () async { final events = []; await refreshAfterNotificationRoute( resumeAfterBackground: () async { events.add('foreground-catch-up'); }, ); expect(events, ['foreground-catch-up']); }); }); group('initializeAppContainer', () { late ({Directory tempDir, void Function() reset}) pathProvider; late ({Map values, void Function() reset}) secureStorage; setUp(() { pathProvider = _mockPathProvider(); secureStorage = _mockSecureStorage(); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( const MethodChannel('plugins.flutter.io/shared_preferences'), (call) async => {}, ); }); tearDown(() { pathProvider.reset(); secureStorage.reset(); _resetAppGroupContainerPath(); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( const MethodChannel('plugins.flutter.io/shared_preferences'), null, ); }); test('creates data directory', () async { await initializeAppContainer(); expect(Directory('${pathProvider.tempDir.path}/whitenoise/data').existsSync(), isTrue); }); test('creates logs directory', () async { await initializeAppContainer(); expect(Directory('${pathProvider.tempDir.path}/whitenoise/logs').existsSync(), isTrue); }); test('calls createWhitenoiseConfig with data directory', () async { await initializeAppContainer(); expect(mockApi.createdConfigDataDir, '${pathProvider.tempDir.path}/whitenoise/data'); }); test('calls createWhitenoiseConfig with logs directory', () async { await initializeAppContainer(); expect(mockApi.createdConfigLogsDir, '${pathProvider.tempDir.path}/whitenoise/logs'); }); test('calls initializeWhitenoise with config', () async { await initializeAppContainer(); expect(mockApi.initializedConfig, isNotNull); }); test('retries initializeWhitenoise when the database pool is temporarily exhausted', () async { mockApi.initializeWhitenoiseErrors.add( const ApiError.databasePoolTimedOut( message: 'pool timed out while waiting for an open connection', ), ); await initializeAppContainer(initializeRetryDelay: (_) async {}); expect(mockApi.initCallCount, 2); }); test('uses the default retry delay for transient startup errors', () async { mockApi.initializeWhitenoiseErrors.add( const ApiError.databasePoolTimedOut( message: 'pool timed out while waiting for an open connection', ), ); final config = const rust_api.WhitenoiseConfig(dataDir: 'data', logsDir: 'logs'); final stopwatch = Stopwatch()..start(); await initializeWhitenoiseWithRetry(config); expect(mockApi.initCallCount, 2); expect(stopwatch.elapsed, greaterThanOrEqualTo(const Duration(milliseconds: 500))); }); test('does not retry initializeWhitenoise for non-transient startup errors', () async { mockApi.initializeWhitenoiseErrors.add( const ApiError.whitenoise(message: 'Database error: migration failed'), ); await expectLater( initializeAppContainer(initializeRetryDelay: (_) async {}), throwsA(isA()), ); expect(mockApi.initCallCount, 1); }); test('does not retry initializeWhitenoise for text that looks like a pool timeout', () async { mockApi.initializeWhitenoiseErrors.add( const ApiError.whitenoise( message: 'Database error: SQLx error: pool timed out while waiting for an open connection', ), ); await expectLater( initializeAppContainer(initializeRetryDelay: (_) async {}), throwsA(isA()), ); expect(mockApi.initCallCount, 1); }); test('returns a ProviderContainer', () async { final container = await initializeAppContainer(); expect(container, isA()); }); test('awaits authProvider', () async { final container = await initializeAppContainer(); expect(container.read(authProvider), isA()); }); test('writes version file on fresh install', () async { await initializeAppContainer(); final versionFile = File('${pathProvider.tempDir.path}/whitenoise/data/$kDataVersionFile'); expect(versionFile.existsSync(), isTrue); expect(versionFile.readAsStringSync().trim(), '$kDataVersion'); }); test('skips migration when version matches', () async { final dataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data'); await dataDir.create(recursive: true); final versionFile = File('${dataDir.path}/$kDataVersionFile'); versionFile.writeAsStringSync('$kDataVersion'); final marker = File('${dataDir.path}/whitenoise.json'); await marker.create(); await initializeAppContainer(); expect(marker.existsSync(), isTrue); }); test('preserves data directory when no version file exists', () async { final dataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data'); await dataDir.create(recursive: true); final oldSecrets = File('${dataDir.path}/whitenoise.json'); final oldUuid = File('${dataDir.path}/whitenoise_uuid'); final oldDb = File('${dataDir.path}/release/whitenoise.sqlite'); await Directory('${dataDir.path}/release').create(recursive: true); await oldSecrets.create(); await oldUuid.create(); await oldDb.create(); await initializeAppContainer(); expect(oldSecrets.existsSync(), isTrue); expect(oldUuid.existsSync(), isTrue); expect(oldDb.existsSync(), isTrue); expect(dataDir.existsSync(), isTrue); final versionFile = File('${dataDir.path}/$kDataVersionFile'); expect(versionFile.existsSync(), isTrue); expect(versionFile.readAsStringSync().trim(), '$kDataVersion'); }); test('preserves Flutter secure storage when no version file exists', () async { secureStorage.reset(); secureStorage = _mockSecureStorage({'active_account_pubkey': testPubkeyA}); mockApi.accounts = [ accounts_api.Account( pubkey: testPubkeyA, accountType: accounts_api.AccountType.local, createdAt: DateTime(2024), updatedAt: DateTime(2024), ), ]; await initializeAppContainer(); expect(secureStorage.values['active_account_pubkey'], testPubkeyA); }); test('preserves data directory when version is outdated', () async { final dataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data'); await dataDir.create(recursive: true); final versionFile = File('${dataDir.path}/$kDataVersionFile'); versionFile.writeAsStringSync('0'); final marker = File('${dataDir.path}/whitenoise.json'); await marker.create(); await initializeAppContainer(); expect(marker.existsSync(), isTrue); expect(versionFile.readAsStringSync().trim(), '$kDataVersion'); }); test('uses App Group container on iOS when available', () async { final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test'); _mockAppGroupContainerPath(appGroupDir.path); addTearDown(() { if (appGroupDir.existsSync()) { appGroupDir.deleteSync(recursive: true); } }); final baseDir = await resolveWhitenoiseBaseDirectory(isIOS: true); expect(baseDir.path, '${appGroupDir.path}/whitenoise'); }); test('moves existing Documents data into App Group container on iOS', () async { final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test'); _mockAppGroupContainerPath(appGroupDir.path); final oldDataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data'); await oldDataDir.create(recursive: true); final marker = File('${oldDataDir.path}/marker.txt'); await marker.writeAsString('existing'); addTearDown(() { if (appGroupDir.existsSync()) { appGroupDir.deleteSync(recursive: true); } }); final baseDir = await resolveWhitenoiseBaseDirectory(isIOS: true); expect(baseDir.path, '${appGroupDir.path}/whitenoise'); expect(File('${baseDir.path}/data/marker.txt').readAsStringSync(), 'existing'); expect(Directory('${pathProvider.tempDir.path}/whitenoise').existsSync(), isFalse); }); test('moves Documents data when App Group container only has empty directories', () async { final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test'); _mockAppGroupContainerPath(appGroupDir.path); final oldDataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data'); await oldDataDir.create(recursive: true); await File('${oldDataDir.path}/marker.txt').writeAsString('existing'); await Directory('${appGroupDir.path}/whitenoise/data').create(recursive: true); await Directory('${appGroupDir.path}/whitenoise/logs').create(recursive: true); addTearDown(() { if (appGroupDir.existsSync()) { appGroupDir.deleteSync(recursive: true); } }); final baseDir = await resolveWhitenoiseBaseDirectory(isIOS: true); expect(baseDir.path, '${appGroupDir.path}/whitenoise'); expect(File('${baseDir.path}/data/marker.txt').readAsStringSync(), 'existing'); expect(Directory('${pathProvider.tempDir.path}/whitenoise').existsSync(), isFalse); }); test('preserves unversioned App Group data when Documents has no data', () async { final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test'); _mockAppGroupContainerPath(appGroupDir.path); await Directory('${pathProvider.tempDir.path}/whitenoise/data').create(recursive: true); await Directory('${pathProvider.tempDir.path}/whitenoise/logs').create(recursive: true); final appGroupDataDir = Directory('${appGroupDir.path}/whitenoise/data'); await appGroupDataDir.create(recursive: true); await File('${appGroupDataDir.path}/nse-created.sqlite').writeAsString('nse'); addTearDown(() { if (appGroupDir.existsSync()) { appGroupDir.deleteSync(recursive: true); } }); final baseDir = await resolveWhitenoiseBaseDirectory(isIOS: true); expect(baseDir.path, '${appGroupDir.path}/whitenoise'); expect(File('${baseDir.path}/data/nse-created.sqlite').readAsStringSync(), 'nse'); expect(Directory('${pathProvider.tempDir.path}/whitenoise').existsSync(), isFalse); }); test('copies Documents data when App Group migration rename fails', () async { final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test'); _mockAppGroupContainerPath(appGroupDir.path); final oldDataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data/nested'); await oldDataDir.create(recursive: true); await File('${oldDataDir.path}/marker.txt').writeAsString('existing'); addTearDown(() { if (appGroupDir.existsSync()) { appGroupDir.deleteSync(recursive: true); } }); final baseDir = await resolveWhitenoiseBaseDirectory( isIOS: true, renameDirectory: (_, _) async { throw const FileSystemException('rename failed'); }, ); expect(baseDir.path, '${appGroupDir.path}/whitenoise'); expect(File('${baseDir.path}/data/nested/marker.txt').readAsStringSync(), 'existing'); expect(Directory('${pathProvider.tempDir.path}/whitenoise').existsSync(), isFalse); }); test('removes partial App Group copy when Documents migration copy fails', () async { final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test'); _mockAppGroupContainerPath(appGroupDir.path); final oldDataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data'); await oldDataDir.create(recursive: true); await File('${oldDataDir.path}/marker.txt').writeAsString('existing'); addTearDown(() { if (appGroupDir.existsSync()) { appGroupDir.deleteSync(recursive: true); } }); final baseDir = await resolveWhitenoiseBaseDirectory( isIOS: true, renameDirectory: (_, _) async { throw const FileSystemException('rename failed'); }, copyDirectory: (_, to) async { await Directory('${to.path}/data').create(recursive: true); await File('${to.path}/data/partial.txt').writeAsString('partial'); throw const FileSystemException('copy failed'); }, ); expect(baseDir.path, '${pathProvider.tempDir.path}/whitenoise'); expect(File('${oldDataDir.path}/marker.txt').readAsStringSync(), 'existing'); expect(Directory('${appGroupDir.path}/whitenoise').existsSync(), isFalse); }); test('returns App Group path when copied Documents data cannot be deleted', () async { final previousLogLevel = Logger.root.level; Logger.root.level = Level.ALL; final records = []; final logSubscription = Logger('WnApp').onRecord.listen(records.add); addTearDown(() async { await logSubscription.cancel(); Logger.root.level = previousLogLevel; }); final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test'); _mockAppGroupContainerPath(appGroupDir.path); final oldDataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data'); await oldDataDir.create(recursive: true); await File('${oldDataDir.path}/marker.txt').writeAsString('existing'); addTearDown(() { if (appGroupDir.existsSync()) { appGroupDir.deleteSync(recursive: true); } }); final baseDir = await resolveWhitenoiseBaseDirectory( isIOS: true, renameDirectory: (_, _) async { throw const FileSystemException('rename failed'); }, copyDirectory: (from, to) async { await Directory('${to.path}/data').create(recursive: true); await File('${to.path}/data/marker.txt').writeAsString( File('${from.path}/data/marker.txt').readAsStringSync(), ); }, deleteSourceDirectory: (_) async { throw const FileSystemException('delete failed'); }, ); expect(baseDir.path, '${appGroupDir.path}/whitenoise'); expect(File('${baseDir.path}/data/marker.txt').readAsStringSync(), 'existing'); expect(File('${oldDataDir.path}/marker.txt').readAsStringSync(), 'existing'); expect( records.any( (record) => record.message == 'Failed to remove old Documents data after App Group migration' && record.error is FileSystemException && record.stackTrace != null, ), isTrue, ); }); test( 'moves Documents data over unversioned App Group data created before app launch', () async { final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test'); _mockAppGroupContainerPath(appGroupDir.path); final oldDataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data'); await oldDataDir.create(recursive: true); await File('${oldDataDir.path}/old-marker.txt').writeAsString('old'); final appGroupDataDir = Directory('${appGroupDir.path}/whitenoise/data'); await appGroupDataDir.create(recursive: true); await File('${appGroupDataDir.path}/nse-scratch.sqlite').writeAsString('scratch'); addTearDown(() { if (appGroupDir.existsSync()) { appGroupDir.deleteSync(recursive: true); } }); final baseDir = await resolveWhitenoiseBaseDirectory(isIOS: true); expect(baseDir.path, '${appGroupDir.path}/whitenoise'); expect(File('${baseDir.path}/data/old-marker.txt').readAsStringSync(), 'old'); expect(File('${baseDir.path}/data/nse-scratch.sqlite').existsSync(), isFalse); expect(Directory('${pathProvider.tempDir.path}/whitenoise').existsSync(), isFalse); }, ); test('recovers pending reset before moving Documents data into App Group', () async { secureStorage.reset(); secureStorage = _mockSecureStorage({'active_account_pubkey': testPubkeyA}); final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test'); _mockAppGroupContainerPath(appGroupDir.path); final oldDataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data'); await oldDataDir.create(recursive: true); await File('${oldDataDir.path}/old-marker.txt').writeAsString('old'); await File('${pathProvider.tempDir.path}/whitenoise/reset_pending').writeAsString('pending'); addTearDown(() { if (appGroupDir.existsSync()) { appGroupDir.deleteSync(recursive: true); } }); await initializeAppContainer(isIOS: true); final appGroupBase = '${appGroupDir.path}/whitenoise'; expect(mockApi.createdConfigDataDir, '$appGroupBase/data'); expect(File('$appGroupBase/data/old-marker.txt').existsSync(), isFalse); expect(File('$appGroupBase/data/$kDataVersionFile').readAsStringSync().trim(), '1'); expect( File('${pathProvider.tempDir.path}/whitenoise/data/old-marker.txt').existsSync(), isFalse, ); expect(File('${pathProvider.tempDir.path}/whitenoise/reset_pending').existsSync(), isFalse); expect(secureStorage.values, isEmpty); }); test('preserves App Group data when both storage locations contain data', () async { final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test'); _mockAppGroupContainerPath(appGroupDir.path); final oldDataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data'); await oldDataDir.create(recursive: true); await File('${oldDataDir.path}/old-marker.txt').writeAsString('old'); final appGroupDataDir = Directory('${appGroupDir.path}/whitenoise/data'); await appGroupDataDir.create(recursive: true); await File('${appGroupDataDir.path}/new-marker.txt').writeAsString('new'); await File('${appGroupDataDir.path}/$kDataVersionFile').writeAsString('$kDataVersion'); addTearDown(() { if (appGroupDir.existsSync()) { appGroupDir.deleteSync(recursive: true); } }); final baseDir = await resolveWhitenoiseBaseDirectory(isIOS: true); expect(baseDir.path, '${appGroupDir.path}/whitenoise'); expect(File('${baseDir.path}/data/new-marker.txt').readAsStringSync(), 'new'); expect( File('${pathProvider.tempDir.path}/whitenoise/data/old-marker.txt').existsSync(), isFalse, ); }); test('keeps legacy Documents data when accepted App Group cleanup fails', () async { final previousLogLevel = Logger.root.level; Logger.root.level = Level.ALL; final records = []; final logSubscription = Logger('WnApp').onRecord.listen(records.add); addTearDown(() async { await logSubscription.cancel(); Logger.root.level = previousLogLevel; }); final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test'); _mockAppGroupContainerPath(appGroupDir.path); final oldDataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data'); await oldDataDir.create(recursive: true); await File('${oldDataDir.path}/old-marker.txt').writeAsString('old'); final appGroupDataDir = Directory('${appGroupDir.path}/whitenoise/data'); await appGroupDataDir.create(recursive: true); await File('${appGroupDataDir.path}/new-marker.txt').writeAsString('new'); await File('${appGroupDataDir.path}/$kDataVersionFile').writeAsString('$kDataVersion'); addTearDown(() { if (appGroupDir.existsSync()) { appGroupDir.deleteSync(recursive: true); } }); final baseDir = await resolveWhitenoiseBaseDirectory( isIOS: true, deleteSourceDirectory: (_) async { throw const FileSystemException('delete failed'); }, ); expect(baseDir.path, '${appGroupDir.path}/whitenoise'); expect(File('${baseDir.path}/data/new-marker.txt').readAsStringSync(), 'new'); expect(File('${oldDataDir.path}/old-marker.txt').readAsStringSync(), 'old'); expect( records.any( (record) => record.message == 'Failed to remove old Documents data after App Group data was already accepted' && record.error is FileSystemException && record.stackTrace != null, ), isTrue, ); }); test('throws on iOS when App Group container is unavailable', () async { _mockAppGroupContainerPath(null); await expectLater( resolveWhitenoiseBaseDirectory(isIOS: true), throwsA( predicate( (error) => error.toString().contains('App Group container unavailable'), ), ), ); }); test('throws on iOS when App Group lookup fails', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( const MethodChannel('org.parres.whitenoise/app_group'), (_) async { throw PlatformException(code: 'unavailable'); }, ); await expectLater( resolveWhitenoiseBaseDirectory(isIOS: true), throwsA( predicate( (error) => error.toString().contains('App Group container unavailable'), ), ), ); }); }); }