import 'dart:convert'; import 'dart:typed_data'; import 'package:commons_core/commons_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/data/export_import/inventory_json_codec.dart'; import 'package:tane/db/database.dart'; import 'package:tane/i18n/strings.g.dart'; import 'package:tane/security/secure_key_store.dart'; import 'package:tane/services/export_import_service.dart'; import 'package:tane/services/file_service.dart'; import 'package:tane/services/recovery_sheet_service.dart'; import 'package:tane/ui/backup_section.dart'; import 'package:tane/ui/settings_screen.dart'; import '../support/test_support.dart'; /// Records calls instead of opening real dialogs. class FakeFileService implements FileService { String? savedName; Uint8List? savedBytes; Uint8List? bytesToPick; @override Future saveFile({ required String suggestedName, required Uint8List bytes, }) async { savedName = suggestedName; savedBytes = bytes; return '/picked/$suggestedName'; } @override Future pickFileBytes({List? allowedExtensions}) async => bytesToPick; } void main() { late AppDatabase db; late FakeFileService files; late SecureKeyStore keys; late ExportImportService service; late RecoverySheetService sheet; setUp(() { db = newTestDatabase(); files = FakeFileService(); keys = SecureKeyStore(store: InMemorySecretStore()); service = ExportImportService( repository: newTestRepository(db), files: files, keys: keys, ); sheet = RecoverySheetService(files: files); }); tearDown(() => db.close()); Widget wrap(Widget child) { LocaleSettings.setLocaleSync(AppLocale.en); return TranslationProvider( child: MaterialApp( supportedLocales: AppLocaleUtils.supportedLocales, localizationsDelegates: const [ GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], home: child, ), ); } Widget section() => wrap( Scaffold( body: BackupSection(service: service, sheet: sheet), ), ); testWidgets('settings shows the backup section with its actions', ( tester, ) async { await tester.binding.setSurfaceSize(const Size(800, 1400)); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget(wrap(SettingsScreen(exportImport: service))); expect(find.text('Backup & restore'), findsOneWidget); expect(find.text('Save a backup'), findsOneWidget); expect(find.text('Restore a backup'), findsOneWidget); expect(find.text('Your recovery code'), findsOneWidget); expect(find.text('Export to a spreadsheet'), findsOneWidget); expect(find.text('Import a list'), findsOneWidget); }); testWidgets('spreadsheet export saves a .csv file and confirms', ( tester, ) async { await tester.pumpWidget(section()); await tester.tap(find.text('Export to a spreadsheet')); await tester.pumpAndSettle(); expect(files.savedName, endsWith('.csv')); expect(utf8.decode(files.savedBytes!), startsWith('variety_id,')); expect(find.text('Copy saved'), findsOneWidget); }); testWidgets('saving a backup writes a sealed .tanemaki file', (tester) async { await tester.pumpWidget(section()); await tester.tap(find.text('Save a backup')); await tester.pumpAndSettle(); expect(files.savedName, endsWith('.tanemaki')); expect(BackupBox.looksSealed(files.savedBytes!), isTrue); expect(find.text('Copy saved'), findsOneWidget); }); testWidgets('restoring my own sealed copy needs no code', (tester) async { final otherDb = newTestDatabase(); addTearDown(otherDb.close); final otherFiles = FakeFileService(); final mine = ExportImportService( repository: newTestRepository(otherDb, nodeId: 'other'), files: otherFiles, keys: keys, // same identity ); final other = newTestRepository(otherDb, nodeId: 'other'); await other.addQuickVariety(label: 'Imported bean'); await mine.exportBackup(); files.bytesToPick = otherFiles.savedBytes; await tester.pumpWidget(section()); await tester.tap(find.text('Restore a backup')); await tester.pumpAndSettle(); expect(find.text('Restore a backup?'), findsOneWidget); await tester.tap(find.text('Import')); await tester.pumpAndSettle(); expect(find.text('Imported: 1 new, 0 updated'), findsOneWidget); final varieties = await db.select(db.varieties).get(); expect(varieties.single.label, 'Imported bean'); }); testWidgets('a copy from another identity asks for the recovery code', ( tester, ) async { final otherDb = newTestDatabase(); addTearDown(otherDb.close); final otherFiles = FakeFileService(); final otherKeys = SecureKeyStore(store: InMemorySecretStore()); final theirs = ExportImportService( repository: newTestRepository(otherDb, nodeId: 'other'), files: otherFiles, keys: otherKeys, ); final other = newTestRepository(otherDb, nodeId: 'other'); await other.addQuickVariety(label: 'Recovered bean'); await theirs.exportBackup(); final code = await theirs.recoveryCode(); files.bytesToPick = otherFiles.savedBytes; await tester.pumpWidget(section()); await tester.tap(find.text('Restore a backup')); await tester.pumpAndSettle(); await tester.tap(find.text('Import')); await tester.pumpAndSettle(); // Wrong seed → the code prompt appears; type the printed code. expect(find.text('Enter your recovery code'), findsOneWidget); await tester.enterText(find.byKey(const Key('backup.recoveryField')), code); await tester.tap(find.byKey(const Key('backup.recoveryConfirm'))); await tester.pumpAndSettle(); expect(find.text('Imported: 1 new, 0 updated'), findsOneWidget); final varieties = await db.select(db.varieties).get(); expect(varieties.single.label, 'Recovered bean'); // The identity travelled with the code. expect(await keys.rootSeedHex(), await otherKeys.rootSeedHex()); }); testWidgets('legacy plain exports still restore', (tester) async { final otherDb = newTestDatabase(); addTearDown(otherDb.close); final other = newTestRepository(otherDb, nodeId: 'node-other'); await other.addQuickVariety(label: 'Old bean'); files.bytesToPick = utf8.encode( const InventoryJsonCodec().encode(await other.exportInventory()), ); await tester.pumpWidget(section()); await tester.tap(find.text('Restore a backup')); await tester.pumpAndSettle(); await tester.tap(find.text('Import')); await tester.pumpAndSettle(); expect(find.text('Imported: 1 new, 0 updated'), findsOneWidget); }); testWidgets('an unreadable file reports a friendly error', (tester) async { files.bytesToPick = utf8.encode('this is not json'); await tester.pumpWidget(section()); await tester.tap(find.text('Restore a backup')); await tester.pumpAndSettle(); await tester.tap(find.text('Import')); await tester.pumpAndSettle(); expect( find.text('This file could not be read as a Tanemaki copy'), findsOneWidget, ); }); testWidgets('the recovery dialog shows the code and saves the sheet', ( tester, ) async { await tester.pumpWidget(section()); await tester.tap(find.byKey(const Key('backup.recovery'))); await tester.pumpAndSettle(); final code = await service.recoveryCode(); expect(find.text(code), findsOneWidget); await tester.tap(find.byKey(const Key('backup.recoverySave'))); await tester.pumpAndSettle(); expect(files.savedName, 'tanemaki-recovery.pdf'); expect(String.fromCharCodes(files.savedBytes!.take(5)), '%PDF-'); // The seed itself never appears in the clear in the PDF stream metadata; // the QR encodes the human code, which IS the secret — that's the point // of the sheet. Just assert it saved and confirmed. expect(find.text('Copy saved'), findsOneWidget); }); }