From 04a38ba91c28a761e4d77ca8a0995cdce2e9fde7 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:45:36 +0530 Subject: [PATCH] refactor: asset update method (#30201) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../domain/models/asset/base_asset.model.dart | 1 + .../models/asset/remote_asset.model.dart | 2 + mobile/lib/domain/services/asset.service.dart | 109 +++++++- .../domain/services/remote_album.service.dart | 6 + .../repositories/remote_asset.repository.dart | 81 +++--- .../repositories/remote_exif.repository.dart | 34 +++ .../presentation/actions/action.widget.dart | 2 +- .../infrastructure/asset.provider.dart | 4 + .../repositories/asset_api.repository.dart | 43 ++-- mobile/lib/utils/asset_filter.dart | 4 +- mobile/lib/utils/error_handler.dart | 10 +- mobile/lib/utils/option.dart | 11 - mobile/test/domain/service.mock.dart | 22 -- .../services/local_sync_service_test.dart | 2 +- .../sync_linked_album_service_test.dart | 2 +- .../domain/services/user_service_test.dart | 2 +- .../asset_viewer_system_ui_test.dart | 8 + .../asset_viewer_provider_test.dart | 2 +- mobile/test/repository.mocks.dart | 11 +- mobile/test/service.mocks.dart | 35 +++ mobile/test/services/action.service_test.dart | 3 - mobile/test/services/auth.service_test.dart | 9 +- .../unit/factories/local_asset_factory.dart | 3 +- .../unit/factories/remote_album_factory.dart | 38 +++ .../unit/factories/remote_asset_factory.dart | 9 +- mobile/test/unit/mocks.dart | 237 +++++++++++++++++- .../actions/favorite_action_test.dart | 2 +- .../actions/partner_action_test.dart | 2 +- .../presentation/presentation_context.dart | 11 +- .../unit/services/asset_service_test.dart | 79 ++++++ .../services/remote_album_service_test.dart | 48 ++++ mobile/test/unit/utils/asset_filter_test.dart | 2 +- 32 files changed, 700 insertions(+), 134 deletions(-) create mode 100644 mobile/lib/infrastructure/repositories/remote_exif.repository.dart delete mode 100644 mobile/test/domain/service.mock.dart create mode 100644 mobile/test/unit/factories/remote_album_factory.dart create mode 100644 mobile/test/unit/services/asset_service_test.dart create mode 100644 mobile/test/unit/services/remote_album_service_test.dart diff --git a/mobile/lib/domain/models/asset/base_asset.model.dart b/mobile/lib/domain/models/asset/base_asset.model.dart index 3397dbc6bd..ea6f0ab287 100644 --- a/mobile/lib/domain/models/asset/base_asset.model.dart +++ b/mobile/lib/domain/models/asset/base_asset.model.dart @@ -60,6 +60,7 @@ sealed class BaseAsset { bool get hasLocal => storage == AssetState.local || storage == AssetState.merged; bool get isLocalOnly => storage == AssetState.local; bool get isRemoteOnly => storage == AssetState.remote; + bool get isMerged => storage == .merged; // Same asset even if localId is known on one side but not the other (heroTag isn't stable then) bool refersToSameAsset(BaseAsset other) { diff --git a/mobile/lib/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index 40e681d47d..387a817eab 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -74,6 +74,8 @@ class RemoteAsset extends BaseAsset { bool get isArchived => visibility == .archive; + bool get isLocked => visibility == .locked; + @override String toString() { return '''Asset { diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index 9ba11fd6a7..f4c4a519d3 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -1,16 +1,26 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/utils/option.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; class AssetService { final RemoteAssetRepository _remoteRepository; + final RemoteExifRepository _exifRepository; final DriftLocalAssetRepository _localRepository; final AssetApiRepository _apiRepository; - const AssetService({required this._remoteRepository, required this._localRepository, required this._apiRepository}); + const AssetService({ + required this._remoteRepository, + required this._exifRepository, + required this._localRepository, + required this._apiRepository, + }); Future getAsset(BaseAsset asset) { final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id; @@ -26,10 +36,6 @@ class AssetService { return _localRepository.getByChecksum(checksum); } - Future getLocalAsset(String id) { - return _localRepository.get(id); - } - Future getRemoteAssetByChecksum(String checksum) { return _remoteRepository.getByChecksum(checksum); } @@ -73,6 +79,99 @@ class AssetService { return _localRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection); } + Future restoreTrash(List remoteIds) async { + if (remoteIds.isEmpty) { + return; + } + + await _apiRepository.restoreTrash(remoteIds); + await _remoteRepository.restoreTrash(remoteIds); + } + + Future stack(String userId, List remoteIds) async { + if (remoteIds.isEmpty) { + return; + } + + final stack = await _apiRepository.stack(remoteIds); + await _remoteRepository.stack(userId, stack); + } + + Future unstack(List stackIds) async { + if (stackIds.isEmpty) { + return; + } + + await _remoteRepository.unStack(stackIds); + await _apiRepository.unStack(stackIds); + } + + Future update( + List remoteIds, { + Option isFavorite = const .none(), + Option visibility = const .none(), + Option location = const .none(), + Option dateTime = const .none(), + }) async { + if (remoteIds.isEmpty) { + return; + } + + final parsedDateTime = dateTime.map((dt) => DateTime.parse(dt)); + final offset = RegExp(r'[+-]\d{2}:\d{2}$').firstMatch(dateTime.unwrapOrNull ?? '')?.group(0); + + await _apiRepository.update( + remoteIds, + isFavorite: isFavorite, + visibility: visibility, + location: location, + dateTimeOriginal: dateTime, + ); + await _remoteRepository.update( + remoteIds, + isFavorite: isFavorite, + visibility: visibility, + createdAt: parsedDateTime, + ); + await _exifRepository.update( + remoteIds, + location: location, + dateTimeOriginal: parsedDateTime, + timeZone: .fromNullable(offset).map((o) => 'UTC$o'), + ); + } + + Future trash(List remoteIds) async { + if (remoteIds.isEmpty) { + return; + } + + await _apiRepository.delete(remoteIds, false); + await _remoteRepository.trash(remoteIds); + } + + Future delete(List remoteIds) async { + if (remoteIds.isEmpty) { + return; + } + + await _apiRepository.delete(remoteIds, true); + await _remoteRepository.delete(remoteIds); + } + + Future applyEdits(String remoteId, List edits) async { + if (edits.isEmpty) { + await _apiRepository.removeEdits(remoteId); + } else { + await _apiRepository.editAsset(remoteId, edits); + } + } + + // TODO(shenlong): remove after action migration + Future getLocalAsset(String id) { + return _localRepository.get(id); + } + Future updateFavorite(List remoteIds, bool isFavorite) async { if (remoteIds.isEmpty) { return; diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index 5141bb455f..c0bbaa8127 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -341,4 +341,10 @@ class RemoteAlbumService { return sortedAlbums; } + + Future removeAssets({required String albumId, required List assetIds}) async { + final result = await _albumApiRepository.removeAssets(albumId, assetIds); + await _repository.removeAssets(albumId, result.removed); + return result.removed.length; + } } diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index db89bfc1fc..cdf8bfa15b 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -72,13 +72,7 @@ class RemoteAssetRepository extends DriftDatabaseRepository { } final query = _db.remoteAssetEntity.select() - ..where( - (row) => - row.stackId.equals(stackId) & - row.id.equals(asset.id).not() & - row.deletedAt.isNull() & - row.visibility.equalsValue(AssetVisibility.timeline), - ) + ..where((row) => row.stackId.equals(stackId) & row.id.equals(asset.id).not()) ..orderBy([(row) => OrderingTerm.desc(row.createdAt)]); return query.map((row) => row.toDto()).get(); @@ -189,38 +183,6 @@ class RemoteAssetRepository extends DriftDatabaseRepository { }); } - Future updateLocation(List ids, LatLng location) { - return _db.batch((batch) async { - for (final id in ids) { - batch.update( - _db.remoteExifEntity, - RemoteExifEntityCompanion(latitude: Value(location.latitude), longitude: Value(location.longitude)), - where: (e) => e.assetId.equals(id), - ); - } - }); - } - - Future updateDateTime(List ids, DateTime dateTime, {String? timeZone}) { - return _db.batch((batch) async { - for (final id in ids) { - batch.update( - _db.remoteExifEntity, - RemoteExifEntityCompanion( - dateTimeOriginal: Value(dateTime), - timeZone: timeZone == null ? const Value.absent() : Value(timeZone), - ), - where: (e) => e.assetId.equals(id), - ); - batch.update( - _db.remoteAssetEntity, - RemoteAssetEntityCompanion(createdAt: Value(dateTime)), - where: (e) => e.id.equals(id), - ); - } - }); - } - Future stack(String userId, StackResponse stack) { return _db.transaction(() async { final stackIds = await _db.managers.stackEntity @@ -298,10 +260,16 @@ class RemoteAssetRepository extends DriftDatabaseRepository { List remoteIds, { Option isFavorite = const .none(), Option visibility = const .none(), - }) { + Option createdAt = const .none(), + }) async { + if ([isFavorite, visibility, createdAt].every((option) => option.isNone)) { + return; + } + final companion = RemoteAssetEntityCompanion( visibility: visibility.toDriftValue(), isFavorite: isFavorite.toDriftValue(), + createdAt: createdAt.toDriftValue(), ); return _db.batch((batch) { for (final remoteId in remoteIds) { @@ -309,4 +277,37 @@ class RemoteAssetRepository extends DriftDatabaseRepository { } }); } + + // TODO(shenlong): remove after action migration + Future updateLocation(List ids, LatLng location) { + return _db.batch((batch) async { + for (final id in ids) { + batch.update( + _db.remoteExifEntity, + RemoteExifEntityCompanion(latitude: Value(location.latitude), longitude: Value(location.longitude)), + where: (e) => e.assetId.equals(id), + ); + } + }); + } + + Future updateDateTime(List ids, DateTime dateTime, {String? timeZone}) { + return _db.batch((batch) async { + for (final id in ids) { + batch.update( + _db.remoteExifEntity, + RemoteExifEntityCompanion( + dateTimeOriginal: Value(dateTime), + timeZone: timeZone == null ? const Value.absent() : Value(timeZone), + ), + where: (e) => e.assetId.equals(id), + ); + batch.update( + _db.remoteAssetEntity, + RemoteAssetEntityCompanion(createdAt: Value(dateTime)), + where: (e) => e.id.equals(id), + ); + } + }); + } } diff --git a/mobile/lib/infrastructure/repositories/remote_exif.repository.dart b/mobile/lib/infrastructure/repositories/remote_exif.repository.dart new file mode 100644 index 0000000000..568342b321 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/remote_exif.repository.dart @@ -0,0 +1,34 @@ +import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/utils/option.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +class RemoteExifRepository extends DriftDatabaseRepository { + final Drift _db; + + const RemoteExifRepository(this._db) : super(_db); + + Future update( + List ids, { + Option dateTimeOriginal = const .none(), + Option timeZone = const .none(), + Option location = const .none(), + }) async { + if ([dateTimeOriginal, timeZone, location].every((option) => option.isNone)) { + return; + } + + final companion = RemoteExifEntityCompanion( + dateTimeOriginal: dateTimeOriginal.toDriftValue(), + timeZone: timeZone.toDriftValue(), + latitude: location.map((loc) => loc.latitude).toDriftValue(), + longitude: location.map((loc) => loc.longitude).toDriftValue(), + ); + + return _db.batch((batch) { + for (final id in ids) { + batch.update(_db.remoteExifEntity, companion, where: (a) => a.assetId.equals(id)); + } + }); + } +} diff --git a/mobile/lib/presentation/actions/action.widget.dart b/mobile/lib/presentation/actions/action.widget.dart index 0f891abde6..eba5e3939c 100644 --- a/mobile/lib/presentation/actions/action.widget.dart +++ b/mobile/lib/presentation/actions/action.widget.dart @@ -23,7 +23,7 @@ class _ActionWidget extends ConsumerWidget { try { await action.onAction(scope); } catch (error, stackTrace) { - handleError(scope.context, error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}'); + handleError(scope.context, stack: stackTrace, description: 'Action failed: ${action.runtimeType}'); } } diff --git a/mobile/lib/providers/infrastructure/asset.provider.dart b/mobile/lib/providers/infrastructure/asset.provider.dart index 6326d003e5..fed55208e4 100644 --- a/mobile/lib/providers/infrastructure/asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset.provider.dart @@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -15,6 +16,8 @@ final remoteAssetRepositoryProvider = Provider( (ref) => RemoteAssetRepository(ref.watch(driftProvider)), ); +final remoteExifRepositoryProvider = Provider((ref) => RemoteExifRepository(ref.watch(driftProvider))); + final trashedLocalAssetRepository = Provider( (ref) => DriftTrashedLocalAssetRepository(ref.watch(driftProvider)), ); @@ -22,6 +25,7 @@ final trashedLocalAssetRepository = Provider( final assetServiceProvider = Provider( (ref) => AssetService( remoteRepository: ref.watch(remoteAssetRepositoryProvider), + exifRepository: ref.watch(remoteExifRepositoryProvider), localRepository: ref.watch(localAssetRepository), apiRepository: ref.watch(assetApiRepositoryProvider), ), diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index f6ab726de6..2024b75c6e 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -43,28 +43,11 @@ class AssetApiRepository extends ApiRepository { return response?.count ?? 0; } + // TODO(shenlong): remove after action migration Future updateVisibility(List ids, AssetVisibility visibility) async { return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility)))); } - Future updateFavorite(List ids, bool isFavorite) async { - return _api.updateAssets(AssetBulkUpdateDto(ids: ids, isFavorite: Optional.present(isFavorite))); - } - - Future updateLocation(List ids, LatLng location) async { - return _api.updateAssets( - AssetBulkUpdateDto( - ids: ids, - latitude: Optional.present(location.latitude), - longitude: Optional.present(location.longitude), - ), - ); - } - - Future updateDateTime(List ids, String dateTime) async { - return _api.updateAssets(AssetBulkUpdateDto(ids: ids, dateTimeOriginal: Optional.present(dateTime))); - } - Future stack(List ids) async { final responseDto = await checkNull(_stacksApi.createStack(StackCreateDto(assetIds: ids))); @@ -113,15 +96,39 @@ class AssetApiRepository extends ApiRepository { List remoteIds, { Option isFavorite = const .none(), Option visibility = const .none(), + Option dateTimeOriginal = const .none(), + Option location = const .none(), }) { return _api.updateAssets( AssetBulkUpdateDto( ids: remoteIds, isFavorite: isFavorite.toOptional(), visibility: visibility.map(_mapVisibility).toOptional(), + dateTimeOriginal: dateTimeOriginal.toOptional(), + latitude: location.map((loc) => loc.latitude).toOptional(), + longitude: location.map((loc) => loc.longitude).toOptional(), ), ); } + + // TODO(shenlong): remove after action migration + Future updateFavorite(List ids, bool isFavorite) async { + return _api.updateAssets(AssetBulkUpdateDto(ids: ids, isFavorite: Optional.present(isFavorite))); + } + + Future updateLocation(List ids, LatLng location) async { + return _api.updateAssets( + AssetBulkUpdateDto( + ids: ids, + latitude: Optional.present(location.latitude), + longitude: Optional.present(location.longitude), + ), + ); + } + + Future updateDateTime(List ids, String dateTime) async { + return _api.updateAssets(AssetBulkUpdateDto(ids: ids, dateTimeOriginal: Optional.present(dateTime))); + } } extension on StackResponseDto { diff --git a/mobile/lib/utils/asset_filter.dart b/mobile/lib/utils/asset_filter.dart index 34879fa778..a4de5d2761 100644 --- a/mobile/lib/utils/asset_filter.dart +++ b/mobile/lib/utils/asset_filter.dart @@ -15,8 +15,10 @@ extension type const AssetFilter(Iterable assets) implem remote().where((asset) => asset.visibility != visibility); AssetFilter archived({bool isArchived = true}) => remote().where((asset) => asset.isArchived == isArchived); + AssetFilter locked({bool isLocked = true}) => remote().where((asset) => asset.isLocked == isLocked); AssetFilter stacked({bool isStacked = true}) => remote().where((asset) => asset.isStacked == isStacked); + AssetFilter trashed({bool isTrashed = true}) => remote().where((asset) => asset.isTrashed == isTrashed); AssetFilter local() => AssetFilter(assets.whereType()); - AssetFilter backedUp() => local().where((asset) => asset.remoteAssetId != null); + AssetFilter backedUp({bool isBackedUp = true}) => where((asset) => asset.isMerged == isBackedUp); } diff --git a/mobile/lib/utils/error_handler.dart b/mobile/lib/utils/error_handler.dart index 24387b8002..d82e480575 100644 --- a/mobile/lib/utils/error_handler.dart +++ b/mobile/lib/utils/error_handler.dart @@ -8,7 +8,7 @@ import 'package:openapi/api.dart'; // ignore: depend_on_referenced_packages import 'package:stack_trace/stack_trace.dart'; -void handleError(BuildContext context, Object error, {StackTrace? stack, String? description}) { +void handleError(Object error, {StackTrace? stack, String? description}) { String? stackTrace; if (stack != null) { final trace = Trace.from(stack); @@ -23,17 +23,13 @@ void handleError(BuildContext context, Object error, {StackTrace? stack, String? () => 'Error${description != null ? ' ($description)' : ''}: $error${stackTrace != null ? '\n$stackTrace' : ''}', ); - if (!context.mounted) { - return; - } - final String message; if (serverErrorMessage(error) case String serverMessage) { message = serverMessage; } else if (isConnectionError(error)) { - message = context.t.login_form_server_error; + message = StaticTranslations.instance.login_form_server_error; } else { - message = context.t.scaffold_body_error_occurred; + message = StaticTranslations.instance.scaffold_body_error_occurred; } snackbar.error(message); diff --git a/mobile/lib/utils/option.dart b/mobile/lib/utils/option.dart index 93e939a6c0..e88ae59c1f 100644 --- a/mobile/lib/utils/option.dart +++ b/mobile/lib/utils/option.dart @@ -32,17 +32,6 @@ sealed class Option { None() => onNone(), }; - Option flatMap(Option Function(T value) f) => switch (this) { - Some(:final value) => f(value), - None() => const Option.none(), - }; - - void ifPresent(void Function(T value) f) { - if (this case Some(:final value)) { - f(value); - } - } - @override String toString() => switch (this) { Some(:final value) => 'Some($value)', diff --git a/mobile/test/domain/service.mock.dart b/mobile/test/domain/service.mock.dart deleted file mode 100644 index 70f706f7fe..0000000000 --- a/mobile/test/domain/service.mock.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:immich_mobile/domain/services/asset.service.dart'; -import 'package:immich_mobile/domain/services/partner.service.dart'; -import 'package:immich_mobile/domain/services/store.service.dart'; -import 'package:immich_mobile/domain/services/user.service.dart'; -import 'package:immich_mobile/domain/utils/background_sync.dart'; -import 'package:immich_mobile/platform/native_sync_api.g.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:mocktail/mocktail.dart'; - -class MockStoreService extends Mock implements StoreService {} - -class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {} - -class MockNativeSyncApi extends Mock implements NativeSyncApi {} - -class MockAppSettingsService extends Mock implements AppSettingsService {} - -class MockPartnerService extends Mock implements PartnerService {} - -class MockAssetService extends Mock implements AssetService {} - -class MockUserService extends Mock implements UserService {} diff --git a/mobile/test/domain/services/local_sync_service_test.dart b/mobile/test/domain/services/local_sync_service_test.dart index e0e6b663a7..14277709da 100644 --- a/mobile/test/domain/services/local_sync_service_test.dart +++ b/mobile/test/domain/services/local_sync_service_test.dart @@ -16,10 +16,10 @@ import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:mocktail/mocktail.dart'; -import '../../domain/service.mock.dart'; import '../../fixtures/asset.stub.dart'; import '../../infrastructure/repository.mock.dart'; import '../../repository.mocks.dart'; +import '../../service.mocks.dart'; void main() { late LocalSyncService sut; diff --git a/mobile/test/domain/services/sync_linked_album_service_test.dart b/mobile/test/domain/services/sync_linked_album_service_test.dart index d4fc1396c5..e5f4c3b5dc 100644 --- a/mobile/test/domain/services/sync_linked_album_service_test.dart +++ b/mobile/test/domain/services/sync_linked_album_service_test.dart @@ -6,7 +6,7 @@ import 'package:immich_mobile/providers/infrastructure/store.provider.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import '../../infrastructure/repository.mock.dart'; -import '../service.mock.dart'; +import '../../service.mocks.dart'; void main() { // A container with the service's deps overridden but cancellationProvider left diff --git a/mobile/test/domain/services/user_service_test.dart b/mobile/test/domain/services/user_service_test.dart index 80b6d80457..7b46a44339 100644 --- a/mobile/test/domain/services/user_service_test.dart +++ b/mobile/test/domain/services/user_service_test.dart @@ -9,7 +9,7 @@ import 'package:mocktail/mocktail.dart'; import '../../fixtures/user.stub.dart'; import '../../infrastructure/repository.mock.dart'; -import '../service.mock.dart'; +import '../../service.mocks.dart'; void main() { late UserService sut; diff --git a/mobile/test/presentation/widgets/asset_viewer/asset_viewer_system_ui_test.dart b/mobile/test/presentation/widgets/asset_viewer/asset_viewer_system_ui_test.dart index c9a01598f0..14d876a654 100644 --- a/mobile/test/presentation/widgets/asset_viewer/asset_viewer_system_ui_test.dart +++ b/mobile/test/presentation/widgets/asset_viewer/asset_viewer_system_ui_test.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart' import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import '../../../fixtures/asset.stub.dart'; +import '../../../unit/presentation/presentation_context.dart'; class _SeededAssetViewerNotifier extends AssetViewerStateNotifier { @override @@ -32,6 +33,12 @@ TimelineService _stubTimelineService() { } void main() { + late PresentationContext context; + + setUp(() async { + context = await PresentationContext.create(); + }); + testWidgets('status bar icons are light while the asset viewer is open in light mode', (tester) async { // Emulate arriving from a light-themed page whose AppBar set dark status // bar icons (the state the viewer is opened from in light mode). @@ -48,6 +55,7 @@ void main() { assetLoader: const CodegenLoader(), child: ProviderScope( overrides: [ + ...context.overrides, timelineServiceProvider.overrideWithValue(_stubTimelineService()), assetViewerProvider.overrideWith(_SeededAssetViewerNotifier.new), ], diff --git a/mobile/test/providers/asset_viewer/asset_viewer_provider_test.dart b/mobile/test/providers/asset_viewer/asset_viewer_provider_test.dart index 67eb1dd9d1..27033a5774 100644 --- a/mobile/test/providers/asset_viewer/asset_viewer_provider_test.dart +++ b/mobile/test/providers/asset_viewer/asset_viewer_provider_test.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart' import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:mocktail/mocktail.dart'; -import '../../domain/service.mock.dart'; +import '../../service.mocks.dart'; import '../../unit/factories/remote_asset_factory.dart'; void main() { diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index 80786420fd..82c9395b58 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -1,9 +1,12 @@ +import 'package:immich_mobile/domain/services/tag.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/auth.repository.dart'; import 'package:immich_mobile/repositories/auth_api.repository.dart'; -import 'package:immich_mobile/domain/services/tag.service.dart'; +import 'package:immich_mobile/repositories/download.repository.dart'; import 'package:immich_mobile/repositories/permission.repository.dart'; +import 'package:immich_mobile/repositories/toast.repository.dart'; import 'package:mocktail/mocktail.dart'; class MockAssetApiRepository extends Mock implements AssetApiRepository {} @@ -17,3 +20,9 @@ class MockAuthApiRepository extends Mock implements AuthApiRepository {} class MockAuthRepository extends Mock implements AuthRepository {} class MockTagService extends Mock implements TagService {} + +class MockDownloadRepository extends Mock implements DownloadRepository {} + +class MockRemoteExifRepository extends Mock implements RemoteExifRepository {} + +class MockToastRepository extends Mock implements ToastRepository {} diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart index 4591dd845d..300c54dcbb 100644 --- a/mobile/test/service.mocks.dart +++ b/mobile/test/service.mocks.dart @@ -1,6 +1,17 @@ +import 'package:immich_mobile/domain/services/asset.service.dart'; +import 'package:immich_mobile/domain/services/partner.service.dart'; +import 'package:immich_mobile/domain/services/remote_album.service.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/domain/services/user.service.dart'; +import 'package:immich_mobile/domain/utils/background_sync.dart'; +import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/services/cleanup.service.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; +import 'package:immich_mobile/services/gcast.service.dart'; import 'package:immich_mobile/services/network.service.dart'; +import 'package:immich_mobile/services/server_info.service.dart'; import 'package:mocktail/mocktail.dart'; class MockApiService extends Mock implements ApiService {} @@ -8,3 +19,27 @@ class MockApiService extends Mock implements ApiService {} class MockNetworkService extends Mock implements NetworkService {} class MockAppSettingService extends Mock implements AppSettingsService {} + +class MockStoreService extends Mock implements StoreService {} + +class MockNativeSyncApi extends Mock implements NativeSyncApi {} + +class MockAppSettingsService extends Mock implements AppSettingsService {} + +class MockPartnerService extends Mock implements PartnerService {} + +class MockAssetService extends Mock implements AssetService {} + +class MockUserService extends Mock implements UserService {} + +class MockRemoteAlbumService extends Mock implements RemoteAlbumService {} + +class MockGCastService extends Mock implements GCastService {} + +class MockForegroundUploadService extends Mock implements ForegroundUploadService {} + +class MockServerInfoService extends Mock implements ServerInfoService {} + +class MockCleanupService extends Mock implements CleanupService {} + +class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {} diff --git a/mobile/test/services/action.service_test.dart b/mobile/test/services/action.service_test.dart index ef5ece38e9..72691a3802 100644 --- a/mobile/test/services/action.service_test.dart +++ b/mobile/test/services/action.service_test.dart @@ -7,15 +7,12 @@ 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; diff --git a/mobile/test/services/auth.service_test.dart b/mobile/test/services/auth.service_test.dart index 584ea57027..d71a52f2ae 100644 --- a/mobile/test/services/auth.service_test.dart +++ b/mobile/test/services/auth.service_test.dart @@ -10,7 +10,6 @@ import 'package:immich_mobile/services/auth.service.dart'; import 'package:mocktail/mocktail.dart'; import 'package:openapi/api.dart'; -import '../domain/service.mock.dart'; import '../repository.mocks.dart'; import '../service.mocks.dart'; @@ -29,13 +28,7 @@ void main() { apiService = MockApiService(); networkService = MockNetworkService(); backgroundSyncManager = MockBackgroundSyncManager(); - sut = AuthService( - authApiRepository, - authRepository, - apiService, - networkService, - backgroundSyncManager, - ); + sut = AuthService(authApiRepository, authRepository, apiService, networkService, backgroundSyncManager); registerFallbackValue(Uri()); }); diff --git a/mobile/test/unit/factories/local_asset_factory.dart b/mobile/test/unit/factories/local_asset_factory.dart index 2f4391813f..bebe24e5b9 100644 --- a/mobile/test/unit/factories/local_asset_factory.dart +++ b/mobile/test/unit/factories/local_asset_factory.dart @@ -5,12 +5,13 @@ import '../../utils.dart'; class LocalAssetFactory { const LocalAssetFactory(); - static LocalAsset create({String? id, String? name}) { + static LocalAsset create({String? id, String? name, String? remoteId}) { id = TestUtils.uuid(id); return LocalAsset( id: id, name: name ?? 'local_$id.jpg', + remoteId: remoteId, type: AssetType.image, createdAt: TestUtils.yesterday(), updatedAt: TestUtils.now(), diff --git a/mobile/test/unit/factories/remote_album_factory.dart b/mobile/test/unit/factories/remote_album_factory.dart new file mode 100644 index 0000000000..bffe62b651 --- /dev/null +++ b/mobile/test/unit/factories/remote_album_factory.dart @@ -0,0 +1,38 @@ +import 'package:immich_mobile/domain/models/album/album.model.dart'; + +import '../../utils.dart'; + +class RemoteAlbumFactory { + const RemoteAlbumFactory(); + + static RemoteAlbum create({ + String? id, + String? name, + String? ownerId, + String? description, + DateTime? createdAt, + DateTime? updatedAt, + String? thumbnailAssetId, + bool isActivityEnabled = false, + AlbumAssetOrder order = AlbumAssetOrder.desc, + int assetCount = 0, + String? ownerName, + bool isShared = false, + }) { + id = TestUtils.uuid(id); + return RemoteAlbum( + id: id, + name: name ?? 'remote_album_$id', + ownerId: TestUtils.uuid(ownerId), + description: description ?? '', + createdAt: TestUtils.date(createdAt), + updatedAt: TestUtils.date(updatedAt), + thumbnailAssetId: thumbnailAssetId, + isActivityEnabled: isActivityEnabled, + order: order, + assetCount: assetCount, + ownerName: ownerName ?? 'owner_$id', + isShared: isShared, + ); + } +} diff --git a/mobile/test/unit/factories/remote_asset_factory.dart b/mobile/test/unit/factories/remote_asset_factory.dart index 3ab76ae15e..0876b9d793 100644 --- a/mobile/test/unit/factories/remote_asset_factory.dart +++ b/mobile/test/unit/factories/remote_asset_factory.dart @@ -10,8 +10,11 @@ class RemoteAssetFactory { String? name, String? ownerId, bool isFavorite = false, - AssetVisibility visibility = AssetVisibility.timeline, + AssetVisibility visibility = .timeline, + AssetType type = .image, String? stackId, + DateTime? deletedAt, + String? localId, }) { id = TestUtils.uuid(id); @@ -20,13 +23,15 @@ class RemoteAssetFactory { name: name ?? 'remote_$id.jpg', ownerId: TestUtils.uuid(ownerId), checksum: 'checksum-$id', - type: .image, + type: type, createdAt: TestUtils.yesterday(), updatedAt: TestUtils.now(), isFavorite: isFavorite, visibility: visibility, stackId: stackId, isEdited: false, + deletedAt: deletedAt, + localId: localId, ); } } diff --git a/mobile/test/unit/mocks.dart b/mobile/test/unit/mocks.dart index 06993f854d..d8eadda7ae 100644 --- a/mobile/test/unit/mocks.dart +++ b/mobile/test/unit/mocks.dart @@ -1,25 +1,43 @@ import 'dart:typed_data'; +import 'package:flutter/widgets.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/domain/models/tag.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; +import 'package:immich_mobile/utils/option.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:mocktail/mocktail.dart' as mock; import 'package:mocktail/mocktail.dart'; -import '../domain/service.mock.dart'; import '../infrastructure/repository.mock.dart'; +import '../repository.mocks.dart'; +import '../service.mocks.dart'; import 'factories/local_album_factory.dart'; import 'factories/local_asset_factory.dart'; +import 'factories/remote_album_factory.dart'; import 'factories/user_factory.dart'; class RepositoryMocks { final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository()); final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository()); + final remoteAsset = RemoteAssetRepositoryStub(MockRemoteAssetRepository()); + final remoteExif = RemoteExifRepositoryStub(MockRemoteExifRepository()); final trashedAsset = MockTrashedLocalAssetRepository(); + final toast = MockToastRepository(); + final remoteAlbum = MockRemoteAlbumRepository(); + final albumApi = MockDriftAlbumApiRepository(); final nativeApi = NativeSyncApiStub(MockNativeSyncApi()); + final assetApi = AssetApiRepositoryStub(MockAssetApiRepository()); + final assetMedia = AssetMediaRepositoryStub(MockAssetMediaRepository()); + final download = DownloadRepositoryStub(MockDownloadRepository()); RepositoryMocks() { resetAll(); @@ -29,11 +47,34 @@ class RepositoryMocks { _registerFallbacks(); localAlbum.reset(); localAsset.reset(); + remoteAsset.reset(); + remoteExif.reset(); reset(trashedAsset); + reset(remoteAlbum); + reset(albumApi); nativeApi.reset(); + assetApi.reset(); + assetMedia.reset(); + download.reset(); + reset(toast); _stubLocalAlbumRepository(); _stubLocalAssetRepository(); + _stubRemoteAssetRepository(); + _stubRemoteExifRepository(); _stubNativeSyncApi(); + _stubAssetApiRepository(); + _stubAssetMediaRepository(); + _stubDownloadRepository(); + } + + void _stubRemoteAssetRepository() { + when(remoteAsset.getExif).thenAnswer((_) async => null); + when(remoteAsset.getAssetEdits).thenAnswer((_) async => const []); + when(remoteAsset.update).thenAnswer((_) async {}); + } + + void _stubRemoteExifRepository() { + when(remoteExif.update).thenAnswer((_) async {}); } void _stubLocalAlbumRepository() { @@ -49,12 +90,31 @@ class RepositoryMocks { void _stubNativeSyncApi() { when(nativeApi.hashAssets).thenAnswer((_) async => []); } + + void _stubAssetApiRepository() { + when(assetApi.update).thenAnswer((_) async => {}); + } + + void _stubAssetMediaRepository() { + when(assetMedia.shareAssets).thenAnswer((_) async => 1); + } + + void _stubDownloadRepository() { + when(download.downloadAllAssets).thenAnswer((_) async => const []); + } } class ServiceMocks { final partner = PartnerServiceStub(MockPartnerService()); final user = UserServiceStub(MockUserService()); final asset = AssetServiceStub(MockAssetService()); + final album = RemoteAlbumServiceStub(MockRemoteAlbumService()); + final cleanup = CleanupServiceStub(MockCleanupService()); + final tag = TagServiceStub(MockTagService()); + final backgroundSync = MockBackgroundSyncManager(); + final upload = MockForegroundUploadService(); + final cast = MockGCastService(); + final serverInfo = MockServerInfoService(); ServiceMocks() { resetAll(); @@ -65,9 +125,21 @@ class ServiceMocks { partner.reset(); user.reset(); asset.reset(); + album.reset(); + cleanup.reset(); + tag.reset(); + reset(cast); + reset(serverInfo); + reset(backgroundSync); + reset(upload); _stubUserService(); _stubPartnerService(); _stubAssetService(); + _stubRemoteAlbumService(); + _stubCleanupService(); + _stubTagService(); + _stubBackgroundSync(); + _stubForegroundUpload(); } void _stubUserService() { @@ -88,7 +160,43 @@ class ServiceMocks { } void _stubAssetService() { - when(asset.updateFavorite).thenAnswer((_) async {}); + when(asset.update).thenAnswer((_) async {}); + when(asset.stack).thenAnswer((_) async {}); + when(asset.unstack).thenAnswer((_) async {}); + when(asset.restoreTrash).thenAnswer((_) async {}); + when(asset.trash).thenAnswer((_) async {}); + when(asset.delete).thenAnswer((_) async {}); + when(asset.applyEdits).thenAnswer((_) async {}); + } + + void _stubRemoteAlbumService() { + when(album.removeAssets).thenAnswer((_) async => 0); + when(album.updateAlbum).thenAnswer((_) async => RemoteAlbumFactory.create()); + } + + void _stubCleanupService() { + when(cleanup.deleteLocalAssets).thenAnswer((_) async => 0); + } + + void _stubTagService() { + when(tag.bulkTagAssets).thenAnswer((_) async => 0); + when(tag.upsertTags).thenAnswer((_) async => const []); + when(tag.getAllTags).thenAnswer((_) async => const {}); + } + + void _stubBackgroundSync() { + when(() => backgroundSync.syncLocal()).thenAnswer((_) async {}); + when(() => backgroundSync.hashAssets()).thenAnswer((_) async {}); + } + + void _stubForegroundUpload() { + when( + () => upload.uploadManual( + any(), + cancelToken: any(named: 'cancelToken'), + callbacks: any(named: 'callbacks'), + ), + ).thenAnswer((_) async {}); } } @@ -96,8 +204,24 @@ void _registerFallbacks() { registerFallbackValue(LocalAlbumFactory.create()); registerFallbackValue(LocalAssetFactory.create()); registerFallbackValue(Uint8List(0)); + registerFallbackValue(AssetVisibility.timeline); + registerFallbackValue(const LatLng(0, 0)); + registerFallbackValue([]); + registerFallbackValue(const Option.none()); + registerFallbackValue(const Option.none()); + registerFallbackValue(const Option.none()); + registerFallbackValue(const Option.none()); + registerFallbackValue(const Option.none()); + registerFallbackValue([]); + registerFallbackValue([]); + registerFallbackValue([]); + registerFallbackValue(ShareAssetType.original); + registerFallbackValue(const UploadCallbacks()); + registerFallbackValue(_FakeBuildContext()); } +class _FakeBuildContext extends Fake implements BuildContext {} + extension type const Stub(T mockedClass) { void reset() => mock.reset(mockedClass); } @@ -119,6 +243,33 @@ extension type const LocalAssetRepositoryStub(MockDriftLocalAssetRepository repo () => repo.updateHashes(any()); } +extension type const RemoteAssetRepositoryStub(MockRemoteAssetRepository repo) + implements Stub { + Future Function() get getExif => + () => repo.getExif(any()); + + Future> Function() get getAssetEdits => + () => repo.getAssetEdits(any()); + + Future Function() get update => + () => repo.update( + any(), + isFavorite: any(named: 'isFavorite'), + visibility: any(named: 'visibility'), + createdAt: any(named: 'createdAt'), + ); +} + +extension type const RemoteExifRepositoryStub(MockRemoteExifRepository repo) implements Stub { + Future Function() get update => + () => repo.update( + any(), + dateTimeOriginal: any(named: 'dateTimeOriginal'), + timeZone: any(named: 'timeZone'), + location: any(named: 'location'), + ); +} + extension type const PartnerServiceStub(MockPartnerService service) implements Stub { Stream> Function() get getCandidates => () => service.getCandidates(any()); @@ -165,11 +316,89 @@ extension type const UserServiceStub(MockUserService service) implements Stub { - Future Function() get updateFavorite => - () => service.updateFavorite(any(), any()); + Future Function() get update => + () => service.update( + any(), + isFavorite: any(named: 'isFavorite'), + visibility: any(named: 'visibility'), + dateTime: any(named: 'dateTime'), + location: any(named: 'location'), + ); + + Future Function() get stack => + () => service.stack(any(), any()); + + Future Function() get unstack => + () => service.unstack(any()); + + Future Function() get restoreTrash => + () => service.restoreTrash(any()); + + Future Function() get trash => + () => service.trash(any()); + + Future Function() get delete => + () => service.delete(any()); + + Future Function() get applyEdits => + () => service.applyEdits(any(), any()); +} + +extension type const RemoteAlbumServiceStub(MockRemoteAlbumService service) implements Stub { + Future Function() get removeAssets => + () => service.removeAssets( + albumId: any(named: 'albumId'), + assetIds: any(named: 'assetIds'), + ); + + Future Function() get updateAlbum => + () => service.updateAlbum(any(), thumbnailAssetId: any(named: 'thumbnailAssetId')); +} + +extension type const CleanupServiceStub(MockCleanupService service) implements Stub { + Future Function() get deleteLocalAssets => + () => service.deleteLocalAssets(any()); } extension type const NativeSyncApiStub(MockNativeSyncApi api) implements Stub { Future> Function() get hashAssets => () => api.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')); } + +extension type const AssetApiRepositoryStub(MockAssetApiRepository api) implements Stub { + Future Function() get update => + () => api.update( + any(), + isFavorite: any(named: 'isFavorite'), + visibility: any(named: 'visibility'), + dateTimeOriginal: any(named: 'dateTimeOriginal'), + location: any(named: 'location'), + ); +} + +extension type const AssetMediaRepositoryStub(MockAssetMediaRepository api) implements Stub { + Future Function() get shareAssets => + () => api.shareAssets( + any(), + any(), + fileType: any(named: 'fileType'), + cancelCompleter: any(named: 'cancelCompleter'), + onAssetDownloadProgress: any(named: 'onAssetDownloadProgress'), + ); +} + +extension type const DownloadRepositoryStub(MockDownloadRepository repo) implements Stub { + Future> Function() get downloadAllAssets => + () => repo.downloadAllAssets(any()); +} + +extension type const TagServiceStub(MockTagService service) implements Stub { + Future Function() get bulkTagAssets => + () => service.bulkTagAssets(any(), any()); + + Future> Function() get upsertTags => + () => service.upsertTags(any()); + + Future> Function() get getAllTags => + () => service.getAllTags(); +} diff --git a/mobile/test/unit/presentation/actions/favorite_action_test.dart b/mobile/test/unit/presentation/actions/favorite_action_test.dart index cd0fc19ea2..722d9d1dc7 100644 --- a/mobile/test/unit/presentation/actions/favorite_action_test.dart +++ b/mobile/test/unit/presentation/actions/favorite_action_test.dart @@ -4,7 +4,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/presentation/actions/favorite.action.dart'; import 'package:mocktail/mocktail.dart'; -import '../../../domain/service.mock.dart'; +import '../../../service.mocks.dart'; import '../../factories/remote_asset_factory.dart'; import '../presentation_context.dart'; diff --git a/mobile/test/unit/presentation/actions/partner_action_test.dart b/mobile/test/unit/presentation/actions/partner_action_test.dart index 6c09337fa2..a1d645ffec 100644 --- a/mobile/test/unit/presentation/actions/partner_action_test.dart +++ b/mobile/test/unit/presentation/actions/partner_action_test.dart @@ -6,7 +6,7 @@ import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/presentation/actions/partner.action.dart'; import 'package:mocktail/mocktail.dart'; -import '../../../domain/service.mock.dart'; +import '../../../service.mocks.dart'; import '../../factories/user_factory.dart'; import '../presentation_context.dart'; diff --git a/mobile/test/unit/presentation/presentation_context.dart b/mobile/test/unit/presentation/presentation_context.dart index 585cb3707e..25de583049 100644 --- a/mobile/test/unit/presentation/presentation_context.dart +++ b/mobile/test/unit/presentation/presentation_context.dart @@ -15,7 +15,10 @@ import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/services/gcast.service.dart'; +import 'package:immich_mobile/services/server_info.service.dart'; import 'package:immich_ui/immich_ui.dart'; import 'package:mocktail/mocktail.dart'; @@ -29,6 +32,7 @@ class PresentationContext { service = ServiceMocks(), repository = RepositoryMocks() { setup(); + addTearDown(dispose); } static const String serverEndpoint = 'http://localhost:3000'; @@ -43,6 +47,9 @@ class PresentationContext { currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)), assetServiceProvider.overrideWithValue(service.asset.service), partnerServiceProvider.overrideWithValue(service.partner.service), + gCastServiceProvider.overrideWithValue(service.cast), + serverInfoServiceProvider.overrideWithValue(service.serverInfo), + inLockedViewProvider.overrideWithValue(false), ]; static Future create() async { @@ -61,9 +68,7 @@ class PresentationContext { } void dispose() { - addTearDown(() { - service.resetAll(); - }); + service.resetAll(); } } diff --git a/mobile/test/unit/services/asset_service_test.dart b/mobile/test/unit/services/asset_service_test.dart new file mode 100644 index 0000000000..5e473d8f66 --- /dev/null +++ b/mobile/test/unit/services/asset_service_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/services/asset.service.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../infrastructure/repository.mock.dart'; +import '../../repository.mocks.dart'; +import '../mocks.dart'; + +void main() { + late AssetService sut; + late RepositoryMocks mocks; + late MockAssetApiRepository apiRepository; + late MockRemoteAssetRepository remoteRepository; + late MockRemoteExifRepository exifRepository; + + setUp(() { + mocks = RepositoryMocks(); + apiRepository = mocks.assetApi.api; + remoteRepository = mocks.remoteAsset.repo; + exifRepository = mocks.remoteExif.repo; + + sut = AssetService( + remoteRepository: remoteRepository, + exifRepository: exifRepository, + localRepository: MockDriftLocalAssetRepository(), + apiRepository: apiRepository, + ); + }); + + group('AssetService.updateDateTime', () { + const ids = ['asset_id_1']; + + test('sends the picked value to the api with its offset intact', () async { + const picked = '2026-06-10T19:15:00.000+06:00'; + await sut.update(ids, dateTime: const .some(picked)); + + verify(() => apiRepository.update(ids, dateTimeOriginal: const .some(picked))).called(1); + verify(() => remoteRepository.update(ids, createdAt: .some(DateTime.parse(picked)))).called(1); + verify( + () => exifRepository.update( + ids, + dateTimeOriginal: .some(DateTime.parse(picked)), + timeZone: const .some('UTC+06:00'), + ), + ).called(1); + }); + + test('handles negative offsets', () async { + const picked = '2026-01-05T08:00:00.000-05:30'; + await sut.update(ids, dateTime: const .some(picked)); + + verify(() => remoteRepository.update(ids, createdAt: .some(DateTime.parse(picked)))).called(1); + verify( + () => exifRepository.update( + ids, + dateTimeOriginal: .some(DateTime.parse(picked)), + timeZone: const .some('UTC-05:30'), + ), + ).called(1); + }); + + test('writes no timezone when the value has no offset', () async { + const picked = '2026-06-10T13:15:00.000Z'; + await sut.update(ids, dateTime: const .some(picked)); + + verify(() => remoteRepository.update(ids, createdAt: .some(DateTime.parse(picked)))).called(1); + verify( + () => exifRepository.update(ids, dateTimeOriginal: .some(DateTime.parse(picked)), timeZone: const .none()), + ).called(1); + }); + + test('is a no-op when there are no asset ids', () async { + await sut.update(const [], dateTime: const .some('2026-06-10T19:15:00.000+06:00')); + + verifyZeroInteractions(apiRepository); + verifyZeroInteractions(remoteRepository); + }); + }); +} diff --git a/mobile/test/unit/services/remote_album_service_test.dart b/mobile/test/unit/services/remote_album_service_test.dart new file mode 100644 index 0000000000..9417f14653 --- /dev/null +++ b/mobile/test/unit/services/remote_album_service_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/services/remote_album.service.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../service.mocks.dart'; +import '../mocks.dart'; + +void main() { + late RemoteAlbumService sut; + final mocks = RepositoryMocks(); + + setUpAll(() { + registerFallbackValue([]); + }); + + setUp(() { + sut = RemoteAlbumService(mocks.remoteAlbum, mocks.albumApi, MockForegroundUploadService()); + }); + + tearDown(() { + mocks.resetAll(); + }); + + group('RemoteAlbumService', () { + group('removeAssets', () { + test('persists only the assets the server actually removed, not the whole request', () async { + const albumId = 'album-1'; + const requested = ['asset-1', 'asset-2', 'asset-3']; + const removed = ['asset-1', 'asset-3']; + + // The server rejected 'asset-2' + when( + () => mocks.albumApi.removeAssets(albumId, requested), + ).thenAnswer((_) async => (removed: removed, failed: ['asset-2'])); + when(() => mocks.remoteAlbum.removeAssets(albumId, any())).thenAnswer((_) async {}); + + final count = await sut.removeAssets(albumId: albumId, assetIds: requested); + + final persisted = + verify(() => mocks.remoteAlbum.removeAssets(albumId, captureAny())).captured.single as List; + expect(persisted, removed); + expect(persisted, isNot(contains('asset-2'))); + + expect(count, removed.length); + }); + }); + }); +} diff --git a/mobile/test/unit/utils/asset_filter_test.dart b/mobile/test/unit/utils/asset_filter_test.dart index ceb28ba932..1db287b346 100644 --- a/mobile/test/unit/utils/asset_filter_test.dart +++ b/mobile/test/unit/utils/asset_filter_test.dart @@ -52,7 +52,7 @@ void main() { final offlinePhoto = LocalAssetFactory.create(); final remotePhoto = RemoteAssetFactory.create(); - final AssetFilter syncedPhotos = AssetFilter([ + final AssetFilter syncedPhotos = AssetFilter([ syncedPhoto, offlinePhoto, remotePhoto,