|
|
|
@ -1,425 +1,292 @@
|
|
|
|
import 'dart:convert';
|
|
|
|
import 'dart:convert';
|
|
|
|
import 'dart:io';
|
|
|
|
import 'dart:io';
|
|
|
|
import 'dart:math';
|
|
|
|
import 'dart:typed_data';
|
|
|
|
|
|
|
|
|
|
|
|
import 'package:collection/collection.dart';
|
|
|
|
|
|
|
|
import 'package:file/memory.dart';
|
|
|
|
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
|
|
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
|
|
import 'package:immich_mobile/domain/models/device_asset.model.dart';
|
|
|
|
import 'package:immich_mobile/domain/models/local_album.model.dart';
|
|
|
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
|
|
|
import 'package:immich_mobile/domain/services/hash.service.dart';
|
|
|
|
import 'package:immich_mobile/services/background.service.dart';
|
|
|
|
|
|
|
|
import 'package:immich_mobile/services/hash.service.dart';
|
|
|
|
|
|
|
|
import 'package:mocktail/mocktail.dart';
|
|
|
|
import 'package:mocktail/mocktail.dart';
|
|
|
|
import 'package:photo_manager/photo_manager.dart';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import '../../fixtures/album.stub.dart';
|
|
|
|
import '../../fixtures/asset.stub.dart';
|
|
|
|
import '../../fixtures/asset.stub.dart';
|
|
|
|
import '../../infrastructure/repository.mock.dart';
|
|
|
|
import '../../infrastructure/repository.mock.dart';
|
|
|
|
import '../../service.mocks.dart';
|
|
|
|
import '../service.mock.dart';
|
|
|
|
|
|
|
|
|
|
|
|
class MockAsset extends Mock implements Asset {}
|
|
|
|
class MockFile extends Mock implements File {}
|
|
|
|
|
|
|
|
|
|
|
|
class MockAssetEntity extends Mock implements AssetEntity {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
void main() {
|
|
|
|
late HashService sut;
|
|
|
|
late HashService sut;
|
|
|
|
late BackgroundService mockBackgroundService;
|
|
|
|
late MockLocalAlbumRepository mockAlbumRepo;
|
|
|
|
late IDeviceAssetRepository mockDeviceAssetRepository;
|
|
|
|
late MockLocalAssetRepository mockAssetRepo;
|
|
|
|
|
|
|
|
late MockStorageRepository mockStorageRepo;
|
|
|
|
|
|
|
|
late MockNativeSyncApi mockNativeApi;
|
|
|
|
|
|
|
|
|
|
|
|
setUp(() {
|
|
|
|
setUp(() {
|
|
|
|
mockBackgroundService = MockBackgroundService();
|
|
|
|
mockAlbumRepo = MockLocalAlbumRepository();
|
|
|
|
mockDeviceAssetRepository = MockDeviceAssetRepository();
|
|
|
|
mockAssetRepo = MockLocalAssetRepository();
|
|
|
|
|
|
|
|
mockStorageRepo = MockStorageRepository();
|
|
|
|
|
|
|
|
mockNativeApi = MockNativeSyncApi();
|
|
|
|
|
|
|
|
|
|
|
|
sut = HashService(
|
|
|
|
sut = HashService(
|
|
|
|
deviceAssetRepository: mockDeviceAssetRepository,
|
|
|
|
localAlbumRepository: mockAlbumRepo,
|
|
|
|
backgroundService: mockBackgroundService,
|
|
|
|
localAssetRepository: mockAssetRepo,
|
|
|
|
|
|
|
|
storageRepository: mockStorageRepo,
|
|
|
|
|
|
|
|
nativeSyncApi: mockNativeApi,
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
|
|
|
|
registerFallbackValue(LocalAlbumStub.recent);
|
|
|
|
.thenAnswer((_) async {
|
|
|
|
registerFallbackValue(LocalAssetStub.image1);
|
|
|
|
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]))
|
|
|
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
|
|
|
.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", () {
|
|
|
|
group('HashService hashAssets', () {
|
|
|
|
test("when the asset is not modified", () async {
|
|
|
|
test('processes albums in correct order', () async {
|
|
|
|
final hash = utf8.encode("image1-hash");
|
|
|
|
final album1 = LocalAlbumStub.recent
|
|
|
|
|
|
|
|
.copyWith(id: "1", backupSelection: BackupSelection.none);
|
|
|
|
when(
|
|
|
|
final album2 = LocalAlbumStub.recent
|
|
|
|
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
|
|
|
.copyWith(id: "2", backupSelection: BackupSelection.excluded);
|
|
|
|
).thenAnswer(
|
|
|
|
final album3 = LocalAlbumStub.recent
|
|
|
|
(_) async => [
|
|
|
|
.copyWith(id: "3", backupSelection: BackupSelection.selected);
|
|
|
|
DeviceAsset(
|
|
|
|
final album4 = LocalAlbumStub.recent.copyWith(
|
|
|
|
assetId: AssetStub.image1.localId!,
|
|
|
|
id: "4",
|
|
|
|
hash: hash,
|
|
|
|
backupSelection: BackupSelection.selected,
|
|
|
|
modifiedTime: AssetStub.image1.fileModifiedAt,
|
|
|
|
isIosSharedAlbum: true,
|
|
|
|
),
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
);
|
|
|
|
);
|
|
|
|
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, [
|
|
|
|
when(() => mockAlbumRepo.getAll())
|
|
|
|
AssetStub.image1.copyWith(checksum: base64.encode(hash)),
|
|
|
|
.thenAnswer((_) async => [album1, album2, album4, album3]);
|
|
|
|
]);
|
|
|
|
when(() => mockAlbumRepo.getAssetsToHash(any()))
|
|
|
|
});
|
|
|
|
.thenAnswer((_) async => []);
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
await sut.hashAssets();
|
|
|
|
|
|
|
|
|
|
|
|
expect(result, [
|
|
|
|
verifyInOrder([
|
|
|
|
AssetStub.image1.copyWith(checksum: base64.encode(hash)),
|
|
|
|
() => mockAlbumRepo.getAll(),
|
|
|
|
|
|
|
|
() => mockAlbumRepo.getAssetsToHash(album3.id),
|
|
|
|
|
|
|
|
() => mockAlbumRepo.getAssetsToHash(album4.id),
|
|
|
|
|
|
|
|
() => mockAlbumRepo.getAssetsToHash(album1.id),
|
|
|
|
|
|
|
|
() => mockAlbumRepo.getAssetsToHash(album2.id),
|
|
|
|
]);
|
|
|
|
]);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
group("HashService: Cleanup", () {
|
|
|
|
test('skips albums with no assets to hash', () async {
|
|
|
|
late Asset mockAsset;
|
|
|
|
when(() => mockAlbumRepo.getAll()).thenAnswer(
|
|
|
|
late Uint8List hash;
|
|
|
|
(_) async => [LocalAlbumStub.recent.copyWith(assetCount: 0)],
|
|
|
|
late DeviceAsset deviceAsset;
|
|
|
|
);
|
|
|
|
late File file;
|
|
|
|
when(() => mockAlbumRepo.getAssetsToHash(LocalAlbumStub.recent.id))
|
|
|
|
|
|
|
|
.thenAnswer((_) async => []);
|
|
|
|
|
|
|
|
|
|
|
|
setUp(() async {
|
|
|
|
await sut.hashAssets();
|
|
|
|
(mockAsset, file, deviceAsset, hash) =
|
|
|
|
|
|
|
|
await _createAssetMock(AssetStub.image1);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
when(() => mockBackgroundService.digestFiles([file.path]))
|
|
|
|
verifyNever(() => mockStorageRepo.getFileForAsset(any()));
|
|
|
|
.thenAnswer((_) async => [hash]);
|
|
|
|
verifyNever(() => mockNativeApi.hashPaths(any()));
|
|
|
|
when(
|
|
|
|
});
|
|
|
|
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
|
|
|
|
|
|
|
).thenAnswer((_) async => [deviceAsset]);
|
|
|
|
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("cleanups DeviceAsset when local file cannot be obtained", () async {
|
|
|
|
group('HashService _hashAssets', () {
|
|
|
|
when(() => mockAsset.local).thenThrow(Exception("File not found"));
|
|
|
|
test('skips assets without files', () async {
|
|
|
|
final result = await sut.hashAssets([mockAsset]);
|
|
|
|
final album = LocalAlbumStub.recent;
|
|
|
|
|
|
|
|
final asset = LocalAssetStub.image1;
|
|
|
|
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
|
|
|
when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]);
|
|
|
|
verifyNever(() => mockBackgroundService.digestFile(any()));
|
|
|
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
|
|
|
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
|
|
|
.thenAnswer((_) async => [asset]);
|
|
|
|
verify(
|
|
|
|
when(() => mockStorageRepo.getFileForAsset(asset))
|
|
|
|
() => mockDeviceAssetRepository.deleteIds([AssetStub.image1.localId!]),
|
|
|
|
.thenAnswer((_) async => null);
|
|
|
|
).called(1);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
expect(result, isEmpty);
|
|
|
|
await sut.hashAssets();
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test("cleanups DeviceAsset when hashing failed", () async {
|
|
|
|
verifyNever(() => mockNativeApi.hashPaths(any()));
|
|
|
|
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(
|
|
|
|
test('processes assets when available', () async {
|
|
|
|
// Invalid hash, length != 20
|
|
|
|
final album = LocalAlbumStub.recent;
|
|
|
|
(_) async => [Uint8List.fromList(hash.slice(2).toList())],
|
|
|
|
final asset = LocalAssetStub.image1;
|
|
|
|
|
|
|
|
final mockFile = MockFile();
|
|
|
|
|
|
|
|
final hash = Uint8List.fromList(List.generate(20, (i) => i));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
when(() => mockFile.length()).thenAnswer((_) async => 1000);
|
|
|
|
|
|
|
|
when(() => mockFile.path).thenReturn('image-path');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]);
|
|
|
|
|
|
|
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
|
|
|
|
|
|
|
.thenAnswer((_) async => [asset]);
|
|
|
|
|
|
|
|
when(() => mockStorageRepo.getFileForAsset(asset))
|
|
|
|
|
|
|
|
.thenAnswer((_) async => mockFile);
|
|
|
|
|
|
|
|
when(() => mockNativeApi.hashPaths(['image-path'])).thenAnswer(
|
|
|
|
|
|
|
|
(_) async => [hash],
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
final result = await sut.hashAssets([mockAsset]);
|
|
|
|
await sut.hashAssets();
|
|
|
|
|
|
|
|
|
|
|
|
verify(() => mockBackgroundService.digestFiles([file.path])).called(1);
|
|
|
|
verify(() => mockNativeApi.hashPaths(['image-path'])).called(1);
|
|
|
|
expect(result, isEmpty);
|
|
|
|
final captured = verify(() => mockAssetRepo.updateHashes(captureAny()))
|
|
|
|
});
|
|
|
|
.captured
|
|
|
|
|
|
|
|
.first as List<LocalAsset>;
|
|
|
|
|
|
|
|
expect(captured.length, 1);
|
|
|
|
|
|
|
|
expect(captured[0].checksum, base64.encode(hash));
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
group("HashService: Batch processing", () {
|
|
|
|
test('handles failed hashes', () async {
|
|
|
|
test("processes assets in batches when size limit is reached", () async {
|
|
|
|
final album = LocalAlbumStub.recent;
|
|
|
|
// Setup multiple assets with large file sizes
|
|
|
|
final asset = LocalAssetStub.image1;
|
|
|
|
final (mock1, mock2, mock3) = await (
|
|
|
|
final mockFile = MockFile();
|
|
|
|
_createAssetMock(AssetStub.image1),
|
|
|
|
when(() => mockFile.length()).thenAnswer((_) async => 1000);
|
|
|
|
_createAssetMock(AssetStub.image2),
|
|
|
|
when(() => mockFile.path).thenReturn('image-path');
|
|
|
|
_createAssetMock(AssetStub.image3),
|
|
|
|
|
|
|
|
).wait;
|
|
|
|
when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]);
|
|
|
|
|
|
|
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
|
|
|
final (asset1, file1, deviceAsset1, hash1) = mock1;
|
|
|
|
.thenAnswer((_) async => [asset]);
|
|
|
|
final (asset2, file2, deviceAsset2, hash2) = mock2;
|
|
|
|
when(() => mockStorageRepo.getFileForAsset(asset))
|
|
|
|
final (asset3, file3, deviceAsset3, hash3) = mock3;
|
|
|
|
.thenAnswer((_) async => mockFile);
|
|
|
|
|
|
|
|
when(() => mockNativeApi.hashPaths(['image-path']))
|
|
|
|
when(() => mockDeviceAssetRepository.getByIds(any()))
|
|
|
|
.thenAnswer((_) async => [null]);
|
|
|
|
.thenAnswer((_) async => []);
|
|
|
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
|
|
|
|
|
|
|
|
|
|
|
// Setup for multiple batch processing calls
|
|
|
|
await sut.hashAssets();
|
|
|
|
when(() => mockBackgroundService.digestFiles([file1.path, file2.path]))
|
|
|
|
|
|
|
|
.thenAnswer((_) async => [hash1, hash2]);
|
|
|
|
final captured = verify(() => mockAssetRepo.updateHashes(captureAny()))
|
|
|
|
when(() => mockBackgroundService.digestFiles([file3.path]))
|
|
|
|
.captured
|
|
|
|
.thenAnswer((_) async => [hash3]);
|
|
|
|
.first as List<LocalAsset>;
|
|
|
|
|
|
|
|
expect(captured.length, 0);
|
|
|
|
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 {
|
|
|
|
test('handles invalid hash length', () async {
|
|
|
|
// Setup multiple assets with large file sizes
|
|
|
|
final album = LocalAlbumStub.recent;
|
|
|
|
final (mock1, mock2, mock3) = await (
|
|
|
|
final asset = LocalAssetStub.image1;
|
|
|
|
_createAssetMock(AssetStub.image1),
|
|
|
|
final mockFile = MockFile();
|
|
|
|
_createAssetMock(AssetStub.image2),
|
|
|
|
when(() => mockFile.length()).thenAnswer((_) async => 1000);
|
|
|
|
_createAssetMock(AssetStub.image3),
|
|
|
|
when(() => mockFile.path).thenReturn('image-path');
|
|
|
|
).wait;
|
|
|
|
|
|
|
|
|
|
|
|
when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]);
|
|
|
|
final (asset1, file1, deviceAsset1, hash1) = mock1;
|
|
|
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
|
|
|
final (asset2, file2, deviceAsset2, hash2) = mock2;
|
|
|
|
.thenAnswer((_) async => [asset]);
|
|
|
|
final (asset3, file3, deviceAsset3, hash3) = mock3;
|
|
|
|
when(() => mockStorageRepo.getFileForAsset(asset))
|
|
|
|
|
|
|
|
.thenAnswer((_) async => mockFile);
|
|
|
|
when(() => mockDeviceAssetRepository.getByIds(any()))
|
|
|
|
|
|
|
|
.thenAnswer((_) async => []);
|
|
|
|
final invalidHash = Uint8List.fromList([1, 2, 3]);
|
|
|
|
|
|
|
|
when(() => mockNativeApi.hashPaths(['image-path']))
|
|
|
|
when(() => mockBackgroundService.digestFiles([file1.path]))
|
|
|
|
.thenAnswer((_) async => [invalidHash]);
|
|
|
|
.thenAnswer((_) async => [hash1]);
|
|
|
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
|
|
|
when(() => mockBackgroundService.digestFiles([file2.path]))
|
|
|
|
|
|
|
|
.thenAnswer((_) async => [hash2]);
|
|
|
|
await sut.hashAssets();
|
|
|
|
when(() => mockBackgroundService.digestFiles([file3.path]))
|
|
|
|
|
|
|
|
.thenAnswer((_) async => [hash3]);
|
|
|
|
final captured = verify(() => mockAssetRepo.updateHashes(captureAny()))
|
|
|
|
|
|
|
|
.captured
|
|
|
|
|
|
|
|
.first as List<LocalAsset>;
|
|
|
|
|
|
|
|
expect(captured.length, 0);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
sut = HashService(
|
|
|
|
test('batches by file count limit', () async {
|
|
|
|
deviceAssetRepository: mockDeviceAssetRepository,
|
|
|
|
final sut = HashService(
|
|
|
|
backgroundService: mockBackgroundService,
|
|
|
|
localAlbumRepository: mockAlbumRepo,
|
|
|
|
|
|
|
|
localAssetRepository: mockAssetRepo,
|
|
|
|
|
|
|
|
storageRepository: mockStorageRepo,
|
|
|
|
|
|
|
|
nativeSyncApi: mockNativeApi,
|
|
|
|
batchFileLimit: 1,
|
|
|
|
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 album = LocalAlbumStub.recent;
|
|
|
|
final (asset1, file1, deviceAsset1, hash1) =
|
|
|
|
final asset1 = LocalAssetStub.image1;
|
|
|
|
await _createAssetMock(AssetStub.image1); // Will need rehashing
|
|
|
|
final asset2 = LocalAssetStub.image2;
|
|
|
|
final (asset2, file2, deviceAsset2, hash2) =
|
|
|
|
final mockFile1 = MockFile();
|
|
|
|
await _createAssetMock(AssetStub.image2); // Will have matching hash
|
|
|
|
final mockFile2 = MockFile();
|
|
|
|
final (asset3, file3, deviceAsset3, hash3) =
|
|
|
|
when(() => mockFile1.length()).thenAnswer((_) async => 100);
|
|
|
|
await _createAssetMock(AssetStub.image3); // No DB entry
|
|
|
|
when(() => mockFile1.path).thenReturn('path-1');
|
|
|
|
final asset4 =
|
|
|
|
when(() => mockFile2.length()).thenAnswer((_) async => 100);
|
|
|
|
AssetStub.image3.copyWith(localId: "image4"); // Cannot be hashed
|
|
|
|
when(() => mockFile2.path).thenReturn('path-2');
|
|
|
|
|
|
|
|
|
|
|
|
when(() => mockBackgroundService.digestFiles([file1.path, file3.path]))
|
|
|
|
when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]);
|
|
|
|
.thenAnswer((_) async => [hash1, hash3]);
|
|
|
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
|
|
|
// DB entries are not sorted and a dummy entry added
|
|
|
|
.thenAnswer((_) async => [asset1, asset2]);
|
|
|
|
when(
|
|
|
|
when(() => mockStorageRepo.getFileForAsset(asset1))
|
|
|
|
() => mockDeviceAssetRepository.getByIds([
|
|
|
|
.thenAnswer((_) async => mockFile1);
|
|
|
|
AssetStub.image1.localId!,
|
|
|
|
when(() => mockStorageRepo.getFileForAsset(asset2))
|
|
|
|
AssetStub.image2.localId!,
|
|
|
|
.thenAnswer((_) async => mockFile2);
|
|
|
|
AssetStub.image3.localId!,
|
|
|
|
|
|
|
|
asset4.localId!,
|
|
|
|
final hash = Uint8List.fromList(List.generate(20, (i) => i));
|
|
|
|
]),
|
|
|
|
when(() => mockNativeApi.hashPaths(any()))
|
|
|
|
).thenAnswer(
|
|
|
|
.thenAnswer((_) async => [hash]);
|
|
|
|
(_) async => [
|
|
|
|
when(() => mockAssetRepo.updateHashes(any())).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]);
|
|
|
|
await sut.hashAssets();
|
|
|
|
|
|
|
|
|
|
|
|
// Verify correct processing of all assets
|
|
|
|
verify(() => mockNativeApi.hashPaths(['path-1'])).called(1);
|
|
|
|
verify(() => mockBackgroundService.digestFiles([file1.path, file3.path]))
|
|
|
|
verify(() => mockNativeApi.hashPaths(['path-2'])).called(1);
|
|
|
|
.called(1);
|
|
|
|
verify(() => mockAssetRepo.updateHashes(any())).called(2);
|
|
|
|
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('batches by size limit', () async {
|
|
|
|
test("handles empty list of assets", () async {
|
|
|
|
final sut = HashService(
|
|
|
|
when(() => mockDeviceAssetRepository.getByIds(any()))
|
|
|
|
localAlbumRepository: mockAlbumRepo,
|
|
|
|
.thenAnswer((_) async => []);
|
|
|
|
localAssetRepository: mockAssetRepo,
|
|
|
|
|
|
|
|
storageRepository: mockStorageRepo,
|
|
|
|
|
|
|
|
nativeSyncApi: mockNativeApi,
|
|
|
|
|
|
|
|
batchSizeLimit: 80,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
final result = await sut.hashAssets([]);
|
|
|
|
final album = LocalAlbumStub.recent;
|
|
|
|
|
|
|
|
final asset1 = LocalAssetStub.image1;
|
|
|
|
|
|
|
|
final asset2 = LocalAssetStub.image2;
|
|
|
|
|
|
|
|
final mockFile1 = MockFile();
|
|
|
|
|
|
|
|
final mockFile2 = MockFile();
|
|
|
|
|
|
|
|
when(() => mockFile1.length()).thenAnswer((_) async => 100);
|
|
|
|
|
|
|
|
when(() => mockFile1.path).thenReturn('path-1');
|
|
|
|
|
|
|
|
when(() => mockFile2.length()).thenAnswer((_) async => 100);
|
|
|
|
|
|
|
|
when(() => mockFile2.path).thenReturn('path-2');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]);
|
|
|
|
|
|
|
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
|
|
|
|
|
|
|
.thenAnswer((_) async => [asset1, asset2]);
|
|
|
|
|
|
|
|
when(() => mockStorageRepo.getFileForAsset(asset1))
|
|
|
|
|
|
|
|
.thenAnswer((_) async => mockFile1);
|
|
|
|
|
|
|
|
when(() => mockStorageRepo.getFileForAsset(asset2))
|
|
|
|
|
|
|
|
.thenAnswer((_) async => mockFile2);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
final hash = Uint8List.fromList(List.generate(20, (i) => i));
|
|
|
|
|
|
|
|
when(() => mockNativeApi.hashPaths(any()))
|
|
|
|
|
|
|
|
.thenAnswer((_) async => [hash]);
|
|
|
|
|
|
|
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
|
|
|
|
|
|
|
|
|
|
|
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
|
|
|
await sut.hashAssets();
|
|
|
|
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
|
|
|
|
|
|
|
verifyNever(() => mockDeviceAssetRepository.deleteIds(any()));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
expect(result, isEmpty);
|
|
|
|
verify(() => mockNativeApi.hashPaths(['path-1'])).called(1);
|
|
|
|
|
|
|
|
verify(() => mockNativeApi.hashPaths(['path-2'])).called(1);
|
|
|
|
|
|
|
|
verify(() => mockAssetRepo.updateHashes(any())).called(2);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("handles all file access failures", () async {
|
|
|
|
test('handles mixed success and failure in batch', () async {
|
|
|
|
// No DB entries
|
|
|
|
final album = LocalAlbumStub.recent;
|
|
|
|
when(
|
|
|
|
final asset1 = LocalAssetStub.image1;
|
|
|
|
() => mockDeviceAssetRepository.getByIds(
|
|
|
|
final asset2 = LocalAssetStub.image2;
|
|
|
|
[AssetStub.image1.localId!, AssetStub.image2.localId!],
|
|
|
|
final mockFile1 = MockFile();
|
|
|
|
),
|
|
|
|
final mockFile2 = MockFile();
|
|
|
|
).thenAnswer((_) async => []);
|
|
|
|
when(() => mockFile1.length()).thenAnswer((_) async => 100);
|
|
|
|
|
|
|
|
when(() => mockFile1.path).thenReturn('path-1');
|
|
|
|
final result = await sut.hashAssets([
|
|
|
|
when(() => mockFile2.length()).thenAnswer((_) async => 100);
|
|
|
|
AssetStub.image1,
|
|
|
|
when(() => mockFile2.path).thenReturn('path-2');
|
|
|
|
AssetStub.image2,
|
|
|
|
|
|
|
|
]);
|
|
|
|
when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]);
|
|
|
|
|
|
|
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
|
|
|
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
|
|
|
.thenAnswer((_) async => [asset1, asset2]);
|
|
|
|
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
|
|
|
when(() => mockStorageRepo.getFileForAsset(asset1))
|
|
|
|
expect(result, isEmpty);
|
|
|
|
.thenAnswer((_) async => mockFile1);
|
|
|
|
});
|
|
|
|
when(() => mockStorageRepo.getFileForAsset(asset2))
|
|
|
|
|
|
|
|
.thenAnswer((_) async => mockFile2);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
final validHash = Uint8List.fromList(List.generate(20, (i) => i));
|
|
|
|
|
|
|
|
when(() => mockNativeApi.hashPaths(['path-1', 'path-2']))
|
|
|
|
|
|
|
|
.thenAnswer((_) async => [validHash, null]);
|
|
|
|
|
|
|
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
await sut.hashAssets();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
final captured = verify(() => mockAssetRepo.updateHashes(captureAny()))
|
|
|
|
|
|
|
|
.captured
|
|
|
|
|
|
|
|
.first as List<LocalAsset>;
|
|
|
|
|
|
|
|
expect(captured.length, 1);
|
|
|
|
|
|
|
|
expect(captured.first.id, asset1.id);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|