import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:app_boilerplate/core/exceptions/sync_exception.dart'; import 'package:app_boilerplate/data/sync/sync_engine.dart'; import 'package:app_boilerplate/data/sync/models/sync_status.dart'; import 'package:app_boilerplate/data/sync/models/sync_operation.dart'; import 'package:app_boilerplate/data/local/local_storage_service.dart'; import 'package:app_boilerplate/data/local/models/item.dart'; import 'package:app_boilerplate/data/immich/immich_service.dart'; import 'package:app_boilerplate/data/nostr/nostr_service.dart'; import 'package:app_boilerplate/data/nostr/models/nostr_keypair.dart'; import 'package:path/path.dart' as path; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:dio/dio.dart'; void main() { // Initialize Flutter bindings and sqflite for testing TestWidgetsFlutterBinding.ensureInitialized(); sqfliteFfiInit(); databaseFactory = databaseFactoryFfi; late LocalStorageService localStorage; late Directory testDir; late String testDbPath; late Directory testCacheDir; late ImmichService immichService; late NostrService nostrService; late NostrKeyPair nostrKeyPair; late SyncEngine syncEngine; setUp(() async { // Create temporary directory for testing testDir = await Directory.systemTemp.createTemp('sync_test_'); testDbPath = path.join(testDir.path, 'test_local_storage.db'); testCacheDir = Directory(path.join(testDir.path, 'image_cache')); // Initialize local storage localStorage = LocalStorageService( testDbPath: testDbPath, testCacheDir: testCacheDir, ); await localStorage.initialize(); // Create mock services final mockDio = _createMockDio(); immichService = ImmichService( baseUrl: 'https://immich.example.com', apiKey: 'test-api-key', localStorage: localStorage, dio: mockDio, ); nostrService = NostrService(); nostrKeyPair = nostrService.generateKeyPair(); // Create sync engine syncEngine = SyncEngine( localStorage: localStorage, immichService: immichService, nostrService: nostrService, nostrKeyPair: nostrKeyPair, ); }); tearDown(() async { syncEngine.dispose(); await localStorage.close(); try { if (await testDir.exists()) { await testDir.delete(recursive: true); } } catch (_) { // Ignore cleanup errors } }); group('SyncEngine - Queue Management', () { /// Tests queuing a sync operation. test('queueOperation - success', () { // Arrange final operation = SyncOperation( id: 'test-op-1', type: SyncOperationType.upload, itemId: 'item-1', source: 'local', target: 'immich', ); // Act syncEngine.queueOperation(operation); // Assert - operation is queued (may be processing, so check all operations) final all = syncEngine.getAllOperations(); expect(all.length, equals(1)); expect(all[0].id, equals('test-op-1')); }); /// Tests that queue is sorted by priority. test('queueOperation - priority sorting', () { // Arrange final lowOp = SyncOperation( id: 'low', type: SyncOperationType.upload, itemId: 'item-1', source: 'local', target: 'immich', priority: SyncPriority.low, ); final highOp = SyncOperation( id: 'high', type: SyncOperationType.upload, itemId: 'item-2', source: 'local', target: 'immich', priority: SyncPriority.high, ); // Act syncEngine.queueOperation(lowOp); syncEngine.queueOperation(highOp); // Assert - both operations are queued (may be processing, so check all operations) final all = syncEngine.getAllOperations(); expect(all.length, equals(2)); // Processing order is tested in integration tests }); /// Tests that queue throws exception when full. test('queueOperation - queue full', () { // Arrange final engine = SyncEngine( localStorage: localStorage, maxQueueSize: 2, ); // Act engine.queueOperation(SyncOperation( id: 'op-1', type: SyncOperationType.upload, itemId: 'item-1', source: 'local', target: 'immich', )); engine.queueOperation(SyncOperation( id: 'op-2', type: SyncOperationType.upload, itemId: 'item-2', source: 'local', target: 'immich', )); // Assert expect( () => engine.queueOperation(SyncOperation( id: 'op-3', type: SyncOperationType.upload, itemId: 'item-3', source: 'local', target: 'immich', )), throwsA(isA()), ); engine.dispose(); }); }); group('SyncEngine - Sync Operations', () { /// Tests syncing to Immich. test('syncToImmich - queues operation', () async { // Arrange final item = Item(id: 'item-1', data: {'name': 'Test'}); await localStorage.insertItem(item); // Act final operationId = await syncEngine.syncToImmich('item-1'); // Assert expect(operationId, isNotEmpty); final operations = syncEngine.getAllOperations(); expect(operations.length, equals(1)); expect(operations[0].target, equals('immich')); }); /// Tests syncing to Immich fails when service not configured. test('syncToImmich - fails when service not configured', () async { // Arrange final engine = SyncEngine(localStorage: localStorage); // Act & Assert expect( () => engine.syncToImmich('item-1'), throwsA(isA()), ); engine.dispose(); }); /// Tests syncing to Nostr. test('syncToNostr - queues operation', () async { // Arrange final item = Item(id: 'item-1', data: {'name': 'Test'}); await localStorage.insertItem(item); // Act final operationId = await syncEngine.syncToNostr('item-1'); // Assert expect(operationId, isNotEmpty); final operations = syncEngine.getAllOperations(); expect(operations.length, equals(1)); expect(operations[0].target, equals('nostr')); }); /// Tests syncing to Nostr fails when keypair not set. test('syncToNostr - fails when keypair not set', () async { // Arrange final engine = SyncEngine( localStorage: localStorage, nostrService: nostrService, // No keypair ); // Act & Assert expect( () => engine.syncToNostr('item-1'), throwsA(isA()), ); engine.dispose(); }); /// Tests syncing from Immich. test('syncFromImmich - queues operation', () async { // Act final operationId = await syncEngine.syncFromImmich('asset-1'); // Assert expect(operationId, isNotEmpty); final operations = syncEngine.getAllOperations(); expect(operations.length, greaterThanOrEqualTo(1)); expect(operations[0].source, equals('immich')); expect(operations[0].target, equals('local')); }); }); group('SyncEngine - Conflict Resolution', () { /// Tests conflict resolution with useLocal strategy. test('resolveConflict - useLocal', () { // Arrange syncEngine.setConflictResolution(ConflictResolution.useLocal); final local = {'id': 'item-1', 'value': 'local', 'updatedAt': 1000}; final remote = {'id': 'item-1', 'value': 'remote', 'updatedAt': 2000}; // Act final resolved = syncEngine.resolveConflict(local, remote); // Assert expect(resolved['value'], equals('local')); }); /// Tests conflict resolution with useRemote strategy. test('resolveConflict - useRemote', () { // Arrange syncEngine.setConflictResolution(ConflictResolution.useRemote); final local = {'id': 'item-1', 'value': 'local', 'updatedAt': 1000}; final remote = {'id': 'item-1', 'value': 'remote', 'updatedAt': 2000}; // Act final resolved = syncEngine.resolveConflict(local, remote); // Assert expect(resolved['value'], equals('remote')); }); /// Tests conflict resolution with useLatest strategy. test('resolveConflict - useLatest', () { // Arrange syncEngine.setConflictResolution(ConflictResolution.useLatest); final local = {'id': 'item-1', 'value': 'local', 'updatedAt': 3000}; final remote = {'id': 'item-1', 'value': 'remote', 'updatedAt': 2000}; // Act final resolved = syncEngine.resolveConflict(local, remote); // Assert expect(resolved['value'], equals('local')); // Local is newer }); /// Tests conflict resolution with merge strategy. test('resolveConflict - merge', () { // Arrange syncEngine.setConflictResolution(ConflictResolution.merge); final local = {'id': 'item-1', 'value': 'local', 'field1': 'local1'}; final remote = {'id': 'item-1', 'value': 'remote', 'field2': 'remote2'}; // Act final resolved = syncEngine.resolveConflict(local, remote); // Assert expect(resolved['field1'], equals('local1')); expect(resolved['field2'], equals('remote2')); expect(resolved['value'], equals('remote')); // Remote overwrites in merge }); }); group('SyncEngine - Retry and Offline Queue', () { /// Tests that failed operations can be retried. test('retry - operation can retry', () { // Arrange final operation = SyncOperation( id: 'test-op', type: SyncOperationType.upload, itemId: 'item-1', source: 'local', target: 'immich', maxRetries: 3, ); // Act operation.markFailed('Test error'); expect(operation.canRetry(), isTrue); operation.incrementRetry(); operation.markFailed('Test error'); expect(operation.canRetry(), isTrue); operation.incrementRetry(); operation.markFailed('Test error'); expect(operation.canRetry(), isTrue); operation.incrementRetry(); operation.markFailed('Test error'); // Assert expect(operation.canRetry(), isFalse); // Max retries reached expect(operation.retryCount, equals(3)); }); /// Tests that operations are queued offline. test('offline queue - operations persist', () { // Arrange final operation1 = SyncOperation( id: 'op-1', type: SyncOperationType.upload, itemId: 'item-1', source: 'local', target: 'immich', ); final operation2 = SyncOperation( id: 'op-2', type: SyncOperationType.upload, itemId: 'item-2', source: 'local', target: 'nostr', ); // Act syncEngine.queueOperation(operation1); syncEngine.queueOperation(operation2); // Assert - operations are queued (may be processing, so check all operations) final all = syncEngine.getAllOperations(); expect(all.length, equals(2)); }); }); group('SyncEngine - Error Handling', () { /// Tests error handling when item not found. test('syncToImmich - handles missing item', () async { // Act final operationId = await syncEngine.syncToImmich('non-existent'); // Assert - operation is queued but will fail when executed expect(operationId, isNotEmpty); final operations = syncEngine.getAllOperations(); expect(operations.length, equals(1)); }); /// Tests error handling when service not available. test('syncToImmich - handles missing service', () async { // Arrange final engine = SyncEngine(localStorage: localStorage); // Act & Assert expect( () => engine.syncToImmich('item-1'), throwsA(isA()), ); engine.dispose(); }); }); group('SyncEngine - Status Stream', () { /// Tests that status updates are streamed. test('statusStream - emits updates', () async { // Arrange final updates = []; syncEngine.statusStream.listen((op) { updates.add(op); }); // Act final operation = SyncOperation( id: 'test-op', type: SyncOperationType.upload, itemId: 'item-1', source: 'local', target: 'immich', ); syncEngine.queueOperation(operation); // Assert await Future.delayed(const Duration(milliseconds: 100)); expect(updates.length, greaterThan(0)); }); }); group('SyncEngine - Cleanup', () { /// Tests clearing completed operations. test('clearCompleted - removes completed operations', () { // Arrange final op1 = SyncOperation( id: 'op-1', type: SyncOperationType.upload, itemId: 'item-1', source: 'local', target: 'immich', ); final op2 = SyncOperation( id: 'op-2', type: SyncOperationType.upload, itemId: 'item-2', source: 'local', target: 'immich', ); op1.markSuccess(); op2.markFailed('Error'); syncEngine.queueOperation(op1); syncEngine.queueOperation(op2); // Act syncEngine.clearCompleted(); // Assert final operations = syncEngine.getAllOperations(); expect(operations.length, equals(1)); // Only failed remains expect(operations[0].status, equals(SyncStatus.failed)); }); /// Tests clearing failed operations. test('clearFailed - removes failed operations', () { // Arrange final op1 = SyncOperation( id: 'op-1', type: SyncOperationType.upload, itemId: 'item-1', source: 'local', target: 'immich', ); final op2 = SyncOperation( id: 'op-2', type: SyncOperationType.upload, itemId: 'item-2', source: 'local', target: 'immich', ); op1.markSuccess(); op2.markFailed('Error'); syncEngine.queueOperation(op1); syncEngine.queueOperation(op2); // Act syncEngine.clearFailed(); // Assert final operations = syncEngine.getAllOperations(); expect(operations.length, equals(1)); // Only success remains expect(operations[0].status, equals(SyncStatus.success)); }); }); } /// Helper to create a mock Dio instance for testing. Dio _createMockDio({ Response Function(String path, dynamic data)? onPost, Response Function(String path)? onGet, }) { final dio = Dio(); dio.options.baseUrl = 'https://immich.example.com'; dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) { if (options.method == 'POST' && onPost != null) { final response = onPost(options.path, options.data); handler.resolve(response); } else if (options.method == 'GET' && onGet != null) { final queryString = options.queryParameters.entries .map((e) => '${e.key}=${e.value}') .join('&'); final fullPath = queryString.isNotEmpty ? '${options.path}?$queryString' : options.path; final response = onGet(fullPath); handler.resolve(response); } else { handler.next(options); } }, )); return dio; }