Source Code added
This commit is contained in:
parent
800376eafd
commit
9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions
118
mobile/test/services/action.service_test.dart
Normal file
118
mobile/test/services/action.service_test.dart
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.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/repositories/download.repository.dart';
|
||||
import 'package:immich_mobile/services/action.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
|
||||
class MockDownloadRepository extends Mock implements DownloadRepository {}
|
||||
|
||||
void main() {
|
||||
late ActionService sut;
|
||||
|
||||
late MockAssetApiRepository assetApiRepository;
|
||||
late MockRemoteAssetRepository remoteAssetRepository;
|
||||
late MockDriftLocalAssetRepository localAssetRepository;
|
||||
late MockDriftAlbumApiRepository albumApiRepository;
|
||||
late MockRemoteAlbumRepository remoteAlbumRepository;
|
||||
late MockTrashedLocalAssetRepository trashedLocalAssetRepository;
|
||||
late MockAssetMediaRepository assetMediaRepository;
|
||||
late MockDownloadRepository downloadRepository;
|
||||
|
||||
late Drift db;
|
||||
|
||||
setUpAll(() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
|
||||
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||
await StoreService.init(storeRepository: DriftStoreRepository(db));
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
await Store.clear();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
assetApiRepository = MockAssetApiRepository();
|
||||
remoteAssetRepository = MockRemoteAssetRepository();
|
||||
localAssetRepository = MockDriftLocalAssetRepository();
|
||||
albumApiRepository = MockDriftAlbumApiRepository();
|
||||
remoteAlbumRepository = MockRemoteAlbumRepository();
|
||||
trashedLocalAssetRepository = MockTrashedLocalAssetRepository();
|
||||
assetMediaRepository = MockAssetMediaRepository();
|
||||
downloadRepository = MockDownloadRepository();
|
||||
|
||||
sut = ActionService(
|
||||
assetApiRepository,
|
||||
remoteAssetRepository,
|
||||
localAssetRepository,
|
||||
albumApiRepository,
|
||||
remoteAlbumRepository,
|
||||
trashedLocalAssetRepository,
|
||||
assetMediaRepository,
|
||||
downloadRepository,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await Store.clear();
|
||||
});
|
||||
|
||||
group('ActionService.deleteLocal', () {
|
||||
test('routes deleted ids to trashed repository when Android trash handling is enabled', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
const ids = ['a', 'b'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
|
||||
when(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).thenAnswer((_) async {});
|
||||
|
||||
final result = await sut.deleteLocal(ids);
|
||||
|
||||
expect(result, ids.length);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verify(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).called(1);
|
||||
verifyNever(() => localAssetRepository.delete(any()));
|
||||
});
|
||||
|
||||
test('deletes locally when Android trash handling is disabled', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
const ids = ['c'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
|
||||
when(() => localAssetRepository.delete(ids)).thenAnswer((_) async {});
|
||||
|
||||
final result = await sut.deleteLocal(ids);
|
||||
|
||||
expect(result, ids.length);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verify(() => localAssetRepository.delete(ids)).called(1);
|
||||
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
|
||||
});
|
||||
|
||||
test('short-circuits when nothing was deleted', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
const ids = ['x'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => <String>[]);
|
||||
|
||||
final result = await sut.deleteLocal(ids);
|
||||
|
||||
expect(result, 0);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
|
||||
verifyNever(() => localAssetRepository.delete(any()));
|
||||
});
|
||||
});
|
||||
}
|
||||
177
mobile/test/services/album.service_test.dart
Normal file
177
mobile/test/services/album.service_test.dart
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
103
mobile/test/services/asset.service_test.dart
Normal file
103
mobile/test/services/asset.service_test.dart
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
285
mobile/test/services/auth.service_test.dart
Normal file
285
mobile/test/services/auth.service_test.dart
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
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/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;
|
||||
late MockAuthApiRepository authApiRepository;
|
||||
late MockAuthRepository authRepository;
|
||||
late MockApiService apiService;
|
||||
late MockNetworkService networkService;
|
||||
late MockBackgroundSyncManager backgroundSyncManager;
|
||||
late MockAppSettingService appSettingsService;
|
||||
late Isar db;
|
||||
|
||||
setUp(() async {
|
||||
authApiRepository = MockAuthApiRepository();
|
||||
authRepository = MockAuthRepository();
|
||||
apiService = MockApiService();
|
||||
networkService = MockNetworkService();
|
||||
backgroundSyncManager = MockBackgroundSyncManager();
|
||||
appSettingsService = MockAppSettingService();
|
||||
|
||||
sut = AuthService(
|
||||
authApiRepository,
|
||||
authRepository,
|
||||
apiService,
|
||||
networkService,
|
||||
backgroundSyncManager,
|
||||
appSettingsService,
|
||||
);
|
||||
|
||||
registerFallbackValue(Uri());
|
||||
});
|
||||
|
||||
setUpAll(() async {
|
||||
db = await TestUtils.initIsar();
|
||||
db.writeTxnSync(() => db.clearSync());
|
||||
await StoreService.init(storeRepository: IsarStoreRepository(db));
|
||||
});
|
||||
|
||||
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';
|
||||
|
||||
when(() => apiService.resolveAndSetEndpoint(testUrl)).thenAnswer((_) async => resolvedUrl);
|
||||
when(() => apiService.setDeviceInfoHeader()).thenAnswer((_) async => {});
|
||||
|
||||
final result = await sut.validateServerUrl(testUrl);
|
||||
|
||||
expect(result, resolvedUrl);
|
||||
|
||||
verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1);
|
||||
verify(() => apiService.setDeviceInfoHeader()).called(1);
|
||||
});
|
||||
|
||||
test('Should resolve HTTPS endpoint', () async {
|
||||
const testUrl = 'https://immich.domain.com';
|
||||
const resolvedUrl = 'https://immich.domain.com/api';
|
||||
|
||||
when(() => apiService.resolveAndSetEndpoint(testUrl)).thenAnswer((_) async => resolvedUrl);
|
||||
when(() => apiService.setDeviceInfoHeader()).thenAnswer((_) async => {});
|
||||
|
||||
final result = await sut.validateServerUrl(testUrl);
|
||||
|
||||
expect(result, resolvedUrl);
|
||||
|
||||
verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1);
|
||||
verify(() => apiService.setDeviceInfoHeader()).called(1);
|
||||
});
|
||||
|
||||
test('Should throw error on invalid URL', () async {
|
||||
const testUrl = 'invalid-url';
|
||||
|
||||
when(() => apiService.resolveAndSetEndpoint(testUrl)).thenThrow(Exception('Invalid URL'));
|
||||
|
||||
expect(() async => await sut.validateServerUrl(testUrl), throwsA(isA<Exception>()));
|
||||
|
||||
verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1);
|
||||
verifyNever(() => apiService.setDeviceInfoHeader());
|
||||
});
|
||||
|
||||
test('Should throw error on unreachable server', () async {
|
||||
const testUrl = 'https://unreachable.server';
|
||||
|
||||
when(() => apiService.resolveAndSetEndpoint(testUrl)).thenThrow(Exception('Server is not reachable'));
|
||||
|
||||
expect(() async => await sut.validateServerUrl(testUrl), throwsA(isA<Exception>()));
|
||||
|
||||
verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1);
|
||||
verifyNever(() => apiService.setDeviceInfoHeader());
|
||||
});
|
||||
});
|
||||
|
||||
group('logout', () {
|
||||
test('Should logout user', () async {
|
||||
when(() => authApiRepository.logout()).thenAnswer((_) async => {});
|
||||
when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {});
|
||||
when(() => authRepository.clearLocalData()).thenAnswer((_) => Future.value(null));
|
||||
when(
|
||||
() => appSettingsService.setSetting(AppSettingsEnum.enableBackup, false),
|
||||
).thenAnswer((_) => Future.value(null));
|
||||
await sut.logout();
|
||||
|
||||
verify(() => authApiRepository.logout()).called(1);
|
||||
verify(() => backgroundSyncManager.cancel()).called(1);
|
||||
verify(() => authRepository.clearLocalData()).called(1);
|
||||
});
|
||||
|
||||
test('Should clear local data even on server error', () async {
|
||||
when(() => authApiRepository.logout()).thenThrow(Exception('Server error'));
|
||||
when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {});
|
||||
when(() => authRepository.clearLocalData()).thenAnswer((_) => Future.value(null));
|
||||
when(
|
||||
() => appSettingsService.setSetting(AppSettingsEnum.enableBackup, false),
|
||||
).thenAnswer((_) => Future.value(null));
|
||||
await sut.logout();
|
||||
|
||||
verify(() => authApiRepository.logout()).called(1);
|
||||
verify(() => backgroundSyncManager.cancel()).called(1);
|
||||
verify(() => authRepository.clearLocalData()).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('setOpenApiServiceEndpoint', () {
|
||||
setUp(() {
|
||||
when(() => networkService.getWifiName()).thenAnswer((_) async => 'TestWifi');
|
||||
});
|
||||
|
||||
test('Should return null if auto endpoint switching is disabled', () async {
|
||||
when(() => authRepository.getEndpointSwitchingFeature()).thenReturn((false));
|
||||
|
||||
final result = await sut.setOpenApiServiceEndpoint();
|
||||
|
||||
expect(result, isNull);
|
||||
verify(() => authRepository.getEndpointSwitchingFeature()).called(1);
|
||||
verifyNever(() => networkService.getWifiName());
|
||||
});
|
||||
|
||||
test('Should set local connection if wifi name matches', () async {
|
||||
when(() => authRepository.getEndpointSwitchingFeature()).thenReturn(true);
|
||||
when(() => authRepository.getPreferredWifiName()).thenReturn('TestWifi');
|
||||
when(() => authRepository.getLocalEndpoint()).thenReturn('http://local.endpoint');
|
||||
when(
|
||||
() => apiService.resolveAndSetEndpoint('http://local.endpoint'),
|
||||
).thenAnswer((_) async => 'http://local.endpoint');
|
||||
|
||||
final result = await sut.setOpenApiServiceEndpoint();
|
||||
|
||||
expect(result, 'http://local.endpoint');
|
||||
verify(() => authRepository.getEndpointSwitchingFeature()).called(1);
|
||||
verify(() => networkService.getWifiName()).called(1);
|
||||
verify(() => authRepository.getPreferredWifiName()).called(1);
|
||||
verify(() => authRepository.getLocalEndpoint()).called(1);
|
||||
verify(() => apiService.resolveAndSetEndpoint('http://local.endpoint')).called(1);
|
||||
});
|
||||
|
||||
test('Should set external endpoint if wifi name not matching', () async {
|
||||
when(() => authRepository.getEndpointSwitchingFeature()).thenReturn(true);
|
||||
when(() => authRepository.getPreferredWifiName()).thenReturn('DifferentWifi');
|
||||
when(
|
||||
() => authRepository.getExternalEndpointList(),
|
||||
).thenReturn([const AuxilaryEndpoint(url: 'https://external.endpoint', status: AuxCheckStatus.valid)]);
|
||||
when(
|
||||
() => apiService.resolveAndSetEndpoint('https://external.endpoint'),
|
||||
).thenAnswer((_) async => 'https://external.endpoint/api');
|
||||
|
||||
final result = await sut.setOpenApiServiceEndpoint();
|
||||
|
||||
expect(result, 'https://external.endpoint/api');
|
||||
verify(() => authRepository.getEndpointSwitchingFeature()).called(1);
|
||||
verify(() => networkService.getWifiName()).called(1);
|
||||
verify(() => authRepository.getPreferredWifiName()).called(1);
|
||||
verify(() => authRepository.getExternalEndpointList()).called(1);
|
||||
verify(() => apiService.resolveAndSetEndpoint('https://external.endpoint')).called(1);
|
||||
});
|
||||
|
||||
test('Should set second external endpoint if the first throw any error', () async {
|
||||
when(() => authRepository.getEndpointSwitchingFeature()).thenReturn(true);
|
||||
when(() => authRepository.getPreferredWifiName()).thenReturn('DifferentWifi');
|
||||
when(() => authRepository.getExternalEndpointList()).thenReturn([
|
||||
const AuxilaryEndpoint(url: 'https://external.endpoint', status: AuxCheckStatus.valid),
|
||||
const AuxilaryEndpoint(url: 'https://external.endpoint2', status: AuxCheckStatus.valid),
|
||||
]);
|
||||
|
||||
when(
|
||||
() => apiService.resolveAndSetEndpoint('https://external.endpoint'),
|
||||
).thenThrow(Exception('Invalid endpoint'));
|
||||
when(
|
||||
() => apiService.resolveAndSetEndpoint('https://external.endpoint2'),
|
||||
).thenAnswer((_) async => 'https://external.endpoint2/api');
|
||||
|
||||
final result = await sut.setOpenApiServiceEndpoint();
|
||||
|
||||
expect(result, 'https://external.endpoint2/api');
|
||||
verify(() => authRepository.getEndpointSwitchingFeature()).called(1);
|
||||
verify(() => networkService.getWifiName()).called(1);
|
||||
verify(() => authRepository.getPreferredWifiName()).called(1);
|
||||
verify(() => authRepository.getExternalEndpointList()).called(1);
|
||||
verify(() => apiService.resolveAndSetEndpoint('https://external.endpoint2')).called(1);
|
||||
});
|
||||
|
||||
test('Should set second external endpoint if the first throw ApiException', () async {
|
||||
when(() => authRepository.getEndpointSwitchingFeature()).thenReturn(true);
|
||||
when(() => authRepository.getPreferredWifiName()).thenReturn('DifferentWifi');
|
||||
when(() => authRepository.getExternalEndpointList()).thenReturn([
|
||||
const AuxilaryEndpoint(url: 'https://external.endpoint', status: AuxCheckStatus.valid),
|
||||
const AuxilaryEndpoint(url: 'https://external.endpoint2', status: AuxCheckStatus.valid),
|
||||
]);
|
||||
|
||||
when(
|
||||
() => apiService.resolveAndSetEndpoint('https://external.endpoint'),
|
||||
).thenThrow(ApiException(503, 'Invalid endpoint'));
|
||||
when(
|
||||
() => apiService.resolveAndSetEndpoint('https://external.endpoint2'),
|
||||
).thenAnswer((_) async => 'https://external.endpoint2/api');
|
||||
|
||||
final result = await sut.setOpenApiServiceEndpoint();
|
||||
|
||||
expect(result, 'https://external.endpoint2/api');
|
||||
verify(() => authRepository.getEndpointSwitchingFeature()).called(1);
|
||||
verify(() => networkService.getWifiName()).called(1);
|
||||
verify(() => authRepository.getPreferredWifiName()).called(1);
|
||||
verify(() => authRepository.getExternalEndpointList()).called(1);
|
||||
verify(() => apiService.resolveAndSetEndpoint('https://external.endpoint2')).called(1);
|
||||
});
|
||||
|
||||
test('Should handle error when setting local connection', () async {
|
||||
when(() => authRepository.getEndpointSwitchingFeature()).thenReturn(true);
|
||||
when(() => authRepository.getPreferredWifiName()).thenReturn('TestWifi');
|
||||
when(() => authRepository.getLocalEndpoint()).thenReturn('http://local.endpoint');
|
||||
when(
|
||||
() => apiService.resolveAndSetEndpoint('http://local.endpoint'),
|
||||
).thenThrow(Exception('Local endpoint error'));
|
||||
|
||||
final result = await sut.setOpenApiServiceEndpoint();
|
||||
|
||||
expect(result, isNull);
|
||||
verify(() => authRepository.getEndpointSwitchingFeature()).called(1);
|
||||
verify(() => networkService.getWifiName()).called(1);
|
||||
verify(() => authRepository.getPreferredWifiName()).called(1);
|
||||
verify(() => authRepository.getLocalEndpoint()).called(1);
|
||||
verify(() => apiService.resolveAndSetEndpoint('http://local.endpoint')).called(1);
|
||||
});
|
||||
|
||||
test('Should handle error when setting external connection', () async {
|
||||
when(() => authRepository.getEndpointSwitchingFeature()).thenReturn(true);
|
||||
when(() => authRepository.getPreferredWifiName()).thenReturn('DifferentWifi');
|
||||
when(
|
||||
() => authRepository.getExternalEndpointList(),
|
||||
).thenReturn([const AuxilaryEndpoint(url: 'https://external.endpoint', status: AuxCheckStatus.valid)]);
|
||||
when(
|
||||
() => apiService.resolveAndSetEndpoint('https://external.endpoint'),
|
||||
).thenThrow(Exception('External endpoint error'));
|
||||
|
||||
final result = await sut.setOpenApiServiceEndpoint();
|
||||
|
||||
expect(result, isNull);
|
||||
verify(() => authRepository.getEndpointSwitchingFeature()).called(1);
|
||||
verify(() => networkService.getWifiName()).called(1);
|
||||
verify(() => authRepository.getPreferredWifiName()).called(1);
|
||||
verify(() => authRepository.getExternalEndpointList()).called(1);
|
||||
verify(() => apiService.resolveAndSetEndpoint('https://external.endpoint')).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
351
mobile/test/services/background_upload.service_test.dart
Normal file
351
mobile/test/services/background_upload.service_test.dart
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../domain/service.mock.dart';
|
||||
import '../fixtures/asset.stub.dart';
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
import '../mocks/asset_entity.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
|
||||
void main() {
|
||||
late BackgroundUploadService sut;
|
||||
late MockUploadRepository mockUploadRepository;
|
||||
late MockStorageRepository mockStorageRepository;
|
||||
late MockDriftLocalAssetRepository mockLocalAssetRepository;
|
||||
late MockDriftBackupRepository mockBackupRepository;
|
||||
late MockAppSettingsService mockAppSettingsService;
|
||||
late MockAssetMediaRepository mockAssetMediaRepository;
|
||||
late Drift db;
|
||||
|
||||
setUpAll(() async {
|
||||
registerFallbackValue(AppSettingsEnum.useCellularForUploadPhotos);
|
||||
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/path_provider'),
|
||||
(MethodCall methodCall) async => 'test',
|
||||
);
|
||||
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||
await StoreService.init(storeRepository: DriftStoreRepository(db));
|
||||
|
||||
await Store.put(StoreKey.serverEndpoint, 'http://test-server.com');
|
||||
await Store.put(StoreKey.deviceId, 'test-device-id');
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
mockUploadRepository = MockUploadRepository();
|
||||
mockStorageRepository = MockStorageRepository();
|
||||
mockLocalAssetRepository = MockDriftLocalAssetRepository();
|
||||
mockBackupRepository = MockDriftBackupRepository();
|
||||
mockAppSettingsService = MockAppSettingsService();
|
||||
mockAssetMediaRepository = MockAssetMediaRepository();
|
||||
|
||||
when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)).thenReturn(false);
|
||||
when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)).thenReturn(false);
|
||||
|
||||
sut = BackgroundUploadService(
|
||||
mockUploadRepository,
|
||||
mockStorageRepository,
|
||||
mockLocalAssetRepository,
|
||||
mockBackupRepository,
|
||||
mockAppSettingsService,
|
||||
mockAssetMediaRepository,
|
||||
);
|
||||
|
||||
mockUploadRepository.onUploadStatus = (_) {};
|
||||
mockUploadRepository.onTaskProgress = (_) {};
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
sut.dispose();
|
||||
});
|
||||
|
||||
group('getUploadTask', () {
|
||||
test('should call getOriginalFilename from AssetMediaRepository for regular photo', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final mockEntity = MockAssetEntity();
|
||||
final mockFile = File('/path/to/file.jpg');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'OriginalPhoto.jpg');
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields['filename'], equals('OriginalPhoto.jpg'));
|
||||
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
|
||||
});
|
||||
|
||||
test('should call getOriginalFilename when original filename is null', () async {
|
||||
final asset = LocalAssetStub.image2;
|
||||
final mockEntity = MockAssetEntity();
|
||||
final mockFile = File('/path/to/file.jpg');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null);
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields['filename'], equals(asset.name));
|
||||
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
|
||||
});
|
||||
|
||||
test('should call getOriginalFilename for live photo', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final mockEntity = MockAssetEntity();
|
||||
final mockFile = File('/path/to/file.mov');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(true);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getMotionFileForAsset(asset)).thenAnswer((_) async => mockFile);
|
||||
when(
|
||||
() => mockAssetMediaRepository.getOriginalFilename(asset.id),
|
||||
).thenAnswer((_) async => 'OriginalLivePhoto.HEIC');
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
expect(task, isNotNull);
|
||||
// For live photos, extension should be changed to match the video file
|
||||
expect(task!.fields['filename'], equals('OriginalLivePhoto.mov'));
|
||||
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('getLivePhotoUploadTask', () {
|
||||
test('should call getOriginalFilename for live photo upload task', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final mockEntity = MockAssetEntity();
|
||||
final mockFile = File('/path/to/livephoto.heic');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(true);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(
|
||||
() => mockAssetMediaRepository.getOriginalFilename(asset.id),
|
||||
).thenAnswer((_) async => 'OriginalLivePhoto.HEIC');
|
||||
|
||||
final task = await sut.getLivePhotoUploadTask(asset, 'video-id-123');
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields['filename'], equals('OriginalLivePhoto.HEIC'));
|
||||
expect(task.fields['livePhotoVideoId'], equals('video-id-123'));
|
||||
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
|
||||
});
|
||||
|
||||
test('should call getOriginalFilename when original filename is null', () async {
|
||||
final asset = LocalAssetStub.image2;
|
||||
final mockEntity = MockAssetEntity();
|
||||
final mockFile = File('/path/to/fallback.heic');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(true);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null);
|
||||
|
||||
final task = await sut.getLivePhotoUploadTask(asset, 'video-id-456');
|
||||
expect(task, isNotNull);
|
||||
// Should fall back to asset.name when original filename is null
|
||||
expect(task!.fields['filename'], equals(asset.name));
|
||||
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('Server Info - cloudId and eTag metadata', () {
|
||||
test('should include cloudId and eTag metadata on iOS when server version is 2.4+', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
final sutWithV24 = BackgroundUploadService(
|
||||
mockUploadRepository,
|
||||
mockStorageRepository,
|
||||
mockLocalAssetRepository,
|
||||
mockBackupRepository,
|
||||
mockAppSettingsService,
|
||||
mockAssetMediaRepository,
|
||||
);
|
||||
addTearDown(() => sutWithV24.dispose());
|
||||
|
||||
final assetWithCloudId = LocalAsset(
|
||||
id: 'test-asset-id',
|
||||
name: 'test.jpg',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025, 1, 1),
|
||||
updatedAt: DateTime(2025, 1, 2),
|
||||
cloudId: 'cloud-id-123',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
adjustmentTime: DateTime(2026, 1, 2),
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
final mockEntity = MockAssetEntity();
|
||||
final mockFile = File('/path/to/test.jpg');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
|
||||
|
||||
final task = await sutWithV24.getUploadTask(assetWithCloudId);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields.containsKey('metadata'), isTrue);
|
||||
|
||||
final metadata = jsonDecode(task.fields['metadata']!) as List;
|
||||
expect(metadata, hasLength(1));
|
||||
expect(metadata[0]['key'], equals('mobile-app'));
|
||||
expect(metadata[0]['value']['iCloudId'], equals('cloud-id-123'));
|
||||
expect(metadata[0]['value']['createdAt'], isNotNull);
|
||||
expect(metadata[0]['value']['adjustmentTime'], isNotNull);
|
||||
expect(metadata[0]['value']['latitude'], isNotNull);
|
||||
expect(metadata[0]['value']['longitude'], isNotNull);
|
||||
});
|
||||
|
||||
test('should NOT include metadata on Android regardless of server version', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
final sutAndroid = BackgroundUploadService(
|
||||
mockUploadRepository,
|
||||
mockStorageRepository,
|
||||
mockLocalAssetRepository,
|
||||
mockBackupRepository,
|
||||
mockAppSettingsService,
|
||||
mockAssetMediaRepository,
|
||||
);
|
||||
addTearDown(() => sutAndroid.dispose());
|
||||
|
||||
final assetWithCloudId = LocalAsset(
|
||||
id: 'test-asset-id',
|
||||
name: 'test.jpg',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025, 1, 1),
|
||||
updatedAt: DateTime(2025, 1, 2),
|
||||
cloudId: 'cloud-id-123',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
final mockEntity = MockAssetEntity();
|
||||
final mockFile = File('/path/to/test.jpg');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
|
||||
|
||||
final task = await sutAndroid.getUploadTask(assetWithCloudId);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields.containsKey('metadata'), isFalse);
|
||||
});
|
||||
|
||||
test('should NOT include metadata when cloudId is null even on iOS with server 2.4+', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
final sutWithV24 = BackgroundUploadService(
|
||||
mockUploadRepository,
|
||||
mockStorageRepository,
|
||||
mockLocalAssetRepository,
|
||||
mockBackupRepository,
|
||||
mockAppSettingsService,
|
||||
mockAssetMediaRepository,
|
||||
);
|
||||
addTearDown(() => sutWithV24.dispose());
|
||||
|
||||
final assetWithoutCloudId = LocalAsset(
|
||||
id: 'test-asset-id',
|
||||
name: 'test.jpg',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025, 1, 1),
|
||||
updatedAt: DateTime(2025, 1, 2),
|
||||
cloudId: null, // No cloudId
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
final mockEntity = MockAssetEntity();
|
||||
final mockFile = File('/path/to/test.jpg');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithoutCloudId)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(
|
||||
() => mockAssetMediaRepository.getOriginalFilename(assetWithoutCloudId.id),
|
||||
).thenAnswer((_) async => 'test.jpg');
|
||||
|
||||
final task = await sutWithV24.getUploadTask(assetWithoutCloudId);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields.containsKey('metadata'), isFalse);
|
||||
});
|
||||
|
||||
test('should include metadata for live photos with cloudId on iOS 2.4+', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
final sutWithV24 = BackgroundUploadService(
|
||||
mockUploadRepository,
|
||||
mockStorageRepository,
|
||||
mockLocalAssetRepository,
|
||||
mockBackupRepository,
|
||||
mockAppSettingsService,
|
||||
mockAssetMediaRepository,
|
||||
);
|
||||
addTearDown(() => sutWithV24.dispose());
|
||||
|
||||
final assetWithCloudId = LocalAsset(
|
||||
id: 'test-livephoto-id',
|
||||
name: 'livephoto.heic',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025, 1, 1),
|
||||
updatedAt: DateTime(2025, 1, 2),
|
||||
cloudId: 'cloud-id-livephoto',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
final mockEntity = MockAssetEntity();
|
||||
final mockFile = File('/path/to/livephoto.heic');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(true);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(
|
||||
() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id),
|
||||
).thenAnswer((_) async => 'livephoto.heic');
|
||||
|
||||
final task = await sutWithV24.getLivePhotoUploadTask(assetWithCloudId, 'video-123');
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields.containsKey('metadata'), isTrue);
|
||||
expect(task.fields['livePhotoVideoId'], equals('video-123'));
|
||||
|
||||
final metadata = jsonDecode(task.fields['metadata']!) as List;
|
||||
expect(metadata, hasLength(1));
|
||||
expect(metadata[0]['key'], equals('mobile-app'));
|
||||
expect(metadata[0]['value']['iCloudId'], equals('cloud-id-livephoto'));
|
||||
});
|
||||
});
|
||||
}
|
||||
76
mobile/test/services/entity.service_test.dart
Normal file
76
mobile/test/services/entity.service_test.dart
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
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
mobile/test/services/hash_service_test.dart
Normal file
349
mobile/test/services/hash_service_test.dart
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue