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.

514 lines
15 KiB

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<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;
}

Powered by TurnKey Linux.