* Add remote push notification plumbing * Checkpoint notification reliability cleanup * Prepare notification reliability PR * Fix notification PR review findings * Address notification review follow-ups * Clean up failed iOS data migration copies * Address app resume and migration cleanup review * Restore coverage for notification review fixes * Fix App Group migration guard * Address notification PR review feedback * Cover notification review edge cases * Address notification test review feedback * Address NSE migration and startup retry review * Use foreground catch-up for notification refresh * Update whitenoise-rs foreground catch-up dependency * Cover notification catch-up error paths * Fix notification review edge cases
935 lines
34 KiB
Dart
935 lines
34 KiB
Dart
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<String, String> values, void Function() reset}) _mockSecureStorage([
|
|
Map<String, String> initialValues = const {},
|
|
]) {
|
|
final values = Map<String, String>.from(initialValues);
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
|
|
(call) async {
|
|
final args = Map<Object?, Object?>.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<String, String>.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<String?> build() async {
|
|
state = const AsyncData(testPubkeyA);
|
|
return testPubkeyA;
|
|
}
|
|
|
|
@override
|
|
Future<void> ensureExternalSignersRegistered() async {
|
|
ensureExternalSignersRegisteredCount++;
|
|
final error = ensureExternalSignersRegisteredError;
|
|
if (error != null) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
class _MockThemeNotifier extends ThemeNotifier {
|
|
ThemeMode _mode = ThemeMode.system;
|
|
|
|
@override
|
|
Future<ThemeMode> 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 = <Object>[];
|
|
|
|
@override
|
|
Future<rust_api.WhitenoiseConfig> crateApiCreateWhitenoiseConfig({
|
|
required String dataDir,
|
|
required String logsDir,
|
|
List<String>? defaultRelayUrls,
|
|
}) async {
|
|
createdConfigDataDir = dataDir;
|
|
createdConfigLogsDir = logsDir;
|
|
return rust_api.WhitenoiseConfig(
|
|
dataDir: dataDir,
|
|
logsDir: logsDir,
|
|
defaultRelayUrls: defaultRelayUrls,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> 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<void> 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<MaterialApp>(find.byType(MaterialApp));
|
|
expect(app.title, 'White Noise');
|
|
});
|
|
|
|
testWidgets('defaults to system theme mode', (tester) async {
|
|
await pumpWnApp(tester);
|
|
final app = tester.widget<MaterialApp>(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<MaterialApp>(find.byType(MaterialApp));
|
|
expect(app.themeMode, ThemeMode.dark);
|
|
});
|
|
|
|
testWidgets('has routes configured', (tester) async {
|
|
await pumpWnApp(tester);
|
|
final app = tester.widget<MaterialApp>(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 = <String>[];
|
|
|
|
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<String, String> values, void Function() reset}) secureStorage;
|
|
|
|
setUp(() {
|
|
pathProvider = _mockPathProvider();
|
|
secureStorage = _mockSecureStorage();
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
const MethodChannel('plugins.flutter.io/shared_preferences'),
|
|
(call) async => <String, Object>{},
|
|
);
|
|
});
|
|
|
|
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<ApiError>()),
|
|
);
|
|
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<ApiError>()),
|
|
);
|
|
expect(mockApi.initCallCount, 1);
|
|
});
|
|
|
|
test('returns a ProviderContainer', () async {
|
|
final container = await initializeAppContainer();
|
|
|
|
expect(container, isA<ProviderContainer>());
|
|
});
|
|
|
|
test('awaits authProvider', () async {
|
|
final container = await initializeAppContainer();
|
|
|
|
expect(container.read(authProvider), isA<AsyncData>());
|
|
});
|
|
|
|
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 = <LogRecord>[];
|
|
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 = <LogRecord>[];
|
|
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<Object>(
|
|
(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<Object>(
|
|
(error) => error.toString().contains('App Group container unavailable'),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
});
|
|
}
|