mirror of
https://github.com/immich-app/immich.git
synced 2026-07-30 23:50:36 -07:00
refactor: test organisation and service test (#27991)
* refactor: test organisation # Conflicts: # mobile/test/unit/utils/editor_test.dart * regroup hash_service_test --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
co-authored by
shenlong-tanwen
parent
f909648bce
commit
d9011c0829
@@ -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(<String, String>{});
|
|
||||||
|
|
||||||
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<String, String>;
|
|
||||||
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<String, String>;
|
|
||||||
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<String, String>;
|
|
||||||
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 = <List<String>>[];
|
|
||||||
|
|
||||||
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<String>;
|
|
||||||
capturedCalls.add(List<String>.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<String, String>;
|
|
||||||
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<String>;
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+1
-1
@@ -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/infrastructure/repositories/backup.repository.dart';
|
||||||
import 'package:immich_mobile/utils/option.dart';
|
import 'package:immich_mobile/utils/option.dart';
|
||||||
|
|
||||||
import '../../medium/repository_context.dart';
|
import '../repository_context.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late MediumRepositoryContext ctx;
|
late MediumRepositoryContext ctx;
|
||||||
+1
-1
@@ -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/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/utils/option.dart';
|
import 'package:immich_mobile/utils/option.dart';
|
||||||
|
|
||||||
import '../../medium/repository_context.dart';
|
import '../repository_context.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late MediumRepositoryContext ctx;
|
late MediumRepositoryContext ctx;
|
||||||
+1
-1
@@ -2,7 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||||
|
|
||||||
import '../../medium/repository_context.dart';
|
import '../repository_context.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late MediumRepositoryContext ctx;
|
late MediumRepositoryContext ctx;
|
||||||
@@ -18,6 +18,8 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
|||||||
import 'package:immich_mobile/utils/option.dart';
|
import 'package:immich_mobile/utils/option.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../utils.dart';
|
||||||
|
|
||||||
class MediumRepositoryContext {
|
class MediumRepositoryContext {
|
||||||
final Drift db;
|
final Drift db;
|
||||||
final Random _random = Random();
|
final Random _random = Random();
|
||||||
@@ -51,7 +53,7 @@ class MediumRepositoryContext {
|
|||||||
DateTime? profileChangedAt,
|
DateTime? profileChangedAt,
|
||||||
bool? hasProfileImage,
|
bool? hasProfileImage,
|
||||||
}) async {
|
}) async {
|
||||||
id = id ?? const Uuid().v4();
|
id = TestUtils.uuid(id);
|
||||||
return await db
|
return await db
|
||||||
.into(db.userEntity)
|
.into(db.userEntity)
|
||||||
.insertReturning(
|
.insertReturning(
|
||||||
@@ -60,7 +62,7 @@ class MediumRepositoryContext {
|
|||||||
email: Value(email ?? '$id@test.com'),
|
email: Value(email ?? '$id@test.com'),
|
||||||
name: Value(email ?? 'user_$id'),
|
name: Value(email ?? 'user_$id'),
|
||||||
avatarColor: Value(avatarColor ?? AvatarColor.values[_random.nextInt(AvatarColor.values.length)]),
|
avatarColor: Value(avatarColor ?? AvatarColor.values[_random.nextInt(AvatarColor.values.length)]),
|
||||||
profileChangedAt: Value(profileChangedAt ?? DateTime.now()),
|
profileChangedAt: Value(TestUtils.date(profileChangedAt)),
|
||||||
hasProfileImage: Value(hasProfileImage ?? false),
|
hasProfileImage: Value(hasProfileImage ?? false),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -85,19 +87,19 @@ class MediumRepositoryContext {
|
|||||||
String? thumbHash,
|
String? thumbHash,
|
||||||
String? libraryId,
|
String? libraryId,
|
||||||
}) async {
|
}) async {
|
||||||
id = id ?? const Uuid().v4();
|
id = TestUtils.uuid(id);
|
||||||
createdAt = createdAt ?? DateTime.now();
|
createdAt = TestUtils.date(createdAt);
|
||||||
return db
|
return db
|
||||||
.into(db.remoteAssetEntity)
|
.into(db.remoteAssetEntity)
|
||||||
.insertReturning(
|
.insertReturning(
|
||||||
RemoteAssetEntityCompanion(
|
RemoteAssetEntityCompanion(
|
||||||
id: Value(id),
|
id: Value(id),
|
||||||
name: Value('remote_$id.jpg'),
|
name: Value('remote_$id.jpg'),
|
||||||
checksum: Value(checksum ?? const Uuid().v4()),
|
checksum: Value(TestUtils.uuid(checksum)),
|
||||||
type: Value(type ?? AssetType.image),
|
type: Value(type ?? AssetType.image),
|
||||||
createdAt: Value(createdAt),
|
createdAt: Value(createdAt),
|
||||||
updatedAt: Value(updatedAt ?? DateTime.now()),
|
updatedAt: Value(TestUtils.date(updatedAt)),
|
||||||
ownerId: Value(ownerId ?? const Uuid().v4()),
|
ownerId: Value(TestUtils.uuid(ownerId)),
|
||||||
visibility: Value(visibility ?? AssetVisibility.timeline),
|
visibility: Value(visibility ?? AssetVisibility.timeline),
|
||||||
deletedAt: Value(deletedAt),
|
deletedAt: Value(deletedAt),
|
||||||
durationInSeconds: Value(durationInSeconds ?? 0),
|
durationInSeconds: Value(durationInSeconds ?? 0),
|
||||||
@@ -108,8 +110,8 @@ class MediumRepositoryContext {
|
|||||||
livePhotoVideoId: Value(livePhotoVideoId),
|
livePhotoVideoId: Value(livePhotoVideoId),
|
||||||
stackId: Value(stackId),
|
stackId: Value(stackId),
|
||||||
localDateTime: Value(createdAt.toLocal()),
|
localDateTime: Value(createdAt.toLocal()),
|
||||||
thumbHash: Value(thumbHash ?? const Uuid().v4()),
|
thumbHash: Value(TestUtils.uuid(thumbHash)),
|
||||||
libraryId: Value(libraryId ?? const Uuid().v4()),
|
libraryId: Value(TestUtils.uuid(libraryId)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -127,9 +129,9 @@ class MediumRepositoryContext {
|
|||||||
.into(db.remoteAssetCloudIdEntity)
|
.into(db.remoteAssetCloudIdEntity)
|
||||||
.insertReturning(
|
.insertReturning(
|
||||||
RemoteAssetCloudIdEntityCompanion(
|
RemoteAssetCloudIdEntityCompanion(
|
||||||
assetId: Value(id ?? const Uuid().v4()),
|
assetId: Value(TestUtils.uuid(id)),
|
||||||
cloudId: Value(cloudId ?? const Uuid().v4()),
|
cloudId: Value(TestUtils.uuid(cloudId)),
|
||||||
createdAt: Value(createdAt ?? DateTime.now()),
|
createdAt: Value(TestUtils.date(createdAt)),
|
||||||
adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()),
|
adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()),
|
||||||
latitude: _resolveOption(latitude, _random.nextDouble() * 180 - 90),
|
latitude: _resolveOption(latitude, _random.nextDouble() * 180 - 90),
|
||||||
longitude: _resolveOption(longitude, _random.nextDouble() * 360 - 180),
|
longitude: _resolveOption(longitude, _random.nextDouble() * 360 - 180),
|
||||||
@@ -148,16 +150,16 @@ class MediumRepositoryContext {
|
|||||||
AlbumAssetOrder? order,
|
AlbumAssetOrder? order,
|
||||||
String? thumbnailAssetId,
|
String? thumbnailAssetId,
|
||||||
}) async {
|
}) async {
|
||||||
id = id ?? const Uuid().v4();
|
id = TestUtils.uuid(id);
|
||||||
return db
|
return db
|
||||||
.into(db.remoteAlbumEntity)
|
.into(db.remoteAlbumEntity)
|
||||||
.insertReturning(
|
.insertReturning(
|
||||||
RemoteAlbumEntityCompanion(
|
RemoteAlbumEntityCompanion(
|
||||||
id: Value(id),
|
id: Value(id),
|
||||||
name: Value(name ?? 'remote_album_$id'),
|
name: Value(name ?? 'remote_album_$id'),
|
||||||
ownerId: Value(ownerId ?? const Uuid().v4()),
|
ownerId: Value(TestUtils.uuid(ownerId)),
|
||||||
createdAt: Value(createdAt ?? DateTime.now()),
|
createdAt: Value(TestUtils.date(createdAt)),
|
||||||
updatedAt: Value(updatedAt ?? DateTime.now()),
|
updatedAt: Value(TestUtils.date(updatedAt)),
|
||||||
description: Value(description ?? 'Description for album $id'),
|
description: Value(description ?? 'Description for album $id'),
|
||||||
isActivityEnabled: Value(isActivityEnabled ?? false),
|
isActivityEnabled: Value(isActivityEnabled ?? false),
|
||||||
order: Value(order ?? AlbumAssetOrder.asc),
|
order: Value(order ?? AlbumAssetOrder.asc),
|
||||||
@@ -191,7 +193,7 @@ class MediumRepositoryContext {
|
|||||||
int? orientation,
|
int? orientation,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
}) async {
|
}) async {
|
||||||
id = id ?? const Uuid().v4();
|
id = TestUtils.uuid(id);
|
||||||
return db
|
return db
|
||||||
.into(db.localAssetEntity)
|
.into(db.localAssetEntity)
|
||||||
.insertReturning(
|
.insertReturning(
|
||||||
@@ -202,12 +204,12 @@ class MediumRepositoryContext {
|
|||||||
width: Value(width ?? _random.nextInt(1000)),
|
width: Value(width ?? _random.nextInt(1000)),
|
||||||
durationInSeconds: Value(durationInSeconds ?? 0),
|
durationInSeconds: Value(durationInSeconds ?? 0),
|
||||||
orientation: Value(orientation ?? 0),
|
orientation: Value(orientation ?? 0),
|
||||||
updatedAt: Value(updatedAt ?? DateTime.now()),
|
updatedAt: Value(TestUtils.date(updatedAt)),
|
||||||
checksum: _resolveUndefined(checksum, checksumOption, const Uuid().v4()),
|
checksum: _resolveUndefined(checksum, checksumOption, const Uuid().v4()),
|
||||||
createdAt: Value(createdAt ?? DateTime.now()),
|
createdAt: Value(TestUtils.date(createdAt)),
|
||||||
type: Value(type ?? AssetType.image),
|
type: Value(type ?? AssetType.image),
|
||||||
isFavorite: Value(isFavorite ?? false),
|
isFavorite: Value(isFavorite ?? false),
|
||||||
iCloudId: Value(iCloudId ?? const Uuid().v4()),
|
iCloudId: Value(TestUtils.uuid(iCloudId)),
|
||||||
adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()),
|
adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()),
|
||||||
latitude: Value(latitude ?? _random.nextDouble() * 180 - 90),
|
latitude: Value(latitude ?? _random.nextDouble() * 180 - 90),
|
||||||
longitude: Value(longitude ?? _random.nextDouble() * 360 - 180),
|
longitude: Value(longitude ?? _random.nextDouble() * 360 - 180),
|
||||||
@@ -223,14 +225,14 @@ class MediumRepositoryContext {
|
|||||||
bool? isIosSharedAlbum,
|
bool? isIosSharedAlbum,
|
||||||
String? linkedRemoteAlbumId,
|
String? linkedRemoteAlbumId,
|
||||||
}) {
|
}) {
|
||||||
id = id ?? const Uuid().v4();
|
id = TestUtils.uuid(id);
|
||||||
return db
|
return db
|
||||||
.into(db.localAlbumEntity)
|
.into(db.localAlbumEntity)
|
||||||
.insertReturning(
|
.insertReturning(
|
||||||
LocalAlbumEntityCompanion(
|
LocalAlbumEntityCompanion(
|
||||||
id: Value(id),
|
id: Value(id),
|
||||||
name: Value(name ?? 'local_album_$id'),
|
name: Value(name ?? 'local_album_$id'),
|
||||||
updatedAt: Value(updatedAt ?? DateTime.now()),
|
updatedAt: Value(TestUtils.date(updatedAt)),
|
||||||
backupSelection: Value(backupSelection ?? BackupSelection.none),
|
backupSelection: Value(backupSelection ?? BackupSelection.none),
|
||||||
isIosSharedAlbum: Value(isIosSharedAlbum ?? false),
|
isIosSharedAlbum: Value(isIosSharedAlbum ?? false),
|
||||||
linkedRemoteAlbumId: Value(linkedRemoteAlbumId),
|
linkedRemoteAlbumId: Value(linkedRemoteAlbumId),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String>;
|
||||||
|
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<String, String>;
|
||||||
|
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<String, String>;
|
||||||
|
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 = <List<String>>[];
|
||||||
|
|
||||||
|
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<String>;
|
||||||
|
capturedCalls.add(List<String>.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<String, String>;
|
||||||
|
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<String>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user