import 'dart:convert'; import 'dart:io'; import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:drift/native.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/models/server_info/server_config.model.dart'; import 'package:immich_mobile/models/server_info/server_disk_info.model.dart'; import 'package:immich_mobile/models/server_info/server_features.model.dart'; import 'package:immich_mobile/models/server_info/server_info.model.dart'; import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/upload.service.dart'; import 'package:mocktail/mocktail.dart'; import '../domain/service.mock.dart'; import '../fixtures/asset.stub.dart'; import '../infrastructure/repository.mock.dart'; import '../mocks/asset_entity.mock.dart'; import '../repository.mocks.dart'; // Test ServerInfo stub const _serverInfo = ServerInfo( serverVersion: ServerVersion(major: 2, minor: 4, patch: 0), latestVersion: ServerVersion(major: 2, minor: 4, patch: 0), serverFeatures: ServerFeatures(trash: true, map: true, oauthEnabled: false, passwordLogin: true, ocr: false), serverConfig: ServerConfig( trashDays: 30, oauthButtonText: 'Login with OAuth', externalDomain: '', mapDarkStyleUrl: '', mapLightStyleUrl: '', ), serverDiskInfo: ServerDiskInfo( diskAvailable: '100GB', diskSize: '500GB', diskUse: '400GB', diskUsagePercentage: 80.0, ), versionStatus: VersionStatus.upToDate, ); void main() { late UploadService sut; late MockUploadRepository mockUploadRepository; late MockDriftBackupRepository mockBackupRepository; late MockStorageRepository mockStorageRepository; late MockDriftLocalAssetRepository mockLocalAssetRepository; late MockAppSettingsService mockAppSettingsService; late MockAssetMediaRepository mockAssetMediaRepository; late Drift db; setUpAll(() async { registerFallbackValue(AppSettingsEnum.useCellularForUploadPhotos); TestWidgetsFlutterBinding.ensureInitialized(); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( const MethodChannel('plugins.flutter.io/path_provider'), (MethodCall methodCall) async => 'test', ); db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); await StoreService.init(storeRepository: DriftStoreRepository(db)); await Store.put(StoreKey.serverEndpoint, 'http://test-server.com'); await Store.put(StoreKey.deviceId, 'test-device-id'); }); setUp(() { mockUploadRepository = MockUploadRepository(); mockBackupRepository = MockDriftBackupRepository(); mockStorageRepository = MockStorageRepository(); mockLocalAssetRepository = MockDriftLocalAssetRepository(); mockAppSettingsService = MockAppSettingsService(); mockAssetMediaRepository = MockAssetMediaRepository(); when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)).thenReturn(false); when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)).thenReturn(false); sut = UploadService( mockUploadRepository, mockBackupRepository, mockStorageRepository, mockLocalAssetRepository, mockAppSettingsService, mockAssetMediaRepository, _serverInfo, ); mockUploadRepository.onUploadStatus = (_) {}; mockUploadRepository.onTaskProgress = (_) {}; }); tearDown(() { sut.dispose(); }); group('getUploadTask', () { test('should call getOriginalFilename from AssetMediaRepository for regular photo', () async { final asset = LocalAssetStub.image1; final mockEntity = MockAssetEntity(); final mockFile = File('/path/to/file.jpg'); when(() => mockEntity.isLivePhoto).thenReturn(false); when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'OriginalPhoto.jpg'); final task = await sut.getUploadTask(asset); expect(task, isNotNull); expect(task!.fields['filename'], equals('OriginalPhoto.jpg')); verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1); }); test('should call getOriginalFilename when original filename is null', () async { final asset = LocalAssetStub.image2; final mockEntity = MockAssetEntity(); final mockFile = File('/path/to/file.jpg'); when(() => mockEntity.isLivePhoto).thenReturn(false); when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null); final task = await sut.getUploadTask(asset); expect(task, isNotNull); expect(task!.fields['filename'], equals(asset.name)); verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1); }); test('should call getOriginalFilename for live photo', () async { final asset = LocalAssetStub.image1; final mockEntity = MockAssetEntity(); final mockFile = File('/path/to/file.mov'); when(() => mockEntity.isLivePhoto).thenReturn(true); when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getMotionFileForAsset(asset)).thenAnswer((_) async => mockFile); when( () => mockAssetMediaRepository.getOriginalFilename(asset.id), ).thenAnswer((_) async => 'OriginalLivePhoto.HEIC'); final task = await sut.getUploadTask(asset); expect(task, isNotNull); // For live photos, extension should be changed to match the video file expect(task!.fields['filename'], equals('OriginalLivePhoto.mov')); verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1); }); }); group('getLivePhotoUploadTask', () { test('should call getOriginalFilename for live photo upload task', () async { final asset = LocalAssetStub.image1; final mockEntity = MockAssetEntity(); final mockFile = File('/path/to/livephoto.heic'); when(() => mockEntity.isLivePhoto).thenReturn(true); when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); when( () => mockAssetMediaRepository.getOriginalFilename(asset.id), ).thenAnswer((_) async => 'OriginalLivePhoto.HEIC'); final task = await sut.getLivePhotoUploadTask(asset, 'video-id-123'); expect(task, isNotNull); expect(task!.fields['filename'], equals('OriginalLivePhoto.HEIC')); expect(task.fields['livePhotoVideoId'], equals('video-id-123')); verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1); }); test('should call getOriginalFilename when original filename is null', () async { final asset = LocalAssetStub.image2; final mockEntity = MockAssetEntity(); final mockFile = File('/path/to/fallback.heic'); when(() => mockEntity.isLivePhoto).thenReturn(true); when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null); final task = await sut.getLivePhotoUploadTask(asset, 'video-id-456'); expect(task, isNotNull); // Should fall back to asset.name when original filename is null expect(task!.fields['filename'], equals(asset.name)); verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1); }); }); group('Server Info - cloudId and eTag metadata', () { test('should include cloudId and eTag metadata on iOS when server version is 2.4+', () async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); final sutWithV24 = UploadService( mockUploadRepository, mockBackupRepository, mockStorageRepository, mockLocalAssetRepository, mockAppSettingsService, mockAssetMediaRepository, _serverInfo, ); addTearDown(() => sutWithV24.dispose()); final assetWithCloudId = LocalAsset( id: 'test-asset-id', name: 'test.jpg', type: AssetType.image, createdAt: DateTime(2025, 1, 1), updatedAt: DateTime(2025, 1, 2), cloudId: 'cloud-id-123', latitude: 37.7749, longitude: -122.4194, ); final mockEntity = MockAssetEntity(); final mockFile = File('/path/to/test.jpg'); when(() => mockEntity.isLivePhoto).thenReturn(false); when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile); when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg'); final task = await sutWithV24.getUploadTask(assetWithCloudId); expect(task, isNotNull); expect(task!.fields.containsKey('metadata'), isTrue); final metadata = jsonDecode(task.fields['metadata']!) as List; expect(metadata, hasLength(1)); expect(metadata[0]['key'], equals('mobile-app')); expect(metadata[0]['value']['iCloudId'], equals('cloud-id-123')); expect(metadata[0]['value']['eTag'], isNotNull); }); test('should NOT include metadata on iOS when server version is below 2.4', () async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); final sutWithV23 = UploadService( mockUploadRepository, mockBackupRepository, mockStorageRepository, mockLocalAssetRepository, mockAppSettingsService, mockAssetMediaRepository, _serverInfo.copyWith( serverVersion: const ServerVersion(major: 2, minor: 3, patch: 0), latestVersion: const ServerVersion(major: 2, minor: 3, patch: 0), ), ); addTearDown(() => sutWithV23.dispose()); final assetWithCloudId = LocalAsset( id: 'test-asset-id', name: 'test.jpg', type: AssetType.image, createdAt: DateTime(2025, 1, 1), updatedAt: DateTime(2025, 1, 2), cloudId: 'cloud-id-123', latitude: 37.7749, longitude: -122.4194, ); final mockEntity = MockAssetEntity(); final mockFile = File('/path/to/test.jpg'); when(() => mockEntity.isLivePhoto).thenReturn(false); when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile); when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg'); final task = await sutWithV23.getUploadTask(assetWithCloudId); expect(task, isNotNull); expect(task!.fields.containsKey('metadata'), isFalse); }); test('should NOT include metadata on Android regardless of server version', () async { debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); final sutAndroid = UploadService( mockUploadRepository, mockBackupRepository, mockStorageRepository, mockLocalAssetRepository, mockAppSettingsService, mockAssetMediaRepository, _serverInfo, ); addTearDown(() => sutAndroid.dispose()); final assetWithCloudId = LocalAsset( id: 'test-asset-id', name: 'test.jpg', type: AssetType.image, createdAt: DateTime(2025, 1, 1), updatedAt: DateTime(2025, 1, 2), cloudId: 'cloud-id-123', latitude: 37.7749, longitude: -122.4194, ); final mockEntity = MockAssetEntity(); final mockFile = File('/path/to/test.jpg'); when(() => mockEntity.isLivePhoto).thenReturn(false); when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile); when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg'); final task = await sutAndroid.getUploadTask(assetWithCloudId); expect(task, isNotNull); expect(task!.fields.containsKey('metadata'), isFalse); }); test('should NOT include metadata when cloudId is null even on iOS with server 2.4+', () async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); final sutWithV24 = UploadService( mockUploadRepository, mockBackupRepository, mockStorageRepository, mockLocalAssetRepository, mockAppSettingsService, mockAssetMediaRepository, _serverInfo, ); addTearDown(() => sutWithV24.dispose()); final assetWithoutCloudId = LocalAsset( id: 'test-asset-id', name: 'test.jpg', type: AssetType.image, createdAt: DateTime(2025, 1, 1), updatedAt: DateTime(2025, 1, 2), cloudId: null, // No cloudId ); final mockEntity = MockAssetEntity(); final mockFile = File('/path/to/test.jpg'); when(() => mockEntity.isLivePhoto).thenReturn(false); when(() => mockStorageRepository.getAssetEntityForAsset(assetWithoutCloudId)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getFileForAsset(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile); when( () => mockAssetMediaRepository.getOriginalFilename(assetWithoutCloudId.id), ).thenAnswer((_) async => 'test.jpg'); final task = await sutWithV24.getUploadTask(assetWithoutCloudId); expect(task, isNotNull); expect(task!.fields.containsKey('metadata'), isFalse); }); test('should include metadata for live photos with cloudId on iOS 2.4+', () async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); final sutWithV24 = UploadService( mockUploadRepository, mockBackupRepository, mockStorageRepository, mockLocalAssetRepository, mockAppSettingsService, mockAssetMediaRepository, _serverInfo, ); addTearDown(() => sutWithV24.dispose()); final assetWithCloudId = LocalAsset( id: 'test-livephoto-id', name: 'livephoto.heic', type: AssetType.image, createdAt: DateTime(2025, 1, 1), updatedAt: DateTime(2025, 1, 2), cloudId: 'cloud-id-livephoto', latitude: 37.7749, longitude: -122.4194, ); final mockEntity = MockAssetEntity(); final mockFile = File('/path/to/livephoto.heic'); when(() => mockEntity.isLivePhoto).thenReturn(true); when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile); when( () => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id), ).thenAnswer((_) async => 'livephoto.heic'); final task = await sutWithV24.getLivePhotoUploadTask(assetWithCloudId, 'video-123'); expect(task, isNotNull); expect(task!.fields.containsKey('metadata'), isTrue); expect(task.fields['livePhotoVideoId'], equals('video-123')); final metadata = jsonDecode(task.fields['metadata']!) as List; expect(metadata, hasLength(1)); expect(metadata[0]['key'], equals('mobile-app')); expect(metadata[0]['value']['iCloudId'], equals('cloud-id-livephoto')); }); }); }