You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
513 lines
15 KiB
513 lines
15 KiB
import 'dart:io';
|
|
import 'package:flutter_test/flutter_test.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<SyncException>()),
|
|
);
|
|
|
|
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<SyncException>()),
|
|
);
|
|
|
|
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<SyncException>()),
|
|
);
|
|
|
|
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<SyncException>()),
|
|
);
|
|
|
|
engine.dispose();
|
|
});
|
|
});
|
|
|
|
group('SyncEngine - Status Stream', () {
|
|
/// Tests that status updates are streamed.
|
|
test('statusStream - emits updates', () async {
|
|
// Arrange
|
|
final updates = <SyncOperation>[];
|
|
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;
|
|
}
|