diff --git a/mobile/lib/domain/interfaces/store.interface.dart b/mobile/lib/domain/interfaces/store.interface.dart index a2d248e801..7a45f9dbe0 100644 --- a/mobile/lib/domain/interfaces/store.interface.dart +++ b/mobile/lib/domain/interfaces/store.interface.dart @@ -1,6 +1,7 @@ +import 'package:immich_mobile/domain/interfaces/db.interface.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; -abstract interface class IStoreRepository { +abstract interface class IStoreRepository implements IDatabaseRepository { Future<bool> insert<T>(StoreKey<T> key, T value); Future<T?> tryGet<T>(StoreKey<T> key); diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index 4aca207c6f..06b946b3f6 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -77,4 +77,23 @@ class StoreUpdateEvent<T> { final T? value; const StoreUpdateEvent(this.key, this.value); + + @override + String toString() { + return ''' +StoreUpdateEvent: { + key: $key, + value: ${value ?? '<NA>'}, +}'''; + } + + @override + bool operator ==(covariant StoreUpdateEvent<T> other) { + if (identical(this, other)) return true; + + return other.key == key && other.value == value; + } + + @override + int get hashCode => key.hashCode ^ value.hashCode; } diff --git a/mobile/lib/infrastructure/repositories/store.repository.dart b/mobile/lib/infrastructure/repositories/store.repository.dart index f22f0f987e..5cf6838ee1 100644 --- a/mobile/lib/infrastructure/repositories/store.repository.dart +++ b/mobile/lib/infrastructure/repositories/store.repository.dart @@ -21,7 +21,7 @@ class IsarStoreRepository extends IsarDatabaseRepository @override Stream<StoreUpdateEvent> watchAll() { - return _db.storeValues.where().watch().asyncExpand( + return _db.storeValues.where().watch(fireImmediately: true).asyncExpand( (entities) => Stream.fromFutures(entities.map((e) async => _toUpdateEvent(e))), ); @@ -86,17 +86,11 @@ class IsarStoreRepository extends IsarDatabaseRepository final (int? intValue, String? strValue) = switch (key.type) { const (int) => (value as int, null), const (String) => (null, value as String), - const (bool) => ( - (value as bool) ? 1 : 0, - null, - ), - const (DateTime) => ( - (value as DateTime).millisecondsSinceEpoch, - null, - ), + const (bool) => ((value as bool) ? 1 : 0, null), + const (DateTime) => ((value as DateTime).millisecondsSinceEpoch, null), const (User) => ( (await UserRepository(_db).update(value as User)).isarId, - null + null, ), _ => throw UnsupportedError( "Unsupported primitive type: ${key.type} for key: ${key.name}", diff --git a/mobile/test/domain/services/store_service_test.dart b/mobile/test/domain/services/store_service_test.dart new file mode 100644 index 0000000000..554ca73500 --- /dev/null +++ b/mobile/test/domain/services/store_service_test.dart @@ -0,0 +1,184 @@ +// ignore_for_file: avoid-dynamic + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.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<StoreUpdateEvent> controller; + + setUp(() async { + controller = StreamController<StoreUpdateEvent>.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<StoreKey<dynamic>>())) + .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(storeRepository: mockStoreRepo); + }); + + tearDown(() async { + sut.dispose(); + await controller.close(); + }); + + group("Store Service Init:", () { + test('Populates the internal cache on init', () { + verify(() => mockStoreRepo.tryGet(any<StoreKey<dynamic>>())) + .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.currentUser), 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 Service 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.currentUser), + throwsA(isA<StoreKeyNotFoundException>()), + ); + }); + + test('Returns the stored value for the given key or the defaultValue', () { + expect(sut.get(StoreKey.currentUser, 5), 5); + }); + }); + + group('Store Service put:', () { + setUp(() { + when(() => mockStoreRepo.insert<String>(any<StoreKey<String>>(), any())) + .thenAnswer((_) async => true); + }); + + test('Skip insert when value is not modified', () async { + await sut.put(StoreKey.accessToken, _kAccessToken); + verifyNever( + () => mockStoreRepo.insert<String>(StoreKey.accessToken, any()), + ); + }); + + test('Insert value when modified', () async { + final newAccessToken = _kAccessToken.toUpperCase(); + await sut.put(StoreKey.accessToken, newAccessToken); + verify( + () => + mockStoreRepo.insert<String>(StoreKey.accessToken, newAccessToken), + ).called(1); + expect(sut.tryGet(StoreKey.accessToken), newAccessToken); + }); + }); + + group('Store Service watch:', () { + late StreamController<String?> valueController; + + setUp(() { + valueController = StreamController<String?>.broadcast(); + when(() => mockStoreRepo.watch<String>(any<StoreKey<String>>())) + .thenAnswer((_) => valueController.stream); + }); + + tearDown(() async { + await valueController.close(); + }); + + test('Watches a specific key for changes', () async { + final stream = sut.watch(StoreKey.accessToken); + final events = <String?>[ + _kAccessToken, + _kAccessToken.toUpperCase(), + null, + _kAccessToken.toLowerCase(), + ]; + + expectLater(stream, emitsInOrder(events)); + + for (final event in events) { + valueController.add(event); + } + + await pumpEventQueue(); + verify(() => mockStoreRepo.watch<String>(StoreKey.accessToken)).called(1); + }); + }); + + group('Store Service delete:', () { + setUp(() { + when(() => mockStoreRepo.delete<String>(any<StoreKey<String>>())) + .thenAnswer((_) async => true); + }); + + test('Removes the value from the DB', () async { + await sut.delete(StoreKey.accessToken); + verify(() => mockStoreRepo.delete<String>(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 Service 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/infrastructure/repositories/store_repository_test.dart b/mobile/test/infrastructure/repositories/store_repository_test.dart new file mode 100644 index 0000000000..6fd3d3963a --- /dev/null +++ b/mobile/test/infrastructure/repositories/store_repository_test.dart @@ -0,0 +1,181 @@ +// ignore_for_file: avoid-dynamic + +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:isar/isar.dart'; + +import '../../fixtures/user.stub.dart'; +import '../../test_utils.dart'; + +const _kTestAccessToken = "#TestToken"; +final _kTestBackupFailed = DateTime(2025, 2, 20, 11, 45); +const _kTestVersion = 10; +const _kTestColorfulInterface = false; +final _kTestUser = UserStub.admin; + +Future<void> _addIntStoreValue(Isar db, StoreKey key, int? value) async { + await db.storeValues.put(StoreValue(key.id, intValue: value, strValue: null)); +} + +Future<void> _addStrStoreValue(Isar db, StoreKey key, String? value) async { + await db.storeValues.put(StoreValue(key.id, intValue: null, strValue: value)); +} + +Future<void> _populateStore(Isar db) async { + await db.writeTxn(() async { + await _addIntStoreValue( + db, + StoreKey.colorfulInterface, + _kTestColorfulInterface ? 1 : 0, + ); + await _addIntStoreValue( + db, + StoreKey.backupFailedSince, + _kTestBackupFailed.millisecondsSinceEpoch, + ); + await _addStrStoreValue(db, StoreKey.accessToken, _kTestAccessToken); + await _addIntStoreValue(db, StoreKey.version, _kTestVersion); + }); +} + +void main() { + late Isar db; + late IStoreRepository sut; + + setUp(() async { + db = await TestUtils.initIsar(); + sut = IsarStoreRepository(db); + }); + + group('Store Repository converters:', () { + test('converts int', () async { + int? version = await sut.tryGet(StoreKey.version); + expect(version, isNull); + await sut.insert(StoreKey.version, _kTestVersion); + version = await sut.tryGet(StoreKey.version); + expect(version, _kTestVersion); + }); + + test('converts string', () async { + String? accessToken = await sut.tryGet(StoreKey.accessToken); + expect(accessToken, isNull); + await sut.insert(StoreKey.accessToken, _kTestAccessToken); + accessToken = await sut.tryGet(StoreKey.accessToken); + expect(accessToken, _kTestAccessToken); + }); + + test('converts datetime', () async { + DateTime? backupFailedSince = + await sut.tryGet(StoreKey.backupFailedSince); + expect(backupFailedSince, isNull); + await sut.insert(StoreKey.backupFailedSince, _kTestBackupFailed); + backupFailedSince = await sut.tryGet(StoreKey.backupFailedSince); + expect(backupFailedSince, _kTestBackupFailed); + }); + + test('converts bool', () async { + bool? colorfulInterface = await sut.tryGet(StoreKey.colorfulInterface); + expect(colorfulInterface, isNull); + await sut.insert(StoreKey.colorfulInterface, _kTestColorfulInterface); + colorfulInterface = await sut.tryGet(StoreKey.colorfulInterface); + expect(colorfulInterface, _kTestColorfulInterface); + }); + + test('converts user', () async { + User? user = await sut.tryGet(StoreKey.currentUser); + expect(user, isNull); + await sut.insert(StoreKey.currentUser, _kTestUser); + user = await sut.tryGet(StoreKey.currentUser); + expect(user, _kTestUser); + }); + }); + + group('Store Repository Deletes:', () { + setUp(() async { + await _populateStore(db); + }); + + test('delete()', () async { + bool? isColorful = await sut.tryGet(StoreKey.colorfulInterface); + expect(isColorful, isFalse); + await sut.delete(StoreKey.colorfulInterface); + isColorful = await sut.tryGet(StoreKey.colorfulInterface); + expect(isColorful, isNull); + }); + + test('deleteAll()', () async { + final count = await db.storeValues.count(); + expect(count, isNot(isZero)); + await sut.deleteAll(); + expectLater(await db.storeValues.count(), isZero); + }); + }); + + group('Store Repository Updates:', () { + setUp(() async { + await _populateStore(db); + }); + + test('update()', () async { + int? version = await sut.tryGet(StoreKey.version); + expect(version, _kTestVersion); + await sut.update(StoreKey.version, _kTestVersion + 10); + version = await sut.tryGet(StoreKey.version); + expect(version, _kTestVersion + 10); + }); + }); + + group('Store Repository Watchers:', () { + setUp(() async { + await _populateStore(db); + }); + + test('watch()', () async { + final stream = sut.watch(StoreKey.version); + expectLater(stream, emitsInOrder([_kTestVersion, _kTestVersion + 10])); + await pumpEventQueue(); + await sut.update(StoreKey.version, _kTestVersion + 10); + }); + + test('watchAll()', () async { + final stream = sut.watchAll(); + expectLater( + stream, + emitsInAnyOrder([ + emits( + const StoreUpdateEvent<dynamic>(StoreKey.version, _kTestVersion), + ), + emits( + StoreUpdateEvent<dynamic>( + StoreKey.backupFailedSince, + _kTestBackupFailed, + ), + ), + emits( + const StoreUpdateEvent<dynamic>( + StoreKey.accessToken, + _kTestAccessToken, + ), + ), + emits( + const StoreUpdateEvent<dynamic>( + StoreKey.colorfulInterface, + _kTestColorfulInterface, + ), + ), + emits( + const StoreUpdateEvent<dynamic>( + StoreKey.version, + _kTestVersion + 10, + ), + ), + ]), + ); + await sut.update(StoreKey.version, _kTestVersion + 10); + }); + }); +} diff --git a/mobile/test/infrastructure/repository.mock.dart b/mobile/test/infrastructure/repository.mock.dart new file mode 100644 index 0000000000..ff25bdac9d --- /dev/null +++ b/mobile/test/infrastructure/repository.mock.dart @@ -0,0 +1,4 @@ +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockStoreRepository extends Mock implements IStoreRepository {} diff --git a/mobile/test/test_utils.dart b/mobile/test/test_utils.dart index 39837b6e56..35ab1fb0aa 100644 --- a/mobile/test/test_utils.dart +++ b/mobile/test/test_utils.dart @@ -21,10 +21,11 @@ import 'mock_http_override.dart'; // Listener Mock to test when a provider notifies its listeners class ListenerMock<T> extends Mock { + // ignore: avoid-declaring-call-method void call(T? previous, T next); } -final class TestUtils { +abstract final class TestUtils { const TestUtils._(); /// Downloads Isar binaries (if required) and initializes a new Isar db @@ -50,13 +51,14 @@ final class TestUtils { AndroidDeviceAssetSchema, IOSDeviceAssetSchema, ], - maxSizeMiB: 1024, directory: "test/", + maxSizeMiB: 1024, + inspector: false, ); // Clear and close db on test end addTearDown(() async { - await db.writeTxn(() => db.clear()); + await db.writeTxn(() async => await db.clear()); await db.close(); }); return db;