From 56eb69b32869ef8e0901c218f6d3b8ff5c055ff4 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:08:00 +0530 Subject: [PATCH] chore: cleanup unit tests (#29357) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/test/unit/mocks.dart | 70 ++++++++++++--- .../actions/asset_debug_action_test.dart | 8 +- .../actions/favorite_action_test.dart | 34 ++++---- .../actions/partner_action_test.dart | 30 ++++--- .../actions/timeline_action_test.dart | 10 +-- .../unit/presentation/partner_page_test.dart | 20 +++-- .../presentation_context.dart | 38 +++++--- .../test/unit/services/hash_service_test.dart | 86 +++++++++---------- 8 files changed, 172 insertions(+), 124 deletions(-) rename mobile/test/unit/{ => presentation}/presentation_context.dart (70%) diff --git a/mobile/test/unit/mocks.dart b/mobile/test/unit/mocks.dart index 49bab00704..06993f854d 100644 --- a/mobile/test/unit/mocks.dart +++ b/mobile/test/unit/mocks.dart @@ -1,7 +1,10 @@ import 'dart:typed_data'; import 'package:immich_mobile/constants/enums.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/user.model.dart'; +import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:mocktail/mocktail.dart' as mock; import 'package:mocktail/mocktail.dart'; @@ -12,11 +15,11 @@ import 'factories/local_asset_factory.dart'; import 'factories/user_factory.dart'; class RepositoryMocks { - final localAlbum = MockLocalAlbumRepository(); - final localAsset = MockDriftLocalAssetRepository(); + final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository()); + final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository()); final trashedAsset = MockTrashedLocalAssetRepository(); - final nativeApi = MockNativeSyncApi(); + final nativeApi = NativeSyncApiStub(MockNativeSyncApi()); RepositoryMocks() { resetAll(); @@ -24,17 +27,34 @@ class RepositoryMocks { void resetAll() { _registerFallbacks(); - reset(localAlbum); - reset(localAsset); + localAlbum.reset(); + localAsset.reset(); reset(trashedAsset); - reset(nativeApi); + nativeApi.reset(); + _stubLocalAlbumRepository(); + _stubLocalAssetRepository(); + _stubNativeSyncApi(); + } + + void _stubLocalAlbumRepository() { + when(localAlbum.getBackupAlbums).thenAnswer((_) async => []); + when(localAlbum.getAssetsToHash).thenAnswer((_) async => []); + } + + void _stubLocalAssetRepository() { + when(localAsset.reconcileHashesFromCloudId).thenAnswer((_) async => {}); + when(localAsset.updateHashes).thenAnswer((_) async => {}); + } + + void _stubNativeSyncApi() { + when(nativeApi.hashAssets).thenAnswer((_) async => []); } } class ServiceMocks { - final PartnerStub partner = PartnerStub(MockPartnerService()); - final UserStub user = UserStub(MockUserService()); - final asset = AssetStub(MockAssetService()); + final partner = PartnerServiceStub(MockPartnerService()); + final user = UserServiceStub(MockUserService()); + final asset = AssetServiceStub(MockAssetService()); ServiceMocks() { resetAll(); @@ -78,11 +98,28 @@ void _registerFallbacks() { registerFallbackValue(Uint8List(0)); } -extension type const Stub(T mockedService) { - void reset() => mock.reset(mockedService); +extension type const Stub(T mockedClass) { + void reset() => mock.reset(mockedClass); } -extension type const PartnerStub(MockPartnerService service) implements Stub { +extension type const LocalAlbumRepositoryStub(MockLocalAlbumRepository repo) implements Stub { + Future> Function() get getBackupAlbums => + () => repo.getBackupAlbums(); + + Future> Function() get getAssetsToHash => + () => repo.getAssetsToHash(any()); +} + +extension type const LocalAssetRepositoryStub(MockDriftLocalAssetRepository repo) + implements Stub { + Future Function() get reconcileHashesFromCloudId => + () => repo.reconcileHashesFromCloudId(); + + Future Function() get updateHashes => + () => repo.updateHashes(any()); +} + +extension type const PartnerServiceStub(MockPartnerService service) implements Stub { Stream> Function() get getCandidates => () => service.getCandidates(any()); @@ -110,7 +147,7 @@ extension type const PartnerStub(MockPartnerService service) implements Stub { +extension type const UserServiceStub(MockUserService service) implements Stub { UserDto Function() get getMyUser => () => service.getMyUser(); @@ -127,7 +164,12 @@ extension type const UserStub(MockUserService service) implements Stub service.createProfileImage(any(), any()); } -extension type const AssetStub(MockAssetService service) implements Stub { +extension type const AssetServiceStub(MockAssetService service) implements Stub { Future Function() get updateFavorite => () => service.updateFavorite(any(), any()); } + +extension type const NativeSyncApiStub(MockNativeSyncApi api) implements Stub { + Future> Function() get hashAssets => + () => api.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')); +} diff --git a/mobile/test/unit/presentation/actions/asset_debug_action_test.dart b/mobile/test/unit/presentation/actions/asset_debug_action_test.dart index b720c8720e..4e84c100cb 100644 --- a/mobile/test/unit/presentation/actions/asset_debug_action_test.dart +++ b/mobile/test/unit/presentation/actions/asset_debug_action_test.dart @@ -6,7 +6,7 @@ import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; import 'package:immich_ui/immich_ui.dart'; import '../../factories/remote_asset_factory.dart'; -import '../../presentation_context.dart'; +import '../presentation_context.dart'; void main() { late PresentationContext context; @@ -23,8 +23,8 @@ void main() { group('AssetDebugAction', () { testWidgets('visible for a single asset when advanced troubleshooting is on', (tester) async { await tester.pumpTestWidget( + context, ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])), - overrides: context.overrides, ); expect(find.byType(ImmichIconButton), findsOneWidget); @@ -32,10 +32,10 @@ void main() { testWidgets('hidden for multiple assets', (tester) async { await tester.pumpTestWidget( + context, ActionIconButtonWidget( action: AssetDebugAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()]), ), - overrides: context.overrides, ); expect(find.byType(ImmichIconButton), findsNothing); @@ -44,8 +44,8 @@ void main() { testWidgets('hidden when advanced troubleshooting is off', (tester) async { await StoreService.I.put(StoreKey.advancedTroubleshooting, false); await tester.pumpTestWidget( + context, ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])), - overrides: context.overrides, ); expect(find.byType(ImmichIconButton), findsNothing); diff --git a/mobile/test/unit/presentation/actions/favorite_action_test.dart b/mobile/test/unit/presentation/actions/favorite_action_test.dart index cc8a2fc66c..cd0fc19ea2 100644 --- a/mobile/test/unit/presentation/actions/favorite_action_test.dart +++ b/mobile/test/unit/presentation/actions/favorite_action_test.dart @@ -1,30 +1,26 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/presentation/actions/favorite.action.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:mocktail/mocktail.dart'; +import '../../../domain/service.mock.dart'; import '../../factories/remote_asset_factory.dart'; -import '../../presentation_context.dart'; +import '../presentation_context.dart'; void main() { late PresentationContext context; + late MockAssetService assetService; setUp(() async { context = await PresentationContext.create(); + assetService = context.service.asset.service; }); tearDown(() { context.dispose(); }); - List overrides() => [ - ...context.overrides, - assetServiceProvider.overrideWithValue(context.mocks.asset.service), - ]; - RemoteAsset owned({bool isFavorite = false}) => RemoteAssetFactory.create(ownerId: context.currentUser.id, isFavorite: isFavorite); @@ -32,48 +28,48 @@ void main() { testWidgets('favorites the eligible owned assets', (tester) async { final asset = owned(); - await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides()); + await tester.pumpTestAction(context, FavoriteAction(assets: [asset])); - verify(() => context.mocks.asset.service.updateFavorite([asset.id], true)).called(1); + verify(() => assetService.updateFavorite([asset.id], true)).called(1); }); testWidgets('unfavorite the eligible owned assets', (tester) async { final asset = owned(isFavorite: true); - await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides()); + await tester.pumpTestAction(context, FavoriteAction(assets: [asset])); - verify(() => context.mocks.asset.service.updateFavorite([asset.id], false)).called(1); + verify(() => assetService.updateFavorite([asset.id], false)).called(1); }); testWidgets('ignores assets owned by someone else', (tester) async { final mine = owned(); final theirs = RemoteAssetFactory.create(); - await tester.pumpTestAction(FavoriteAction(assets: [mine, theirs]), overrides: overrides()); + await tester.pumpTestAction(context, FavoriteAction(assets: [mine, theirs])); - verify(() => context.mocks.asset.service.updateFavorite([mine.id], true)).called(1); + verify(() => assetService.updateFavorite([mine.id], true)).called(1); }); testWidgets('batches every eligible owned asset into a single call', (tester) async { final first = owned(); final second = owned(); - await tester.pumpTestAction(FavoriteAction(assets: [first, second]), overrides: overrides()); + await tester.pumpTestAction(context, FavoriteAction(assets: [first, second])); - verify(() => context.mocks.asset.service.updateFavorite([first.id, second.id], true)).called(1); + verify(() => assetService.updateFavorite([first.id, second.id], true)).called(1); }); testWidgets('skips owned assets already in the target state', (tester) async { final stale = owned(); final alreadyFavorite = owned(isFavorite: true); - await tester.pumpTestAction(FavoriteAction(assets: [stale, alreadyFavorite]), overrides: overrides()); + await tester.pumpTestAction(context, FavoriteAction(assets: [stale, alreadyFavorite])); - verify(() => context.mocks.asset.service.updateFavorite([stale.id], true)).called(1); + verify(() => assetService.updateFavorite([stale.id], true)).called(1); }); testWidgets('shows a confirmation snackbar on success', (tester) async { - await tester.pumpTestAction(FavoriteAction(assets: [owned()]), overrides: overrides()); + await tester.pumpTestAction(context, FavoriteAction(assets: [owned()])); await tester.pumpUntilFound(find.byType(SnackBar)); expect(find.byType(SnackBar), findsOneWidget); diff --git a/mobile/test/unit/presentation/actions/partner_action_test.dart b/mobile/test/unit/presentation/actions/partner_action_test.dart index 0284371c91..6c09337fa2 100644 --- a/mobile/test/unit/presentation/actions/partner_action_test.dart +++ b/mobile/test/unit/presentation/actions/partner_action_test.dart @@ -4,17 +4,19 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/presentation/actions/partner.action.dart'; -import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:mocktail/mocktail.dart'; +import '../../../domain/service.mock.dart'; import '../../factories/user_factory.dart'; -import '../../presentation_context.dart'; +import '../presentation_context.dart'; void main() { late PresentationContext context; + late MockPartnerService partnerService; setUp(() async { context = await PresentationContext.create(); + partnerService = context.service.partner.service; }); tearDown(() { @@ -22,8 +24,6 @@ void main() { }); List overrides({List candidates = const []}) => [ - ...context.overrides, - partnerServiceProvider.overrideWithValue(context.mocks.partner.service), candidatesStateProvider.overrideWith((ref) => Stream>.value(candidates)), ]; @@ -31,22 +31,24 @@ void main() { testWidgets('creates a partner for the selected candidate', (tester) async { final candidate = UserFactory.create(); - await tester.pumpTestAction(const PartnerAddAction(), overrides: overrides(candidates: [candidate])); + await tester.pumpTestAction(context, const PartnerAddAction(), overrides: overrides(candidates: [candidate])); await tester.pumpUntilFound(find.text(candidate.name)); await tester.tap(find.text(candidate.name)); await tester.pumpAndSettle(); - verify( - () => context.mocks.partner.service.create(sharedById: context.currentUser.id, sharedWithId: candidate.id), - ).called(1); + verify(() => partnerService.create(sharedById: context.currentUser.id, sharedWithId: candidate.id)).called(1); }); testWidgets('creates nothing when the selection dialog is dismissed', (tester) async { - await tester.pumpTestAction(const PartnerAddAction(), overrides: overrides(candidates: [UserFactory.create()])); + await tester.pumpTestAction( + context, + const PartnerAddAction(), + overrides: overrides(candidates: [UserFactory.create()]), + ); await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting await tester.pumpAndSettle(); - verifyNever(context.mocks.partner.create); + verifyNever(context.service.partner.create); }); }); @@ -54,27 +56,27 @@ void main() { testWidgets('deletes the partner after confirmation', (tester) async { final partner = UserFactory.create(); await tester.pumpTestAction( + context, PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name), overrides: overrides(), ); await tester.tap(find.byType(TextButton).last); // confirm await tester.pumpAndSettle(); - verify( - () => context.mocks.partner.service.delete(sharedById: context.currentUser.id, sharedWithId: partner.id), - ).called(1); + verify(() => partnerService.delete(sharedById: context.currentUser.id, sharedWithId: partner.id)).called(1); }); testWidgets('deletes nothing when the confirmation is cancelled', (tester) async { final partner = UserFactory.create(); await tester.pumpTestAction( + context, PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name), overrides: overrides(), ); await tester.tap(find.byType(TextButton).first); // cancel await tester.pumpAndSettle(); - verifyNever(context.mocks.partner.delete); + verifyNever(context.service.partner.delete); }); }); } diff --git a/mobile/test/unit/presentation/actions/timeline_action_test.dart b/mobile/test/unit/presentation/actions/timeline_action_test.dart index 6c14053317..5661be72c0 100644 --- a/mobile/test/unit/presentation/actions/timeline_action_test.dart +++ b/mobile/test/unit/presentation/actions/timeline_action_test.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/presentation/actions/timeline.action.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import '../../factories/remote_asset_factory.dart'; -import '../../presentation_context.dart'; +import '../presentation_context.dart'; class _FakeAction extends BaseAction { _FakeAction({this.visible = true, this.error}); @@ -48,8 +48,7 @@ void main() { context.dispose(); }); - List seededOverrides() => [ - ...context.overrides, + List overrides() => [ multiSelectProvider.overrideWith( () => MultiSelectNotifier( MultiSelectState(selectedAssets: {RemoteAssetFactory.create()}, lockedSelectionAssets: const {}), @@ -61,6 +60,7 @@ void main() { late ActionScope scope; late ProviderContainer container; await tester.pumpTestWidget( + context, Consumer( builder: (innerContext, ref, _) { scope = ActionScope(context: innerContext, ref: ref, authUser: context.currentUser); @@ -68,7 +68,7 @@ void main() { return const SizedBox.shrink(); }, ), - overrides: seededOverrides(), + overrides: overrides(), ); return (scope, container); } @@ -97,8 +97,8 @@ void main() { testWidgets('delegates visibility to the wrapped action', (tester) async { await tester.pumpTestWidget( + context, ActionIconButtonWidget(action: TimelineAction(action: _FakeAction(visible: false))), - overrides: context.overrides, ); expect(find.byType(ActionIconButtonWidget), findsOneWidget); diff --git a/mobile/test/unit/presentation/partner_page_test.dart b/mobile/test/unit/presentation/partner_page_test.dart index 6575b4a15c..162094c720 100644 --- a/mobile/test/unit/presentation/partner_page_test.dart +++ b/mobile/test/unit/presentation/partner_page_test.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/presentation/actions/partner.action.dart'; import '../factories/partner_user_factory.dart'; import '../factories/user_factory.dart'; -import '../presentation_context.dart'; +import 'presentation_context.dart'; void main() { late PresentationContext context; @@ -19,7 +19,7 @@ void main() { testWidgets('shows the empty-state add button when there are no partners', (tester) async { final action = const PartnerAddAction(); - await tester.pumpTestWidget(const PartnerSharedByList(partners: []), overrides: context.overrides); + await tester.pumpTestWidget(context, const PartnerSharedByList(partners: [])); expect(find.byType(ListView), findsNothing); expect(find.widgetWithIcon(TextButton, action.icon), findsOneWidget); @@ -28,8 +28,7 @@ void main() { testWidgets('renders a tile per partner with name and email', (tester) async { final partner1 = PartnerFactory.create(); final partner2 = PartnerFactory.create(); - await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides); - + await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2])); expect(find.byType(ListTile), findsNWidgets(2)); expect(find.text(partner1.name), findsOneWidget); expect(find.text(partner1.email), findsOneWidget); @@ -41,7 +40,7 @@ void main() { final partner1 = PartnerFactory.create(inTimeline: true); final partner2 = PartnerFactory.create(); final action = const PartnerRemoveAction(sharedWithId: '', partnerName: ''); - await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides); + await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2])); expect(find.byIcon(action.icon), findsNWidgets(2)); }); }); @@ -62,13 +61,12 @@ void main() { } List withCandidates(List candidates) => [ - ...context.overrides, candidatesStateProvider.overrideWith((ref) => Stream>.value(candidates)), ]; testWidgets('renders an option per candidate fetched from the provider', (tester) async { final user = UserFactory.create(); - await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates([user])); + await tester.pumpTestWidget(context, dialogWidget(), overrides: withCandidates([user])); await tester.tap(find.byKey(dialogButtonKey)); await tester.pumpAndSettle(); @@ -78,7 +76,7 @@ void main() { }); testWidgets('shows no options when the provider returns no candidates', (tester) async { - await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates(const [])); + await tester.pumpTestWidget(context, dialogWidget(), overrides: withCandidates(const [])); await tester.tap(find.byKey(dialogButtonKey)); await tester.pumpAndSettle(); @@ -89,7 +87,11 @@ void main() { testWidgets('pops the selected candidate when an option is tapped', (tester) async { final user = UserFactory.create(); User? selected; - await tester.pumpTestWidget(dialogWidget(onClosed: (user) => selected = user), overrides: withCandidates([user])); + await tester.pumpTestWidget( + context, + dialogWidget(onClosed: (user) => selected = user), + overrides: withCandidates([user]), + ); await tester.tap(find.byKey(dialogButtonKey)); await tester.pumpAndSettle(); diff --git a/mobile/test/unit/presentation_context.dart b/mobile/test/unit/presentation/presentation_context.dart similarity index 70% rename from mobile/test/unit/presentation_context.dart rename to mobile/test/unit/presentation/presentation_context.dart index d3998994c9..585cb3707e 100644 --- a/mobile/test/unit/presentation_context.dart +++ b/mobile/test/unit/presentation/presentation_context.dart @@ -13,16 +13,21 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; 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/user.provider.dart'; import 'package:immich_ui/immich_ui.dart'; import 'package:mocktail/mocktail.dart'; -import '../test_utils.dart'; -import 'factories/user_factory.dart'; -import 'mocks.dart'; +import '../../test_utils.dart'; +import '../factories/user_factory.dart'; +import '../mocks.dart'; class PresentationContext { - PresentationContext._({required UserDto user}) : currentUser = user, mocks = ServiceMocks() { + PresentationContext._({required UserDto user}) + : currentUser = user, + service = ServiceMocks(), + repository = RepositoryMocks() { setup(); } @@ -31,9 +36,14 @@ class PresentationContext { static Drift? _db; final UserDto currentUser; - final ServiceMocks mocks; + final ServiceMocks service; + final RepositoryMocks repository; - List get overrides => [currentUserProvider.overrideWith((ref) => CurrentUserProvider(mocks.user.service))]; + List get overrides => [ + currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)), + assetServiceProvider.overrideWithValue(service.asset.service), + partnerServiceProvider.overrideWithValue(service.partner.service), + ]; static Future create() async { TestUtils.init(); @@ -47,18 +57,18 @@ class PresentationContext { } void setup() { - when(mocks.user.tryGetMyUser).thenReturn(currentUser); + when(service.user.tryGetMyUser).thenReturn(currentUser); } void dispose() { addTearDown(() { - mocks.resetAll(); + service.resetAll(); }); } } extension PumpPresentationWidget on WidgetTester { - Future pumpTestWidget(Widget widget, {List overrides = const []}) async { + Future pumpTestWidget(PresentationContext context, Widget widget, {List overrides = const []}) async { await pumpWidget( EasyLocalization( supportedLocales: locales.values.toList(), @@ -69,7 +79,7 @@ extension PumpPresentationWidget on WidgetTester { useFallbackTranslations: true, assetLoader: const CodegenLoader(), child: ProviderScope( - overrides: overrides, + overrides: [...context.overrides, ...overrides], child: Builder( builder: (context) => MaterialApp( debugShowCheckedModeBanner: false, @@ -86,8 +96,12 @@ extension PumpPresentationWidget on WidgetTester { await pumpAndSettle(); } - Future pumpTestAction(BaseAction action, {List overrides = const []}) async { - await pumpTestWidget(ActionIconButtonWidget(action: action), overrides: overrides); + Future pumpTestAction( + PresentationContext context, + BaseAction action, { + List overrides = const [], + }) async { + await pumpTestWidget(context, ActionIconButtonWidget(action: action), overrides: overrides); await tap(find.byType(ImmichIconButton)); await pump(); } diff --git a/mobile/test/unit/services/hash_service_test.dart b/mobile/test/unit/services/hash_service_test.dart index 07cf2badf9..2623042f4b 100644 --- a/mobile/test/unit/services/hash_service_test.dart +++ b/mobile/test/unit/services/hash_service_test.dart @@ -14,14 +14,11 @@ void main() { setUp(() { sut = HashService( - localAlbumRepository: mocks.localAlbum, - localAssetRepository: mocks.localAsset, - nativeSyncApi: mocks.nativeApi, + localAlbumRepository: mocks.localAlbum.repo, + localAssetRepository: mocks.localAsset.repo, + nativeSyncApi: mocks.nativeApi.api, trashedLocalAssetRepository: mocks.trashedAsset, ); - - when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {}); - when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {}); }); tearDown(() { @@ -32,22 +29,20 @@ void main() { group('hashAssets', () { test('skips albums with no assets to hash', () async { final album = LocalAlbumFactory.create(assetCount: 0); - when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]); - when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []); + when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]); await sut.hashAssets(); - verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))); + verifyNever(mocks.nativeApi.hashAssets); }); test('skips empty batches', () async { final album = LocalAlbumFactory.create(); - when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]); - when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []); + when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]); await sut.hashAssets(); - verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))); + verifyNever(mocks.nativeApi.hashAssets); }); test('processes assets when available', () async { @@ -55,15 +50,17 @@ void main() { final asset = LocalAssetFactory.create(); final result = HashResult(assetId: asset.id, hash: 'test-hash'); - when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]); - when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]); - when(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).thenAnswer((_) async => [result]); + when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]); + when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]); + when( + () => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false), + ).thenAnswer((_) async => [result]); await sut.hashAssets(); - verify(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).called(1); + verify(() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false)).called(1); final captured = - verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map; + verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map; expect(captured.length, 1); expect(captured[asset.id], result.hash); }); @@ -72,16 +69,16 @@ void main() { final album = LocalAlbumFactory.create(); final asset = LocalAssetFactory.create(); - when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]); - when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]); + when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]); + when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]); when( - () => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false), + () => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false), ).thenAnswer((_) async => [HashResult(assetId: asset.id, error: 'Failed to hash')]); await sut.hashAssets(); final captured = - verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map; + verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map; expect(captured.length, 0); }); @@ -89,25 +86,25 @@ void main() { final album = LocalAlbumFactory.create(); final asset = LocalAssetFactory.create(); - when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]); - when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]); + when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]); + when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]); when( - () => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false), + () => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false), ).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: null)]); await sut.hashAssets(); final captured = - verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map; + verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map; expect(captured.length, 0); }); test('batches by size limit', () async { const batchSize = 2; final sut = HashService( - localAlbumRepository: mocks.localAlbum, - localAssetRepository: mocks.localAsset, - nativeSyncApi: mocks.nativeApi, + localAlbumRepository: mocks.localAlbum.repo, + localAssetRepository: mocks.localAsset.repo, + nativeSyncApi: mocks.nativeApi.api, batchSize: batchSize, trashedLocalAssetRepository: mocks.trashedAsset, ); @@ -119,12 +116,9 @@ void main() { final capturedCalls = >[]; - when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {}); - when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]); - when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]); - when(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer(( - invocation, - ) async { + when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]); + when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]); + when(mocks.nativeApi.hashAssets).thenAnswer((invocation) async { final assetIds = invocation.positionalArguments[0] as List; capturedCalls.add(List.from(assetIds)); return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList(); @@ -136,7 +130,7 @@ void main() { expect(capturedCalls[0], [asset1.id, asset2.id], reason: 'First call should batch the first two assets'); expect(capturedCalls[1], [asset3.id], reason: 'Second call should have the remaining asset'); - verify(() => mocks.localAsset.updateHashes(any())).called(2); + verify(() => mocks.localAsset.repo.updateHashes(any())).called(2); }); test('handles mixed success and failure in batch', () async { @@ -144,9 +138,9 @@ void main() { final asset1 = LocalAssetFactory.create(); final asset2 = LocalAssetFactory.create(); - when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]); - when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]); - when(() => mocks.nativeApi.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer( + when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]); + when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]); + when(() => mocks.nativeApi.api.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer( (_) async => [ HashResult(assetId: asset1.id, hash: 'asset1-hash'), HashResult(assetId: asset2.id, error: 'Failed to hash asset2'), @@ -156,7 +150,7 @@ void main() { await sut.hashAssets(); final captured = - verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map; + verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map; expect(captured.length, 1); expect(captured[asset1.id], 'asset1-hash'); }); @@ -167,20 +161,18 @@ void main() { final asset1 = LocalAssetFactory.create(); final asset2 = LocalAssetFactory.create(); - when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]); - when(() => mocks.localAlbum.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]); - when(() => mocks.localAlbum.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]); - when(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer(( - invocation, - ) async { + when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]); + when(() => mocks.localAlbum.repo.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]); + when(() => mocks.localAlbum.repo.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]); + when(mocks.nativeApi.hashAssets).thenAnswer((invocation) async { final assetIds = invocation.positionalArguments[0] as List; return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList(); }); await sut.hashAssets(); - verify(() => mocks.nativeApi.hashAssets([asset1.id], allowNetworkAccess: true)).called(1); - verify(() => mocks.nativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1); + verify(() => mocks.nativeApi.api.hashAssets([asset1.id], allowNetworkAccess: true)).called(1); + verify(() => mocks.nativeApi.api.hashAssets([asset2.id], allowNetworkAccess: false)).called(1); }); }); });