import 'dart:io'; import 'dart:typed_data'; import 'package:commons_core/commons_core.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/db/database.dart'; import 'package:tane/security/secure_key_store.dart'; import 'package:tane/services/auto_backup_service.dart'; import 'package:tane/services/auto_backup_store.dart'; import 'package:tane/services/export_import_service.dart'; import 'package:tane/services/file_service.dart'; import '../support/test_support.dart'; /// [FileService] the exporter never actually calls for [buildSealedBackup]; the /// automatic path writes files itself. class _UnusedFiles implements FileService { @override Future saveFile({ required String suggestedName, required Uint8List bytes, }) async => null; @override Future pickFileBytes({List? allowedExtensions}) async => null; } /// An exporter whose backup build always fails, to prove failures stay silent. class _ThrowingExporter extends ExportImportService { _ThrowingExporter(AppDatabase db) : super( repository: newTestRepository(db), files: _UnusedFiles(), keys: SecureKeyStore(store: InMemorySecretStore()), ); @override Future buildSealedBackup() => throw Exception('disk full'); } void main() { late AppDatabase db; late Directory dir; setUp(() async { db = newTestDatabase(); dir = await Directory.systemTemp.createTemp('tane_auto_backup_test'); }); tearDown(() async { await db.close(); if (dir.existsSync()) await dir.delete(recursive: true); }); ExportImportService newExporter() => ExportImportService( repository: newTestRepository(db), files: _UnusedFiles(), keys: SecureKeyStore(store: InMemorySecretStore()), ); AutoBackupService newService( ExportImportService exporter, { required AutoBackupStore store, required DateTime now, }) => AutoBackupService( exporter: exporter, store: store, directory: () async => dir, now: () => now, ); List autoCopies() => dir .listSync() .whereType() .map((f) => f.uri.pathSegments.last) .where((n) => n.startsWith('tanemaki-auto-')) .toList() ..sort(); test('first run writes one sealed copy', () async { await newTestRepository(db).addQuickVariety(label: 'Maize'); final service = newService( newExporter(), store: AutoBackupStore(InMemorySecretStore()), now: DateTime.utc(2026, 7, 10), ); expect(await service.runIfDue(), isTrue); final copies = autoCopies(); expect(copies, ['tanemaki-auto-2026-07-10.tanemaki']); final bytes = File('${dir.path}/${copies.single}').readAsBytesSync(); expect(BackupBox.looksSealed(bytes), isTrue); }); test('a second run within the interval is a no-op', () async { final store = AutoBackupStore(InMemorySecretStore()); await store.markBackupAt(DateTime.utc(2026, 7, 8)); final service = newService( newExporter(), store: store, now: DateTime.utc(2026, 7, 10), // 2 days later < 7-day interval ); expect(await service.runIfDue(), isFalse); expect(autoCopies(), isEmpty); }); test('a run after the interval writes again', () async { final store = AutoBackupStore(InMemorySecretStore()); await store.markBackupAt(DateTime.utc(2026, 7, 1)); final service = newService( newExporter(), store: store, now: DateTime.utc(2026, 7, 10), // 9 days later >= 7 ); expect(await service.runIfDue(), isTrue); expect(autoCopies(), ['tanemaki-auto-2026-07-10.tanemaki']); }); test('rotation keeps only the newest three copies', () async { for (final d in ['2020-01-01', '2020-01-02', '2020-01-03', '2020-01-04']) { File('${dir.path}/tanemaki-auto-$d.tanemaki').writeAsBytesSync([0]); } final service = newService( newExporter(), store: AutoBackupStore(InMemorySecretStore()), now: DateTime.utc(2026, 7, 10), ); await service.runIfDue(); expect(autoCopies(), [ 'tanemaki-auto-2020-01-03.tanemaki', 'tanemaki-auto-2020-01-04.tanemaki', 'tanemaki-auto-2026-07-10.tanemaki', ]); }); test('backupNow writes even when a copy is not yet due', () async { final store = AutoBackupStore(InMemorySecretStore()); await store.markBackupAt(DateTime.utc(2026, 7, 10)); // today: not due final service = newService( newExporter(), store: store, now: DateTime.utc(2026, 7, 10), ); // runIfDue is a no-op today, but an explicit tap still makes a copy. expect(await service.runIfDue(), isFalse); expect(await service.backupNow(), isTrue); expect(autoCopies(), ['tanemaki-auto-2026-07-10.tanemaki']); }); test('a failed backup returns false and never throws', () async { final service = newService( _ThrowingExporter(db), store: AutoBackupStore(InMemorySecretStore()), now: DateTime.utc(2026, 7, 10), ); expect(await service.runIfDue(), isFalse); expect(autoCopies(), isEmpty); }); }