Merge branch 'main' into fix/mobile-unawaited-futures

This commit is contained in:
Mert
2025-09-19 09:49:23 -04:00
committed by GitHub
177 changed files with 10370 additions and 1418 deletions
@@ -1,11 +1,7 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:mocktail/mocktail.dart';
import '../../fixtures/album.stub.dart';
@@ -13,192 +9,137 @@ import '../../fixtures/asset.stub.dart';
import '../../infrastructure/repository.mock.dart';
import '../service.mock.dart';
class MockFile extends Mock implements File {}
void main() {
late HashService sut;
late MockLocalAlbumRepository mockAlbumRepo;
late MockLocalAssetRepository mockAssetRepo;
late MockStorageRepository mockStorageRepo;
late MockNativeSyncApi mockNativeApi;
final sortBy = {SortLocalAlbumsBy.backupSelection, SortLocalAlbumsBy.isIosSharedAlbum};
setUp(() {
mockAlbumRepo = MockLocalAlbumRepository();
mockAssetRepo = MockLocalAssetRepository();
mockStorageRepo = MockStorageRepository();
mockNativeApi = MockNativeSyncApi();
sut = HashService(
localAlbumRepository: mockAlbumRepo,
localAssetRepository: mockAssetRepo,
storageRepository: mockStorageRepo,
nativeSyncApi: mockNativeApi,
);
registerFallbackValue(LocalAlbumStub.recent);
registerFallbackValue(LocalAssetStub.image1);
registerFallbackValue(<String, String>{});
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
when(() => mockStorageRepo.clearCache()).thenAnswer((_) async => {});
});
group('HashService hashAssets', () {
test('skips albums with no assets to hash', () async {
when(
() => mockAlbumRepo.getAll(sortBy: sortBy),
() => mockAlbumRepo.getBackupAlbums(),
).thenAnswer((_) async => [LocalAlbumStub.recent.copyWith(assetCount: 0)]);
when(() => mockAlbumRepo.getAssetsToHash(LocalAlbumStub.recent.id)).thenAnswer((_) async => []);
await sut.hashAssets();
verifyNever(() => mockStorageRepo.getFileForAsset(any()));
verifyNever(() => mockNativeApi.hashPaths(any()));
verifyNever(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
});
group('HashService _hashAssets', () {
test('skips assets without files', () async {
test('skips empty batches', () async {
final album = LocalAlbumStub.recent;
final asset = LocalAssetStub.image1;
when(() => mockAlbumRepo.getAll(sortBy: sortBy)).thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(() => mockStorageRepo.getFileForAsset(asset.id)).thenAnswer((_) async => null);
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => []);
await sut.hashAssets();
verifyNever(() => mockNativeApi.hashPaths(any()));
verifyNever(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('processes assets when available', () async {
final album = LocalAlbumStub.recent;
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(sortBy: sortBy)).thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(() => mockStorageRepo.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockNativeApi.hashPaths(['image-path'])).thenAnswer((_) async => [hash]);
when(
() => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: 'test-hash')]);
await sut.hashAssets();
verify(() => mockNativeApi.hashPaths(['image-path'])).called(1);
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as List<LocalAsset>;
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[0].checksum, base64.encode(hash));
expect(captured[asset.id], 'test-hash');
});
test('handles failed hashes', () async {
final album = LocalAlbumStub.recent;
final asset = LocalAssetStub.image1;
final mockFile = MockFile();
when(() => mockFile.length()).thenAnswer((_) async => 1000);
when(() => mockFile.path).thenReturn('image-path');
when(() => mockAlbumRepo.getAll(sortBy: sortBy)).thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(() => mockStorageRepo.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockNativeApi.hashPaths(['image-path'])).thenAnswer((_) async => [null]);
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
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 List<LocalAsset>;
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 0);
});
test('handles invalid hash length', () async {
test('handles null hash results', () async {
final album = LocalAlbumStub.recent;
final asset = LocalAssetStub.image1;
final mockFile = MockFile();
when(() => mockFile.length()).thenAnswer((_) async => 1000);
when(() => mockFile.path).thenReturn('image-path');
when(() => mockAlbumRepo.getAll(sortBy: sortBy)).thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(() => mockStorageRepo.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
final invalidHash = Uint8List.fromList([1, 2, 3]);
when(() => mockNativeApi.hashPaths(['image-path'])).thenAnswer((_) async => [invalidHash]);
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
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 List<LocalAsset>;
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 0);
});
test('batches by file count limit', () async {
final sut = HashService(
localAlbumRepository: mockAlbumRepo,
localAssetRepository: mockAssetRepo,
storageRepository: mockStorageRepo,
nativeSyncApi: mockNativeApi,
batchFileLimit: 1,
);
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(sortBy: sortBy)).thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
when(() => mockStorageRepo.getFileForAsset(asset1.id)).thenAnswer((_) async => mockFile1);
when(() => mockStorageRepo.getFileForAsset(asset2.id)).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 => {});
await sut.hashAssets();
verify(() => mockNativeApi.hashPaths(['path-1'])).called(1);
verify(() => mockNativeApi.hashPaths(['path-2'])).called(1);
verify(() => mockAssetRepo.updateHashes(any())).called(2);
});
test('batches by size limit', () async {
const batchSize = 2;
final sut = HashService(
localAlbumRepository: mockAlbumRepo,
localAssetRepository: mockAssetRepo,
storageRepository: mockStorageRepo,
nativeSyncApi: mockNativeApi,
batchSizeLimit: 80,
batchSize: batchSize,
);
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');
final asset3 = LocalAssetStub.image1.copyWith(id: 'image3', name: 'image3.jpg');
when(() => mockAlbumRepo.getAll(sortBy: sortBy)).thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
when(() => mockStorageRepo.getFileForAsset(asset1.id)).thenAnswer((_) async => mockFile1);
when(() => mockStorageRepo.getFileForAsset(asset2.id)).thenAnswer((_) async => mockFile2);
final capturedCalls = <List<String>>[];
final hash = Uint8List.fromList(List.generate(20, (i) => i));
when(() => mockNativeApi.hashPaths(any())).thenAnswer((_) async => [hash]);
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();
verify(() => mockNativeApi.hashPaths(['path-1'])).called(1);
verify(() => mockNativeApi.hashPaths(['path-2'])).called(1);
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);
});
@@ -206,27 +147,43 @@ void main() {
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(sortBy: sortBy)).thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
when(() => mockStorageRepo.getFileForAsset(asset1.id)).thenAnswer((_) async => mockFile1);
when(() => mockStorageRepo.getFileForAsset(asset2.id)).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 => {});
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 List<LocalAsset>;
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 1);
expect(captured.first.id, asset1.id);
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);
});
});
}
+4 -1
View File
@@ -13,6 +13,7 @@ import 'schema_v7.dart' as v7;
import 'schema_v8.dart' as v8;
import 'schema_v9.dart' as v9;
import 'schema_v10.dart' as v10;
import 'schema_v11.dart' as v11;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -38,10 +39,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v9.DatabaseAtV9(db);
case 10:
return v10.DatabaseAtV10(db);
case 11:
return v11.DatabaseAtV11(db);
default:
throw MissingSchemaException(version, versions);
}
}
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,131 @@
import 'package:drift/drift.dart' hide isNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:mocktail/mocktail.dart';
import '../../infrastructure/repository.mock.dart';
void main() {
late Drift db;
late SyncStreamRepository mockSyncStreamRepository;
setUpAll(() async {
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
mockSyncStreamRepository = MockSyncStreamRepository();
when(() => mockSyncStreamRepository.reset()).thenAnswer((_) async => {});
});
tearDown(() async {
await Store.clear();
});
group('handleBetaMigration Tests', () {
group("version < 15", () {
test('already on new timeline', () async {
await Store.put(StoreKey.betaTimeline, true);
await handleBetaMigration(14, false, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.betaTimeline), true);
expect(Store.tryGet(StoreKey.needBetaMigration), false);
});
test('already on old timeline', () async {
await Store.put(StoreKey.betaTimeline, false);
await handleBetaMigration(14, false, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.needBetaMigration), true);
});
test('fresh install', () async {
await Store.delete(StoreKey.betaTimeline);
await handleBetaMigration(14, true, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.betaTimeline), true);
expect(Store.tryGet(StoreKey.needBetaMigration), false);
});
});
group("version == 15", () {
test('already on new timeline', () async {
await Store.put(StoreKey.betaTimeline, true);
await handleBetaMigration(15, false, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.betaTimeline), true);
expect(Store.tryGet(StoreKey.needBetaMigration), false);
});
test('already on old timeline', () async {
await Store.put(StoreKey.betaTimeline, false);
await handleBetaMigration(15, false, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.needBetaMigration), true);
});
test('fresh install', () async {
await Store.delete(StoreKey.betaTimeline);
await handleBetaMigration(15, true, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.betaTimeline), true);
expect(Store.tryGet(StoreKey.needBetaMigration), false);
});
});
group("version > 15", () {
test('already on new timeline', () async {
await Store.put(StoreKey.betaTimeline, true);
await handleBetaMigration(16, false, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.betaTimeline), true);
expect(Store.tryGet(StoreKey.needBetaMigration), false);
});
test('already on old timeline', () async {
await Store.put(StoreKey.betaTimeline, false);
await handleBetaMigration(16, false, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.betaTimeline), false);
expect(Store.tryGet(StoreKey.needBetaMigration), false);
});
test('fresh install', () async {
await Store.delete(StoreKey.betaTimeline);
await handleBetaMigration(16, true, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.betaTimeline), true);
expect(Store.tryGet(StoreKey.needBetaMigration), false);
});
});
});
group('sync reset tests', () {
test('version < 16', () async {
await Store.put(StoreKey.shouldResetSync, false);
await handleBetaMigration(15, false, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.shouldResetSync), true);
});
test('version >= 16', () async {
await Store.put(StoreKey.shouldResetSync, false);
await handleBetaMigration(16, false, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.shouldResetSync), false);
});
});
}
@@ -502,6 +502,21 @@ void main() {
expect(ActionButtonType.deleteLocal.shouldShow(context), isFalse);
});
test('should show when asset is merged', () {
final context = ActionButtonContext(
asset: mergedAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue);
});
});
group('upload button', () {