immich/mobile/test/domain/services/hash_service_test.dart
shenlong 97e52c5156
refactor(mobile): device asset entity to use modified time (#17064)
* refactor: device asset entity to use modified time

* chore: cleanup

* refactor: remove album media dependency from hashservice

* refactor: return updated copy of asset

* add hash service tests

* chore: rename hash batch constants

* chore: log the number of assets processed during migration

* chore: more logs

* refactor: use lookup and more tests

* use sort approach

* refactor hash service to use for loop instead

* refactor: rename to getByIds

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-04-03 14:42:35 -05:00

426 lines
16 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:file/memory.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
import 'package:immich_mobile/domain/models/device_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/hash.service.dart';
import 'package:mocktail/mocktail.dart';
import 'package:photo_manager/photo_manager.dart';
import '../../fixtures/asset.stub.dart';
import '../../infrastructure/repository.mock.dart';
import '../../service.mocks.dart';
class MockAsset extends Mock implements Asset {}
class MockAssetEntity extends Mock implements AssetEntity {}
void main() {
late HashService sut;
late BackgroundService mockBackgroundService;
late IDeviceAssetRepository mockDeviceAssetRepository;
setUp(() {
mockBackgroundService = MockBackgroundService();
mockDeviceAssetRepository = MockDeviceAssetRepository();
sut = HashService(
deviceAssetRepository: mockDeviceAssetRepository,
backgroundService: mockBackgroundService,
);
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
.thenAnswer((_) async {
final capturedCallback = verify(
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
).captured;
// Invoke the transaction callback
await (capturedCallback.firstOrNull as Future<Null> Function()?)?.call();
});
when(() => mockDeviceAssetRepository.updateAll(any()))
.thenAnswer((_) async => true);
when(() => mockDeviceAssetRepository.deleteIds(any()))
.thenAnswer((_) async => true);
});
group("HashService: No DeviceAsset entry", () {
test("hash successfully", () async {
final (mockAsset, file, deviceAsset, hash) =
await _createAssetMock(AssetStub.image1);
when(() => mockBackgroundService.digestFiles([file.path]))
.thenAnswer((_) async => [hash]);
// No DB entries for this asset
when(
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
).thenAnswer((_) async => []);
final result = await sut.hashAssets([mockAsset]);
// Verify we stored the new hash in DB
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
.thenAnswer((_) async {
final capturedCallback = verify(
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
).captured;
// Invoke the transaction callback
await (capturedCallback.firstOrNull as Future<Null> Function()?)
?.call();
verify(
() => mockDeviceAssetRepository.updateAll([
deviceAsset.copyWith(modifiedTime: AssetStub.image1.fileModifiedAt),
]),
).called(1);
verify(() => mockDeviceAssetRepository.deleteIds([])).called(1);
});
expect(
result,
[AssetStub.image1.copyWith(checksum: base64.encode(hash))],
);
});
});
group("HashService: Has DeviceAsset entry", () {
test("when the asset is not modified", () async {
final hash = utf8.encode("image1-hash");
when(
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
).thenAnswer(
(_) async => [
DeviceAsset(
assetId: AssetStub.image1.localId!,
hash: hash,
modifiedTime: AssetStub.image1.fileModifiedAt,
),
],
);
final result = await sut.hashAssets([AssetStub.image1]);
verifyNever(() => mockBackgroundService.digestFiles(any()));
verifyNever(() => mockBackgroundService.digestFile(any()));
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
verifyNever(() => mockDeviceAssetRepository.deleteIds(any()));
expect(result, [
AssetStub.image1.copyWith(checksum: base64.encode(hash)),
]);
});
test("hashed successful when asset is modified", () async {
final (mockAsset, file, deviceAsset, hash) =
await _createAssetMock(AssetStub.image1);
when(() => mockBackgroundService.digestFiles([file.path]))
.thenAnswer((_) async => [hash]);
when(
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
).thenAnswer((_) async => [deviceAsset]);
final result = await sut.hashAssets([mockAsset]);
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
.thenAnswer((_) async {
final capturedCallback = verify(
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
).captured;
// Invoke the transaction callback
await (capturedCallback.firstOrNull as Future<Null> Function()?)
?.call();
verify(
() => mockDeviceAssetRepository.updateAll([
deviceAsset.copyWith(modifiedTime: AssetStub.image1.fileModifiedAt),
]),
).called(1);
verify(() => mockDeviceAssetRepository.deleteIds([])).called(1);
});
verify(() => mockBackgroundService.digestFiles([file.path])).called(1);
expect(result, [
AssetStub.image1.copyWith(checksum: base64.encode(hash)),
]);
});
});
group("HashService: Cleanup", () {
late Asset mockAsset;
late Uint8List hash;
late DeviceAsset deviceAsset;
late File file;
setUp(() async {
(mockAsset, file, deviceAsset, hash) =
await _createAssetMock(AssetStub.image1);
when(() => mockBackgroundService.digestFiles([file.path]))
.thenAnswer((_) async => [hash]);
when(
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
).thenAnswer((_) async => [deviceAsset]);
});
test("cleanups DeviceAsset when local file cannot be obtained", () async {
when(() => mockAsset.local).thenThrow(Exception("File not found"));
final result = await sut.hashAssets([mockAsset]);
verifyNever(() => mockBackgroundService.digestFiles(any()));
verifyNever(() => mockBackgroundService.digestFile(any()));
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
verify(
() => mockDeviceAssetRepository.deleteIds([AssetStub.image1.localId!]),
).called(1);
expect(result, isEmpty);
});
test("cleanups DeviceAsset when hashing failed", () async {
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
.thenAnswer((_) async {
final capturedCallback = verify(
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
).captured;
// Invoke the transaction callback
await (capturedCallback.firstOrNull as Future<Null> Function()?)
?.call();
// Verify the callback inside the transaction because, doing it outside results
// in a small delay before the callback is invoked, resulting in other LOCs getting executed
// resulting in an incorrect state
//
// i.e, consider the following piece of code
// await _deviceAssetRepository.transaction(() async {
// await _deviceAssetRepository.updateAll(toBeAdded);
// await _deviceAssetRepository.deleteIds(toBeDeleted);
// });
// toBeDeleted.clear();
// since the transaction method is mocked, the callback is not invoked until it is captured
// and executed manually in the next event loop. However, the toBeDeleted.clear() is executed
// immediately once the transaction stub is executed, resulting in the deleteIds method being
// called with an empty list.
//
// To avoid this, we capture the callback and execute it within the transaction stub itself
// and verify the results inside the transaction stub
verify(() => mockDeviceAssetRepository.updateAll([])).called(1);
verify(
() =>
mockDeviceAssetRepository.deleteIds([AssetStub.image1.localId!]),
).called(1);
});
when(() => mockBackgroundService.digestFiles([file.path])).thenAnswer(
// Invalid hash, length != 20
(_) async => [Uint8List.fromList(hash.slice(2).toList())],
);
final result = await sut.hashAssets([mockAsset]);
verify(() => mockBackgroundService.digestFiles([file.path])).called(1);
expect(result, isEmpty);
});
});
group("HashService: Batch processing", () {
test("processes assets in batches when size limit is reached", () async {
// Setup multiple assets with large file sizes
final (mock1, mock2, mock3) = await (
_createAssetMock(AssetStub.image1),
_createAssetMock(AssetStub.image2),
_createAssetMock(AssetStub.image3),
).wait;
final (asset1, file1, deviceAsset1, hash1) = mock1;
final (asset2, file2, deviceAsset2, hash2) = mock2;
final (asset3, file3, deviceAsset3, hash3) = mock3;
when(() => mockDeviceAssetRepository.getByIds(any()))
.thenAnswer((_) async => []);
// Setup for multiple batch processing calls
when(() => mockBackgroundService.digestFiles([file1.path, file2.path]))
.thenAnswer((_) async => [hash1, hash2]);
when(() => mockBackgroundService.digestFiles([file3.path]))
.thenAnswer((_) async => [hash3]);
final size = await file1.length() + await file2.length();
sut = HashService(
deviceAssetRepository: mockDeviceAssetRepository,
backgroundService: mockBackgroundService,
batchSizeLimit: size,
);
final result = await sut.hashAssets([asset1, asset2, asset3]);
// Verify multiple batch process calls
verify(() => mockBackgroundService.digestFiles([file1.path, file2.path]))
.called(1);
verify(() => mockBackgroundService.digestFiles([file3.path])).called(1);
expect(
result,
[
AssetStub.image1.copyWith(checksum: base64.encode(hash1)),
AssetStub.image2.copyWith(checksum: base64.encode(hash2)),
AssetStub.image3.copyWith(checksum: base64.encode(hash3)),
],
);
});
test("processes assets in batches when file limit is reached", () async {
// Setup multiple assets with large file sizes
final (mock1, mock2, mock3) = await (
_createAssetMock(AssetStub.image1),
_createAssetMock(AssetStub.image2),
_createAssetMock(AssetStub.image3),
).wait;
final (asset1, file1, deviceAsset1, hash1) = mock1;
final (asset2, file2, deviceAsset2, hash2) = mock2;
final (asset3, file3, deviceAsset3, hash3) = mock3;
when(() => mockDeviceAssetRepository.getByIds(any()))
.thenAnswer((_) async => []);
when(() => mockBackgroundService.digestFiles([file1.path]))
.thenAnswer((_) async => [hash1]);
when(() => mockBackgroundService.digestFiles([file2.path]))
.thenAnswer((_) async => [hash2]);
when(() => mockBackgroundService.digestFiles([file3.path]))
.thenAnswer((_) async => [hash3]);
sut = HashService(
deviceAssetRepository: mockDeviceAssetRepository,
backgroundService: mockBackgroundService,
batchFileLimit: 1,
);
final result = await sut.hashAssets([asset1, asset2, asset3]);
// Verify multiple batch process calls
verify(() => mockBackgroundService.digestFiles([file1.path])).called(1);
verify(() => mockBackgroundService.digestFiles([file2.path])).called(1);
verify(() => mockBackgroundService.digestFiles([file3.path])).called(1);
expect(
result,
[
AssetStub.image1.copyWith(checksum: base64.encode(hash1)),
AssetStub.image2.copyWith(checksum: base64.encode(hash2)),
AssetStub.image3.copyWith(checksum: base64.encode(hash3)),
],
);
});
test("HashService: Sort & Process different states", () async {
final (asset1, file1, deviceAsset1, hash1) =
await _createAssetMock(AssetStub.image1); // Will need rehashing
final (asset2, file2, deviceAsset2, hash2) =
await _createAssetMock(AssetStub.image2); // Will have matching hash
final (asset3, file3, deviceAsset3, hash3) =
await _createAssetMock(AssetStub.image3); // No DB entry
final asset4 =
AssetStub.image3.copyWith(localId: "image4"); // Cannot be hashed
when(() => mockBackgroundService.digestFiles([file1.path, file3.path]))
.thenAnswer((_) async => [hash1, hash3]);
// DB entries are not sorted and a dummy entry added
when(
() => mockDeviceAssetRepository.getByIds([
AssetStub.image1.localId!,
AssetStub.image2.localId!,
AssetStub.image3.localId!,
asset4.localId!,
]),
).thenAnswer(
(_) async => [
// Same timestamp to reuse deviceAsset
deviceAsset2.copyWith(modifiedTime: asset2.fileModifiedAt),
deviceAsset1,
deviceAsset3.copyWith(assetId: asset4.localId!),
],
);
final result = await sut.hashAssets([asset1, asset2, asset3, asset4]);
// Verify correct processing of all assets
verify(() => mockBackgroundService.digestFiles([file1.path, file3.path]))
.called(1);
expect(result.length, 3);
expect(result, [
AssetStub.image2.copyWith(checksum: base64.encode(hash2)),
AssetStub.image1.copyWith(checksum: base64.encode(hash1)),
AssetStub.image3.copyWith(checksum: base64.encode(hash3)),
]);
});
group("HashService: Edge cases", () {
test("handles empty list of assets", () async {
when(() => mockDeviceAssetRepository.getByIds(any()))
.thenAnswer((_) async => []);
final result = await sut.hashAssets([]);
verifyNever(() => mockBackgroundService.digestFiles(any()));
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
verifyNever(() => mockDeviceAssetRepository.deleteIds(any()));
expect(result, isEmpty);
});
test("handles all file access failures", () async {
// No DB entries
when(
() => mockDeviceAssetRepository.getByIds(
[AssetStub.image1.localId!, AssetStub.image2.localId!],
),
).thenAnswer((_) async => []);
final result = await sut.hashAssets([
AssetStub.image1,
AssetStub.image2,
]);
verifyNever(() => mockBackgroundService.digestFiles(any()));
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
expect(result, isEmpty);
});
});
});
}
Future<(Asset, File, DeviceAsset, Uint8List)> _createAssetMock(
Asset asset,
) async {
final random = Random();
final hash =
Uint8List.fromList(List.generate(20, (i) => random.nextInt(255)));
final mockAsset = MockAsset();
final mockAssetEntity = MockAssetEntity();
final fs = MemoryFileSystem();
final deviceAsset = DeviceAsset(
assetId: asset.localId!,
hash: Uint8List.fromList(hash),
modifiedTime: DateTime.now(),
);
final tmp = await fs.systemTempDirectory.createTemp();
final file = tmp.childFile("${asset.fileName}-path");
await file.writeAsString("${asset.fileName}-content");
when(() => mockAsset.localId).thenReturn(asset.localId);
when(() => mockAsset.fileName).thenReturn(asset.fileName);
when(() => mockAsset.fileCreatedAt).thenReturn(asset.fileCreatedAt);
when(() => mockAsset.fileModifiedAt).thenReturn(asset.fileModifiedAt);
when(() => mockAsset.copyWith(checksum: any(named: "checksum")))
.thenReturn(asset.copyWith(checksum: base64.encode(hash)));
when(() => mockAsset.local).thenAnswer((_) => mockAssetEntity);
when(() => mockAssetEntity.originFile).thenAnswer((_) async => file);
return (mockAsset, file, deviceAsset, hash);
}