immich/mobile/test/services/hash_service_test.dart
Peter Ombodi 493cde9d55
feat: opt-in sync of deletes and restores from web to Android (beta timeline) (#20473)
* feature(mobile, beta, Android): handle remote asset trash/restore events and rescan media
- Handle move to trash and restore from trash for remote assets on Android
- Trigger MediaScannerConnection to rescan affected media files

* feature(mobile, beta, Android): fix rescan

* fix imports

* fix checking conditions

* refactor naming

* fix line breaks

* refactor code
rollback changes in BackgroundServicePlugin

* refactor code (use separate TrashService)

* refactor code

* parallelize restoreFromTrash calls with Future.wait
format trash.provider.dart

* try to re-format trash.provider.dart

* re-format trash.provider.dart

* rename TrashService to TrashSyncService to avoid duplicated names
revert changes in original trash.provider.dart

* refactor code (minor nitpicks)

* process restoreFromTrash sequentially instead of Future.wait

* group local assets by checksum before moving to trash
delete LocalAssetEntity records when moved to trash
refactor code

* fix format

* use checksum for asset restoration
refactro code

* fix format

* sync trash only for backup-selected assets

* feat(db): add local_trashed_asset table and integrate with restoration flow
- Add new `local_trashed_asset` table to store metadata of trashed assets
- Save trashed asset info into `local_trashed_asset` before deletion
- Use `local_trashed_asset` as source for asset restoration
- Implement file restoration by `mediaId`

* resolve merge conflicts

* fix index creating on migration

* rework trashed assets handling
- add new table trashed_local_asset
- mirror trashed assets data in trashed_local_asset.
- compute checksums for assets trashed out-of-app.
- restore assets present in trashed_local_asset and non-trashed in remote_asset.
- simplify moving-to-trash logic based on remote_asset events.

* resolve merge conflicts
use updated approach for calculating checksums

* use CurrentPlatform instead _platform
fix mocks

* revert redundant changes

* Include trashed items in getMediaChanges
Process trashed items delta during incremental sync

* fix merge conflicts

* fix format

* trashed_local_asset table mirror of local_asset table structure
trashed_local_asset<->local_asset transfer data on move to trash or restore
refactor code

* refactor and format code

* refactor TrashedAsset model
fix missed data transfering

* refactor code
remove unused model

* fix label

* fix merge conflicts

* optimize, refactor code
remove redundant code and checking
getTrashedAssetsForAlbum for iOS
tests for hash trashed assets

* format code

* fix migration
fix tests

* fix generated file

* reuse exist checksums on trash data update
handle restoration errors
fix import

* format code

* sync_stream.service depend on repos
refactor assets restoration
update dependencies in tests

* remove trashed asset model
remove trash_sync.service
refactor DriftTrashedLocalAssetRepository, LocalSyncService

* rework fetching trashed assets data on native side
optimize handling trashed assets in local sync service
refactor code

* update NativeSyncApi on iOS side
remove unused code

* optimize sync trashed assets call in full sync mode
refactor code

* fix format

* remove albumIds from getTrashedAssets params
fix upsert in trashed local asset repo
refactor code

* fix getTrashedAssets params

* fix(trash-sync): clean up NativeSyncApiImplBase and correct applyDelta

* refactor(trash-sync): optimize performance and fix minor issues

* refactor(trash-sync): add missed index

* feat(trash-sync): remove sinceLastCheckpoint param from getTrashedAssets

* fix(trash-sync): fix target table

* fix(trash-sync): remove unused extension

* fix(trash-sync): remove unused code

* fix(trash-sync): refactor code

* fix(trash-sync): reformat file

* fix(trash_sync): refactor code

* fix(trash_sync): improve moving to trash

* refactor(trash_sync): integrate MANAGE_MEDIA permission request into login flow and advanced settings

* refactor(trash_sync): add additional checking for experimental trash sync flag and MANAGE_MEDIA permission.

* refactor(trash_sync): resolve merge conflicts

* refactor(trash_sync): fix format

* resolve merge conflicts
add await for alert dialog
add missed request

* refactor(trash_sync): rework MANAGE_MEDIA info widget
show rationale text in permission request alert dialog
refactor setting getter

* fix(trash_sync): restore missing text values

* fix(trash_sync): format file

* fix(trash_sync): check backup enabled and remove remote asset existence check

* fix(trash_sync): remove checking backup enabled
test(trash_sync): cover sync-stream trash/restore paths and dedupe mocks

* test(trash_sync): cover trash/restore flows for local_sync_service

* chore(e2e): restore test-assets submodule pointer

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-11-10 16:20:51 +00:00

350 lines
15 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/models/device_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/device_asset.repository.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/hash.service.dart';
import 'package:mocktail/mocktail.dart';
import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart';
import '../service.mocks.dart';
import '../mocks/asset_entity.mock.dart';
class MockAsset extends Mock implements Asset {}
void main() {
late HashService sut;
late BackgroundService mockBackgroundService;
late IsarDeviceAssetRepository 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);
}