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.
152 lines
4.5 KiB
Dart
152 lines
4.5 KiB
Dart
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<String?> saveFile({
|
|
required String suggestedName,
|
|
required Uint8List bytes,
|
|
}) async => null;
|
|
|
|
@override
|
|
Future<Uint8List?> pickFileBytes({List<String>? 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<Uint8List> 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<String> autoCopies() =>
|
|
dir
|
|
.listSync()
|
|
.whereType<File>()
|
|
.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('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);
|
|
});
|
|
}
|