diff --git a/mobile/test/domain/services/hash_service_test.dart b/mobile/test/domain/services/hash_service_test.dart deleted file mode 100644 index 9f36a5635e..0000000000 --- a/mobile/test/domain/services/hash_service_test.dart +++ /dev/null @@ -1,194 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:immich_mobile/domain/models/album/local_album.model.dart'; -import 'package:immich_mobile/domain/services/hash.service.dart'; -import 'package:immich_mobile/platform/native_sync_api.g.dart'; -import 'package:mocktail/mocktail.dart'; - -import '../../fixtures/album.stub.dart'; -import '../../fixtures/asset.stub.dart'; -import '../../infrastructure/repository.mock.dart'; -import '../service.mock.dart'; - -void main() { - late HashService sut; - late MockLocalAlbumRepository mockAlbumRepo; - late MockLocalAssetRepository mockAssetRepo; - late MockNativeSyncApi mockNativeApi; - late MockTrashedLocalAssetRepository mockTrashedAssetRepo; - - setUp(() { - mockAlbumRepo = MockLocalAlbumRepository(); - mockAssetRepo = MockLocalAssetRepository(); - mockNativeApi = MockNativeSyncApi(); - mockTrashedAssetRepo = MockTrashedLocalAssetRepository(); - - sut = HashService( - localAlbumRepository: mockAlbumRepo, - localAssetRepository: mockAssetRepo, - nativeSyncApi: mockNativeApi, - trashedLocalAssetRepository: mockTrashedAssetRepo, - ); - - registerFallbackValue(LocalAlbumStub.recent); - registerFallbackValue(LocalAssetStub.image1); - registerFallbackValue({}); - - when(() => mockAssetRepo.reconcileHashesFromCloudId()).thenAnswer((_) async => {}); - when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {}); - }); - - group('HashService hashAssets', () { - test('skips albums with no assets to hash', () async { - when( - () => mockAlbumRepo.getBackupAlbums(), - ).thenAnswer((_) async => [LocalAlbumStub.recent.copyWith(assetCount: 0)]); - when(() => mockAlbumRepo.getAssetsToHash(LocalAlbumStub.recent.id)).thenAnswer((_) async => []); - - await sut.hashAssets(); - - verifyNever(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))); - }); - }); - - group('HashService _hashAssets', () { - test('skips empty batches', () async { - final album = LocalAlbumStub.recent; - when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]); - when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => []); - - await sut.hashAssets(); - - verifyNever(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))); - }); - - test('processes assets when available', () async { - final album = LocalAlbumStub.recent; - final asset = LocalAssetStub.image1; - - when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]); - when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]); - when( - () => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false), - ).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: 'test-hash')]); - - await sut.hashAssets(); - - verify(() => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false)).called(1); - final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map; - expect(captured.length, 1); - expect(captured[asset.id], 'test-hash'); - }); - - test('handles failed hashes', () async { - final album = LocalAlbumStub.recent; - final asset = LocalAssetStub.image1; - - when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]); - when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]); - when( - () => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false), - ).thenAnswer((_) async => [HashResult(assetId: asset.id, error: 'Failed to hash')]); - - await sut.hashAssets(); - - final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map; - expect(captured.length, 0); - }); - - test('handles null hash results', () async { - final album = LocalAlbumStub.recent; - final asset = LocalAssetStub.image1; - - when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]); - when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]); - when( - () => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false), - ).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: null)]); - - await sut.hashAssets(); - - final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map; - expect(captured.length, 0); - }); - - test('batches by size limit', () async { - const batchSize = 2; - final sut = HashService( - localAlbumRepository: mockAlbumRepo, - localAssetRepository: mockAssetRepo, - nativeSyncApi: mockNativeApi, - batchSize: batchSize, - trashedLocalAssetRepository: mockTrashedAssetRepo, - ); - - final album = LocalAlbumStub.recent; - final asset1 = LocalAssetStub.image1; - final asset2 = LocalAssetStub.image2; - final asset3 = LocalAssetStub.image1.copyWith(id: 'image3', name: 'image3.jpg'); - - final capturedCalls = >[]; - - when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {}); - when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]); - when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]); - when(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer(( - invocation, - ) async { - final assetIds = invocation.positionalArguments[0] as List; - capturedCalls.add(List.from(assetIds)); - return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList(); - }); - - await sut.hashAssets(); - - expect(capturedCalls.length, 2, reason: 'Should make exactly 2 calls to hashAssets'); - expect(capturedCalls[0], [asset1.id, asset2.id], reason: 'First call should batch the first two assets'); - expect(capturedCalls[1], [asset3.id], reason: 'Second call should have the remaining asset'); - - verify(() => mockAssetRepo.updateHashes(any())).called(2); - }); - - test('handles mixed success and failure in batch', () async { - final album = LocalAlbumStub.recent; - final asset1 = LocalAssetStub.image1; - final asset2 = LocalAssetStub.image2; - - when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]); - when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]); - when(() => mockNativeApi.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer( - (_) async => [ - HashResult(assetId: asset1.id, hash: 'asset1-hash'), - HashResult(assetId: asset2.id, error: 'Failed to hash asset2'), - ], - ); - - await sut.hashAssets(); - - final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map; - expect(captured.length, 1); - expect(captured[asset1.id], 'asset1-hash'); - }); - - test('uses allowNetworkAccess based on album backup selection', () async { - final selectedAlbum = LocalAlbumStub.recent.copyWith(backupSelection: BackupSelection.selected); - final nonSelectedAlbum = LocalAlbumStub.recent.copyWith(id: 'album2', backupSelection: BackupSelection.excluded); - final asset1 = LocalAssetStub.image1; - final asset2 = LocalAssetStub.image2; - - when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]); - when(() => mockAlbumRepo.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]); - when(() => mockAlbumRepo.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]); - when(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer(( - invocation, - ) async { - final assetIds = invocation.positionalArguments[0] as List; - return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList(); - }); - - await sut.hashAssets(); - - verify(() => mockNativeApi.hashAssets([asset1.id], allowNetworkAccess: true)).called(1); - verify(() => mockNativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1); - }); - }); -} diff --git a/mobile/test/infrastructure/repositories/backup_repository_test.dart b/mobile/test/medium/repositories/backup_repository_test.dart similarity index 99% rename from mobile/test/infrastructure/repositories/backup_repository_test.dart rename to mobile/test/medium/repositories/backup_repository_test.dart index c042685779..00ab28df87 100644 --- a/mobile/test/infrastructure/repositories/backup_repository_test.dart +++ b/mobile/test/medium/repositories/backup_repository_test.dart @@ -3,7 +3,7 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; import 'package:immich_mobile/utils/option.dart'; -import '../../medium/repository_context.dart'; +import '../repository_context.dart'; void main() { late MediumRepositoryContext ctx; diff --git a/mobile/test/infrastructure/repositories/local_asset_repository_test.dart b/mobile/test/medium/repositories/local_asset_repository_test.dart similarity index 99% rename from mobile/test/infrastructure/repositories/local_asset_repository_test.dart rename to mobile/test/medium/repositories/local_asset_repository_test.dart index 88f8d00e03..7a2e95f168 100644 --- a/mobile/test/infrastructure/repositories/local_asset_repository_test.dart +++ b/mobile/test/medium/repositories/local_asset_repository_test.dart @@ -4,7 +4,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/utils/option.dart'; -import '../../medium/repository_context.dart'; +import '../repository_context.dart'; void main() { late MediumRepositoryContext ctx; diff --git a/mobile/test/infrastructure/repositories/remote_album_repository_test.dart b/mobile/test/medium/repositories/remote_album_repository_test.dart similarity index 99% rename from mobile/test/infrastructure/repositories/remote_album_repository_test.dart rename to mobile/test/medium/repositories/remote_album_repository_test.dart index 1bc797f6e1..1ae994f68b 100644 --- a/mobile/test/infrastructure/repositories/remote_album_repository_test.dart +++ b/mobile/test/medium/repositories/remote_album_repository_test.dart @@ -2,7 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; -import '../../medium/repository_context.dart'; +import '../repository_context.dart'; void main() { late MediumRepositoryContext ctx; diff --git a/mobile/test/medium/repository_context.dart b/mobile/test/medium/repository_context.dart index 2c4758400c..b6e1c4f0d5 100644 --- a/mobile/test/medium/repository_context.dart +++ b/mobile/test/medium/repository_context.dart @@ -18,6 +18,8 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/utils/option.dart'; import 'package:uuid/uuid.dart'; +import '../utils.dart'; + class MediumRepositoryContext { final Drift db; final Random _random = Random(); @@ -51,7 +53,7 @@ class MediumRepositoryContext { DateTime? profileChangedAt, bool? hasProfileImage, }) async { - id = id ?? const Uuid().v4(); + id = TestUtils.uuid(id); return await db .into(db.userEntity) .insertReturning( @@ -60,7 +62,7 @@ class MediumRepositoryContext { email: Value(email ?? '$id@test.com'), name: Value(email ?? 'user_$id'), avatarColor: Value(avatarColor ?? AvatarColor.values[_random.nextInt(AvatarColor.values.length)]), - profileChangedAt: Value(profileChangedAt ?? DateTime.now()), + profileChangedAt: Value(TestUtils.date(profileChangedAt)), hasProfileImage: Value(hasProfileImage ?? false), ), ); @@ -85,19 +87,19 @@ class MediumRepositoryContext { String? thumbHash, String? libraryId, }) async { - id = id ?? const Uuid().v4(); - createdAt = createdAt ?? DateTime.now(); + id = TestUtils.uuid(id); + createdAt = TestUtils.date(createdAt); return db .into(db.remoteAssetEntity) .insertReturning( RemoteAssetEntityCompanion( id: Value(id), name: Value('remote_$id.jpg'), - checksum: Value(checksum ?? const Uuid().v4()), + checksum: Value(TestUtils.uuid(checksum)), type: Value(type ?? AssetType.image), createdAt: Value(createdAt), - updatedAt: Value(updatedAt ?? DateTime.now()), - ownerId: Value(ownerId ?? const Uuid().v4()), + updatedAt: Value(TestUtils.date(updatedAt)), + ownerId: Value(TestUtils.uuid(ownerId)), visibility: Value(visibility ?? AssetVisibility.timeline), deletedAt: Value(deletedAt), durationInSeconds: Value(durationInSeconds ?? 0), @@ -108,8 +110,8 @@ class MediumRepositoryContext { livePhotoVideoId: Value(livePhotoVideoId), stackId: Value(stackId), localDateTime: Value(createdAt.toLocal()), - thumbHash: Value(thumbHash ?? const Uuid().v4()), - libraryId: Value(libraryId ?? const Uuid().v4()), + thumbHash: Value(TestUtils.uuid(thumbHash)), + libraryId: Value(TestUtils.uuid(libraryId)), ), ); } @@ -127,9 +129,9 @@ class MediumRepositoryContext { .into(db.remoteAssetCloudIdEntity) .insertReturning( RemoteAssetCloudIdEntityCompanion( - assetId: Value(id ?? const Uuid().v4()), - cloudId: Value(cloudId ?? const Uuid().v4()), - createdAt: Value(createdAt ?? DateTime.now()), + assetId: Value(TestUtils.uuid(id)), + cloudId: Value(TestUtils.uuid(cloudId)), + createdAt: Value(TestUtils.date(createdAt)), adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()), latitude: _resolveOption(latitude, _random.nextDouble() * 180 - 90), longitude: _resolveOption(longitude, _random.nextDouble() * 360 - 180), @@ -148,16 +150,16 @@ class MediumRepositoryContext { AlbumAssetOrder? order, String? thumbnailAssetId, }) async { - id = id ?? const Uuid().v4(); + id = TestUtils.uuid(id); return db .into(db.remoteAlbumEntity) .insertReturning( RemoteAlbumEntityCompanion( id: Value(id), name: Value(name ?? 'remote_album_$id'), - ownerId: Value(ownerId ?? const Uuid().v4()), - createdAt: Value(createdAt ?? DateTime.now()), - updatedAt: Value(updatedAt ?? DateTime.now()), + ownerId: Value(TestUtils.uuid(ownerId)), + createdAt: Value(TestUtils.date(createdAt)), + updatedAt: Value(TestUtils.date(updatedAt)), description: Value(description ?? 'Description for album $id'), isActivityEnabled: Value(isActivityEnabled ?? false), order: Value(order ?? AlbumAssetOrder.asc), @@ -191,7 +193,7 @@ class MediumRepositoryContext { int? orientation, DateTime? updatedAt, }) async { - id = id ?? const Uuid().v4(); + id = TestUtils.uuid(id); return db .into(db.localAssetEntity) .insertReturning( @@ -202,12 +204,12 @@ class MediumRepositoryContext { width: Value(width ?? _random.nextInt(1000)), durationInSeconds: Value(durationInSeconds ?? 0), orientation: Value(orientation ?? 0), - updatedAt: Value(updatedAt ?? DateTime.now()), + updatedAt: Value(TestUtils.date(updatedAt)), checksum: _resolveUndefined(checksum, checksumOption, const Uuid().v4()), - createdAt: Value(createdAt ?? DateTime.now()), + createdAt: Value(TestUtils.date(createdAt)), type: Value(type ?? AssetType.image), isFavorite: Value(isFavorite ?? false), - iCloudId: Value(iCloudId ?? const Uuid().v4()), + iCloudId: Value(TestUtils.uuid(iCloudId)), adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()), latitude: Value(latitude ?? _random.nextDouble() * 180 - 90), longitude: Value(longitude ?? _random.nextDouble() * 360 - 180), @@ -223,14 +225,14 @@ class MediumRepositoryContext { bool? isIosSharedAlbum, String? linkedRemoteAlbumId, }) { - id = id ?? const Uuid().v4(); + id = TestUtils.uuid(id); return db .into(db.localAlbumEntity) .insertReturning( LocalAlbumEntityCompanion( id: Value(id), name: Value(name ?? 'local_album_$id'), - updatedAt: Value(updatedAt ?? DateTime.now()), + updatedAt: Value(TestUtils.date(updatedAt)), backupSelection: Value(backupSelection ?? BackupSelection.none), isIosSharedAlbum: Value(isIosSharedAlbum ?? false), linkedRemoteAlbumId: Value(linkedRemoteAlbumId), diff --git a/mobile/test/unit/factories/local_album_factory.dart b/mobile/test/unit/factories/local_album_factory.dart new file mode 100644 index 0000000000..8ac5c11eca --- /dev/null +++ b/mobile/test/unit/factories/local_album_factory.dart @@ -0,0 +1,28 @@ +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; + +import '../../utils.dart'; + +class LocalAlbumFactory { + const LocalAlbumFactory(); + + static LocalAlbum create({ + String? id, + String? name, + DateTime? updatedAt, + BackupSelection? backupSelection, + bool? isIosSharedAlbum, + String? linkedRemoteAlbumId, + int? assetCount, + }) { + id = TestUtils.uuid(id); + return LocalAlbum( + id: id, + name: name ?? 'local_album_$id', + updatedAt: TestUtils.date(updatedAt), + backupSelection: backupSelection ?? BackupSelection.none, + isIosSharedAlbum: isIosSharedAlbum ?? false, + linkedRemoteAlbumId: linkedRemoteAlbumId, + assetCount: assetCount ?? 10, + ); + } +} diff --git a/mobile/test/unit/factories/local_asset_factory.dart b/mobile/test/unit/factories/local_asset_factory.dart new file mode 100644 index 0000000000..8ad35725c4 --- /dev/null +++ b/mobile/test/unit/factories/local_asset_factory.dart @@ -0,0 +1,21 @@ +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; + +import '../../utils.dart'; + +class LocalAssetFactory { + const LocalAssetFactory(); + + static LocalAsset create({String? id, String? name}) { + id = TestUtils.uuid(id); + + return LocalAsset( + id: id, + name: name ?? 'local_$id.jpg', + type: AssetType.image, + createdAt: TestUtils.yesterday(), + updatedAt: TestUtils.now(), + playbackStyle: AssetPlaybackStyle.image, + isEdited: false, + ); + } +} diff --git a/mobile/test/unit/mocks.dart b/mobile/test/unit/mocks.dart new file mode 100644 index 0000000000..b5d91527ea --- /dev/null +++ b/mobile/test/unit/mocks.dart @@ -0,0 +1,36 @@ +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:mocktail/mocktail.dart' as mocktail; + +import '../domain/service.mock.dart'; +import '../infrastructure/repository.mock.dart'; + +class UnitMocks { + final localAlbum = MockLocalAlbumRepository(); + final localAsset = MockDriftLocalAssetRepository(); + final trashedAsset = MockTrashedLocalAssetRepository(); + + final nativeApi = MockNativeSyncApi(); + + UnitMocks() { + mocktail.registerFallbackValue(LocalAlbum(id: '', name: '', updatedAt: DateTime.now())); + mocktail.registerFallbackValue( + LocalAsset( + id: '', + name: '', + type: AssetType.image, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + playbackStyle: AssetPlaybackStyle.image, + isEdited: false, + ), + ); + } + + void reset() { + mocktail.reset(localAlbum); + mocktail.reset(localAsset); + mocktail.reset(trashedAsset); + mocktail.reset(nativeApi); + } +} diff --git a/mobile/test/unit/services/hash_service_test.dart b/mobile/test/unit/services/hash_service_test.dart new file mode 100644 index 0000000000..8c4a23c06a --- /dev/null +++ b/mobile/test/unit/services/hash_service_test.dart @@ -0,0 +1,187 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/domain/services/hash.service.dart'; +import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../factories/local_album_factory.dart'; +import '../factories/local_asset_factory.dart'; +import '../mocks.dart'; + +void main() { + late HashService sut; + final mocks = UnitMocks(); + + setUp(() { + sut = HashService( + localAlbumRepository: mocks.localAlbum, + localAssetRepository: mocks.localAsset, + nativeSyncApi: mocks.nativeApi, + trashedLocalAssetRepository: mocks.trashedAsset, + ); + + when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {}); + when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {}); + }); + + tearDown(() { + mocks.reset(); + }); + + group('HashService', () { + group('hashAssets', () { + test('skips albums with no assets to hash', () async { + final album = LocalAlbumFactory.create(assetCount: 0); + when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]); + when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []); + + await sut.hashAssets(); + + verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))); + }); + + test('skips empty batches', () async { + final album = LocalAlbumFactory.create(); + when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]); + when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []); + + await sut.hashAssets(); + + verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))); + }); + + test('processes assets when available', () async { + final album = LocalAlbumFactory.create(); + final asset = LocalAssetFactory.create(); + final result = HashResult(assetId: asset.id, hash: 'test-hash'); + + when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]); + when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]); + when(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).thenAnswer((_) async => [result]); + + await sut.hashAssets(); + + verify(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).called(1); + final captured = + verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map; + expect(captured.length, 1); + expect(captured[asset.id], result.hash); + }); + + test('handles failed hashes', () async { + final album = LocalAlbumFactory.create(); + final asset = LocalAssetFactory.create(); + + when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]); + when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]); + when( + () => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false), + ).thenAnswer((_) async => [HashResult(assetId: asset.id, error: 'Failed to hash')]); + + await sut.hashAssets(); + + final captured = + verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map; + expect(captured.length, 0); + }); + + test('handles null hash results', () async { + final album = LocalAlbumFactory.create(); + final asset = LocalAssetFactory.create(); + + when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]); + when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]); + when( + () => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false), + ).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: null)]); + + await sut.hashAssets(); + + final captured = + verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map; + expect(captured.length, 0); + }); + + test('batches by size limit', () async { + const batchSize = 2; + final sut = HashService( + localAlbumRepository: mocks.localAlbum, + localAssetRepository: mocks.localAsset, + nativeSyncApi: mocks.nativeApi, + batchSize: batchSize, + trashedLocalAssetRepository: mocks.trashedAsset, + ); + + final album = LocalAlbumFactory.create(); + final asset1 = LocalAssetFactory.create(); + final asset2 = LocalAssetFactory.create(); + final asset3 = LocalAssetFactory.create(); + + final capturedCalls = >[]; + + when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {}); + when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]); + when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]); + when(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer(( + invocation, + ) async { + final assetIds = invocation.positionalArguments[0] as List; + capturedCalls.add(List.from(assetIds)); + return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList(); + }); + + await sut.hashAssets(); + + expect(capturedCalls.length, 2, reason: 'Should make exactly 2 calls to hashAssets'); + expect(capturedCalls[0], [asset1.id, asset2.id], reason: 'First call should batch the first two assets'); + expect(capturedCalls[1], [asset3.id], reason: 'Second call should have the remaining asset'); + + verify(() => mocks.localAsset.updateHashes(any())).called(2); + }); + + test('handles mixed success and failure in batch', () async { + final album = LocalAlbumFactory.create(); + final asset1 = LocalAssetFactory.create(); + final asset2 = LocalAssetFactory.create(); + + when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]); + when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]); + when(() => mocks.nativeApi.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer( + (_) async => [ + HashResult(assetId: asset1.id, hash: 'asset1-hash'), + HashResult(assetId: asset2.id, error: 'Failed to hash asset2'), + ], + ); + + await sut.hashAssets(); + + final captured = + verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map; + expect(captured.length, 1); + expect(captured[asset1.id], 'asset1-hash'); + }); + + test('uses allowNetworkAccess based on album backup selection', () async { + final selectedAlbum = LocalAlbumFactory.create(backupSelection: BackupSelection.selected); + final nonSelectedAlbum = LocalAlbumFactory.create(id: 'album2', backupSelection: BackupSelection.excluded); + final asset1 = LocalAssetFactory.create(); + final asset2 = LocalAssetFactory.create(); + + when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]); + when(() => mocks.localAlbum.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]); + when(() => mocks.localAlbum.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]); + when(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer(( + invocation, + ) async { + final assetIds = invocation.positionalArguments[0] as List; + return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList(); + }); + + await sut.hashAssets(); + + verify(() => mocks.nativeApi.hashAssets([asset1.id], allowNetworkAccess: true)).called(1); + verify(() => mocks.nativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1); + }); + }); + }); +} diff --git a/mobile/test/utils/editor_test.dart b/mobile/test/unit/utils/editor_test.dart similarity index 100% rename from mobile/test/utils/editor_test.dart rename to mobile/test/unit/utils/editor_test.dart diff --git a/mobile/test/utils/option_test.dart b/mobile/test/unit/utils/option_test.dart similarity index 100% rename from mobile/test/utils/option_test.dart rename to mobile/test/unit/utils/option_test.dart diff --git a/mobile/test/utils/semver_test.dart b/mobile/test/unit/utils/semver_test.dart similarity index 100% rename from mobile/test/utils/semver_test.dart rename to mobile/test/unit/utils/semver_test.dart diff --git a/mobile/test/utils/timezone_test.dart b/mobile/test/unit/utils/timezone_test.dart similarity index 100% rename from mobile/test/utils/timezone_test.dart rename to mobile/test/unit/utils/timezone_test.dart diff --git a/mobile/test/utils.dart b/mobile/test/utils.dart new file mode 100644 index 0000000000..7967083efc --- /dev/null +++ b/mobile/test/utils.dart @@ -0,0 +1,10 @@ +import 'package:uuid/uuid.dart'; + +class TestUtils { + static String uuid([String? id]) => id ?? const Uuid().v4(); + + static DateTime date([DateTime? date]) => date ?? DateTime.now(); + static DateTime now() => DateTime.now(); + static DateTime yesterday() => DateTime.now().subtract(const Duration(days: 1)); + static DateTime tomorrow() => DateTime.now().add(const Duration(days: 1)); +} diff --git a/mobile/test/utils/action_button_utils_test.dart b/mobile/test/utils_legacy/action_button_utils_test.dart similarity index 100% rename from mobile/test/utils/action_button_utils_test.dart rename to mobile/test/utils_legacy/action_button_utils_test.dart