refactor: yeet old timeline (#27666)

* refactor: yank old timeline

# Conflicts:
#	mobile/lib/presentation/pages/editing/drift_edit.page.dart
#	mobile/lib/providers/websocket.provider.dart
#	mobile/lib/routing/router.dart

* more cleanup

* remove native code

* chore: bump sqlite-data version

* remove old background tasks from BGTaskSchedulerPermittedIdentifiers

* rebase

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
shenlong
2026-04-15 23:00:27 +05:30
committed by GitHub
parent 6dd6053222
commit 79fccdbee0
367 changed files with 332 additions and 50870 deletions
@@ -1,177 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:mocktail/mocktail.dart';
import '../domain/service.mock.dart';
import '../fixtures/album.stub.dart';
import '../fixtures/asset.stub.dart';
import '../fixtures/user.stub.dart';
import '../repository.mocks.dart';
import '../service.mocks.dart';
void main() {
late AlbumService sut;
late MockUserService userService;
late MockSyncService syncService;
late MockEntityService entityService;
late MockAlbumRepository albumRepository;
late MockAssetRepository assetRepository;
late MockBackupRepository backupRepository;
late MockAlbumMediaRepository albumMediaRepository;
late MockAlbumApiRepository albumApiRepository;
setUp(() {
userService = MockUserService();
syncService = MockSyncService();
entityService = MockEntityService();
albumRepository = MockAlbumRepository();
assetRepository = MockAssetRepository();
backupRepository = MockBackupRepository();
albumMediaRepository = MockAlbumMediaRepository();
albumApiRepository = MockAlbumApiRepository();
when(() => userService.getMyUser()).thenReturn(UserStub.user1);
when(
() => albumRepository.transaction<void>(any()),
).thenAnswer((call) => (call.positionalArguments.first as Function).call());
when(
() => assetRepository.transaction<Null>(any()),
).thenAnswer((call) => (call.positionalArguments.first as Function).call());
sut = AlbumService(
syncService,
userService,
entityService,
albumRepository,
assetRepository,
backupRepository,
albumMediaRepository,
albumApiRepository,
);
});
group('refreshDeviceAlbums', () {
test('empty selection with one album in db', () async {
when(() => backupRepository.getIdsBySelection(BackupSelection.exclude)).thenAnswer((_) async => []);
when(() => backupRepository.getIdsBySelection(BackupSelection.select)).thenAnswer((_) async => []);
when(() => albumMediaRepository.getAll()).thenAnswer((_) async => []);
when(() => albumRepository.count(local: true)).thenAnswer((_) async => 1);
when(() => syncService.removeAllLocalAlbumsAndAssets()).thenAnswer((_) async => true);
final result = await sut.refreshDeviceAlbums();
expect(result, false);
verify(() => syncService.removeAllLocalAlbumsAndAssets());
});
test('one selected albums, two on device', () async {
when(() => backupRepository.getIdsBySelection(BackupSelection.exclude)).thenAnswer((_) async => []);
when(
() => backupRepository.getIdsBySelection(BackupSelection.select),
).thenAnswer((_) async => [AlbumStub.oneAsset.localId!]);
when(() => albumMediaRepository.getAll()).thenAnswer((_) async => [AlbumStub.oneAsset, AlbumStub.twoAsset]);
when(() => syncService.syncLocalAlbumAssetsToDb(any(), any())).thenAnswer((_) async => true);
final result = await sut.refreshDeviceAlbums();
expect(result, true);
verify(() => syncService.syncLocalAlbumAssetsToDb([AlbumStub.oneAsset], null)).called(1);
verifyNoMoreInteractions(syncService);
});
});
group('refreshRemoteAlbums', () {
test('is working', () async {
when(() => syncService.getUsersFromServer()).thenAnswer((_) async => []);
when(() => syncService.syncUsersFromServer(any())).thenAnswer((_) async => true);
when(() => albumApiRepository.getAll(shared: true)).thenAnswer((_) async => [AlbumStub.sharedWithUser]);
when(
() => albumApiRepository.getAll(shared: null),
).thenAnswer((_) async => [AlbumStub.oneAsset, AlbumStub.twoAsset]);
when(
() => syncService.syncRemoteAlbumsToDb([AlbumStub.twoAsset, AlbumStub.oneAsset, AlbumStub.sharedWithUser]),
).thenAnswer((_) async => true);
final result = await sut.refreshRemoteAlbums();
expect(result, true);
verify(() => syncService.getUsersFromServer()).called(1);
verify(() => syncService.syncUsersFromServer([])).called(1);
verify(() => albumApiRepository.getAll(shared: true)).called(1);
verify(() => albumApiRepository.getAll(shared: null)).called(1);
verify(
() => syncService.syncRemoteAlbumsToDb([AlbumStub.twoAsset, AlbumStub.oneAsset, AlbumStub.sharedWithUser]),
).called(1);
verifyNoMoreInteractions(userService);
verifyNoMoreInteractions(albumApiRepository);
verifyNoMoreInteractions(syncService);
});
});
group('createAlbum', () {
test('shared with assets', () async {
when(
() => albumApiRepository.create(
"name",
assetIds: any(named: "assetIds"),
sharedUserIds: any(named: "sharedUserIds"),
),
).thenAnswer((_) async => AlbumStub.oneAsset);
when(
() => entityService.fillAlbumWithDatabaseEntities(AlbumStub.oneAsset),
).thenAnswer((_) async => AlbumStub.oneAsset);
when(() => albumRepository.create(AlbumStub.oneAsset)).thenAnswer((_) async => AlbumStub.twoAsset);
final result = await sut.createAlbum("name", [AssetStub.image1], [UserStub.user1]);
expect(result, AlbumStub.twoAsset);
verify(
() => albumApiRepository.create(
"name",
assetIds: [AssetStub.image1.remoteId!],
sharedUserIds: [UserStub.user1.id],
),
).called(1);
verify(() => entityService.fillAlbumWithDatabaseEntities(AlbumStub.oneAsset)).called(1);
});
});
group('addAdditionalAssetToAlbum', () {
test('one added, one duplicate', () async {
when(
() => albumApiRepository.addAssets(AlbumStub.oneAsset.remoteId!, any()),
).thenAnswer((_) async => (added: [AssetStub.image2.remoteId!], duplicates: [AssetStub.image1.remoteId!]));
when(() => albumRepository.get(AlbumStub.oneAsset.id)).thenAnswer((_) async => AlbumStub.oneAsset);
when(() => albumRepository.addAssets(AlbumStub.oneAsset, [AssetStub.image2])).thenAnswer((_) async {});
when(() => albumRepository.removeAssets(AlbumStub.oneAsset, [])).thenAnswer((_) async {});
when(() => albumRepository.recalculateMetadata(AlbumStub.oneAsset)).thenAnswer((_) async => AlbumStub.oneAsset);
when(() => albumRepository.update(AlbumStub.oneAsset)).thenAnswer((_) async => AlbumStub.oneAsset);
final result = await sut.addAssets(AlbumStub.oneAsset, [AssetStub.image1, AssetStub.image2]);
expect(result != null, true);
expect(result!.alreadyInAlbum, [AssetStub.image1.remoteId!]);
expect(result.successfullyAdded, 1);
});
});
group('addAdditionalUserToAlbum', () {
test('one added', () async {
when(
() => albumApiRepository.addUsers(AlbumStub.emptyAlbum.remoteId!, any()),
).thenAnswer((_) async => AlbumStub.sharedWithUser);
when(
() => albumRepository.addUsers(
AlbumStub.emptyAlbum,
AlbumStub.emptyAlbum.sharedUsers.map((u) => u.toDto()).toList(),
),
).thenAnswer((_) async => AlbumStub.emptyAlbum);
when(() => albumRepository.update(AlbumStub.emptyAlbum)).thenAnswer((_) async => AlbumStub.emptyAlbum);
final result = await sut.addUsers(AlbumStub.emptyAlbum, [UserStub.user2.id]);
expect(result, true);
});
});
}
@@ -1,103 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/services/asset.service.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
import '../api.mocks.dart';
import '../domain/service.mock.dart';
import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
import '../service.mocks.dart';
class FakeAssetBulkUpdateDto extends Fake implements AssetBulkUpdateDto {}
void main() {
late AssetService sut;
late MockAssetRepository assetRepository;
late MockAssetApiRepository assetApiRepository;
late MockExifInfoRepository exifInfoRepository;
late MockETagRepository eTagRepository;
late MockBackupAlbumRepository backupAlbumRepository;
late MockIsarUserRepository userRepository;
late MockAssetMediaRepository assetMediaRepository;
late MockApiService apiService;
late MockSyncService syncService;
late MockAlbumService albumService;
late MockBackupService backupService;
late MockUserService userService;
setUp(() {
assetRepository = MockAssetRepository();
assetApiRepository = MockAssetApiRepository();
exifInfoRepository = MockExifInfoRepository();
userRepository = MockIsarUserRepository();
eTagRepository = MockETagRepository();
backupAlbumRepository = MockBackupAlbumRepository();
apiService = MockApiService();
assetMediaRepository = MockAssetMediaRepository();
syncService = MockSyncService();
userService = MockUserService();
albumService = MockAlbumService();
backupService = MockBackupService();
sut = AssetService(
assetApiRepository,
assetRepository,
exifInfoRepository,
userRepository,
eTagRepository,
backupAlbumRepository,
apiService,
syncService,
backupService,
albumService,
userService,
assetMediaRepository,
);
registerFallbackValue(FakeAssetBulkUpdateDto());
});
group("Edit ExifInfo", () {
late AssetsApi assetsApi;
setUp(() {
assetsApi = MockAssetsApi();
when(() => apiService.assetsApi).thenReturn(assetsApi);
when(() => assetsApi.updateAssets(any())).thenAnswer((_) async => Future.value());
});
test("asset is updated with DateTime", () async {
final assets = [AssetStub.image1, AssetStub.image2];
final dateTime = DateTime.utc(2025, 6, 4, 2, 57);
await sut.changeDateTime(assets, dateTime.toIso8601String());
verify(() => assetsApi.updateAssets(any())).called(1);
final upsertExifCallback = verify(() => syncService.upsertAssetsWithExif(captureAny()));
upsertExifCallback.called(1);
final receivedAssets = upsertExifCallback.captured.firstOrNull as List<Object>? ?? [];
final receivedDatetime = receivedAssets.cast<Asset>().map((a) => a.exifInfo?.dateTimeOriginal ?? DateTime(0));
expect(receivedDatetime.every((d) => d == dateTime), isTrue);
});
test("asset is updated with LatLng", () async {
final assets = [AssetStub.image1, AssetStub.image2];
final latLng = const LatLng(37.7749, -122.4194);
await sut.changeLocation(assets, latLng);
verify(() => assetsApi.updateAssets(any())).called(1);
final upsertExifCallback = verify(() => syncService.upsertAssetsWithExif(captureAny()));
upsertExifCallback.called(1);
final receivedAssets = upsertExifCallback.captured.firstOrNull as List<Object>? ?? [];
final receivedCoords = receivedAssets.cast<Asset>().map(
(a) => LatLng(a.exifInfo?.latitude ?? 0, a.exifInfo?.longitude ?? 0),
);
expect(receivedCoords.every((l) => l == latLng), isTrue);
});
});
}
+11 -13
View File
@@ -1,18 +1,19 @@
import 'package:drift/drift.dart' hide isNull;
import 'package:drift/native.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
import 'package:isar/isar.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
import '../domain/service.mock.dart';
import '../repository.mocks.dart';
import '../service.mocks.dart';
import '../test_utils.dart';
void main() {
late AuthService sut;
@@ -22,7 +23,7 @@ void main() {
late MockNetworkService networkService;
late MockBackgroundSyncManager backgroundSyncManager;
late MockAppSettingService appSettingsService;
late Isar db;
late Drift db;
setUp(() async {
authApiRepository = MockAuthApiRepository();
@@ -45,19 +46,16 @@ void main() {
});
setUpAll(() async {
db = await TestUtils.initIsar();
db.writeTxnSync(() => db.clearSync());
await StoreService.init(storeRepository: IsarStoreRepository(db));
WidgetsFlutterBinding.ensureInitialized();
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
});
tearDownAll(() async {
await db.close();
});
group('validateServerUrl', () {
setUpAll(() async {
WidgetsFlutterBinding.ensureInitialized();
final db = await TestUtils.initIsar();
db.writeTxnSync(() => db.clearSync());
await StoreService.init(storeRepository: IsarStoreRepository(db));
});
test('Should resolve HTTP endpoint', () async {
const testUrl = 'http://ip:2283';
const resolvedUrl = 'http://ip:2283/api';
@@ -1,76 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/services/entity.service.dart';
import 'package:mocktail/mocktail.dart';
import '../fixtures/asset.stub.dart';
import '../fixtures/user.stub.dart';
import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
void main() {
late EntityService sut;
late MockAssetRepository assetRepository;
late MockIsarUserRepository userRepository;
setUp(() {
assetRepository = MockAssetRepository();
userRepository = MockIsarUserRepository();
sut = EntityService(assetRepository, userRepository);
});
group('fillAlbumWithDatabaseEntities', () {
test('remote album with owner, thumbnail, sharedUsers and assets', () async {
final Album album =
Album(
name: "album-with-two-assets-and-two-users",
localId: "album-with-two-assets-and-two-users-local",
remoteId: "album-with-two-assets-and-two-users-remote",
createdAt: DateTime(2001),
modifiedAt: DateTime(2010),
shared: true,
activityEnabled: true,
startDate: DateTime(2019),
endDate: DateTime(2020),
)
..remoteThumbnailAssetId = AssetStub.image1.remoteId
..assets.addAll([AssetStub.image1, AssetStub.image1])
..owner.value = User.fromDto(UserStub.user1)
..sharedUsers.addAll([User.fromDto(UserStub.admin), User.fromDto(UserStub.admin)]);
when(() => userRepository.getByUserId(any())).thenAnswer((_) async => UserStub.admin);
when(() => userRepository.getByUserId(any())).thenAnswer((_) async => UserStub.admin);
when(() => assetRepository.getByRemoteId(AssetStub.image1.remoteId!)).thenAnswer((_) async => AssetStub.image1);
when(() => userRepository.getByUserIds(any())).thenAnswer((_) async => [UserStub.user1, UserStub.user2]);
when(() => assetRepository.getAllByRemoteId(any())).thenAnswer((_) async => [AssetStub.image1, AssetStub.image2]);
await sut.fillAlbumWithDatabaseEntities(album);
expect(album.owner.value?.toDto(), UserStub.admin);
expect(album.thumbnail.value, AssetStub.image1);
expect(album.remoteUsers.map((u) => u.toDto()).toSet(), {UserStub.user1, UserStub.user2});
expect(album.remoteAssets.toSet(), {AssetStub.image1, AssetStub.image2});
});
test('remote album without any info', () async {
makeEmptyAlbum() => Album(
name: "album-without-info",
localId: "album-without-info-local",
remoteId: "album-without-info-remote",
createdAt: DateTime(2001),
modifiedAt: DateTime(2010),
shared: false,
activityEnabled: false,
);
final album = makeEmptyAlbum();
await sut.fillAlbumWithDatabaseEntities(album);
verifyNoMoreInteractions(assetRepository);
verifyNoMoreInteractions(userRepository);
expect(album, makeEmptyAlbum());
});
});
}
-349
View File
@@ -1,349 +0,0 @@
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);
}