diff --git a/mobile/lib/domain/interfaces/user.interface.dart b/mobile/lib/domain/interfaces/user.interface.dart index c3bbc79782..f6e062cf9a 100644 --- a/mobile/lib/domain/interfaces/user.interface.dart +++ b/mobile/lib/domain/interfaces/user.interface.dart @@ -3,5 +3,5 @@ import 'package:immich_mobile/domain/models/user.model.dart'; abstract interface class IUserRepository { Future tryGet(String id); - Future update(User user); + Future update(User user); } diff --git a/mobile/lib/domain/models/user.model.dart b/mobile/lib/domain/models/user.model.dart index 1fe6a228b3..9feb5de89c 100644 --- a/mobile/lib/domain/models/user.model.dart +++ b/mobile/lib/domain/models/user.model.dart @@ -11,8 +11,8 @@ class User { this.profileImagePath = '', this.memoryEnabled = true, this.inTimeline = false, - required this.quotaSizeInBytes, - required this.quotaUsageInBytes, + this.quotaSizeInBytes = 0, + this.quotaUsageInBytes = 0, this.isPartnerSharedBy = false, this.isPartnerSharedWith = false, }); diff --git a/mobile/lib/domain/services/auth.service.dart b/mobile/lib/domain/services/auth.service.dart index 43ae9fdced..b4ba9fbf3e 100644 --- a/mobile/lib/domain/services/auth.service.dart +++ b/mobile/lib/domain/services/auth.service.dart @@ -13,49 +13,50 @@ class AuthService { User? _currentUser; late final StreamController _userStreamController; - String? _currentUserId; - late final StreamSubscription _userIdStream; - AuthService({ required IStoreRepository storeRepo, required IUserRepository userRepo, }) : _storeRepo = storeRepo, _userRepo = userRepo { _userStreamController = StreamController.broadcast(); - _userIdStream = _storeRepo.watch(StoreKey.currentUserId).listen((userId) { - if (_currentUserId == userId) { - return; - } - _currentUserId = userId; - unawaited(loadOfflineUser()); - }); + // Pre-load offline user + unawaited(_loadOfflineUser()); } - void _updateCurrentUser(User? user) { + void _notifyListeners(User? user) { _currentUser = user; _userStreamController.add(user); } - User getCurrentUser() { + Future _loadOfflineUser() async { + final userId = await _storeRepo.tryGet(StoreKey.currentUserId); + if (userId == null) { + _currentUser = null; + return; + } + final user = await _userRepo.tryGet(userId); + _notifyListeners(user); + } + + User? tryGetUser() => _currentUser; + + User getUser() { if (_currentUser == null) { throw const UserNotLoggedInException(); } return _currentUser!; } - User? tryGetCurrentUser() => _currentUser; + Stream watchUser() => _userStreamController.stream; - Stream watchCurrentUser() => _userStreamController.stream; - - Future loadOfflineUser() async { - if (_currentUserId == null) return null; - final user = await _userRepo.tryGet(_currentUserId!); - _updateCurrentUser(user); + Future updateUser(User user) async { + await _userRepo.update(user); + await _storeRepo.update(StoreKey.currentUserId, user.id); + _notifyListeners(user); return user; } Future cleanup() async { - await _userIdStream.cancel(); await _userStreamController.close(); } } diff --git a/mobile/lib/domain/services/store.service.dart b/mobile/lib/domain/services/store.service.dart index c4c5f1861b..68a184f7e5 100644 --- a/mobile/lib/domain/services/store.service.dart +++ b/mobile/lib/domain/services/store.service.dart @@ -24,17 +24,21 @@ class StoreService { return _instance!; } + // TODO: Replace the implementation with the one from create after removing the typedef /// Initializes the store with the given [storeRepository] static Future init(IStoreRepository storeRepository) async { - if (_instance != null) { - return _instance!; - } - _instance = StoreService._(storeRepository); - await _instance!._populateCache(); - _instance!._subscription = _instance!._listenForChange(); + _instance ??= await create(storeRepository); return _instance!; } + /// Initializes the store with the given [storeRepository] + static Future create(IStoreRepository storeRepository) async { + final instance = StoreService._(storeRepository); + await instance._populateCache(); + instance._subscription = instance._listenForChange(); + return instance; + } + /// Fills the cache with the values from the DB Future _populateCache() async { for (StoreKey key in StoreKey.values) { diff --git a/mobile/lib/domain/services/user.service.dart b/mobile/lib/domain/services/user.service.dart index ee8ca4bf0f..c31cfd6d52 100644 --- a/mobile/lib/domain/services/user.service.dart +++ b/mobile/lib/domain/services/user.service.dart @@ -6,5 +6,5 @@ class UserService { const UserService({required IUserRepository userRepo}) : _userRepo = userRepo; - Future updateUser(User user) => _userRepo.update(user); + Future updateUser(User user) => _userRepo.update(user); } diff --git a/mobile/lib/infrastructure/repositories/user.repository.dart b/mobile/lib/infrastructure/repositories/user.repository.dart index a696d8e9ef..83e867b3ed 100644 --- a/mobile/lib/infrastructure/repositories/user.repository.dart +++ b/mobile/lib/infrastructure/repositories/user.repository.dart @@ -16,7 +16,7 @@ class IsarUserRepository extends IsarDatabaseRepository } @override - Future update(User user) { + Future update(User user) { return nestTxn(() async { await _db.users.put(user.toOldUser()); return user; diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 86626230e7..630b6c034a 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -4,13 +4,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/auth.service.dart' as nauth; import 'package:immich_mobile/domain/services/store.service.dart'; -import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/models/auth/auth_state.model.dart'; import 'package:immich_mobile/models/auth/login_response.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/domain/auth.provider.dart'; -import 'package:immich_mobile/providers/domain/user.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/auth.service.dart'; import 'package:immich_mobile/utils/hash.dart'; @@ -22,7 +20,6 @@ final authProvider = StateNotifierProvider((ref) { ref.watch(authServiceProviderOld), ref.watch(apiServiceProvider), ref.watch(authServiceProvider), - ref.watch(userServiceProvider), ); }); @@ -30,17 +27,12 @@ class AuthNotifier extends StateNotifier { final AuthService _authService; final ApiService _apiService; final nauth.AuthService _nAuthService; - final UserService _userService; final _log = Logger("AuthenticationNotifier"); static const Duration _timeoutDuration = Duration(seconds: 7); - AuthNotifier( - this._authService, - this._apiService, - this._nAuthService, - this._userService, - ) : super( + AuthNotifier(this._authService, this._apiService, this._nAuthService) + : super( AuthState( deviceId: "", userId: "", @@ -115,8 +107,7 @@ class AuthNotifier extends StateNotifier { String deviceId = Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid; - _nAuthService.loadOfflineUser(); - User? user = _nAuthService.tryGetCurrentUser()?.toOldUser(); + User? user = _nAuthService.tryGetUser()?.toOldUser(); UserAdminResponseDto? userResponse; UserPreferencesResponseDto? userPreferences; @@ -155,13 +146,10 @@ class AuthNotifier extends StateNotifier { if (userResponse != null) { await Store.put(StoreKey.deviceId, deviceId); await Store.put(StoreKey.deviceIdHash, fastHash(deviceId)); - user = (await _userService.updateUser( + final updatedUser = await _nAuthService.updateUser( User.fromUserDto(userResponse, userPreferences).toDomain(), - )) - ?.toOldUser(); - if (user != null) { - await Store.put(StoreKey.currentUserId, user.id); - } + ); + user = updatedUser?.toOldUser(); await Store.put(StoreKey.accessToken, accessToken); } else { _log.severe("Unable to get user information from the server."); diff --git a/mobile/lib/providers/user.provider.dart b/mobile/lib/providers/user.provider.dart index fbf4b59f22..1d3ffff365 100644 --- a/mobile/lib/providers/user.provider.dart +++ b/mobile/lib/providers/user.provider.dart @@ -1,31 +1,26 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/auth.service.dart'; -import 'package:immich_mobile/domain/services/store.service.dart'; -import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/domain/auth.provider.dart'; -import 'package:immich_mobile/providers/domain/user.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:isar/isar.dart'; class CurrentUserProvider extends StateNotifier { - CurrentUserProvider(this._apiService, this._authService, this._userService) - : super(null) { - state = _authService.tryGetCurrentUser()?.toOldUser(); + CurrentUserProvider(this._apiService, this._authService) : super(null) { + state = _authService.tryGetUser()?.toOldUser(); streamSub = _authService - .watchCurrentUser() + .watchUser() .map((user) => user?.toOldUser()) .listen((user) => state = user); } final ApiService _apiService; final AuthService _authService; - final UserService _userService; + late final StreamSubscription streamSub; // TODO: Move method this to AuthService @@ -34,13 +29,8 @@ class CurrentUserProvider extends StateNotifier { final user = await _apiService.usersApi.getMyUser(); if (user != null) { final userPreferences = await _apiService.usersApi.getMyPreferences(); - final updatedUser = (await _userService.updateUser( - User.fromUserDto(user, userPreferences).toDomain(), - )) - ?.toOldUser(); - if (updatedUser != null) { - await Store.put(StoreKey.currentUserId, updatedUser.id); - } + await _authService + .updateUser(User.fromUserDto(user, userPreferences).toDomain()); } } catch (_) {} } @@ -57,7 +47,6 @@ final currentUserProvider = return CurrentUserProvider( ref.watch(apiServiceProvider), ref.watch(authServiceProvider), - ref.watch(userServiceProvider), ); }); diff --git a/mobile/lib/repositories/album.repository.dart b/mobile/lib/repositories/album.repository.dart index 85433a7e6a..600ed3ae31 100644 --- a/mobile/lib/repositories/album.repository.dart +++ b/mobile/lib/repositories/album.repository.dart @@ -51,7 +51,7 @@ class AlbumRepository extends DatabaseRepository implements IAlbumRepository { if (shared != null) { query = query.sharedEqualTo(shared); } - final user = _authService.getCurrentUser().toOldUser(); + final user = _authService.getUser().toOldUser(); if (owner == true) { query = query.owner((q) => q.isarIdEqualTo(user.isarId)); } else if (owner == false) { @@ -140,7 +140,7 @@ class AlbumRepository extends DatabaseRepository implements IAlbumRepository { String searchTerm, QuickFilterMode filterMode, ) async { - final user = _authService.getCurrentUser().toOldUser(); + final user = _authService.getUser().toOldUser(); var query = db.albums .filter() .nameContains(searchTerm, caseSensitive: false) diff --git a/mobile/lib/repositories/album_media.repository.dart b/mobile/lib/repositories/album_media.repository.dart index 5605c5522e..51c8fd5fbd 100644 --- a/mobile/lib/repositories/album_media.repository.dart +++ b/mobile/lib/repositories/album_media.repository.dart @@ -98,7 +98,7 @@ class AlbumMediaRepository implements IAlbumMediaRepository { shared: false, activityEnabled: false, ); - album.owner.value = _authService.getCurrentUser().toOldUser(); + album.owner.value = _authService.getUser().toOldUser(); album.localId = assetPathEntity.id; album.isAll = assetPathEntity.isAll; return album; diff --git a/mobile/lib/repositories/asset_media.repository.dart b/mobile/lib/repositories/asset_media.repository.dart index 8492b412c8..2cf2d64186 100644 --- a/mobile/lib/repositories/asset_media.repository.dart +++ b/mobile/lib/repositories/asset_media.repository.dart @@ -33,7 +33,7 @@ class AssetMediaRepository implements IAssetMediaRepository { final Asset asset = Asset( checksum: "", localId: local.id, - ownerId: authService.getCurrentUser().toOldUser().isarId, + ownerId: authService.getUser().toOldUser().isarId, fileCreatedAt: local.createDateTime, fileModifiedAt: local.modifiedDateTime, updatedAt: local.modifiedDateTime, diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart index adc0828a82..b4a4d82fc6 100644 --- a/mobile/lib/repositories/user.repository.dart +++ b/mobile/lib/repositories/user.repository.dart @@ -29,7 +29,7 @@ class UserRepository extends DatabaseRepository implements IUserRepository { @override Future> getAll({bool self = true, UserSort? sortBy}) { - final user = _authService.getCurrentUser().toOldUser(); + final user = _authService.getUser().toOldUser(); final baseQuery = db.users.where(); final int userId = user.isarId; final QueryBuilder afterWhere = @@ -48,7 +48,7 @@ class UserRepository extends DatabaseRepository implements IUserRepository { } @override - Future me() => Future.value(_authService.getCurrentUser().toOldUser()); + Future me() => Future.value(_authService.getUser().toOldUser()); @override Future deleteById(List ids) => txn(() => db.users.deleteAll(ids)); @@ -64,6 +64,6 @@ class UserRepository extends DatabaseRepository implements IUserRepository { .filter() .isPartnerSharedWithEqualTo(true) .or() - .isarIdEqualTo(_authService.getCurrentUser().toOldUser().isarId) + .isarIdEqualTo(_authService.getUser().toOldUser().isarId) .findAll(); } diff --git a/mobile/lib/routing/tab_navigation_observer.dart b/mobile/lib/routing/tab_navigation_observer.dart index 6251a49d2a..ddc4aedb39 100644 --- a/mobile/lib/routing/tab_navigation_observer.dart +++ b/mobile/lib/routing/tab_navigation_observer.dart @@ -1,12 +1,10 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; -import 'package:immich_mobile/providers/domain/user.provider.dart'; +import 'package:immich_mobile/providers/domain/auth.provider.dart'; import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -14,9 +12,7 @@ class TabNavigationObserver extends AutoRouterObserver { /// Riverpod Instance final WidgetRef ref; - TabNavigationObserver({ - required this.ref, - }); + TabNavigationObserver({required this.ref}); @override Future didChangeTabRoute( @@ -38,15 +34,11 @@ class TabNavigationObserver extends AutoRouterObserver { return; } - final user = (await ref.read(userServiceProvider).updateUser( - User.fromUserDto(userResponseDto, userPreferences).toDomain(), - )) - ?.toOldUser(); - if (user != null) { - await Store.put(StoreKey.currentUserId, user.id); - } + await ref.read(authServiceProvider).updateUser( + User.fromUserDto(userResponseDto, userPreferences).toDomain(), + ); - ref.read(serverInfoProvider.notifier).getServerVersion(); + await ref.read(serverInfoProvider.notifier).getServerVersion(); } catch (e) { debugPrint("Error refreshing user info $e"); } diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index 44699b44ee..934231c993 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -295,7 +295,7 @@ class AlbumService { Future deleteAlbum(Album album) async { try { - final userId = _authService.getCurrentUser().toOldUser().isarId; + final userId = _authService.getUser().toOldUser().isarId; if (album.owner.value?.isarId == userId) { await _albumApiRepository.delete(album.remoteId!); } diff --git a/mobile/lib/services/backup_verification.service.dart b/mobile/lib/services/backup_verification.service.dart index fedf75804f..42d0d5ffc3 100644 --- a/mobile/lib/services/backup_verification.service.dart +++ b/mobile/lib/services/backup_verification.service.dart @@ -37,7 +37,7 @@ class BackupVerificationService { /// Returns at most [limit] assets that were backed up without exif Future> findWronglyBackedUpAssets({int limit = 100}) async { - final owner = _authService.getCurrentUser().toOldUser().isarId; + final owner = _authService.getUser().toOldUser().isarId; final List onlyLocal = await _assetRepository.getAll( ownerId: owner, state: AssetState.local, diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart index 57372233b4..d75d597c02 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart @@ -1,13 +1,11 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; -import 'package:immich_mobile/providers/domain/user.provider.dart'; +import 'package:immich_mobile/providers/domain/auth.provider.dart'; import 'package:immich_mobile/providers/upload_profile_image.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; @@ -70,12 +68,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { ); if (user != null) { user.profileImagePath = profileImagePath; - final updatedUser = (await ref - .read(userServiceProvider) - .updateUser(user.toDomain())); - if (updatedUser != null) { - await Store.put(StoreKey.currentUserId, updatedUser.id); - } + await ref.read(authServiceProvider).updateUser(user.toDomain()); ref.read(currentUserProvider.notifier).refresh(); } } diff --git a/mobile/test/domain/auth_service_test.dart b/mobile/test/domain/auth_service_test.dart new file mode 100644 index 0000000000..36aa2a116c --- /dev/null +++ b/mobile/test/domain/auth_service_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/exceptions/auth.exception.dart'; +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/interfaces/user.interface.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/auth.service.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../fixtures/user.stub.dart'; +import '../infrastructure/repository.mock.dart'; + +void main() { + late AuthService sut; + late IUserRepository mockUserRepo; + late IStoreRepository mockStoreRepo; + + setUp(() { + mockUserRepo = MockUserRepository(); + mockStoreRepo = MockStoreRepository(); + + registerFallbackValue(StoreKey.currentUserId); + registerFallbackValue(UserStub.admin); + }); + + tearDown(() async { + await sut.cleanup(); + }); + + group('AuthService Init:', () { + test('Loads the offline user on init', () async { + when(() => mockStoreRepo.tryGet(any())) + .thenAnswer((_) async => UserStub.admin.id); + when(() => mockUserRepo.tryGet(any())) + .thenAnswer((_) async => UserStub.admin); + sut = AuthService(storeRepo: mockStoreRepo, userRepo: mockUserRepo); + await pumpEventQueue(); + + verify(() => mockStoreRepo.tryGet(StoreKey.currentUserId)) + .called(1); + verify(() => mockUserRepo.tryGet(any())).called(1); + expect(sut.tryGetUser(), UserStub.admin); + }); + + test('Null current user when no user is stored', () { + when(() => mockStoreRepo.tryGet(any())) + .thenAnswer((_) async => null); + sut = AuthService(storeRepo: mockStoreRepo, userRepo: mockUserRepo); + + expect(sut.tryGetUser(), isNull); + verifyNever(() => mockUserRepo.tryGet(any())); + }); + }); + + group('AuthService Get:', () { + test('Throws UserNotLoggedInException when user is unavailable', () { + when(() => mockStoreRepo.tryGet(any())) + .thenAnswer((_) async => null); + sut = AuthService(storeRepo: mockStoreRepo, userRepo: mockUserRepo); + expect(() => sut.getUser(), throwsA(isA())); + }); + + test('Returns the current user', () async { + when(() => mockStoreRepo.tryGet(any())) + .thenAnswer((_) async => UserStub.admin.id); + when(() => mockUserRepo.tryGet(any())) + .thenAnswer((_) async => UserStub.admin); + + sut = AuthService(storeRepo: mockStoreRepo, userRepo: mockUserRepo); + await pumpEventQueue(); + expect(sut.getUser(), UserStub.admin); + }); + }); + + group('AuthService Try Get:', () { + test('Returns null when user is unavailable', () { + when(() => mockStoreRepo.tryGet(any())) + .thenAnswer((_) async => null); + sut = AuthService(storeRepo: mockStoreRepo, userRepo: mockUserRepo); + expect(sut.tryGetUser(), isNull); + }); + + test('Returns the current user', () async { + when(() => mockStoreRepo.tryGet(any())) + .thenAnswer((_) async => UserStub.admin.id); + when(() => mockUserRepo.tryGet(any())) + .thenAnswer((_) async => UserStub.admin); + + sut = AuthService(storeRepo: mockStoreRepo, userRepo: mockUserRepo); + await pumpEventQueue(); + expect(sut.tryGetUser(), UserStub.admin); + }); + }); + + group('AuthService Update:', () { + setUp(() { + when(() => mockUserRepo.update(any())) + .thenAnswer((_) async => UserStub.admin); + when(() => mockStoreRepo.update(any(), any())) + .thenAnswer((_) async => true); + }); + + test('Updates the user and internal cache', () async { + when(() => mockStoreRepo.tryGet(any())) + .thenAnswer((_) async => null); + sut = AuthService(storeRepo: mockStoreRepo, userRepo: mockUserRepo); + + await sut.updateUser(UserStub.admin); + expect(sut.tryGetUser(), UserStub.admin); + verify(() => mockUserRepo.update(UserStub.admin)).called(1); + verify(() => + mockStoreRepo.update(StoreKey.currentUserId, UserStub.admin.id)) + .called(1); + }); + + test('Notifies listeners', () async { + when(() => mockStoreRepo.tryGet(any())) + .thenAnswer((_) async => null); + sut = AuthService(storeRepo: mockStoreRepo, userRepo: mockUserRepo); + final stream = sut.watchUser(); + expectLater(stream, emits(UserStub.admin)); + await sut.updateUser(UserStub.admin); + }); + }); +} diff --git a/mobile/test/domain/store_service_test.dart b/mobile/test/domain/store_service_test.dart new file mode 100644 index 0000000000..49b41e4d43 --- /dev/null +++ b/mobile/test/domain/store_service_test.dart @@ -0,0 +1,185 @@ +// ignore_for_file: avoid-dynamic + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/exceptions/store.exception.dart'; +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../infrastructure/repository.mock.dart'; + +const _kAccessToken = '#ThisIsAToken'; +const _kBackgroundBackup = false; +const _kGroupAssetsBy = 2; +final _kBackupFailedSince = DateTime.utc(2023); + +void main() { + late StoreService sut; + late IStoreRepository mockStoreRepo; + late StreamController controller; + + setUp(() async { + controller = StreamController.broadcast(); + mockStoreRepo = MockStoreRepository(); + // For generics, we need to provide fallback to each concrete type to avoid runtime errors + registerFallbackValue(StoreKey.accessToken); + registerFallbackValue(StoreKey.backupTriggerDelay); + registerFallbackValue(StoreKey.backgroundBackup); + registerFallbackValue(StoreKey.backupFailedSince); + + when(() => mockStoreRepo.tryGet(any>())) + .thenAnswer((invocation) async { + final key = invocation.positionalArguments.firstOrNull as StoreKey; + return switch (key) { + StoreKey.accessToken => _kAccessToken, + StoreKey.backgroundBackup => _kBackgroundBackup, + StoreKey.groupAssetsBy => _kGroupAssetsBy, + StoreKey.backupFailedSince => _kBackupFailedSince, + // ignore: avoid-wildcard-cases-with-enums + _ => null, + }; + }); + when(() => mockStoreRepo.watchAll()).thenAnswer((_) => controller.stream); + + sut = await StoreService.create(mockStoreRepo); + }); + + tearDown(() async { + sut.dispose(); + await controller.close(); + }); + + group("Store Init:", () { + test('Populates the internal cache on init', () { + verify(() => mockStoreRepo.tryGet(any>())) + .called(equals(StoreKey.values.length)); + expect(sut.tryGet(StoreKey.accessToken), _kAccessToken); + expect(sut.tryGet(StoreKey.backgroundBackup), _kBackgroundBackup); + expect(sut.tryGet(StoreKey.groupAssetsBy), _kGroupAssetsBy); + expect(sut.tryGet(StoreKey.backupFailedSince), _kBackupFailedSince); + // Other keys should be null + expect(sut.tryGet(StoreKey.currentUserId), isNull); + }); + + test('Listens to stream of store updates', () async { + final event = + StoreUpdateEvent(StoreKey.accessToken, _kAccessToken.toUpperCase()); + controller.add(event); + + await pumpEventQueue(); + + verify(() => mockStoreRepo.watchAll()).called(1); + expect(sut.tryGet(StoreKey.accessToken), _kAccessToken.toUpperCase()); + }); + }); + + group('Store get:', () { + test('Returns the stored value for the given key', () { + expect(sut.get(StoreKey.accessToken), _kAccessToken); + }); + + test('Throws StoreKeyNotFoundException for nonexistent keys', () { + expect( + () => sut.get(StoreKey.currentUserId), + throwsA(isA()), + ); + }); + + test('Returns the stored value for the given key or the defaultValue', () { + expect(sut.get(StoreKey.currentUserId, 5), 5); + }); + }); + + group('Store put:', () { + setUp(() { + when(() => mockStoreRepo.insert(any>(), any())) + .thenAnswer((_) async => true); + }); + + test('Skip insert when value is not modified', () async { + await sut.put(StoreKey.accessToken, _kAccessToken); + verifyNever( + () => mockStoreRepo.insert(StoreKey.accessToken, any()), + ); + }); + + test('Insert value when modified', () async { + final newAccessToken = _kAccessToken.toUpperCase(); + await sut.put(StoreKey.accessToken, newAccessToken); + verify( + () => + mockStoreRepo.insert(StoreKey.accessToken, newAccessToken), + ).called(1); + expect(sut.tryGet(StoreKey.accessToken), newAccessToken); + }); + }); + + group('Store watch:', () { + late StreamController valueController; + + setUp(() { + valueController = StreamController.broadcast(); + when(() => mockStoreRepo.watch(any>())) + .thenAnswer((_) => valueController.stream); + }); + + tearDown(() async { + await valueController.close(); + }); + + test('Watches a specific key for changes', () async { + final stream = sut.watch(StoreKey.accessToken); + final events = [ + _kAccessToken, + _kAccessToken.toUpperCase(), + null, + _kAccessToken.toLowerCase(), + ]; + + expectLater(stream, emitsInOrder(events)); + + for (final event in events) { + valueController.add(event); + } + + await pumpEventQueue(); + verify(() => mockStoreRepo.watch(StoreKey.accessToken)).called(1); + }); + }); + + group('Store delete:', () { + setUp(() { + when(() => mockStoreRepo.delete(any>())) + .thenAnswer((_) async => true); + }); + + test('Removes the value from the DB', () async { + await sut.delete(StoreKey.accessToken); + verify(() => mockStoreRepo.delete(StoreKey.accessToken)) + .called(1); + }); + + test('Removes the value from the cache', () async { + await sut.delete(StoreKey.accessToken); + expect(sut.tryGet(StoreKey.accessToken), isNull); + }); + }); + + group('Store clear:', () { + setUp(() { + when(() => mockStoreRepo.deleteAll()).thenAnswer((_) async => true); + }); + + test('Clears all values from the store', () async { + await sut.clear(); + verify(() => mockStoreRepo.deleteAll()).called(1); + expect(sut.tryGet(StoreKey.accessToken), isNull); + expect(sut.tryGet(StoreKey.backgroundBackup), isNull); + expect(sut.tryGet(StoreKey.groupAssetsBy), isNull); + expect(sut.tryGet(StoreKey.backupFailedSince), isNull); + }); + }); +} diff --git a/mobile/test/fixtures/album.stub.dart b/mobile/test/fixtures/album.stub.dart index e820f193d5..66e18bb37d 100644 --- a/mobile/test/fixtures/album.stub.dart +++ b/mobile/test/fixtures/album.stub.dart @@ -26,7 +26,7 @@ final class AlbumStub { shared: true, activityEnabled: false, endDate: DateTime(2020), - )..sharedUsers.addAll([UserStub.admin]); + )..sharedUsers.addAll([UserStub.adminOld]); static final oneAsset = Album( name: "album-with-single-asset", @@ -53,7 +53,7 @@ final class AlbumStub { ) ..assets.addAll([AssetStub.image1, AssetStub.image2]) ..activityEnabled = true - ..owner.value = UserStub.admin; + ..owner.value = UserStub.adminOld; static final create2020end2020Album = Album( name: "create2020update2020Album", diff --git a/mobile/test/fixtures/user.stub.dart b/mobile/test/fixtures/user.stub.dart index 38524f782c..965e74deb4 100644 --- a/mobile/test/fixtures/user.stub.dart +++ b/mobile/test/fixtures/user.stub.dart @@ -1,35 +1,46 @@ -import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/entities/user.entity.dart' as entity; -final class UserStub { +abstract final class UserStub { const UserStub._(); - static final admin = User( + static final adminOld = entity.User( id: "admin", updatedAt: DateTime(2021), email: "admin@test.com", name: "admin", - avatarColor: AvatarColorEnum.green, + avatarColor: entity.AvatarColorEnum.green, profileImagePath: '', isAdmin: true, ); - static final user1 = User( + static final user1 = entity.User( id: "user1", updatedAt: DateTime(2022), email: "user1@test.com", name: "user1", - avatarColor: AvatarColorEnum.red, + avatarColor: entity.AvatarColorEnum.red, profileImagePath: '', isAdmin: false, ); - static final user2 = User( + static final user2 = entity.User( id: "user2", updatedAt: DateTime(2023), email: "user2@test.com", name: "user2", - avatarColor: AvatarColorEnum.primary, + avatarColor: entity.AvatarColorEnum.primary, profileImagePath: '', isAdmin: false, ); + + static final admin = User( + id: "admin", + name: "admin", + email: "admin@immich.org", + isAdmin: true, + updatedAt: DateTime(2021), + avatarColor: UserAvatarColor.green, + profileImagePath: '', + ); } diff --git a/mobile/test/infrastructure/repository.mock.dart b/mobile/test/infrastructure/repository.mock.dart new file mode 100644 index 0000000000..12d0da989e --- /dev/null +++ b/mobile/test/infrastructure/repository.mock.dart @@ -0,0 +1,7 @@ +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/interfaces/user.interface.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockUserRepository extends Mock implements IUserRepository {} + +class MockStoreRepository extends Mock implements IStoreRepository {} diff --git a/mobile/test/modules/activity/activities_page_test.dart b/mobile/test/modules/activity/activities_page_test.dart index e2a6ca86b0..bd71f53ff0 100644 --- a/mobile/test/modules/activity/activities_page_test.dart +++ b/mobile/test/modules/activity/activities_page_test.dart @@ -39,7 +39,7 @@ final _activities = [ type: ActivityType.comment, comment: 'First Activity', assetId: 'asset-2', - user: UserStub.admin, + user: UserStub.adminOld, ), Activity( id: '2', @@ -74,7 +74,7 @@ void main() { TestUtils.init(); db = await TestUtils.initIsar(); StoreService.init(IsarStoreRepository(db)); - await Store.put(StoreKey.currentUserId, UserStub.admin.id); + await Store.put(StoreKey.currentUserId, UserStub.adminOld.id); await Store.put(StoreKey.serverEndpoint, ''); await Store.put(StoreKey.accessToken, ''); }); @@ -95,7 +95,7 @@ void main() { await db.writeTxn(() async { await db.clear(); // Save all assets - await db.users.put(UserStub.admin); + await db.users.put(UserStub.adminOld); await db.assets.putAll([AssetStub.image1, AssetStub.image2]); await db.albums.put(AlbumStub.twoAsset); await AlbumStub.twoAsset.owner.save(); diff --git a/mobile/test/modules/activity/activity_provider_test.dart b/mobile/test/modules/activity/activity_provider_test.dart index a3b3e2466e..f0548c5eaa 100644 --- a/mobile/test/modules/activity/activity_provider_test.dart +++ b/mobile/test/modules/activity/activity_provider_test.dart @@ -17,7 +17,7 @@ final _activities = [ type: ActivityType.comment, comment: 'First Activity', assetId: 'asset-2', - user: UserStub.admin, + user: UserStub.adminOld, ), Activity( id: '2', @@ -31,7 +31,7 @@ final _activities = [ createdAt: DateTime(300), type: ActivityType.like, assetId: 'asset-1', - user: UserStub.admin, + user: UserStub.adminOld, ), Activity( id: '4', @@ -108,7 +108,7 @@ void main() { id: '5', createdAt: DateTime(2023), type: ActivityType.like, - user: UserStub.admin, + user: UserStub.adminOld, ); when( @@ -142,7 +142,7 @@ void main() { id: '5', createdAt: DateTime(2023), type: ActivityType.like, - user: UserStub.admin, + user: UserStub.adminOld, ); when( () => activityMock.addActivity( @@ -242,7 +242,7 @@ void main() { id: '5', createdAt: DateTime(2023), type: ActivityType.comment, - user: UserStub.admin, + user: UserStub.adminOld, comment: 'Test-Comment', assetId: 'test-asset', ); @@ -283,7 +283,7 @@ void main() { id: '5', createdAt: DateTime(2023), type: ActivityType.comment, - user: UserStub.admin, + user: UserStub.adminOld, assetId: 'test-asset', comment: 'Test-Comment', ); @@ -324,7 +324,7 @@ void main() { id: '5', createdAt: DateTime(2023), type: ActivityType.comment, - user: UserStub.admin, + user: UserStub.adminOld, comment: 'Test-Comment', assetId: 'test-asset', ); diff --git a/mobile/test/modules/activity/activity_text_field_test.dart b/mobile/test/modules/activity/activity_text_field_test.dart index 53c6130890..37c18b98e8 100644 --- a/mobile/test/modules/activity/activity_text_field_test.dart +++ b/mobile/test/modules/activity/activity_text_field_test.dart @@ -34,7 +34,7 @@ void main() { TestUtils.init(); db = await TestUtils.initIsar(); StoreService.init(IsarStoreRepository(db)); - Store.put(StoreKey.currentUserId, UserStub.admin.id); + Store.put(StoreKey.currentUserId, UserStub.adminOld.id); Store.put(StoreKey.serverEndpoint, ''); }); diff --git a/mobile/test/modules/activity/activity_tile_test.dart b/mobile/test/modules/activity/activity_tile_test.dart index c684a26fbc..1740faeece 100644 --- a/mobile/test/modules/activity/activity_tile_test.dart +++ b/mobile/test/modules/activity/activity_tile_test.dart @@ -30,7 +30,7 @@ void main() { db = await TestUtils.initIsar(); // For UserCircleAvatar StoreService.init(IsarStoreRepository(db)); - await Store.put(StoreKey.currentUserId, UserStub.admin.id); + await Store.put(StoreKey.currentUserId, UserStub.adminOld.id); await Store.put(StoreKey.serverEndpoint, ''); await Store.put(StoreKey.accessToken, ''); }); @@ -47,7 +47,7 @@ void main() { id: '1', createdAt: DateTime(100), type: ActivityType.like, - user: UserStub.admin, + user: UserStub.adminOld, ), ), overrides: overrides, @@ -64,7 +64,7 @@ void main() { id: '1', createdAt: DateTime(100), type: ActivityType.like, - user: UserStub.admin, + user: UserStub.adminOld, ), ), overrides: overrides, @@ -83,7 +83,7 @@ void main() { id: '1', createdAt: DateTime(100), type: ActivityType.like, - user: UserStub.admin, + user: UserStub.adminOld, assetId: '1', ), ), @@ -102,7 +102,7 @@ void main() { id: '1', createdAt: DateTime(100), type: ActivityType.like, - user: UserStub.admin, + user: UserStub.adminOld, assetId: '1', ), ), @@ -121,7 +121,7 @@ void main() { id: '1', createdAt: DateTime(100), type: ActivityType.like, - user: UserStub.admin, + user: UserStub.adminOld, ); testWidgets('Like contains filled heart as leading', (tester) async { @@ -172,7 +172,7 @@ void main() { createdAt: DateTime(100), type: ActivityType.comment, comment: 'This is a test comment', - user: UserStub.admin, + user: UserStub.adminOld, ); testWidgets('Comment contains User Circle Avatar as leading', diff --git a/mobile/test/modules/activity/dismissible_activity_test.dart b/mobile/test/modules/activity/dismissible_activity_test.dart index 494a89db83..44ed10f181 100644 --- a/mobile/test/modules/activity/dismissible_activity_test.dart +++ b/mobile/test/modules/activity/dismissible_activity_test.dart @@ -19,7 +19,7 @@ final activity = Activity( id: '1', createdAt: DateTime(100), type: ActivityType.like, - user: UserStub.admin, + user: UserStub.adminOld, ); void main() { diff --git a/mobile/test/pages/search/search.page_test.dart b/mobile/test/pages/search/search.page_test.dart index a27176cfb9..fd38f1d832 100644 --- a/mobile/test/pages/search/search.page_test.dart +++ b/mobile/test/pages/search/search.page_test.dart @@ -36,7 +36,7 @@ void main() { TestUtils.init(); db = await TestUtils.initIsar(); await StoreService.init(IsarStoreRepository(db)); - await Store.put(StoreKey.currentUserId, UserStub.admin.id); + await Store.put(StoreKey.currentUserId, UserStub.adminOld.id); mockApiService = MockApiService(); mockSearchApi = MockSearchApi(); when(() => mockApiService.searchApi).thenReturn(mockSearchApi); diff --git a/mobile/test/services/entity.service_test.dart b/mobile/test/services/entity.service_test.dart index 8c8b49a7e0..f4d2bed5ee 100644 --- a/mobile/test/services/entity.service_test.dart +++ b/mobile/test/services/entity.service_test.dart @@ -34,10 +34,10 @@ void main() { ..remoteThumbnailAssetId = AssetStub.image1.remoteId ..assets.addAll([AssetStub.image1, AssetStub.image1]) ..owner.value = UserStub.user1 - ..sharedUsers.addAll([UserStub.admin, UserStub.admin]); + ..sharedUsers.addAll([UserStub.adminOld, UserStub.adminOld]); when(() => userRepository.get(album.ownerId!)) - .thenAnswer((_) async => UserStub.admin); + .thenAnswer((_) async => UserStub.adminOld); when(() => assetRepository.getByRemoteId(AssetStub.image1.remoteId!)) .thenAnswer((_) async => AssetStub.image1); @@ -49,7 +49,7 @@ void main() { .thenAnswer((_) async => [AssetStub.image1, AssetStub.image2]); await sut.fillAlbumWithDatabaseEntities(album); - expect(album.owner.value, UserStub.admin); + expect(album.owner.value, UserStub.adminOld); expect(album.thumbnail.value, AssetStub.image1); expect(album.remoteUsers.toSet(), {UserStub.user1, UserStub.user2}); expect(album.remoteAssets.toSet(), {AssetStub.image1, AssetStub.image2});