diff --git a/mobile/test/services/upload.service_test.dart b/mobile/test/services/upload.service_test.dart index d33126782f..0570ad5500 100644 --- a/mobile/test/services/upload.service_test.dart +++ b/mobile/test/services/upload.service_test.dart @@ -1,14 +1,22 @@ +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'; @@ -16,8 +24,29 @@ import 'package:mocktail/mocktail.dart'; import '../domain/service.mock.dart'; import '../fixtures/asset.stub.dart'; import '../infrastructure/repository.mock.dart'; -import '../repository.mocks.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; @@ -62,6 +91,7 @@ void main() { mockLocalAssetRepository, mockAppSettingsService, mockAssetMediaRepository, + _serverInfo, ); mockUploadRepository.onUploadStatus = (_) {}; @@ -165,4 +195,223 @@ void main() { 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')); + }); + }); }