Files
immich/mobile/test/services/background_upload.service_test.dart
T
bo0tzzandGitHub 457da17c5d fix: always set extension from provided file (#29839)
* fix: always set extension from provided file

The livephoto case can also happen with other files; for example, we
receive a DNG file as JPEG but then still call it .dng

Fixes #29805, partially addresses #28602

* chore: test cases for file extension naming
2026-07-22 18:29:54 +05:30

407 lines
17 KiB
Dart

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/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/services/background_upload.service.dart';
import 'package:mocktail/mocktail.dart';
import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart';
import '../mocks/asset_entity.mock.dart';
import '../repository.mocks.dart';
void main() {
late BackgroundUploadService sut;
late MockUploadRepository mockUploadRepository;
late MockStorageRepository mockStorageRepository;
late MockDriftLocalAssetRepository mockLocalAssetRepository;
late MockDriftBackupRepository mockBackupRepository;
late MockAssetMediaRepository mockAssetMediaRepository;
late Drift db;
setUpAll(() async {
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 SettingsRepository.ensureInitialized(db);
await Store.put(StoreKey.serverEndpoint, 'http://test-server.com');
await Store.put(StoreKey.deviceId, 'test-device-id');
});
setUp(() {
mockUploadRepository = MockUploadRepository();
mockStorageRepository = MockStorageRepository();
mockLocalAssetRepository = MockDriftLocalAssetRepository();
mockBackupRepository = MockDriftBackupRepository();
mockAssetMediaRepository = MockAssetMediaRepository();
sut = BackgroundUploadService(
mockUploadRepository,
mockStorageRepository,
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
);
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'));
expect(task.fields['visibility'], equals('hidden'));
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
});
test('should not set visibility for a 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 => 'Regular.jpg');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.fields.containsKey('visibility'), isFalse);
});
test('corrects the extension when iOS returns a rendered file for a .dng asset', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/IMG_6499.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 => 'IMG_6499.dng');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.fields['filename'], equals('IMG_6499.jpg'));
});
test('keeps the .dng extension for a genuine RAW original', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/IMG_5210.dng');
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 => 'IMG_5210.dng');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.fields['filename'], equals('IMG_5210.dng'));
});
test('borrows the extension from the asset name for an extensionless name (DJI/Fusion)', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/DJI_0001');
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 => 'DJI_0001');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.fields['filename'], equals('DJI_0001.jpg'));
});
});
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'));
expect(task.fields.containsKey('visibility'), isFalse);
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 = BackgroundUploadService(
mockUploadRepository,
mockStorageRepository,
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
);
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,
adjustmentTime: DateTime(2026, 1, 2),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
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']['createdAt'], isNotNull);
expect(metadata[0]['value']['adjustmentTime'], isNotNull);
expect(metadata[0]['value']['latitude'], isNotNull);
expect(metadata[0]['value']['longitude'], isNotNull);
});
test('should NOT include metadata on Android regardless of server version', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final sutAndroid = BackgroundUploadService(
mockUploadRepository,
mockStorageRepository,
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
);
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,
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
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 = BackgroundUploadService(
mockUploadRepository,
mockStorageRepository,
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
);
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
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
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 = BackgroundUploadService(
mockUploadRepository,
mockStorageRepository,
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
);
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,
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
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'));
expect(task.fields.containsKey('visibility'), isFalse);
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'));
});
});
}