Public name is now Tane (種, "seed"); Tanemaki (種まき, "to scatter seeds") is kept only as etymology in the About screen and onboarding. App not yet distributed, so format tokens rename cleanly with no migration: backup extension .tanemaki→.tane, QR scheme tanemaki://→tane://, file prefixes tane-*. Website link and species-catalog User-Agent → tane.comunes.org. Android label capitalised to "Tane". Identity/crypto constants already use org.comunes.tane (derivation, HKDF backup, sync namespace, TANE1 recovery tag) — left untouched, so keys, backups and sync are unaffected. Tests updated; analyze + full suite green.
167 lines
5 KiB
Dart
167 lines
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('tane-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, ['tane-auto-2026-07-10.tane']);
|
|
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(), ['tane-auto-2026-07-10.tane']);
|
|
});
|
|
|
|
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}/tane-auto-$d.tane').writeAsBytesSync([0]);
|
|
}
|
|
final service = newService(
|
|
newExporter(),
|
|
store: AutoBackupStore(InMemorySecretStore()),
|
|
now: DateTime.utc(2026, 7, 10),
|
|
);
|
|
|
|
await service.runIfDue();
|
|
|
|
expect(autoCopies(), [
|
|
'tane-auto-2020-01-03.tane',
|
|
'tane-auto-2020-01-04.tane',
|
|
'tane-auto-2026-07-10.tane',
|
|
]);
|
|
});
|
|
|
|
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(), ['tane-auto-2026-07-10.tane']);
|
|
});
|
|
|
|
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);
|
|
});
|
|
}
|