A silent safety net against in-app data loss or DB corruption, with zero friction. Driven off the app lifecycle (startup + when hidden), it writes a sealed .tanemaki copy into the app's private storage once a week, keeping the newest 3 (rotating). Reuses the manual backup's encryption via the extracted ExportImportService.buildSealedBackup(); never throws, so a failed copy can't crash startup or backgrounding. Not registered on web (no file storage). Settings shows a quiet reassurance line with the date of the last automatic copy (localised via intl), hidden where no automatic backup exists.
86 lines
2.5 KiB
Dart
86 lines
2.5 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/material.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 'package:tane/ui/auto_backup_gate.dart';
|
|
|
|
import '../support/test_support.dart';
|
|
|
|
class _NoFiles implements FileService {
|
|
@override
|
|
Future<String?> saveFile({
|
|
required String suggestedName,
|
|
required Uint8List bytes,
|
|
}) async => null;
|
|
|
|
@override
|
|
Future<Uint8List?> pickFileBytes({List<String>? allowedExtensions}) async =>
|
|
null;
|
|
}
|
|
|
|
/// Counts how many times the lifecycle asked for a backup.
|
|
class _SpyAutoBackup extends AutoBackupService {
|
|
_SpyAutoBackup(AppDatabase db)
|
|
: super(
|
|
exporter: ExportImportService(
|
|
repository: newTestRepository(db),
|
|
files: _NoFiles(),
|
|
keys: SecureKeyStore(store: InMemorySecretStore()),
|
|
),
|
|
store: AutoBackupStore(InMemorySecretStore()),
|
|
directory: () async => throw UnimplementedError(),
|
|
);
|
|
|
|
int calls = 0;
|
|
|
|
@override
|
|
Future<bool> runIfDue({Duration interval = const Duration(days: 7)}) async {
|
|
calls++;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
late AppDatabase db;
|
|
|
|
setUp(() => db = newTestDatabase());
|
|
tearDown(() => db.close());
|
|
|
|
testWidgets('runs a backup on startup', (tester) async {
|
|
final spy = _SpyAutoBackup(db);
|
|
await tester.pumpWidget(
|
|
AutoBackupGate(service: spy, child: const SizedBox()),
|
|
);
|
|
await tester.pump(); // let the post-frame callback fire
|
|
|
|
expect(spy.calls, 1);
|
|
});
|
|
|
|
testWidgets('runs a backup again when the app is hidden', (tester) async {
|
|
final spy = _SpyAutoBackup(db);
|
|
await tester.pumpWidget(
|
|
AutoBackupGate(service: spy, child: const SizedBox()),
|
|
);
|
|
await tester.pump(); // startup backup
|
|
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
|
|
|
|
expect(spy.calls, 2);
|
|
});
|
|
|
|
testWidgets('does nothing when no service is provided', (tester) async {
|
|
await tester.pumpWidget(
|
|
const AutoBackupGate(child: Text('ok', textDirection: TextDirection.ltr)),
|
|
);
|
|
await tester.pump();
|
|
|
|
expect(find.text('ok'), findsOneWidget);
|
|
});
|
|
}
|