import 'dart:async'; import 'dart:io'; import 'package:drift/drift.dart' show ApplyInterceptor, QueryExecutor, QueryExecutorUser, QueryInterceptor; import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/database/download_operations.dart'; import 'package:plezy/database/tvos_database_recovery_store.dart'; import 'package:plezy/main.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/models/download_models.dart'; import 'package:plezy/services/base_shared_preferences_service.dart'; import 'test_helpers/prefs.dart'; final class _OpenTrackingInterceptor extends QueryInterceptor { _OpenTrackingInterceptor({this.failure}); final Object? failure; var ensureOpenCalls = 0; var ensureOpenCompleted = false; var closed = false; @override Future ensureOpen(QueryExecutor executor, QueryExecutorUser user) async { ensureOpenCalls++; final failure = this.failure; if (failure != null) throw failure; final result = await executor.ensureOpen(user); ensureOpenCompleted = true; return result; } @override Future close(QueryExecutor inner) async { await inner.close(); closed = true; } } void main() { testWidgets('renders a Flutter frame before starting the initialization gate', (tester) async { final completion = Completer(); var bootstrapWasMounted = false; await tester.pumpWidget( StartupBootstrap( initialize: () { bootstrapWasMounted = find.byKey(startupBootstrapProgressKey).evaluate().isNotEmpty; return completion.future; }, buildApp: (_, value) => MaterialApp(home: Text('ready $value')), ), ); expect(bootstrapWasMounted, isTrue); expect(find.byKey(startupBootstrapProgressKey), findsOneWidget); completion.complete(1); await tester.pump(); }); testWidgets('replaces bootstrap UI with the initialized app on success', (tester) async { final completion = Completer(); await tester.pumpWidget( StartupBootstrap( initialize: () => completion.future, buildApp: (_, value) => MaterialApp(home: Text('ready $value')), ), ); completion.complete(7); await tester.pump(); expect(find.text('ready 7'), findsOneWidget); expect(find.byKey(startupBootstrapProgressKey), findsNothing); }); testWidgets('shows a localized recoverable failure instead of removing Flutter UI', (tester) async { await tester.pumpWidget( StartupBootstrap( initialize: () async => throw StateError('database unavailable'), buildApp: (_, value) => Text('ready $value'), ), ); await tester.pump(); expect(find.byKey(startupBootstrapFailureKey), findsOneWidget); expect(find.text('Error'), findsOneWidget); expect(find.text('Retry'), findsOneWidget); }); testWidgets('retry clears the failed generation and can commit a later success', (tester) async { final retryCompletion = Completer(); var attempts = 0; await tester.pumpWidget( StartupBootstrap( initialize: () { attempts++; if (attempts == 1) return Future.error(StateError('first attempt')); return retryCompletion.future; }, buildApp: (_, value) => MaterialApp(home: Text('ready $value')), ), ); await tester.pump(); await tester.tap(find.byKey(startupBootstrapRetryKey)); await tester.pump(); expect(attempts, 2); expect(find.byKey(startupBootstrapProgressKey), findsOneWidget); retryCompletion.complete(42); await tester.pump(); expect(find.text('ready 42'), findsOneWidget); expect(find.byKey(startupBootstrapFailureKey), findsNothing); }); testWidgets('discards a completion from a disposed bootstrap generation', (tester) async { final completion = Completer(); final discarded = []; await tester.pumpWidget( StartupBootstrap( initialize: () => completion.future, buildApp: (_, value) => Text('ready $value'), discard: discarded.add, ), ); await tester.pumpWidget(const SizedBox.shrink()); completion.complete(9); await tester.pump(); expect(discarded, [9]); expect(find.text('ready 9'), findsNothing); }); test('storage-full lazy database open discards native work before retrying', () async { resetSharedPreferencesForTest(); final tempDir = await Directory.systemTemp.createTemp('plezy_startup_storage_full_'); final file = File('${tempDir.path}/plezy_downloads.db'); final prefs = await BaseSharedPreferencesService.sharedCache(); final failedOpen = _OpenTrackingInterceptor( failure: const FileSystemException('write failed: No space left on device'), ); final successfulOpen = _OpenTrackingInterceptor(); AppDatabase? seeded; AppDatabase? resultDatabase; try { seeded = AppDatabase.forTesting(NativeDatabase(file)); await seeded.insertDownload( serverId: ServerId('srv'), ratingKey: 'active', globalKey: 'srv:active', type: 'movie', status: DownloadStatus.downloading.index, ); await seeded.updateBgTaskId('srv:active', 'native-task'); await seeded.addToQueue(mediaGlobalKey: 'srv:active'); await seeded.insertDownload( serverId: ServerId('srv'), ratingKey: 'complete', globalKey: 'srv:complete', type: 'movie', status: DownloadStatus.completed.index, ); await seeded.close(); seeded = null; var openAttempts = 0; var recoveries = 0; final bootstrap = await openAppDatabaseWithDownloadRecovery( openDatabase: () { return AppDatabase.open( isTvos: false, databaseFile: file, preferences: prefs, executorFactory: (databaseFile) { openAttempts++; final interceptor = openAttempts == 1 ? failedOpen : successfulOpen; return NativeDatabase(databaseFile).interceptWith(interceptor); }, ); }, recoverNativeDownloads: () async { expect(failedOpen.closed, isTrue); recoveries++; }, storageFullMessage: 'Storage full', ); resultDatabase = bootstrap.database; final active = await resultDatabase.getDownloadedMedia('srv:active'); final complete = await resultDatabase.getDownloadedMedia('srv:complete'); expect(bootstrap.recoveryOutcome, TvosDatabaseRecoveryOutcome.notApplicable); expect(openAttempts, 2); expect(recoveries, 1); expect(failedOpen.ensureOpenCalls, 1); expect(successfulOpen.ensureOpenCalls, greaterThanOrEqualTo(1)); expect(successfulOpen.ensureOpenCompleted, isTrue); expect(active?.status, DownloadStatus.failed.index); expect(active?.bgTaskId, isNull); expect(active?.errorMessage, 'Storage full'); expect(complete?.status, DownloadStatus.completed.index); expect(await resultDatabase.select(resultDatabase.downloadQueue).get(), isEmpty); expect(await resultDatabase.customSelect('SELECT 1').get(), isNotEmpty); } finally { await resultDatabase?.close(); await seeded?.close(); await tempDir.delete(recursive: true); } }); test('non-storage lazy database-open errors bypass download recovery', () async { resetSharedPreferencesForTest(); final tempDir = await Directory.systemTemp.createTemp('plezy_startup_open_error_'); final file = File('${tempDir.path}/plezy_downloads.db'); final prefs = await BaseSharedPreferencesService.sharedCache(); final error = StateError('injected database setup failure'); final failedOpen = _OpenTrackingInterceptor(failure: error); var openAttempts = 0; var recoveries = 0; try { final open = openAppDatabaseWithDownloadRecovery( openDatabase: () { return AppDatabase.open( isTvos: false, databaseFile: file, preferences: prefs, executorFactory: (databaseFile) { openAttempts++; return NativeDatabase(databaseFile).interceptWith(failedOpen); }, ); }, recoverNativeDownloads: () async { recoveries++; }, storageFullMessage: 'Storage full', ); await expectLater(open, throwsA(same(error))); expect(failedOpen.ensureOpenCalls, 1); expect(failedOpen.closed, isTrue); expect(openAttempts, 1); expect(recoveries, 0); } finally { await tempDir.delete(recursive: true); } }); }