mirror of
https://github.com/immich-app/immich.git
synced 2026-05-21 15:16:31 -04:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user