import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:dio/dio.dart'; import 'package:app_boilerplate/data/immich/immich_service.dart'; import 'package:app_boilerplate/data/immich/models/immich_asset.dart'; import 'package:app_boilerplate/data/local/local_storage_service.dart'; import 'package:app_boilerplate/data/local/models/item.dart'; import 'package:path/path.dart' as path; import 'package:sqflite_common_ffi/sqflite_ffi.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; setUp(() async { // Create temporary directory for testing testDir = await Directory.systemTemp.createTemp('immich_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(); }); tearDown(() async { await localStorage.close(); try { if (await testDir.exists()) { await testDir.delete(recursive: true); } } catch (_) { // Ignore cleanup errors } }); group('ImmichService - Upload', () { /// Tests successful image upload. test('uploadImage - success', () async { // Arrange final testFile = File(path.join(testDir.path, 'test_image.jpg')); await testFile.writeAsBytes([1, 2, 3, 4, 5]); final mockDio = _createMockDio( onPost: (path, data) => Response( statusCode: 200, data: { 'id': 'asset-123', 'duplicate': false, }, requestOptions: RequestOptions(path: path), ), onGet: (path) => Response( statusCode: 200, data: { 'id': 'asset-123', 'originalFileName': 'test_image.jpg', 'createdAt': DateTime.now().toIso8601String(), 'fileSizeByte': 5, 'mimeType': 'image/jpeg', }, requestOptions: RequestOptions(path: path), ), ); final immichService = ImmichService( baseUrl: 'https://immich.example.com', apiKey: 'test-api-key', localStorage: localStorage, dio: mockDio, ); // Act final response = await immichService.uploadImage(testFile); // Assert expect(response.id, equals('asset-123')); expect(response.duplicate, isFalse); // Verify metadata was stored locally final cachedAsset = await immichService.getCachedAsset('asset-123'); expect(cachedAsset, isNotNull); expect(cachedAsset!.id, equals('asset-123')); }); /// Tests upload failure when file doesn't exist. test('uploadImage - file not found', () async { // Arrange final nonExistentFile = File(path.join(testDir.path, 'non_existent.jpg')); final mockDio = _createMockDio(); final immichService = ImmichService( baseUrl: 'https://immich.example.com', apiKey: 'test-api-key', localStorage: localStorage, dio: mockDio, ); // Act & Assert expect( () => immichService.uploadImage(nonExistentFile), throwsA(isA()), ); }); /// Tests upload failure with network error. test('uploadImage - network error', () async { // Arrange final testFile = File(path.join(testDir.path, 'test_image.jpg')); await testFile.writeAsBytes([1, 2, 3]); final mockDio = _createMockDio( onPost: (path, data) => throw DioException( requestOptions: RequestOptions(path: path), error: 'Network error', ), ); final immichService = ImmichService( baseUrl: 'https://immich.example.com', apiKey: 'test-api-key', localStorage: localStorage, dio: mockDio, ); // Act & Assert expect( () => immichService.uploadImage(testFile), throwsA(isA()), ); }); /// Tests upload with duplicate detection. test('uploadImage - duplicate asset', () async { // Arrange final testFile = File(path.join(testDir.path, 'test_image.jpg')); await testFile.writeAsBytes([1, 2, 3]); final mockDio = _createMockDio( onPost: (path, data) => Response( statusCode: 200, data: { 'id': 'asset-456', 'duplicate': true, }, requestOptions: RequestOptions(path: path), ), onGet: (path) => Response( statusCode: 200, data: { 'id': 'asset-456', 'originalFileName': 'test_image.jpg', 'createdAt': DateTime.now().toIso8601String(), 'fileSizeByte': 3, 'mimeType': 'image/jpeg', }, requestOptions: RequestOptions(path: path), ), ); final immichService = ImmichService( baseUrl: 'https://immich.example.com', apiKey: 'test-api-key', localStorage: localStorage, dio: mockDio, ); // Act final response = await immichService.uploadImage(testFile); // Assert expect(response.duplicate, isTrue); }); }); group('ImmichService - Fetch Assets', () { /// Tests successful asset fetch. test('fetchAssets - success', () async { // Arrange final mockAssets = [ { 'id': 'asset-1', 'originalFileName': 'image1.jpg', 'createdAt': DateTime.now().toIso8601String(), 'fileSizeByte': 1000, 'mimeType': 'image/jpeg', }, { 'id': 'asset-2', 'originalFileName': 'image2.jpg', 'createdAt': DateTime.now().toIso8601String(), 'fileSizeByte': 2000, 'mimeType': 'image/png', }, ]; final mockDio = _createMockDio( onPost: (path, data) { if (path == '/api/search/metadata') { // Return the correct nested response structure return Response( statusCode: 200, data: { 'assets': { 'items': mockAssets, 'total': mockAssets.length, 'count': mockAssets.length, 'nextPage': null, } }, requestOptions: RequestOptions(path: path), ); } return Response( statusCode: 200, data: {}, requestOptions: RequestOptions(path: path), ); }, ); final immichService = ImmichService( baseUrl: 'https://immich.example.com', apiKey: 'test-api-key', localStorage: localStorage, dio: mockDio, ); // Act final assets = await immichService.fetchAssets(limit: 100); // Assert expect(assets.length, equals(2)); expect(assets[0].id, equals('asset-1')); expect(assets[1].id, equals('asset-2')); // Verify assets were cached locally final cachedAssets = await immichService.getCachedAssets(); expect(cachedAssets.length, equals(2)); }); /// Tests fetch with pagination. test('fetchAssets - pagination', () async { // Arrange final mockDio = _createMockDio( onPost: (path, data) { if (path == '/api/search/metadata') { // Extract limit and skip from request body (data), not query params final requestData = data as Map; final limit = requestData['limit'] as int? ?? 100; final skip = requestData['skip'] as int? ?? 0; return Response( statusCode: 200, data: { 'assets': { 'items': List.generate( limit, (i) => { 'id': 'asset-${skip + i}', 'originalFileName': 'image${skip + i}.jpg', 'createdAt': DateTime.now().toIso8601String(), 'fileSizeByte': 1000, 'mimeType': 'image/jpeg', }, ), 'total': 100, // Mock total 'count': limit, 'nextPage': null, } }, requestOptions: RequestOptions(path: path), ); } return Response( statusCode: 200, data: {}, requestOptions: RequestOptions(path: path), ); }, ); final immichService = ImmichService( baseUrl: 'https://immich.example.com', apiKey: 'test-api-key', localStorage: localStorage, dio: mockDio, ); // Act final assets1 = await immichService.fetchAssets(limit: 10, skip: 0); final assets2 = await immichService.fetchAssets(limit: 10, skip: 10); // Assert expect(assets1.length, equals(10)); expect(assets2.length, equals(10)); expect(assets1[0].id, equals('asset-0')); expect(assets2[0].id, equals('asset-10')); }); /// Tests fetch failure with server error. test('fetchAssets - server error', () async { // Arrange final mockDio = _createMockDio( onPost: (path, data) { if (path == '/api/search/metadata') { return Response( statusCode: 500, data: { 'error': 'Internal server error', 'message': 'Internal server error' }, requestOptions: RequestOptions(path: path), ); } return Response( statusCode: 200, data: {}, requestOptions: RequestOptions(path: path), ); }, ); final immichService = ImmichService( baseUrl: 'https://immich.example.com', apiKey: 'test-api-key', localStorage: localStorage, dio: mockDio, ); // Act & Assert expect( () => immichService.fetchAssets(), throwsA(isA()), ); }); }); group('ImmichService - Cached Assets', () { /// Tests getting cached asset by ID. test('getCachedAsset - success', () async { // Arrange - store asset in local storage final asset = ImmichAsset( id: 'asset-789', fileName: 'cached_image.jpg', createdAt: DateTime.now(), fileSize: 5000, mimeType: 'image/jpeg', ); final item = Item( id: 'immich_asset-789', data: { 'type': 'immich_asset', 'asset': asset.toJson(), }, ); await localStorage.insertItem(item); final mockDio = _createMockDio(); final immichService = ImmichService( baseUrl: 'https://immich.example.com', apiKey: 'test-api-key', localStorage: localStorage, dio: mockDio, ); // Act final cached = await immichService.getCachedAsset('asset-789'); // Assert expect(cached, isNotNull); expect(cached!.id, equals('asset-789')); expect(cached.fileName, equals('cached_image.jpg')); }); /// Tests getting cached asset when not found. test('getCachedAsset - not found', () async { // Arrange final mockDio = _createMockDio(); final immichService = ImmichService( baseUrl: 'https://immich.example.com', apiKey: 'test-api-key', localStorage: localStorage, dio: mockDio, ); // Act final cached = await immichService.getCachedAsset('non-existent'); // Assert expect(cached, isNull); }); /// Tests getting all cached assets. test('getCachedAssets - returns all cached', () async { // Arrange - store multiple assets final assets = [ ImmichAsset( id: 'asset-1', fileName: 'image1.jpg', createdAt: DateTime.now(), fileSize: 1000, mimeType: 'image/jpeg', ), ImmichAsset( id: 'asset-2', fileName: 'image2.jpg', createdAt: DateTime.now(), fileSize: 2000, mimeType: 'image/png', ), ]; for (final asset in assets) { final item = Item( id: 'immich_${asset.id}', data: { 'type': 'immich_asset', 'asset': asset.toJson(), }, ); await localStorage.insertItem(item); } final mockDio = _createMockDio(); final immichService = ImmichService( baseUrl: 'https://immich.example.com', apiKey: 'test-api-key', localStorage: localStorage, dio: mockDio, ); // Act final cached = await immichService.getCachedAssets(); // Assert expect(cached.length, equals(2)); expect(cached.map((a) => a.id).toList(), containsAll(['asset-1', 'asset-2'])); }); }); } /// 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) { // Build full URL with query parameters for GET requests 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; }