refactor: yeet old timeline (#27666)

* refactor: yank old timeline

# Conflicts:
#	mobile/lib/presentation/pages/editing/drift_edit.page.dart
#	mobile/lib/providers/websocket.provider.dart
#	mobile/lib/routing/router.dart

* more cleanup

* remove native code

* chore: bump sqlite-data version

* remove old background tasks from BGTaskSchedulerPermittedIdentifiers

* rebase

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
shenlong
2026-04-15 23:00:27 +05:30
committed by GitHub
parent 6dd6053222
commit 79fccdbee0
367 changed files with 332 additions and 50870 deletions
-2
View File
@@ -1,8 +1,6 @@
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
class MockAssetsApi extends Mock implements AssetsApi {}
class MockSyncApi extends Mock implements SyncApi {}
class MockServerApi extends Mock implements ServerApi {}
-7
View File
@@ -1,20 +1,13 @@
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:immich_mobile/services/background_upload.service.dart';
import 'package:mocktail/mocktail.dart';
class MockStoreService extends Mock implements StoreService {}
class MockUserService extends Mock implements UserService {}
class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
class MockAppSettingsService extends Mock implements AppSettingsService {}
class MockBackgroundUploadService extends Mock implements BackgroundUploadService {}
@@ -1,115 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:mocktail/mocktail.dart';
import '../../infrastructure/repository.mock.dart';
void main() {
late RemoteAlbumService sut;
late DriftRemoteAlbumRepository mockRemoteAlbumRepo;
late DriftAlbumApiRepository mockAlbumApiRepo;
final albumA = RemoteAlbum(
id: '1',
name: 'Album A',
description: "",
isActivityEnabled: false,
order: AlbumAssetOrder.asc,
assetCount: 1,
createdAt: DateTime(2023, 1, 1),
updatedAt: DateTime(2023, 1, 2),
ownerId: 'owner1',
ownerName: "Test User",
isShared: false,
);
final albumB = RemoteAlbum(
id: '2',
name: 'Album B',
description: "",
isActivityEnabled: false,
order: AlbumAssetOrder.desc,
assetCount: 2,
createdAt: DateTime(2023, 2, 1),
updatedAt: DateTime(2023, 2, 2),
ownerId: 'owner2',
ownerName: "Test User",
isShared: false,
);
setUp(() {
mockRemoteAlbumRepo = MockRemoteAlbumRepository();
mockAlbumApiRepo = MockDriftAlbumApiRepository();
when(
() => mockRemoteAlbumRepo.getSortedAlbumIds(any(), aggregation: AssetDateAggregation.end),
).thenAnswer((_) async => ['1', '2']);
when(
() => mockRemoteAlbumRepo.getSortedAlbumIds(any(), aggregation: AssetDateAggregation.start),
).thenAnswer((_) async => ['1', '2']);
sut = RemoteAlbumService(mockRemoteAlbumRepo, mockAlbumApiRepo);
});
group('sortAlbums', () {
test('should sort correctly based on name', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.title);
expect(result, [albumA, albumB]);
});
test('should sort correctly based on createdAt', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.created);
expect(result, [albumB, albumA]);
});
test('should sort correctly based on updatedAt', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.lastModified);
expect(result, [albumB, albumA]);
});
test('should sort correctly based on assetCount', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.assetCount);
expect(result, [albumB, albumA]);
});
test('should sort correctly based on newestAssetTimestamp', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.mostRecent);
expect(result, [albumB, albumA]);
});
test('should sort correctly based on oldestAssetTimestamp', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.mostOldest);
expect(result, [albumA, albumB]);
});
test('should flip order when isReverse is true for all modes', () async {
final albums = [albumB, albumA];
for (final mode in AlbumSortMode.values) {
final normal = await sut.sortAlbums(albums, mode, isReverse: false);
final reversed = await sut.sortAlbums(albums, mode, isReverse: true);
// reversed should be the exact inverse of normal
expect(reversed, normal.reversed.toList(), reason: 'Mode: $mode');
}
});
});
}
@@ -1,185 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:mocktail/mocktail.dart';
import '../../infrastructure/repository.mock.dart';
import '../../test_utils.dart';
void main() {
late AssetService sut;
late MockRemoteAssetRepository mockRemoteAssetRepository;
late MockDriftLocalAssetRepository mockLocalAssetRepository;
setUp(() {
mockRemoteAssetRepository = MockRemoteAssetRepository();
mockLocalAssetRepository = MockDriftLocalAssetRepository();
sut = AssetService(
remoteAssetRepository: mockRemoteAssetRepository,
localAssetRepository: mockLocalAssetRepository,
);
});
group('getAspectRatio', () {
test('flips dimensions on Android for 90° and 270° orientations', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
for (final orientation in [90, 270]) {
final localAsset = TestUtils.createLocalAsset(
id: 'local-$orientation',
width: 1920,
height: 1080,
orientation: orientation,
);
final result = await sut.getAspectRatio(localAsset);
expect(result, 1080 / 1920, reason: 'Orientation $orientation should flip on Android');
}
});
test('does not flip dimensions on iOS regardless of orientation', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
for (final orientation in [0, 90, 270]) {
final localAsset = TestUtils.createLocalAsset(
id: 'local-$orientation',
width: 1920,
height: 1080,
orientation: orientation,
);
final result = await sut.getAspectRatio(localAsset);
expect(result, 1920 / 1080, reason: 'iOS should never flip dimensions');
}
});
test('fetches dimensions from remote repository when missing from asset', () async {
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null);
final exif = const ExifInfo(orientation: '1');
final fetchedAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: 1920, height: 1080);
when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif);
when(() => mockRemoteAssetRepository.get('remote-1')).thenAnswer((_) async => fetchedAsset);
final result = await sut.getAspectRatio(remoteAsset);
expect(result, 1920 / 1080);
verify(() => mockRemoteAssetRepository.get('remote-1')).called(1);
});
test('fetches dimensions from local repository when missing from local asset', () async {
final localAsset = TestUtils.createLocalAsset(id: 'local-1', width: null, height: null, orientation: 0);
final fetchedAsset = TestUtils.createLocalAsset(id: 'local-1', width: 1920, height: 1080, orientation: 0);
when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async => fetchedAsset);
final result = await sut.getAspectRatio(localAsset);
expect(result, 1920 / 1080);
verify(() => mockLocalAssetRepository.get('local-1')).called(1);
});
test('uses fetched asset orientation when dimensions are missing on Android', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
// Original asset has default orientation 0, but dimensions are missing
final localAsset = TestUtils.createLocalAsset(id: 'local-1', width: null, height: null, orientation: 0);
// Fetched asset has 90° orientation and proper dimensions
final fetchedAsset = TestUtils.createLocalAsset(id: 'local-1', width: 1920, height: 1080, orientation: 90);
when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async => fetchedAsset);
final result = await sut.getAspectRatio(localAsset);
// Should flip dimensions since fetched asset has 90° orientation
expect(result, 1080 / 1920);
verify(() => mockLocalAssetRepository.get('local-1')).called(1);
});
test('returns 1.0 when dimensions are still unavailable after fetching', () async {
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null);
final exif = const ExifInfo(orientation: '1');
when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif);
when(() => mockRemoteAssetRepository.get('remote-1')).thenAnswer((_) async => null);
final result = await sut.getAspectRatio(remoteAsset);
expect(result, 1.0);
});
test('returns 1.0 when height is zero', () async {
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: 1920, height: 0);
final exif = const ExifInfo(orientation: '1');
when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif);
final result = await sut.getAspectRatio(remoteAsset);
expect(result, 1.0);
});
test('handles local asset with remoteId using local orientation not remote exif', () async {
// When a LocalAsset has a remoteId (merged), we should use local orientation
// because the width/height come from the local asset (pre-corrected on iOS)
final localAsset = TestUtils.createLocalAsset(
id: 'local-1',
remoteId: 'remote-1',
width: 1920,
height: 1080,
orientation: 0,
);
final result = await sut.getAspectRatio(localAsset);
expect(result, 1920 / 1080);
// Should not call remote exif for LocalAsset
verifyNever(() => mockRemoteAssetRepository.getExif(any()));
});
test('handles local asset with remoteId and 90 degree rotation on Android', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final localAsset = TestUtils.createLocalAsset(
id: 'local-1',
remoteId: 'remote-1',
width: 1920,
height: 1080,
orientation: 90,
);
final result = await sut.getAspectRatio(localAsset);
expect(result, 1080 / 1920);
});
test('should not flip remote asset dimensions', () async {
final flippedOrientations = ['1', '2', '3', '4', '5', '6', '7', '8', '90', '-90'];
for (final orientation in flippedOrientations) {
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-$orientation', width: 1920, height: 1080);
final exif = ExifInfo(orientation: orientation);
when(() => mockRemoteAssetRepository.getExif('remote-$orientation')).thenAnswer((_) async => exif);
final result = await sut.getAspectRatio(remoteAsset);
expect(result, 1920 / 1080, reason: 'Should not flipped remote asset dimensions for orientation $orientation');
}
});
});
}
@@ -29,11 +29,11 @@ final _kWarnLog = LogMessage(
void main() {
late LogService sut;
late LogRepository mockLogRepo;
late IsarStoreRepository mockStoreRepo;
late DriftStoreRepository mockStoreRepo;
setUp(() async {
mockLogRepo = MockLogRepository();
mockStoreRepo = MockStoreRepository();
mockStoreRepo = MockDriftStoreRepository();
registerFallbackValue(_kInfoLog);
@@ -15,13 +15,11 @@ final _kBackupFailedSince = DateTime.utc(2023);
void main() {
late StoreService sut;
late IsarStoreRepository mockStoreRepo;
late DriftStoreRepository mockDriftStoreRepo;
late StreamController<List<StoreDto<Object>>> controller;
setUp(() async {
controller = StreamController<List<StoreDto<Object>>>.broadcast();
mockStoreRepo = MockStoreRepository();
mockDriftStoreRepo = MockDriftStoreRepository();
// For generics, we need to provide fallback to each concrete type to avoid runtime errors
registerFallbackValue(StoreKey.accessToken);
@@ -29,16 +27,6 @@ void main() {
registerFallbackValue(StoreKey.backgroundBackup);
registerFallbackValue(StoreKey.backupFailedSince);
when(() => mockStoreRepo.getAll()).thenAnswer(
(_) async => [
const StoreDto(StoreKey.accessToken, _kAccessToken),
const StoreDto(StoreKey.backgroundBackup, _kBackgroundBackup),
const StoreDto(StoreKey.groupAssetsBy, _kGroupAssetsBy),
StoreDto(StoreKey.backupFailedSince, _kBackupFailedSince),
],
);
when(() => mockStoreRepo.watchAll()).thenAnswer((_) => controller.stream);
when(() => mockDriftStoreRepo.getAll()).thenAnswer(
(_) async => [
const StoreDto(StoreKey.accessToken, _kAccessToken),
@@ -49,7 +37,7 @@ void main() {
);
when(() => mockDriftStoreRepo.watchAll()).thenAnswer((_) => controller.stream);
sut = await StoreService.create(storeRepository: mockStoreRepo);
sut = await StoreService.create(storeRepository: mockDriftStoreRepo);
});
tearDown(() async {
@@ -59,7 +47,7 @@ void main() {
group("Store Service Init:", () {
test('Populates the internal cache on init', () {
verify(() => mockStoreRepo.getAll()).called(1);
verify(() => mockDriftStoreRepo.getAll()).called(1);
expect(sut.tryGet(StoreKey.accessToken), _kAccessToken);
expect(sut.tryGet(StoreKey.backgroundBackup), _kBackgroundBackup);
expect(sut.tryGet(StoreKey.groupAssetsBy), _kGroupAssetsBy);
@@ -74,7 +62,7 @@ void main() {
await pumpEventQueue();
verify(() => mockStoreRepo.watchAll()).called(1);
verify(() => mockDriftStoreRepo.watchAll()).called(1);
expect(sut.tryGet(StoreKey.accessToken), _kAccessToken.toUpperCase());
});
});
@@ -95,19 +83,18 @@ void main() {
group('Store Service put:', () {
setUp(() {
when(() => mockStoreRepo.upsert<String>(any<StoreKey<String>>(), any())).thenAnswer((_) async => true);
when(() => mockDriftStoreRepo.upsert<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.upsert<String>(StoreKey.accessToken, any()));
verifyNever(() => mockDriftStoreRepo.upsert<String>(StoreKey.accessToken, any()));
});
test('Insert value when modified', () async {
final newAccessToken = _kAccessToken.toUpperCase();
await sut.put(StoreKey.accessToken, newAccessToken);
verify(() => mockStoreRepo.upsert<String>(StoreKey.accessToken, newAccessToken)).called(1);
verify(() => mockDriftStoreRepo.upsert<String>(StoreKey.accessToken, newAccessToken)).called(1);
expect(sut.tryGet(StoreKey.accessToken), newAccessToken);
});
});
@@ -117,7 +104,6 @@ void main() {
setUp(() {
valueController = StreamController<String?>.broadcast();
when(() => mockStoreRepo.watch<String>(any<StoreKey<String>>())).thenAnswer((_) => valueController.stream);
when(() => mockDriftStoreRepo.watch<String>(any<StoreKey<String>>())).thenAnswer((_) => valueController.stream);
});
@@ -136,19 +122,18 @@ void main() {
}
await pumpEventQueue();
verify(() => mockStoreRepo.watch<String>(StoreKey.accessToken)).called(1);
verify(() => mockDriftStoreRepo.watch<String>(StoreKey.accessToken)).called(1);
});
});
group('Store Service delete:', () {
setUp(() {
when(() => mockStoreRepo.delete<String>(any<StoreKey<String>>())).thenAnswer((_) async => true);
when(() => mockDriftStoreRepo.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);
verify(() => mockDriftStoreRepo.delete<String>(StoreKey.accessToken)).called(1);
});
test('Removes the value from the cache', () async {
@@ -159,13 +144,12 @@ void main() {
group('Store Service clear:', () {
setUp(() {
when(() => mockStoreRepo.deleteAll()).thenAnswer((_) async => true);
when(() => mockDriftStoreRepo.deleteAll()).thenAnswer((_) async => true);
});
test('Clears all values from the store', () async {
await sut.clear();
verify(() => mockStoreRepo.deleteAll()).called(1);
verify(() => mockDriftStoreRepo.deleteAll()).called(1);
expect(sut.tryGet(StoreKey.accessToken), isNull);
expect(sut.tryGet(StoreKey.backgroundBackup), isNull);
expect(sut.tryGet(StoreKey.groupAssetsBy), isNull);
@@ -4,7 +4,6 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:mocktail/mocktail.dart';
@@ -14,19 +13,13 @@ import '../service.mock.dart';
void main() {
late UserService sut;
late IsarUserRepository mockUserRepo;
late UserApiRepository mockUserApiRepo;
late StoreService mockStoreService;
setUp(() {
mockUserRepo = MockIsarUserRepository();
mockUserApiRepo = MockUserApiRepository();
mockStoreService = MockStoreService();
sut = UserService(
isarUserRepository: mockUserRepo,
userApiRepository: mockUserApiRepo,
storeService: mockStoreService,
);
sut = UserService(userApiRepository: mockUserApiRepo, storeService: mockStoreService);
registerFallbackValue(UserStub.admin);
when(() => mockStoreService.get(StoreKey.currentUser)).thenReturn(UserStub.admin);
@@ -77,11 +70,9 @@ void main() {
test('should return user from api and store it', () async {
when(() => mockUserApiRepo.getMyUser()).thenAnswer((_) async => UserStub.admin);
when(() => mockStoreService.put(StoreKey.currentUser, UserStub.admin)).thenAnswer((_) async => true);
when(() => mockUserRepo.update(UserStub.admin)).thenAnswer((_) async => UserStub.admin);
final result = await sut.refreshMyUser();
verify(() => mockStoreService.put(StoreKey.currentUser, UserStub.admin)).called(1);
verify(() => mockUserRepo.update(UserStub.admin)).called(1);
expect(result, UserStub.admin);
});
@@ -90,7 +81,6 @@ void main() {
final result = await sut.refreshMyUser();
verifyNever(() => mockStoreService.put(StoreKey.currentUser, UserStub.admin));
verifyNever(() => mockUserRepo.update(UserStub.admin));
expect(result, isNull);
});
});
@@ -104,12 +94,10 @@ void main() {
() => mockUserApiRepo.createProfileImage(name: profileImagePath, data: Uint8List(0)),
).thenAnswer((_) async => profileImagePath);
when(() => mockStoreService.put(StoreKey.currentUser, updatedUser)).thenAnswer((_) async => true);
when(() => mockUserRepo.update(updatedUser)).thenAnswer((_) async => UserStub.admin);
final result = await sut.createProfileImage(profileImagePath, Uint8List(0));
verify(() => mockStoreService.put(StoreKey.currentUser, updatedUser)).called(1);
verify(() => mockUserRepo.update(updatedUser)).called(1);
expect(result, profileImagePath);
});
@@ -123,7 +111,6 @@ void main() {
final result = await sut.createProfileImage(profileImagePath, Uint8List(0));
verifyNever(() => mockStoreService.put(StoreKey.currentUser, updatedUser));
verifyNever(() => mockUserRepo.update(updatedUser));
expect(result, isNull);
});
});
-6
View File
@@ -1,6 +0,0 @@
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
class MockSmartSearchDto extends Mock implements SmartSearchDto {}
class MockMetadataSearchDto extends Mock implements MetadataSearchDto {}
-104
View File
@@ -1,108 +1,4 @@
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'asset.stub.dart';
import 'user.stub.dart';
final class AlbumStub {
const AlbumStub._();
static final emptyAlbum = Album(
name: "empty-album",
localId: "empty-album-local",
remoteId: "empty-album-remote",
createdAt: DateTime(2000),
modifiedAt: DateTime(2023),
shared: false,
activityEnabled: false,
startDate: DateTime(2020),
);
static final sharedWithUser = Album(
name: "empty-album-shared-with-user",
localId: "empty-album-shared-with-user-local",
remoteId: "empty-album-shared-with-user-remote",
createdAt: DateTime(2023),
modifiedAt: DateTime(2023),
shared: true,
activityEnabled: false,
endDate: DateTime(2020),
)..sharedUsers.addAll([User.fromDto(UserStub.admin)]);
static final oneAsset = Album(
name: "album-with-single-asset",
localId: "album-with-single-asset-local",
remoteId: "album-with-single-asset-remote",
createdAt: DateTime(2022),
modifiedAt: DateTime(2023),
shared: false,
activityEnabled: false,
startDate: DateTime(2020),
endDate: DateTime(2023),
)..assets.addAll([AssetStub.image1]);
static final twoAsset =
Album(
name: "album-with-two-assets",
localId: "album-with-two-assets-local",
remoteId: "album-with-two-assets-remote",
createdAt: DateTime(2001),
modifiedAt: DateTime(2010),
shared: false,
activityEnabled: false,
startDate: DateTime(2019),
endDate: DateTime(2020),
)
..assets.addAll([AssetStub.image1, AssetStub.image2])
..activityEnabled = true
..owner.value = User.fromDto(UserStub.admin);
static final create2020end2020Album = Album(
name: "create2020update2020Album",
localId: "create2020update2020Album-local",
remoteId: "create2020update2020Album-remote",
createdAt: DateTime(2020),
modifiedAt: DateTime(2020),
shared: false,
activityEnabled: false,
startDate: DateTime(2020),
endDate: DateTime(2020),
);
static final create2020end2022Album = Album(
name: "create2020update2021Album",
localId: "create2020update2021Album-local",
remoteId: "create2020update2021Album-remote",
createdAt: DateTime(2020),
modifiedAt: DateTime(2022),
shared: false,
activityEnabled: false,
startDate: DateTime(2020),
endDate: DateTime(2022),
);
static final create2020end2024Album = Album(
name: "create2020update2022Album",
localId: "create2020update2022Album-local",
remoteId: "create2020update2022Album-remote",
createdAt: DateTime(2020),
modifiedAt: DateTime(2024),
shared: false,
activityEnabled: false,
startDate: DateTime(2020),
endDate: DateTime(2024),
);
static final create2020end2026Album = Album(
name: "create2020update2023Album",
localId: "create2020update2023Album-local",
remoteId: "create2020update2023Album-remote",
createdAt: DateTime(2020),
modifiedAt: DateTime(2026),
shared: false,
activityEnabled: false,
startDate: DateTime(2020),
endDate: DateTime(2026),
);
}
abstract final class LocalAlbumStub {
const LocalAlbumStub._();
-55
View File
@@ -1,59 +1,4 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart' as old;
final class AssetStub {
const AssetStub._();
static final image1 = old.Asset(
checksum: "image1-checksum",
localId: "image1",
remoteId: 'image1-remote',
ownerId: 1,
fileCreatedAt: DateTime(2019),
fileModifiedAt: DateTime(2020),
updatedAt: DateTime.now(),
durationInSeconds: 0,
type: old.AssetType.image,
fileName: "image1.jpg",
isFavorite: true,
isArchived: false,
isTrashed: false,
exifInfo: const ExifInfo(isFlipped: false),
);
static final image2 = old.Asset(
checksum: "image2-checksum",
localId: "image2",
remoteId: 'image2-remote',
ownerId: 1,
fileCreatedAt: DateTime(2000),
fileModifiedAt: DateTime(2010),
updatedAt: DateTime.now(),
durationInSeconds: 60,
type: old.AssetType.video,
fileName: "image2.jpg",
isFavorite: false,
isArchived: false,
isTrashed: false,
exifInfo: const ExifInfo(isFlipped: true),
);
static final image3 = old.Asset(
checksum: "image3-checksum",
localId: "image3",
ownerId: 1,
fileCreatedAt: DateTime(2025),
fileModifiedAt: DateTime(2025),
updatedAt: DateTime.now(),
durationInSeconds: 60,
type: old.AssetType.image,
fileName: "image3.jpg",
isFavorite: true,
isArchived: false,
isTrashed: false,
);
}
abstract final class LocalAssetStub {
const LocalAssetStub._();
-18
View File
@@ -1,18 +0,0 @@
import 'package:immich_mobile/domain/models/exif.model.dart';
abstract final class ExifStub {
static final size = const ExifInfo(assetId: 1, fileSize: 1000);
static final gps = const ExifInfo(
assetId: 2,
latitude: 20,
longitude: 20,
city: 'city',
state: 'state',
country: 'country',
);
static final rotated90CW = const ExifInfo(assetId: 3, orientation: "90");
static final rotated270CW = const ExifInfo(assetId: 4, orientation: "-90");
}
-20
View File
@@ -12,24 +12,4 @@ abstract final class UserStub {
profileChangedAt: DateTime(2021),
avatarColor: AvatarColor.green,
);
static final user1 = UserDto(
id: "user1",
email: "user1@test.com",
name: "user1",
isAdmin: false,
updatedAt: DateTime(2022),
profileChangedAt: DateTime(2022),
avatarColor: AvatarColor.red,
);
static final user2 = UserDto(
id: "user2",
email: "user2@test.com",
name: "user2",
isAdmin: false,
updatedAt: DateTime(2023),
profileChangedAt: DateTime(2023),
avatarColor: AvatarColor.primary,
);
}
@@ -1,49 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/exif.repository.dart';
import 'package:isar/isar.dart';
import '../../fixtures/exif.stub.dart';
import '../../test_utils.dart';
Future<void> _populateExifTable(Isar db) async {
await db.writeTxn(() async {
await db.exifInfos.putAll([
ExifInfo.fromDto(ExifStub.size),
ExifInfo.fromDto(ExifStub.gps),
ExifInfo.fromDto(ExifStub.rotated90CW),
ExifInfo.fromDto(ExifStub.rotated270CW),
]);
});
}
void main() {
late Isar db;
late IsarExifRepository sut;
setUp(() async {
db = await TestUtils.initIsar();
sut = IsarExifRepository(db);
});
group("Return with proper orientation", () {
setUp(() async {
await _populateExifTable(db);
});
test("isFlipped true for 90CW", () async {
final exif = await sut.get(ExifStub.rotated90CW.assetId!);
expect(exif!.isFlipped, true);
});
test("isFlipped true for 270CW", () async {
final exif = await sut.get(ExifStub.rotated270CW.assetId!);
expect(exif!.isFlipped, true);
});
test("isFlipped false for the original non-rotated image", () async {
final exif = await sut.get(ExifStub.size.assetId!);
expect(exif!.isFlipped, false);
});
});
}
@@ -1,14 +1,15 @@
import 'dart:async';
import 'package:drift/drift.dart' hide isNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.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);
@@ -16,30 +17,54 @@ 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);
Future<void> _populateStore(Drift db) async {
await db.batch((batch) async {
batch.insert(
db.storeEntity,
StoreEntityCompanion(
id: Value(StoreKey.colorfulInterface.id),
intValue: const Value(_kTestColorfulInterface ? 1 : 0),
stringValue: const Value(null),
),
);
batch.insert(
db.storeEntity,
StoreEntityCompanion(
id: Value(StoreKey.backupFailedSince.id),
intValue: Value(_kTestBackupFailed.millisecondsSinceEpoch),
stringValue: const Value(null),
),
);
batch.insert(
db.storeEntity,
StoreEntityCompanion(
id: Value(StoreKey.accessToken.id),
intValue: const Value(null),
stringValue: const Value(_kTestAccessToken),
),
);
batch.insert(
db.storeEntity,
StoreEntityCompanion(
id: Value(StoreKey.version.id),
intValue: const Value(_kTestVersion),
stringValue: const Value(null),
),
);
});
}
void main() {
late Isar db;
late IsarStoreRepository sut;
late Drift db;
late DriftStoreRepository sut;
setUp(() async {
db = await TestUtils.initIsar();
sut = IsarStoreRepository(db);
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
sut = DriftStoreRepository(db);
});
tearDown(() async {
await db.close();
});
group('Store Repository converters:', () {
@@ -98,10 +123,10 @@ void main() {
});
test('deleteAll()', () async {
final count = await db.storeValues.count();
final count = await db.storeEntity.count().getSingle();
expect(count, isNot(isZero));
await sut.deleteAll();
unawaited(expectLater(await db.storeValues.count(), isZero));
unawaited(expectLater(await db.storeEntity.count().getSingle(), isZero));
});
});
@@ -1,10 +1,13 @@
import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/utils/semver.dart';
@@ -13,7 +16,6 @@ import 'package:openapi/api.dart';
import '../../api.mocks.dart';
import '../../service.mocks.dart';
import '../../test_utils.dart';
class MockHttpClient extends Mock implements http.Client {}
@@ -38,7 +40,8 @@ void main() {
late int testBatchSize = 3;
setUpAll(() async {
await StoreService.init(storeRepository: IsarStoreRepository(await TestUtils.initIsar()));
final db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
});
setUp(() {
@@ -137,7 +140,7 @@ void main() {
bool abortWasCalledInCallback = false;
final Completer<void> firstBatchReceived = Completer<void>();
Future<void> onDataCallback(List<SyncEvent> events, Function() abort, Function() _) async {
Future<void> onDataCallback(List<SyncEvent> _, Function() abort, Function() _) async {
onDataCallCount++;
if (onDataCallCount == 1) {
abort();
@@ -241,7 +244,7 @@ void main() {
final streamError = Exception("Network Error");
int onDataCallCount = 0;
Future<void> onDataCallback(List<SyncEvent> events, Function() _, Function() __) async {
Future<void> onDataCallback(List<SyncEvent> _, Function() _, Function() __) async {
onDataCallCount++;
}
@@ -267,7 +270,7 @@ void main() {
when(() => mockStreamedResponse.stream).thenAnswer((_) => http.ByteStream(errorBodyController.stream));
int onDataCallCount = 0;
Future<void> onDataCallback(List<SyncEvent> events, Function() _, Function() __) async {
Future<void> onDataCallback(List<SyncEvent> _, Function() _, Function() __) async {
onDataCallCount++;
}
@@ -1,5 +1,4 @@
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/device_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
@@ -11,22 +10,15 @@ import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.da
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:mocktail/mocktail.dart';
class MockStoreRepository extends Mock implements IsarStoreRepository {}
class MockDriftStoreRepository extends Mock implements DriftStoreRepository {}
class MockLogRepository extends Mock implements LogRepository {}
class MockIsarUserRepository extends Mock implements IsarUserRepository {}
class MockDeviceAssetRepository extends Mock implements IsarDeviceAssetRepository {}
class MockSyncStreamRepository extends Mock implements SyncStreamRepository {}
class MockLocalAlbumRepository extends Mock implements DriftLocalAlbumRepository {}
@@ -1,175 +0,0 @@
@Skip('currently failing due to mock HTTP client to download ISAR binaries')
@Tags(['widget'])
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/pages/common/activities.page.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/activities/activity_text_field.dart';
import 'package:immich_mobile/widgets/activities/dismissible_activity.dart';
import 'package:isar/isar.dart';
import 'package:mocktail/mocktail.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../fixtures/album.stub.dart';
import '../../fixtures/asset.stub.dart';
import '../../fixtures/user.stub.dart';
import '../../test_utils.dart';
import '../../widget_tester_extensions.dart';
import '../album/album_mocks.dart';
import '../asset_viewer/asset_viewer_mocks.dart';
import '../shared/shared_mocks.dart';
import 'activity_mocks.dart';
final _activities = [
Activity(
id: '1',
createdAt: DateTime(100),
type: ActivityType.comment,
comment: 'First Activity',
assetId: 'asset-2',
user: UserStub.admin,
),
Activity(
id: '2',
createdAt: DateTime(200),
type: ActivityType.comment,
comment: 'Second Activity',
user: UserStub.user1,
),
Activity(id: '3', createdAt: DateTime(300), type: ActivityType.like, assetId: 'asset-1', user: UserStub.user2),
Activity(id: '4', createdAt: DateTime(400), type: ActivityType.like, user: UserStub.user1),
];
void main() {
late MockAlbumActivity activityMock;
late MockCurrentAlbumProvider mockCurrentAlbumProvider;
late MockCurrentAssetProvider mockCurrentAssetProvider;
late List<Override> overrides;
late Isar db;
setUpAll(() async {
TestUtils.init();
db = await TestUtils.initIsar();
await StoreService.init(storeRepository: IsarStoreRepository(db));
await Store.put(StoreKey.currentUser, UserStub.admin);
await Store.put(StoreKey.serverEndpoint, '');
await Store.put(StoreKey.accessToken, '');
});
setUp(() async {
mockCurrentAlbumProvider = MockCurrentAlbumProvider(AlbumStub.twoAsset);
mockCurrentAssetProvider = MockCurrentAssetProvider(AssetStub.image1);
activityMock = MockAlbumActivity(_activities);
overrides = [
albumActivityProvider(AlbumStub.twoAsset.remoteId!, AssetStub.image1.remoteId!).overrideWith(() => activityMock),
currentAlbumProvider.overrideWith(() => mockCurrentAlbumProvider),
currentAssetProvider.overrideWith(() => mockCurrentAssetProvider),
];
await db.writeTxn(() async {
await db.clear();
// Save all assets
await db.users.put(User.fromDto(UserStub.admin));
await db.assets.putAll([AssetStub.image1, AssetStub.image2]);
await db.albums.put(AlbumStub.twoAsset);
await AlbumStub.twoAsset.owner.save();
await AlbumStub.twoAsset.assets.save();
});
expect(db.albums.countSync(), 1);
expect(db.assets.countSync(), 2);
expect(db.users.countSync(), 1);
});
group("App bar", () {
testWidgets("No title when currentAsset != null", (tester) async {
await tester.pumpConsumerWidget(const ActivitiesPage(), overrides: overrides);
final listTile = tester.widget<AppBar>(find.byType(AppBar));
expect(listTile.title, isNull);
});
testWidgets("Album name as title when currentAsset == null", (tester) async {
await tester.pumpConsumerWidget(const ActivitiesPage(), overrides: overrides);
await tester.pumpAndSettle();
mockCurrentAssetProvider.state = null;
await tester.pumpAndSettle();
expect(find.text(AlbumStub.twoAsset.name), findsOneWidget);
final listTile = tester.widget<AppBar>(find.byType(AppBar));
expect(listTile.title, isNotNull);
});
});
group("Body", () {
testWidgets("Contains a stack with Activity List and Activity Input", (tester) async {
await tester.pumpConsumerWidget(const ActivitiesPage(), overrides: overrides);
await tester.pumpAndSettle();
expect(find.descendant(of: find.byType(Stack), matching: find.byType(ActivityTextField)), findsOneWidget);
expect(find.descendant(of: find.byType(Stack), matching: find.byType(ListView)), findsOneWidget);
});
testWidgets("List Contains all dismissible activities", (tester) async {
await tester.pumpConsumerWidget(const ActivitiesPage(), overrides: overrides);
await tester.pumpAndSettle();
final listFinder = find.descendant(of: find.byType(Stack), matching: find.byType(ListView));
final listChildren = find.descendant(of: listFinder, matching: find.byType(DismissibleActivity));
expect(listChildren, findsNWidgets(_activities.length));
});
testWidgets("Submitting text input adds a comment with the text", (tester) async {
await tester.pumpConsumerWidget(const ActivitiesPage(), overrides: overrides);
await tester.pumpAndSettle();
when(() => activityMock.addComment(any())).thenAnswer((_) => Future.value());
final textField = find.byType(TextField);
await tester.enterText(textField, 'Test comment');
await tester.testTextInput.receiveAction(TextInputAction.done);
verify(() => activityMock.addComment('Test comment'));
});
testWidgets("Owner can remove all activities", (tester) async {
await tester.pumpConsumerWidget(const ActivitiesPage(), overrides: overrides);
await tester.pumpAndSettle();
final deletableActivityFinder = find.byWidgetPredicate(
(widget) => widget is DismissibleActivity && widget.onDismiss != null,
);
expect(deletableActivityFinder, findsNWidgets(_activities.length));
});
testWidgets("Non-Owner can remove only their activities", (tester) async {
final mockCurrentUser = MockCurrentUserProvider();
await tester.pumpConsumerWidget(
const ActivitiesPage(),
overrides: [...overrides, currentUserProvider.overrideWith((ref) => mockCurrentUser)],
);
mockCurrentUser.state = UserStub.user1;
await tester.pumpAndSettle();
final deletableActivityFinder = find.byWidgetPredicate(
(widget) => widget is DismissibleActivity && widget.onDismiss != null,
);
expect(deletableActivityFinder, findsNWidgets(_activities.where((a) => a.user == UserStub.user1).length));
});
});
}
@@ -1,19 +0,0 @@
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/activity_statistics.provider.dart';
import 'package:immich_mobile/services/activity.service.dart';
import 'package:mocktail/mocktail.dart';
class ActivityServiceMock extends Mock implements ActivityService {}
class MockAlbumActivity extends AlbumActivityInternal with Mock implements AlbumActivity {
List<Activity>? initActivities;
MockAlbumActivity([this.initActivities]);
@override
Future<List<Activity>> build(String albumId, [String? assetId]) async {
return initActivities ?? [];
}
}
class ActivityStatisticsMock extends ActivityStatisticsInternal with Mock implements ActivityStatistics {}
@@ -1,331 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/activity_service.provider.dart';
import 'package:immich_mobile/providers/activity_statistics.provider.dart';
import 'package:mocktail/mocktail.dart';
import '../../fixtures/user.stub.dart';
import '../../test_utils.dart';
import 'activity_mocks.dart';
final _activities = [
Activity(
id: '1',
createdAt: DateTime(100),
type: ActivityType.comment,
comment: 'First Activity',
assetId: 'asset-2',
user: UserStub.admin,
),
Activity(
id: '2',
createdAt: DateTime(200),
type: ActivityType.comment,
comment: 'Second Activity',
user: UserStub.user1,
),
Activity(id: '3', createdAt: DateTime(300), type: ActivityType.like, assetId: 'asset-1', user: UserStub.admin),
Activity(id: '4', createdAt: DateTime(400), type: ActivityType.like, user: UserStub.user1),
];
void main() {
late ActivityServiceMock activityMock;
late ActivityStatisticsMock activityStatisticsMock;
late ActivityStatisticsMock albumActivityStatisticsMock;
late ProviderContainer container;
late AlbumActivityProvider provider;
late ListenerMock<AsyncValue<List<Activity>>> listener;
setUpAll(() {
registerFallbackValue(AsyncData<List<Activity>>([..._activities]));
});
setUp(() async {
activityMock = ActivityServiceMock();
activityStatisticsMock = ActivityStatisticsMock();
albumActivityStatisticsMock = ActivityStatisticsMock();
container = TestUtils.createContainer(
overrides: [
activityServiceProvider.overrideWith((ref) => activityMock),
activityStatisticsProvider('test-album', 'test-asset').overrideWith(() => activityStatisticsMock),
activityStatisticsProvider('test-album').overrideWith(() => albumActivityStatisticsMock),
],
);
// Mock values
when(() => activityStatisticsMock.build(any(), any())).thenReturn(0);
when(() => albumActivityStatisticsMock.build(any())).thenReturn(0);
when(
() => activityMock.getAllActivities('test-album', assetId: 'test-asset'),
).thenAnswer((_) async => [..._activities]);
when(() => activityMock.getAllActivities('test-album')).thenAnswer((_) async => [..._activities]);
// Init and wait for providers future to complete
provider = albumActivityProvider('test-album', 'test-asset');
listener = ListenerMock();
container.listen(provider, listener.call, fireImmediately: true);
await container.read(provider.future);
});
test('Returns a list of activity', () async {
verifyInOrder([
() => listener.call(null, const AsyncLoading()),
() => listener.call(
const AsyncLoading(),
any(
that: allOf([
isA<AsyncData<List<Activity>>>(),
predicate((AsyncData<List<Activity>> ad) => ad.requireValue.every((e) => _activities.contains(e))),
]),
),
),
]);
verifyNoMoreInteractions(listener);
});
group('addLike()', () {
test('Like successfully added', () async {
final like = Activity(id: '5', createdAt: DateTime(2023), type: ActivityType.like, user: UserStub.admin);
when(
() => activityMock.addActivity('test-album', ActivityType.like, assetId: 'test-asset'),
).thenAnswer((_) async => AsyncData(like));
final albumProvider = albumActivityProvider('test-album');
container.read(albumProvider.notifier);
await container.read(albumProvider.future);
await container.read(provider.notifier).addLike();
verify(() => activityMock.addActivity('test-album', ActivityType.like, assetId: 'test-asset'));
final activities = await container.read(provider.future);
expect(activities, hasLength(5));
expect(activities, contains(like));
// Never bump activity count for new likes
verifyNever(() => activityStatisticsMock.addActivity());
verifyNever(() => albumActivityStatisticsMock.addActivity());
final albumActivities = container.read(albumProvider).requireValue;
expect(albumActivities, hasLength(5));
expect(albumActivities, contains(like));
});
test('Like failed', () async {
final like = Activity(id: '5', createdAt: DateTime(2023), type: ActivityType.like, user: UserStub.admin);
when(
() => activityMock.addActivity('test-album', ActivityType.like, assetId: 'test-asset'),
).thenAnswer((_) async => AsyncError(Exception('Mock'), StackTrace.current));
final albumProvider = albumActivityProvider('test-album');
container.read(albumProvider.notifier);
await container.read(albumProvider.future);
await container.read(provider.notifier).addLike();
verify(() => activityMock.addActivity('test-album', ActivityType.like, assetId: 'test-asset'));
final activities = await container.read(provider.future);
expect(activities, hasLength(4));
expect(activities, isNot(contains(like)));
verifyNever(() => albumActivityStatisticsMock.addActivity());
final albumActivities = container.read(albumProvider).requireValue;
expect(albumActivities, hasLength(4));
expect(albumActivities, isNot(contains(like)));
});
});
group('removeActivity()', () {
test('Like successfully removed', () async {
when(() => activityMock.removeActivity('3')).thenAnswer((_) async => true);
await container.read(provider.notifier).removeActivity('3');
verify(() => activityMock.removeActivity('3'));
final activities = await container.read(provider.future);
expect(activities, hasLength(3));
expect(activities, isNot(anyElement(predicate((Activity a) => a.id == '3'))));
verifyNever(() => activityStatisticsMock.removeActivity());
verifyNever(() => albumActivityStatisticsMock.removeActivity());
});
test('Remove Like failed', () async {
when(() => activityMock.removeActivity('3')).thenAnswer((_) async => false);
await container.read(provider.notifier).removeActivity('3');
final activities = await container.read(provider.future);
expect(activities, hasLength(4));
expect(activities, anyElement(predicate((Activity a) => a.id == '3')));
verifyNever(() => activityStatisticsMock.removeActivity());
verifyNever(() => albumActivityStatisticsMock.removeActivity());
});
test('Comment successfully removed', () async {
when(() => activityMock.removeActivity('1')).thenAnswer((_) async => true);
await container.read(provider.notifier).removeActivity('1');
final activities = await container.read(provider.future);
expect(activities, isNot(anyElement(predicate((Activity a) => a.id == '1'))));
verify(() => activityStatisticsMock.removeActivity());
verify(() => albumActivityStatisticsMock.removeActivity());
});
test('Removes activity from album state when asset scoped', () async {
when(() => activityMock.removeActivity('3')).thenAnswer((_) async => true);
when(() => activityMock.getAllActivities('test-album')).thenAnswer((_) async => [..._activities]);
final albumProvider = albumActivityProvider('test-album');
container.read(albumProvider.notifier);
await container.read(albumProvider.future);
await container.read(provider.notifier).removeActivity('3');
final assetActivities = container.read(provider).requireValue;
final albumActivities = container.read(albumProvider).requireValue;
expect(assetActivities, hasLength(3));
expect(assetActivities, isNot(anyElement(predicate((Activity a) => a.id == '3'))));
expect(albumActivities, hasLength(3));
expect(albumActivities, isNot(anyElement(predicate((Activity a) => a.id == '3'))));
verify(() => activityMock.removeActivity('3'));
verifyNever(() => activityStatisticsMock.removeActivity());
verifyNever(() => albumActivityStatisticsMock.removeActivity());
});
});
group('addComment()', () {
test('Comment successfully added', () async {
final comment = Activity(
id: '5',
createdAt: DateTime(2023),
type: ActivityType.comment,
user: UserStub.admin,
comment: 'Test-Comment',
assetId: 'test-asset',
);
final albumProvider = albumActivityProvider('test-album');
container.read(albumProvider.notifier);
await container.read(albumProvider.future);
when(
() => activityMock.addActivity(
'test-album',
ActivityType.comment,
assetId: 'test-asset',
comment: 'Test-Comment',
),
).thenAnswer((_) async => AsyncData(comment));
when(() => activityStatisticsMock.build('test-album', 'test-asset')).thenReturn(4);
when(() => albumActivityStatisticsMock.build('test-album')).thenReturn(2);
await container.read(provider.notifier).addComment('Test-Comment');
verify(
() => activityMock.addActivity(
'test-album',
ActivityType.comment,
assetId: 'test-asset',
comment: 'Test-Comment',
),
);
final activities = await container.read(provider.future);
expect(activities, hasLength(5));
expect(activities, contains(comment));
verify(() => activityStatisticsMock.addActivity());
verify(() => albumActivityStatisticsMock.addActivity());
final albumActivities = container.read(albumProvider).requireValue;
expect(albumActivities, hasLength(5));
expect(albumActivities, contains(comment));
});
test('Comment successfully added without assetId', () async {
final comment = Activity(
id: '5',
createdAt: DateTime(2023),
type: ActivityType.comment,
user: UserStub.admin,
assetId: 'test-asset',
comment: 'Test-Comment',
);
when(
() => activityMock.addActivity('test-album', ActivityType.comment, comment: 'Test-Comment'),
).thenAnswer((_) async => AsyncData(comment));
when(() => albumActivityStatisticsMock.build('test-album')).thenReturn(2);
when(() => activityMock.getAllActivities('test-album')).thenAnswer((_) async => [..._activities]);
final albumProvider = albumActivityProvider('test-album');
container.read(albumProvider.notifier);
await container.read(albumProvider.future);
await container.read(albumProvider.notifier).addComment('Test-Comment');
verify(
() => activityMock.addActivity('test-album', ActivityType.comment, assetId: null, comment: 'Test-Comment'),
);
final activities = await container.read(albumProvider.future);
expect(activities, hasLength(5));
expect(activities, contains(comment));
verifyNever(() => activityStatisticsMock.addActivity());
verify(() => albumActivityStatisticsMock.addActivity());
});
test('Comment failed', () async {
final comment = Activity(
id: '5',
createdAt: DateTime(2023),
type: ActivityType.comment,
user: UserStub.admin,
comment: 'Test-Comment',
assetId: 'test-asset',
);
when(
() => activityMock.addActivity(
'test-album',
ActivityType.comment,
assetId: 'test-asset',
comment: 'Test-Comment',
),
).thenAnswer((_) async => AsyncError(Exception('Error'), StackTrace.current));
final albumProvider = albumActivityProvider('test-album');
container.read(albumProvider.notifier);
await container.read(albumProvider.future);
await container.read(provider.notifier).addComment('Test-Comment');
final activities = await container.read(provider.future);
expect(activities, hasLength(4));
expect(activities, isNot(contains(comment)));
verifyNever(() => activityStatisticsMock.addActivity());
verifyNever(() => albumActivityStatisticsMock.addActivity());
final albumActivities = container.read(albumProvider).requireValue;
expect(albumActivities, hasLength(4));
expect(albumActivities, isNot(contains(comment)));
});
});
}
@@ -1,71 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/providers/activity_service.provider.dart';
import 'package:immich_mobile/providers/activity_statistics.provider.dart';
import 'package:mocktail/mocktail.dart';
import '../../test_utils.dart';
import 'activity_mocks.dart';
void main() {
late ActivityServiceMock activityMock;
late ProviderContainer container;
late ListenerMock<int> listener;
setUp(() async {
activityMock = ActivityServiceMock();
container = TestUtils.createContainer(overrides: [activityServiceProvider.overrideWith((ref) => activityMock)]);
listener = ListenerMock();
});
test('Returns the proper count family', () async {
when(
() => activityMock.getStatistics('test-album', assetId: 'test-asset'),
).thenAnswer((_) async => const ActivityStats(comments: 5));
// Read here to make the getStatistics call
container.read(activityStatisticsProvider('test-album', 'test-asset'));
container.listen(activityStatisticsProvider('test-album', 'test-asset'), listener.call, fireImmediately: true);
// Sleep for the getStatistics future to resolve
await Future.delayed(const Duration(milliseconds: 1));
verifyInOrder([() => listener.call(null, 0), () => listener.call(0, 5)]);
verifyNoMoreInteractions(listener);
});
test('Adds activity', () async {
when(() => activityMock.getStatistics('test-album')).thenAnswer((_) async => const ActivityStats(comments: 10));
final provider = activityStatisticsProvider('test-album');
container.listen(provider, listener.call, fireImmediately: true);
// Sleep for the getStatistics future to resolve
await Future.delayed(const Duration(milliseconds: 1));
container.read(provider.notifier).addActivity();
container.read(provider.notifier).addActivity();
expect(container.read(provider), 12);
});
test('Removes activity', () async {
when(
() => activityMock.getStatistics('new-album', assetId: 'test-asset'),
).thenAnswer((_) async => const ActivityStats(comments: 10));
final provider = activityStatisticsProvider('new-album', 'test-asset');
container.listen(provider, listener.call, fireImmediately: true);
// Sleep for the getStatistics future to resolve
await Future.delayed(const Duration(milliseconds: 1));
container.read(provider.notifier).removeActivity();
container.read(provider.notifier).removeActivity();
expect(container.read(provider), 8);
});
}
@@ -1,149 +0,0 @@
@Skip('currently failing due to mock HTTP client to download ISAR binaries')
@Tags(['widget'])
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/activities/activity_text_field.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
import 'package:isar/isar.dart';
import 'package:mocktail/mocktail.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../fixtures/album.stub.dart';
import '../../fixtures/user.stub.dart';
import '../../test_utils.dart';
import '../../widget_tester_extensions.dart';
import '../album/album_mocks.dart';
import '../shared/shared_mocks.dart';
import 'activity_mocks.dart';
void main() {
late Isar db;
late MockCurrentAlbumProvider mockCurrentAlbumProvider;
late MockAlbumActivity activityMock;
late List<Override> overrides;
setUpAll(() async {
TestUtils.init();
db = await TestUtils.initIsar();
await StoreService.init(storeRepository: IsarStoreRepository(db));
await Store.put(StoreKey.currentUser, UserStub.admin);
await Store.put(StoreKey.serverEndpoint, '');
});
setUp(() {
mockCurrentAlbumProvider = MockCurrentAlbumProvider(AlbumStub.twoAsset);
activityMock = MockAlbumActivity();
overrides = [
currentAlbumProvider.overrideWith(() => mockCurrentAlbumProvider),
albumActivityProvider(AlbumStub.twoAsset.remoteId!).overrideWith(() => activityMock),
];
});
testWidgets('Returns an Input text field', (tester) async {
await tester.pumpConsumerWidget(ActivityTextField(onSubmit: (_) {}), overrides: overrides);
expect(find.byType(TextField), findsOneWidget);
});
testWidgets('No UserCircleAvatar when user == null', (tester) async {
final userProvider = MockCurrentUserProvider();
await tester.pumpConsumerWidget(
ActivityTextField(onSubmit: (_) {}),
overrides: [currentUserProvider.overrideWith((ref) => userProvider), ...overrides],
);
expect(find.byType(UserCircleAvatar), findsNothing);
});
testWidgets('UserCircleAvatar displayed when user != null', (tester) async {
await tester.pumpConsumerWidget(ActivityTextField(onSubmit: (_) {}), overrides: overrides);
expect(find.byType(UserCircleAvatar), findsOneWidget);
});
testWidgets('Filled icon if likedId != null', (tester) async {
await tester.pumpConsumerWidget(
ActivityTextField(onSubmit: (_) {}, likeId: '1'),
overrides: overrides,
);
expect(find.widgetWithIcon(IconButton, Icons.thumb_up), findsOneWidget);
expect(find.widgetWithIcon(IconButton, Icons.thumb_up_off_alt), findsNothing);
});
testWidgets('Bordered icon if likedId == null', (tester) async {
await tester.pumpConsumerWidget(ActivityTextField(onSubmit: (_) {}), overrides: overrides);
expect(find.widgetWithIcon(IconButton, Icons.thumb_up_off_alt), findsOneWidget);
expect(find.widgetWithIcon(IconButton, Icons.thumb_up), findsNothing);
});
testWidgets('Adds new like', (tester) async {
await tester.pumpConsumerWidget(ActivityTextField(onSubmit: (_) {}), overrides: overrides);
when(() => activityMock.addLike()).thenAnswer((_) => Future.value());
final suffixIcon = find.byType(IconButton);
await tester.tap(suffixIcon);
verify(() => activityMock.addLike());
});
testWidgets('Removes like if already liked', (tester) async {
await tester.pumpConsumerWidget(
ActivityTextField(onSubmit: (_) {}, likeId: 'test-suffix'),
overrides: overrides,
);
when(() => activityMock.removeActivity(any())).thenAnswer((_) => Future.value());
final suffixIcon = find.byType(IconButton);
await tester.tap(suffixIcon);
verify(() => activityMock.removeActivity('test-suffix'));
});
testWidgets('Passes text entered to onSubmit on submit', (tester) async {
String? receivedText;
await tester.pumpConsumerWidget(
ActivityTextField(onSubmit: (text) => receivedText = text, likeId: 'test-suffix'),
overrides: overrides,
);
final textField = find.byType(TextField);
await tester.enterText(textField, 'This is a test comment');
await tester.testTextInput.receiveAction(TextInputAction.done);
expect(receivedText, 'This is a test comment');
});
testWidgets('Input disabled when isEnabled false', (tester) async {
String? receviedText;
await tester.pumpConsumerWidget(
ActivityTextField(onSubmit: (text) => receviedText = text, isEnabled: false, likeId: 'test-suffix'),
overrides: overrides,
);
final suffixIcon = find.byType(IconButton);
await tester.tap(suffixIcon, warnIfMissed: false);
final textField = find.byType(TextField);
await tester.enterText(textField, 'This is a test comment');
await tester.testTextInput.receiveAction(TextInputAction.done);
expect(receviedText, isNull);
verifyNever(() => activityMock.addLike());
verifyNever(() => activityMock.removeActivity(any()));
});
}
@@ -1,165 +0,0 @@
@Skip('currently failing due to mock HTTP client to download ISAR binaries')
@Tags(['widget'])
library;
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/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/widgets/activities/activity_tile.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
import 'package:isar/isar.dart';
import '../../fixtures/asset.stub.dart';
import '../../fixtures/user.stub.dart';
import '../../test_utils.dart';
import '../../widget_tester_extensions.dart';
import '../asset_viewer/asset_viewer_mocks.dart';
void main() {
late MockCurrentAssetProvider assetProvider;
late List<Override> overrides;
late Isar db;
setUpAll(() async {
TestUtils.init();
db = await TestUtils.initIsar();
// For UserCircleAvatar
await StoreService.init(storeRepository: IsarStoreRepository(db));
await Store.put(StoreKey.currentUser, UserStub.admin);
await Store.put(StoreKey.serverEndpoint, '');
await Store.put(StoreKey.accessToken, '');
});
setUp(() {
assetProvider = MockCurrentAssetProvider();
overrides = [currentAssetProvider.overrideWith(() => assetProvider)];
});
testWidgets('Returns a ListTile', (tester) async {
await tester.pumpConsumerWidget(
ActivityTile(Activity(id: '1', createdAt: DateTime(100), type: ActivityType.like, user: UserStub.admin)),
overrides: overrides,
);
expect(find.byType(ListTile), findsOneWidget);
});
testWidgets('No trailing widget when activity assetId == null', (tester) async {
await tester.pumpConsumerWidget(
ActivityTile(Activity(id: '1', createdAt: DateTime(100), type: ActivityType.like, user: UserStub.admin)),
overrides: overrides,
);
final listTile = tester.widget<ListTile>(find.byType(ListTile));
expect(listTile.trailing, isNull);
});
testWidgets('Asset Thumbanil as trailing widget when activity assetId != null', (tester) async {
await tester.pumpConsumerWidget(
ActivityTile(
Activity(id: '1', createdAt: DateTime(100), type: ActivityType.like, user: UserStub.admin, assetId: '1'),
),
overrides: overrides,
);
final listTile = tester.widget<ListTile>(find.byType(ListTile));
expect(listTile.trailing, isNotNull);
// TODO: Validate this to be the common class after migrating ActivityTile#_ActivityAssetThumbnail to a common class
});
testWidgets('No trailing widget when current asset != null', (tester) async {
await tester.pumpConsumerWidget(
ActivityTile(
Activity(id: '1', createdAt: DateTime(100), type: ActivityType.like, user: UserStub.admin, assetId: '1'),
),
overrides: overrides,
);
assetProvider.state = AssetStub.image1;
await tester.pumpAndSettle();
final listTile = tester.widget<ListTile>(find.byType(ListTile));
expect(listTile.trailing, isNull);
});
group('Like Activity', () {
final activity = Activity(id: '1', createdAt: DateTime(100), type: ActivityType.like, user: UserStub.admin);
testWidgets('Like contains filled thumbs-up as leading', (tester) async {
await tester.pumpConsumerWidget(ActivityTile(activity), overrides: overrides);
// Leading widget should not be null
final listTile = tester.widget<ListTile>(find.byType(ListTile));
expect(listTile.leading, isNotNull);
// And should have a thumb_up icon
final thumbUpIconFinder = find.widgetWithIcon(listTile.leading!.runtimeType, Icons.thumb_up);
expect(thumbUpIconFinder, findsOneWidget);
});
testWidgets('Like title is center aligned', (tester) async {
await tester.pumpConsumerWidget(ActivityTile(activity), overrides: overrides);
final listTile = tester.widget<ListTile>(find.byType(ListTile));
expect(listTile.titleAlignment, ListTileTitleAlignment.center);
});
testWidgets('No subtitle for likes', (tester) async {
await tester.pumpConsumerWidget(ActivityTile(activity), overrides: overrides);
final listTile = tester.widget<ListTile>(find.byType(ListTile));
expect(listTile.subtitle, isNull);
});
});
group('Comment Activity', () {
final activity = Activity(
id: '1',
createdAt: DateTime(100),
type: ActivityType.comment,
comment: 'This is a test comment',
user: UserStub.admin,
);
testWidgets('Comment contains User Circle Avatar as leading', (tester) async {
await tester.pumpConsumerWidget(ActivityTile(activity), overrides: overrides);
final userAvatarFinder = find.byType(UserCircleAvatar);
expect(userAvatarFinder, findsOneWidget);
// Leading widget should not be null
final listTile = tester.widget<ListTile>(find.byType(ListTile));
expect(listTile.leading, isNotNull);
// Make sure that the leading widget is the UserCircleAvatar
final userAvatar = tester.widget<UserCircleAvatar>(userAvatarFinder);
expect(listTile.leading, userAvatar);
});
testWidgets('Comment title is top aligned', (tester) async {
await tester.pumpConsumerWidget(ActivityTile(activity), overrides: overrides);
final listTile = tester.widget<ListTile>(find.byType(ListTile));
expect(listTile.titleAlignment, ListTileTitleAlignment.top);
});
testWidgets('Contains comment text as subtitle', (tester) async {
await tester.pumpConsumerWidget(ActivityTile(activity), overrides: overrides);
final listTile = tester.widget<ListTile>(find.byType(ListTile));
expect(listTile.subtitle, isNotNull);
expect(find.descendant(of: find.byType(ListTile), matching: find.text(activity.comment!)), findsOneWidget);
});
});
}
@@ -1,99 +0,0 @@
@Tags(['widget'])
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/widgets/activities/activity_tile.dart';
import 'package:immich_mobile/widgets/activities/dismissible_activity.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../fixtures/user.stub.dart';
import '../../test_utils.dart';
import '../../widget_tester_extensions.dart';
import '../asset_viewer/asset_viewer_mocks.dart';
final activity = Activity(id: '1', createdAt: DateTime(100), type: ActivityType.like, user: UserStub.admin);
void main() {
late MockCurrentAssetProvider assetProvider;
late List<Override> overrides;
setUpAll(() => TestUtils.init());
setUp(() {
assetProvider = MockCurrentAssetProvider();
overrides = [currentAssetProvider.overrideWith(() => assetProvider)];
});
testWidgets('Returns a Dismissible', (tester) async {
await tester.pumpConsumerWidget(
DismissibleActivity('1', ActivityTile(activity), onDismiss: (_) {}),
overrides: overrides,
);
expect(find.byType(Dismissible), findsOneWidget);
});
testWidgets('Dialog displayed when onDismiss is set', (tester) async {
await tester.pumpConsumerWidget(
DismissibleActivity('1', ActivityTile(activity), onDismiss: (_) {}),
overrides: overrides,
);
final dismissible = find.byType(Dismissible);
await tester.drag(dismissible, const Offset(500, 0));
await tester.pumpAndSettle();
expect(find.byType(ConfirmDialog), findsOneWidget);
});
testWidgets('Ok action in ConfirmDialog should call onDismiss with activityId', (tester) async {
String? receivedActivityId;
await tester.pumpConsumerWidget(
DismissibleActivity('1', ActivityTile(activity), onDismiss: (id) => receivedActivityId = id),
overrides: overrides,
);
final dismissible = find.byType(Dismissible);
await tester.drag(dismissible, const Offset(-500, 0));
await tester.pumpAndSettle();
final okButton = find.text('delete');
await tester.tap(okButton);
await tester.pumpAndSettle();
expect(receivedActivityId, '1');
});
testWidgets('Delete icon for background if onDismiss is set', (tester) async {
await tester.pumpConsumerWidget(
DismissibleActivity('1', ActivityTile(activity), onDismiss: (_) {}),
overrides: overrides,
);
final dismissible = find.byType(Dismissible);
await tester.drag(dismissible, const Offset(500, 0));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.delete_sweep_rounded), findsOneWidget);
});
testWidgets('No delete dialog if onDismiss is not set', (tester) async {
await tester.pumpConsumerWidget(DismissibleActivity('1', ActivityTile(activity)), overrides: overrides);
// When onDismiss is not set, the widget should not be wrapped by a Dismissible
expect(find.byType(Dismissible), findsNothing);
expect(find.byType(ConfirmDialog), findsNothing);
});
testWidgets('No icon for background if onDismiss is not set', (tester) async {
await tester.pumpConsumerWidget(DismissibleActivity('1', ActivityTile(activity)), overrides: overrides);
// No Dismissible should exist when onDismiss is not provided, so no delete icon either
expect(find.byType(Dismissible), findsNothing);
expect(find.byIcon(Icons.delete_sweep_rounded), findsNothing);
});
}
@@ -1,13 +0,0 @@
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:mocktail/mocktail.dart';
class MockCurrentAlbumProvider extends CurrentAlbum with Mock implements CurrentAlbumInternal {
Album? initAlbum;
MockCurrentAlbumProvider([this.initAlbum]);
@override
Album? build() {
return initAlbum;
}
}
@@ -1,270 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:isar/isar.dart';
import 'package:mocktail/mocktail.dart';
import '../../fixtures/album.stub.dart';
import '../../fixtures/asset.stub.dart';
import '../../test_utils.dart';
import '../settings/settings_mocks.dart';
void main() {
/// Verify the sort modes
group("AlbumSortMode", () {
late final Isar db;
setUpAll(() async {
db = await TestUtils.initIsar();
});
final albums = [AlbumStub.emptyAlbum, AlbumStub.sharedWithUser, AlbumStub.oneAsset, AlbumStub.twoAsset];
setUp(() {
db.writeTxnSync(() {
db.clearSync();
// Save all assets
db.assets.putAllSync([AssetStub.image1, AssetStub.image2]);
db.albums.putAllSync(albums);
for (final album in albums) {
album.sharedUsers.saveSync();
album.assets.saveSync();
}
});
expect(db.albums.countSync(), 4);
expect(db.assets.countSync(), 2);
});
group("Album sort - Created Time", () {
const created = AlbumSortMode.created;
test("Created time - ASC", () {
final sorted = created.sortFn(albums, false);
final sortedList = [AlbumStub.emptyAlbum, AlbumStub.twoAsset, AlbumStub.oneAsset, AlbumStub.sharedWithUser];
expect(sorted, orderedEquals(sortedList));
});
test("Created time - DESC", () {
final sorted = created.sortFn(albums, true);
final sortedList = [AlbumStub.sharedWithUser, AlbumStub.oneAsset, AlbumStub.twoAsset, AlbumStub.emptyAlbum];
expect(sorted, orderedEquals(sortedList));
});
});
group("Album sort - Asset count", () {
const assetCount = AlbumSortMode.assetCount;
test("Asset Count - ASC", () {
final sorted = assetCount.sortFn(albums, false);
final sortedList = [AlbumStub.emptyAlbum, AlbumStub.sharedWithUser, AlbumStub.oneAsset, AlbumStub.twoAsset];
expect(sorted, orderedEquals(sortedList));
});
test("Asset Count - DESC", () {
final sorted = assetCount.sortFn(albums, true);
final sortedList = [AlbumStub.twoAsset, AlbumStub.oneAsset, AlbumStub.sharedWithUser, AlbumStub.emptyAlbum];
expect(sorted, orderedEquals(sortedList));
});
});
group("Album sort - Last modified", () {
const lastModified = AlbumSortMode.lastModified;
test("Last modified - ASC", () {
final sorted = lastModified.sortFn(albums, false);
final sortedList = [AlbumStub.twoAsset, AlbumStub.emptyAlbum, AlbumStub.sharedWithUser, AlbumStub.oneAsset];
expect(sorted, orderedEquals(sortedList));
});
test("Last modified - DESC", () {
final sorted = lastModified.sortFn(albums, true);
final sortedList = [AlbumStub.oneAsset, AlbumStub.sharedWithUser, AlbumStub.emptyAlbum, AlbumStub.twoAsset];
expect(sorted, orderedEquals(sortedList));
});
});
group("Album sort - Created", () {
const created = AlbumSortMode.created;
test("Created - ASC", () {
final sorted = created.sortFn(albums, false);
final sortedList = [AlbumStub.emptyAlbum, AlbumStub.twoAsset, AlbumStub.oneAsset, AlbumStub.sharedWithUser];
expect(sorted, orderedEquals(sortedList));
});
test("Created - DESC", () {
final sorted = created.sortFn(albums, true);
final sortedList = [AlbumStub.sharedWithUser, AlbumStub.oneAsset, AlbumStub.twoAsset, AlbumStub.emptyAlbum];
expect(sorted, orderedEquals(sortedList));
});
});
group("Album sort - Most Recent", () {
const mostRecent = AlbumSortMode.mostRecent;
test("Most Recent - DESC", () {
final sorted = mostRecent.sortFn([
AlbumStub.create2020end2020Album,
AlbumStub.create2020end2022Album,
AlbumStub.create2020end2024Album,
AlbumStub.create2020end2026Album,
], false);
final sortedList = [
AlbumStub.create2020end2026Album,
AlbumStub.create2020end2024Album,
AlbumStub.create2020end2022Album,
AlbumStub.create2020end2020Album,
];
expect(sorted, orderedEquals(sortedList));
});
test("Most Recent - ASC", () {
final sorted = mostRecent.sortFn([
AlbumStub.create2020end2020Album,
AlbumStub.create2020end2022Album,
AlbumStub.create2020end2024Album,
AlbumStub.create2020end2026Album,
], true);
final sortedList = [
AlbumStub.create2020end2020Album,
AlbumStub.create2020end2022Album,
AlbumStub.create2020end2024Album,
AlbumStub.create2020end2026Album,
];
expect(sorted, orderedEquals(sortedList));
});
});
group("Album sort - Most Oldest", () {
const mostOldest = AlbumSortMode.mostOldest;
test("Most Oldest - ASC", () {
final sorted = mostOldest.sortFn(albums, false);
final sortedList = [AlbumStub.twoAsset, AlbumStub.emptyAlbum, AlbumStub.oneAsset, AlbumStub.sharedWithUser];
expect(sorted, orderedEquals(sortedList));
});
test("Most Oldest - DESC", () {
final sorted = mostOldest.sortFn(albums, true);
final sortedList = [AlbumStub.sharedWithUser, AlbumStub.oneAsset, AlbumStub.emptyAlbum, AlbumStub.twoAsset];
expect(sorted, orderedEquals(sortedList));
});
});
});
/// Verify the sort mode provider
group('AlbumSortByOptions', () {
late AppSettingsService settingsMock;
late ProviderContainer container;
setUp(() async {
settingsMock = MockAppSettingsService();
container = TestUtils.createContainer(
overrides: [appSettingsServiceProvider.overrideWith((ref) => settingsMock)],
);
when(
() => settingsMock.setSetting<bool>(AppSettingsEnum.selectedAlbumSortReverse, any()),
).thenAnswer((_) async => {});
when(
() => settingsMock.setSetting<int>(AppSettingsEnum.selectedAlbumSortOrder, any()),
).thenAnswer((_) async => {});
});
test('Returns the default sort mode when none set', () {
// Returns the default value when nothing is set
when(() => settingsMock.getSetting(AppSettingsEnum.selectedAlbumSortOrder)).thenReturn(0);
expect(container.read(albumSortByOptionsProvider), AlbumSortMode.created);
});
test('Returns the correct sort mode with index from Store', () {
// Returns the default value when nothing is set
when(() => settingsMock.getSetting(AppSettingsEnum.selectedAlbumSortOrder)).thenReturn(3);
expect(container.read(albumSortByOptionsProvider), AlbumSortMode.lastModified);
});
test('Properly saves the correct store index of sort mode', () {
container.read(albumSortByOptionsProvider.notifier).changeSortMode(AlbumSortMode.mostOldest);
verify(
() => settingsMock.setSetting(AppSettingsEnum.selectedAlbumSortOrder, AlbumSortMode.mostOldest.storeIndex),
);
});
test('Notifies listeners on state change', () {
when(() => settingsMock.getSetting(AppSettingsEnum.selectedAlbumSortOrder)).thenReturn(0);
final listener = ListenerMock<AlbumSortMode>();
container.listen(albumSortByOptionsProvider, listener.call, fireImmediately: true);
// Created -> Most Oldest
container.read(albumSortByOptionsProvider.notifier).changeSortMode(AlbumSortMode.mostOldest);
// Most Oldest -> Title
container.read(albumSortByOptionsProvider.notifier).changeSortMode(AlbumSortMode.title);
verifyInOrder([
() => listener.call(null, AlbumSortMode.created),
() => listener.call(AlbumSortMode.created, AlbumSortMode.mostOldest),
() => listener.call(AlbumSortMode.mostOldest, AlbumSortMode.title),
]);
verifyNoMoreInteractions(listener);
});
});
/// Verify the sort order provider
group('AlbumSortOrder', () {
late AppSettingsService settingsMock;
late ProviderContainer container;
registerFallbackValue(AppSettingsEnum.selectedAlbumSortReverse);
setUp(() async {
settingsMock = MockAppSettingsService();
container = TestUtils.createContainer(
overrides: [appSettingsServiceProvider.overrideWith((ref) => settingsMock)],
);
when(
() => settingsMock.setSetting<bool>(AppSettingsEnum.selectedAlbumSortReverse, any()),
).thenAnswer((_) async => {});
when(
() => settingsMock.setSetting<int>(AppSettingsEnum.selectedAlbumSortOrder, any()),
).thenAnswer((_) async => {});
});
test('Returns the default sort order when none set - false', () {
when(() => settingsMock.getSetting(AppSettingsEnum.selectedAlbumSortReverse)).thenReturn(false);
expect(container.read(albumSortOrderProvider), isFalse);
});
test('Properly saves the correct order', () {
container.read(albumSortOrderProvider.notifier).changeSortDirection(true);
verify(() => settingsMock.setSetting(AppSettingsEnum.selectedAlbumSortReverse, true));
});
test('Notifies listeners on state change', () {
when(() => settingsMock.getSetting(AppSettingsEnum.selectedAlbumSortReverse)).thenReturn(false);
final listener = ListenerMock<bool>();
container.listen(albumSortOrderProvider, listener.call, fireImmediately: true);
// false -> true
container.read(albumSortOrderProvider.notifier).changeSortDirection(true);
// true -> false
container.read(albumSortOrderProvider.notifier).changeSortDirection(false);
verifyInOrder([
() => listener.call(null, false),
() => listener.call(false, true),
() => listener.call(true, false),
]);
verifyNoMoreInteractions(listener);
});
});
}
@@ -1,13 +0,0 @@
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:mocktail/mocktail.dart';
class MockCurrentAssetProvider extends CurrentAssetInternal with Mock implements CurrentAsset {
Asset? initAsset;
MockCurrentAssetProvider([this.initAsset]);
@override
Asset? build() {
return initAsset;
}
}
@@ -1,113 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/asset_extensions.dart';
import 'package:timezone/data/latest.dart';
import 'package:timezone/timezone.dart';
ExifInfo makeExif({DateTime? dateTimeOriginal, String? timeZone}) {
return ExifInfo(dateTimeOriginal: dateTimeOriginal, timeZone: timeZone);
}
Asset makeAsset({required String id, required DateTime createdAt, ExifInfo? exifInfo}) {
return Asset(
checksum: '',
localId: id,
remoteId: id,
ownerId: 1,
fileCreatedAt: createdAt,
fileModifiedAt: DateTime.now(),
updatedAt: DateTime.now(),
durationInSeconds: 0,
type: AssetType.image,
fileName: id,
isFavorite: false,
isArchived: false,
isTrashed: false,
exifInfo: exifInfo,
);
}
void main() {
// Init Timezone DB
initializeTimeZones();
group("Returns local time and offset if no exifInfo", () {
test('returns createdAt directly if in local', () {
final createdAt = DateTime(2023, 12, 12, 12, 12, 12);
final a = makeAsset(id: '1', createdAt: createdAt);
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
expect(createdAt, dt);
expect(createdAt.timeZoneOffset, tz);
});
test('returns createdAt in local if in utc', () {
final createdAt = DateTime.utc(2023, 12, 12, 12, 12, 12);
final a = makeAsset(id: '1', createdAt: createdAt);
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
final localCreatedAt = createdAt.toLocal();
expect(localCreatedAt, dt);
expect(localCreatedAt.timeZoneOffset, tz);
});
});
group("Returns dateTimeOriginal", () {
test('Returns dateTimeOriginal in UTC from exifInfo without timezone', () {
final createdAt = DateTime.parse("2023-01-27T14:00:00-0500");
final dateTimeOriginal = DateTime.parse("2022-01-27T14:00:00+0530");
final e = makeExif(dateTimeOriginal: dateTimeOriginal);
final a = makeAsset(id: '1', createdAt: createdAt, exifInfo: e);
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
final dateTimeInUTC = dateTimeOriginal.toUtc();
expect(dateTimeInUTC, dt);
expect(dateTimeInUTC.timeZoneOffset, tz);
});
test('Returns dateTimeOriginal in UTC from exifInfo with invalid timezone', () {
final createdAt = DateTime.parse("2023-01-27T14:00:00-0500");
final dateTimeOriginal = DateTime.parse("2022-01-27T14:00:00+0530");
final e = makeExif(dateTimeOriginal: dateTimeOriginal, timeZone: "#_#"); // Invalid timezone
final a = makeAsset(id: '1', createdAt: createdAt, exifInfo: e);
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
final dateTimeInUTC = dateTimeOriginal.toUtc();
expect(dateTimeInUTC, dt);
expect(dateTimeInUTC.timeZoneOffset, tz);
});
});
group("Returns adjusted time if timezone available", () {
test('With timezone as location', () {
final createdAt = DateTime.parse("2023-01-27T14:00:00-0500");
final dateTimeOriginal = DateTime.parse("2022-01-27T14:00:00+0530");
const location = "Asia/Hong_Kong";
final e = makeExif(dateTimeOriginal: dateTimeOriginal, timeZone: location);
final a = makeAsset(id: '1', createdAt: createdAt, exifInfo: e);
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
final adjustedTime = TZDateTime.from(dateTimeOriginal.toUtc(), getLocation(location));
expect(adjustedTime, dt);
expect(adjustedTime.timeZoneOffset, tz);
});
test('With timezone as offset', () {
final createdAt = DateTime.parse("2023-01-27T14:00:00-0500");
final dateTimeOriginal = DateTime.parse("2022-01-27T14:00:00+0530");
const offset = "utc+08:00";
final e = makeExif(dateTimeOriginal: dateTimeOriginal, timeZone: offset);
final a = makeAsset(id: '1', createdAt: createdAt, exifInfo: e);
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
final location = getLocation("Asia/Hong_Kong");
final offsetFromLocation = Duration(milliseconds: location.currentTimeZone.offset);
final adjustedTime = dateTimeOriginal.toUtc().add(offsetFromLocation);
// Adds the offset to the actual time and returns the offset separately
expect(adjustedTime, dt);
expect(offsetFromLocation, tz);
});
});
}
@@ -1,113 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
void main() {
final List<Asset> testAssets = [];
for (int i = 0; i < 150; i++) {
int month = i ~/ 31;
int day = (i % 31).toInt();
DateTime date = DateTime(2022, month, day);
testAssets.add(
Asset(
checksum: "",
localId: '$i',
ownerId: 1,
fileCreatedAt: date,
fileModifiedAt: date,
updatedAt: date,
durationInSeconds: 0,
type: AssetType.image,
fileName: '',
isFavorite: false,
isArchived: false,
isTrashed: false,
),
);
}
final List<Asset> assets = [];
assets.addAll(
testAssets.sublist(0, 5).map((e) {
e.fileCreatedAt = DateTime(2022, 1, 5);
return e;
}).toList(),
);
assets.addAll(
testAssets.sublist(5, 10).map((e) {
e.fileCreatedAt = DateTime(2022, 1, 10);
return e;
}).toList(),
);
assets.addAll(
testAssets.sublist(10, 15).map((e) {
e.fileCreatedAt = DateTime(2022, 2, 17);
return e;
}).toList(),
);
assets.addAll(
testAssets.sublist(15, 30).map((e) {
e.fileCreatedAt = DateTime(2022, 10, 15);
return e;
}).toList(),
);
group('Test grouped', () {
test('test grouped check months', () async {
final renderList = await RenderList.fromAssets(assets, GroupAssetsBy.day);
// Oct
// Day 1
// 15 Assets => 5 Rows
// Feb
// Day 1
// 5 Assets => 2 Rows
// Jan
// Day 2
// 5 Assets => 2 Rows
// Day 1
// 5 Assets => 2 Rows
expect(renderList.elements, hasLength(4));
expect(renderList.elements[0].type, RenderAssetGridElementType.monthTitle);
expect(renderList.elements[0].date.month, 1);
expect(renderList.elements[1].type, RenderAssetGridElementType.groupDividerTitle);
expect(renderList.elements[1].date.month, 1);
expect(renderList.elements[2].type, RenderAssetGridElementType.monthTitle);
expect(renderList.elements[2].date.month, 2);
expect(renderList.elements[3].type, RenderAssetGridElementType.monthTitle);
expect(renderList.elements[3].date.month, 10);
});
test('test grouped check types', () async {
final renderList = await RenderList.fromAssets(assets, GroupAssetsBy.day);
// Oct
// Day 1
// 15 Assets => 3 Rows
// Feb
// Day 1
// 5 Assets => 1 Row
// Jan
// Day 2
// 5 Assets => 1 Row
// Day 1
// 5 Assets => 1 Row
final types = [
RenderAssetGridElementType.monthTitle,
RenderAssetGridElementType.groupDividerTitle,
RenderAssetGridElementType.monthTitle,
RenderAssetGridElementType.monthTitle,
];
expect(renderList.elements, hasLength(types.length));
for (int i = 0; i < renderList.elements.length; i++) {
expect(renderList.elements[i].type, types[i]);
}
});
});
}
@@ -2,16 +2,18 @@
@Tags(['widget'])
library;
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/models/map/map_state.model.dart';
import 'package:immich_mobile/providers/locale_provider.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
import 'package:isar/isar.dart';
import '../../test_utils.dart';
import '../../widget_tester_extensions.dart';
@@ -21,17 +23,17 @@ void main() {
late MockMapStateNotifier mapStateNotifier;
late List<Override> overrides;
late MapState mapState;
late Isar db;
late Drift db;
setUpAll(() async {
db = await TestUtils.initIsar();
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
TestUtils.init();
});
setUp(() async {
mapState = const MapState(themeMode: ThemeMode.dark);
mapStateNotifier = MockMapStateNotifier(mapState);
await StoreService.init(storeRepository: IsarStoreRepository(db));
await StoreService.init(storeRepository: DriftStoreRepository(db));
overrides = [
mapStateNotifierProvider.overrideWith(() => mapStateNotifier),
localeProvider.overrideWithValue(const Locale("en")),
@@ -1,4 +0,0 @@
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:mocktail/mocktail.dart';
class MockAppSettingsService extends Mock implements AppSettingsService {}
@@ -1,11 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:mocktail/mocktail.dart';
class MockCurrentUserProvider extends StateNotifier<UserDto?> with Mock implements CurrentUserProvider {
MockCurrentUserProvider() : super(null);
@override
set state(UserDto? user) => super.state = user;
}
@@ -1,285 +0,0 @@
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:immich_mobile/services/sync.service.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';
import '../../test_utils.dart';
void main() {
int assetIdCounter = 0;
Asset makeAsset({
required String checksum,
String? localId,
String? remoteId,
int ownerId = 590700560494856554, // hash of "1"
}) {
final DateTime date = DateTime(2000);
return Asset(
id: assetIdCounter++,
checksum: checksum,
localId: localId,
remoteId: remoteId,
ownerId: ownerId,
fileCreatedAt: date,
fileModifiedAt: date,
updatedAt: date,
durationInSeconds: 0,
type: AssetType.image,
fileName: localId ?? remoteId ?? "",
isFavorite: false,
isArchived: false,
isTrashed: false,
);
}
final owner = UserDto(
id: "1",
updatedAt: DateTime.now(),
email: "a@b.c",
name: "first last",
isAdmin: false,
profileChangedAt: DateTime.now(),
);
setUpAll(() async {
final loggerDb = DriftLogger(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
final LogRepository logRepository = LogRepository(loggerDb);
WidgetsFlutterBinding.ensureInitialized();
final db = await TestUtils.initIsar();
db.writeTxnSync(() => db.clearSync());
await StoreService.init(storeRepository: IsarStoreRepository(db));
await Store.put(StoreKey.currentUser, owner);
await LogService.init(logRepository: logRepository, storeRepository: IsarStoreRepository(db));
});
group('Test SyncService grouped', () {
final MockHashService hs = MockHashService();
final MockEntityService entityService = MockEntityService();
final MockAlbumRepository albumRepository = MockAlbumRepository();
final MockAssetRepository assetRepository = MockAssetRepository();
final MockExifInfoRepository exifInfoRepository = MockExifInfoRepository();
final MockIsarUserRepository userRepository = MockIsarUserRepository();
final MockETagRepository eTagRepository = MockETagRepository();
final MockAlbumMediaRepository albumMediaRepository = MockAlbumMediaRepository();
final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository();
final MockAppSettingService appSettingService = MockAppSettingService();
final MockLocalFilesManagerRepository localFilesManagerRepository = MockLocalFilesManagerRepository();
final MockPartnerApiRepository partnerApiRepository = MockPartnerApiRepository();
final MockUserApiRepository userApiRepository = MockUserApiRepository();
final MockPartnerRepository partnerRepository = MockPartnerRepository();
final MockUserService userService = MockUserService();
final owner = UserDto(
id: "1",
updatedAt: DateTime.now(),
email: "a@b.c",
name: "first last",
isAdmin: false,
profileChangedAt: DateTime(2021),
);
late SyncService s;
final List<Asset> initialAssets = [
makeAsset(checksum: "a", remoteId: "0-1"),
makeAsset(checksum: "b", remoteId: "2-1"),
makeAsset(checksum: "c", localId: "1", remoteId: "1-1"),
makeAsset(checksum: "d", localId: "2"),
makeAsset(checksum: "e", localId: "3"),
];
setUp(() {
s = SyncService(
hs,
entityService,
albumMediaRepository,
albumApiRepository,
albumRepository,
assetRepository,
exifInfoRepository,
partnerRepository,
userRepository,
userService,
eTagRepository,
appSettingService,
localFilesManagerRepository,
partnerApiRepository,
userApiRepository,
);
when(() => userService.getMyUser()).thenReturn(owner);
when(() => eTagRepository.get(owner.id)).thenAnswer((_) async => ETag(id: owner.id, time: DateTime.now()));
when(() => eTagRepository.deleteByIds(["1"])).thenAnswer((_) async {});
when(() => eTagRepository.upsertAll(any())).thenAnswer((_) async {});
when(() => partnerRepository.getSharedWith()).thenAnswer((_) async => []);
when(() => userRepository.getAll(sortBy: SortUserBy.id)).thenAnswer((_) async => [owner]);
when(() => userRepository.getAll()).thenAnswer((_) async => [owner]);
when(
() => assetRepository.getAll(ownerId: owner.id, sortBy: AssetSort.checksum),
).thenAnswer((_) async => initialAssets);
when(
() => assetRepository.getAllByOwnerIdChecksum(any(), any()),
).thenAnswer((_) async => [initialAssets[3], null, null]);
when(() => assetRepository.updateAll(any())).thenAnswer((_) async => []);
when(() => assetRepository.deleteByIds(any())).thenAnswer((_) async {});
when(() => exifInfoRepository.updateAll(any())).thenAnswer((_) async => []);
when(
() => assetRepository.transaction<void>(any()),
).thenAnswer((call) => (call.positionalArguments.first as Function).call());
when(
() => assetRepository.transaction<Null>(any()),
).thenAnswer((call) => (call.positionalArguments.first as Function).call());
when(() => userApiRepository.getAll()).thenAnswer((_) async => [owner]);
registerFallbackValue(Direction.sharedByMe);
when(() => partnerApiRepository.getAll(any())).thenAnswer((_) async => []);
});
test('test inserting existing assets', () async {
final List<Asset> remoteAssets = [
makeAsset(checksum: "a", remoteId: "0-1"),
makeAsset(checksum: "b", remoteId: "2-1"),
makeAsset(checksum: "c", remoteId: "1-1"),
];
final bool c1 = await s.syncRemoteAssetsToDb(
users: [owner],
getChangedAssets: _failDiff,
loadAssets: (u, d) => remoteAssets,
);
expect(c1, isFalse);
verifyNever(() => assetRepository.updateAll(any()));
});
test('test inserting new assets', () async {
final List<Asset> remoteAssets = [
makeAsset(checksum: "a", remoteId: "0-1"),
makeAsset(checksum: "b", remoteId: "2-1"),
makeAsset(checksum: "c", remoteId: "1-1"),
makeAsset(checksum: "d", remoteId: "1-2"),
makeAsset(checksum: "f", remoteId: "1-4"),
makeAsset(checksum: "g", remoteId: "3-1"),
];
final bool c1 = await s.syncRemoteAssetsToDb(
users: [owner],
getChangedAssets: _failDiff,
loadAssets: (u, d) => remoteAssets,
);
expect(c1, isTrue);
final updatedAsset = initialAssets[3].updatedCopy(remoteAssets[3]);
verify(() => assetRepository.updateAll([remoteAssets[4], remoteAssets[5], updatedAsset]));
});
test('test syncing duplicate assets', () async {
final List<Asset> remoteAssets = [
makeAsset(checksum: "a", remoteId: "0-1"),
makeAsset(checksum: "b", remoteId: "1-1"),
makeAsset(checksum: "c", remoteId: "2-1"),
makeAsset(checksum: "h", remoteId: "2-1b"),
makeAsset(checksum: "i", remoteId: "2-1c"),
makeAsset(checksum: "j", remoteId: "2-1d"),
];
final bool c1 = await s.syncRemoteAssetsToDb(
users: [owner],
getChangedAssets: _failDiff,
loadAssets: (u, d) => remoteAssets,
);
expect(c1, isTrue);
when(
() => assetRepository.getAll(ownerId: owner.id, sortBy: AssetSort.checksum),
).thenAnswer((_) async => remoteAssets);
final bool c2 = await s.syncRemoteAssetsToDb(
users: [owner],
getChangedAssets: _failDiff,
loadAssets: (u, d) => remoteAssets,
);
expect(c2, isFalse);
final currentState = [...remoteAssets];
when(
() => assetRepository.getAll(ownerId: owner.id, sortBy: AssetSort.checksum),
).thenAnswer((_) async => currentState);
remoteAssets.removeAt(4);
final bool c3 = await s.syncRemoteAssetsToDb(
users: [owner],
getChangedAssets: _failDiff,
loadAssets: (u, d) => remoteAssets,
);
expect(c3, isTrue);
remoteAssets.add(makeAsset(checksum: "k", remoteId: "2-1e"));
remoteAssets.add(makeAsset(checksum: "l", remoteId: "2-2"));
final bool c4 = await s.syncRemoteAssetsToDb(
users: [owner],
getChangedAssets: _failDiff,
loadAssets: (u, d) => remoteAssets,
);
expect(c4, isTrue);
});
test('test efficient sync', () async {
when(
() => assetRepository.deleteAllByRemoteId([
initialAssets[1].remoteId!,
initialAssets[2].remoteId!,
], state: AssetState.remote),
).thenAnswer((_) async {
return;
});
when(
() => assetRepository.getAllByRemoteId(["2-1", "1-1"], state: AssetState.merged),
).thenAnswer((_) async => [initialAssets[2]]);
when(
() => assetRepository.getAllByOwnerIdChecksum(any(), any()),
).thenAnswer((_) async => [initialAssets[0], null, null]); //afg
final List<Asset> toUpsert = [
makeAsset(checksum: "a", remoteId: "0-1"), // changed
makeAsset(checksum: "f", remoteId: "0-2"), // new
makeAsset(checksum: "g", remoteId: "0-3"), // new
];
toUpsert[0].isFavorite = true;
final List<String> toDelete = ["2-1", "1-1"];
final expected = [...toUpsert];
expected[0].id = initialAssets[0].id;
final bool c = await s.syncRemoteAssetsToDb(
users: [owner],
getChangedAssets: (user, since) async => (toUpsert, toDelete),
loadAssets: (user, date) => throw Exception(),
);
expect(c, isTrue);
verify(() => assetRepository.updateAll(expected));
});
group("upsertAssetsWithExif", () {
test('test upsert with EXIF data', () async {
final assets = [AssetStub.image1, AssetStub.image2];
expect(assets.map((a) => a.exifInfo?.assetId), List.filled(assets.length, null));
await s.upsertAssetsWithExif(assets);
verify(
() => exifInfoRepository.updateAll(
any(that: containsAll(assets.map((a) => a.exifInfo!.copyWith(assetId: a.id)))),
),
);
expect(assets.map((a) => a.exifInfo?.assetId), assets.map((a) => a.id));
});
});
});
}
Future<(List<Asset>?, List<String>?)> _failDiff(List<UserDto> user, DateTime time) => Future.value((null, null));
@@ -1,131 +0,0 @@
import 'package:drift/drift.dart' hide isNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:mocktail/mocktail.dart';
import '../../infrastructure/repository.mock.dart';
void main() {
late Drift db;
late SyncStreamRepository mockSyncStreamRepository;
setUpAll(() async {
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
mockSyncStreamRepository = MockSyncStreamRepository();
when(() => mockSyncStreamRepository.reset()).thenAnswer((_) async => {});
});
tearDown(() async {
await Store.clear();
});
group('handleBetaMigration Tests', () {
group("version < 15", () {
test('already on new timeline', () async {
await Store.put(StoreKey.betaTimeline, true);
await handleBetaMigration(14, false, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.betaTimeline), true);
expect(Store.tryGet(StoreKey.needBetaMigration), false);
});
test('already on old timeline', () async {
await Store.put(StoreKey.betaTimeline, false);
await handleBetaMigration(14, false, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.needBetaMigration), true);
});
test('fresh install', () async {
await Store.delete(StoreKey.betaTimeline);
await handleBetaMigration(14, true, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.betaTimeline), true);
expect(Store.tryGet(StoreKey.needBetaMigration), false);
});
});
group("version == 15", () {
test('already on new timeline', () async {
await Store.put(StoreKey.betaTimeline, true);
await handleBetaMigration(15, false, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.betaTimeline), true);
expect(Store.tryGet(StoreKey.needBetaMigration), false);
});
test('already on old timeline', () async {
await Store.put(StoreKey.betaTimeline, false);
await handleBetaMigration(15, false, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.needBetaMigration), true);
});
test('fresh install', () async {
await Store.delete(StoreKey.betaTimeline);
await handleBetaMigration(15, true, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.betaTimeline), true);
expect(Store.tryGet(StoreKey.needBetaMigration), false);
});
});
group("version > 15", () {
test('already on new timeline', () async {
await Store.put(StoreKey.betaTimeline, true);
await handleBetaMigration(16, false, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.betaTimeline), true);
expect(Store.tryGet(StoreKey.needBetaMigration), false);
});
test('already on old timeline', () async {
await Store.put(StoreKey.betaTimeline, false);
await handleBetaMigration(16, false, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.betaTimeline), false);
expect(Store.tryGet(StoreKey.needBetaMigration), false);
});
test('fresh install', () async {
await Store.delete(StoreKey.betaTimeline);
await handleBetaMigration(16, true, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.betaTimeline), true);
expect(Store.tryGet(StoreKey.needBetaMigration), false);
});
});
});
group('sync reset tests', () {
test('version < 16', () async {
await Store.put(StoreKey.shouldResetSync, false);
await handleBetaMigration(15, false, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.shouldResetSync), true);
});
test('version >= 16', () async {
await Store.put(StoreKey.shouldResetSync, false);
await handleBetaMigration(16, false, mockSyncStreamRepository);
expect(Store.tryGet(StoreKey.shouldResetSync), false);
});
});
}
@@ -1,46 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/throttle.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class _Counter {
int _count = 0;
_Counter();
int get count => _count;
void increment() {
dPrint(() => "Counter inside increment: $count");
_count = _count + 1;
}
}
void main() {
test('Executes the method immediately if no calls received previously', () async {
var counter = _Counter();
final throttler = Throttler(interval: const Duration(milliseconds: 300));
throttler.run(() => counter.increment());
expect(counter.count, 1);
});
test('Does not execute calls before throttle interval', () async {
var counter = _Counter();
final throttler = Throttler(interval: const Duration(milliseconds: 100));
throttler.run(() => counter.increment());
throttler.run(() => counter.increment());
throttler.run(() => counter.increment());
throttler.run(() => counter.increment());
throttler.run(() => counter.increment());
await Future.delayed(const Duration(seconds: 1));
expect(counter.count, 1);
});
test('Executes the method if received in intervals', () async {
var counter = _Counter();
final throttler = Throttler(interval: const Duration(milliseconds: 100));
for (final _ in Iterable<int>.generate(10)) {
throttler.run(() => counter.increment());
await Future.delayed(const Duration(milliseconds: 50));
}
await Future.delayed(const Duration(seconds: 1));
expect(counter.count, 5);
});
}
@@ -1,63 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/utils/thumbnail_utils.dart';
void main() {
final dateTime = DateTime(2025, 04, 25, 12, 13, 14);
final dateTimeString = DateFormat.yMMMMd().format(dateTime);
test('returns description if it has one', () {
final result = getAltText(const ExifInfo(description: 'description'), dateTime, AssetType.image, []);
expect(result, 'description');
});
test('returns image alt text with date if no location', () {
final (template, args) = getAltTextTemplate(const ExifInfo(), dateTime, AssetType.image, []);
expect(template, "image_alt_text_date");
expect(args["isVideo"], "false");
expect(args["date"], dateTimeString);
});
test('returns image alt text with date and place', () {
final (template, args) = getAltTextTemplate(
const ExifInfo(city: 'city', country: 'country'),
dateTime,
AssetType.video,
[],
);
expect(template, "image_alt_text_date_place");
expect(args["isVideo"], "true");
expect(args["date"], dateTimeString);
expect(args["city"], "city");
expect(args["country"], "country");
});
test('returns image alt text with date and some people', () {
final (template, args) = getAltTextTemplate(const ExifInfo(), dateTime, AssetType.image, ["Alice", "Bob"]);
expect(template, "image_alt_text_date_2_people");
expect(args["isVideo"], "false");
expect(args["date"], dateTimeString);
expect(args["person1"], "Alice");
expect(args["person2"], "Bob");
});
test('returns image alt text with date and location and many people', () {
final (template, args) = getAltTextTemplate(
const ExifInfo(city: "city", country: 'country'),
dateTime,
AssetType.video,
["Alice", "Bob", "Carol", "David", "Eve"],
);
expect(template, "image_alt_text_date_place_4_or_more_people");
expect(args["isVideo"], "true");
expect(args["date"], dateTimeString);
expect(args["city"], "city");
expect(args["country"], "country");
expect(args["person1"], "Alice");
expect(args["person2"], "Bob");
expect(args["person3"], "Carol");
expect(args["additionalCount"], "2");
});
}
@@ -1,98 +0,0 @@
@Skip('currently failing due to mock HTTP client to download ISAR binaries')
@Tags(['pages'])
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/pages/search/search.page.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:isar/isar.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
import '../../dto.mocks.dart';
import '../../service.mocks.dart';
import '../../test_utils.dart';
import '../../widget_tester_extensions.dart';
void main() {
late List<Override> overrides;
late Isar db;
late MockApiService mockApiService;
late MockSearchApi mockSearchApi;
setUpAll(() async {
TestUtils.init();
db = await TestUtils.initIsar();
await StoreService.init(storeRepository: IsarStoreRepository(db));
mockApiService = MockApiService();
mockSearchApi = MockSearchApi();
when(() => mockApiService.searchApi).thenReturn(mockSearchApi);
registerFallbackValue(MockSmartSearchDto());
registerFallbackValue(MockMetadataSearchDto());
overrides = [
dbProvider.overrideWithValue(db),
isarProvider.overrideWithValue(db),
apiServiceProvider.overrideWithValue(mockApiService),
];
});
final emptyTextSearch = isA<MetadataSearchDto>().having((s) => s.originalFileName, 'originalFileName', null);
testWidgets('contextual search with/without text', (tester) async {
await tester.pumpConsumerWidget(const SearchPage(), overrides: overrides);
await tester.pumpAndSettle();
expect(find.byIcon(Icons.abc_rounded), findsOneWidget, reason: 'Should have contextual search icon');
final searchField = find.byKey(const Key('search_text_field'));
expect(searchField, findsOneWidget);
await tester.enterText(searchField, 'test');
await tester.testTextInput.receiveAction(TextInputAction.search);
var captured = verify(() => mockSearchApi.searchSmart(captureAny())).captured;
expect(captured.first, isA<SmartSearchDto>().having((s) => s.query, 'query', 'test'));
await tester.enterText(searchField, '');
await tester.testTextInput.receiveAction(TextInputAction.search);
captured = verify(() => mockSearchApi.searchAssets(captureAny())).captured;
expect(captured.first, emptyTextSearch);
});
testWidgets('not contextual search with/without text', (tester) async {
await tester.pumpConsumerWidget(const SearchPage(), overrides: overrides);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('contextual_search_button')));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.image_search_rounded), findsOneWidget, reason: 'Should not have contextual search icon');
final searchField = find.byKey(const Key('search_text_field'));
expect(searchField, findsOneWidget);
await tester.enterText(searchField, 'test');
await tester.testTextInput.receiveAction(TextInputAction.search);
var captured = verify(() => mockSearchApi.searchAssets(captureAny())).captured;
expect(captured.first, isA<MetadataSearchDto>().having((s) => s.originalFileName, 'originalFileName', 'test'));
await tester.enterText(searchField, '');
await tester.testTextInput.receiveAction(TextInputAction.search);
captured = verify(() => mockSearchApi.searchAssets(captureAny())).captured;
expect(captured.first, emptyTextSearch);
});
}
+3 -35
View File
@@ -1,48 +1,16 @@
import 'package:immich_mobile/infrastructure/repositories/exif.repository.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/album_api.repository.dart';
import 'package:immich_mobile/repositories/partner.repository.dart';
import 'package:immich_mobile/repositories/etag.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/repositories/backup.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/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/album.repository.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:mocktail/mocktail.dart';
class MockAlbumRepository extends Mock implements AlbumRepository {}
class MockAssetRepository extends Mock implements AssetRepository {}
class MockBackupRepository extends Mock implements BackupAlbumRepository {}
class MockExifInfoRepository extends Mock implements IsarExifRepository {}
class MockETagRepository extends Mock implements ETagRepository {}
class MockAlbumMediaRepository extends Mock implements AlbumMediaRepository {}
class MockBackupAlbumRepository extends Mock implements BackupAlbumRepository {}
class MockAssetApiRepository extends Mock implements AssetApiRepository {}
class MockAssetMediaRepository extends Mock implements AssetMediaRepository {}
class MockFileMediaRepository extends Mock implements FileMediaRepository {}
class MockAlbumApiRepository extends Mock implements AlbumApiRepository {}
class MockAuthApiRepository extends Mock implements AuthApiRepository {}
class MockAuthRepository extends Mock implements AuthRepository {}
class MockPartnerRepository extends Mock implements PartnerRepository {}
class MockPartnerApiRepository extends Mock implements PartnerApiRepository {}
class MockLocalFilesManagerRepository extends Mock implements LocalFilesManagerRepository {}
-21
View File
@@ -1,31 +1,10 @@
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/entity.service.dart';
import 'package:immich_mobile/services/hash.service.dart';
import 'package:immich_mobile/services/network.service.dart';
import 'package:immich_mobile/services/sync.service.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
class MockApiService extends Mock implements ApiService {}
class MockAlbumService extends Mock implements AlbumService {}
class MockBackupService extends Mock implements BackupService {}
class MockSyncService extends Mock implements SyncService {}
class MockHashService extends Mock implements HashService {}
class MockEntityService extends Mock implements EntityService {}
class MockNetworkService extends Mock implements NetworkService {}
class MockSearchApi extends Mock implements SearchApi {}
class MockAppSettingService extends Mock implements AppSettingsService {}
class MockBackgroundService extends Mock implements BackgroundService {}
@@ -1,177 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:mocktail/mocktail.dart';
import '../domain/service.mock.dart';
import '../fixtures/album.stub.dart';
import '../fixtures/asset.stub.dart';
import '../fixtures/user.stub.dart';
import '../repository.mocks.dart';
import '../service.mocks.dart';
void main() {
late AlbumService sut;
late MockUserService userService;
late MockSyncService syncService;
late MockEntityService entityService;
late MockAlbumRepository albumRepository;
late MockAssetRepository assetRepository;
late MockBackupRepository backupRepository;
late MockAlbumMediaRepository albumMediaRepository;
late MockAlbumApiRepository albumApiRepository;
setUp(() {
userService = MockUserService();
syncService = MockSyncService();
entityService = MockEntityService();
albumRepository = MockAlbumRepository();
assetRepository = MockAssetRepository();
backupRepository = MockBackupRepository();
albumMediaRepository = MockAlbumMediaRepository();
albumApiRepository = MockAlbumApiRepository();
when(() => userService.getMyUser()).thenReturn(UserStub.user1);
when(
() => albumRepository.transaction<void>(any()),
).thenAnswer((call) => (call.positionalArguments.first as Function).call());
when(
() => assetRepository.transaction<Null>(any()),
).thenAnswer((call) => (call.positionalArguments.first as Function).call());
sut = AlbumService(
syncService,
userService,
entityService,
albumRepository,
assetRepository,
backupRepository,
albumMediaRepository,
albumApiRepository,
);
});
group('refreshDeviceAlbums', () {
test('empty selection with one album in db', () async {
when(() => backupRepository.getIdsBySelection(BackupSelection.exclude)).thenAnswer((_) async => []);
when(() => backupRepository.getIdsBySelection(BackupSelection.select)).thenAnswer((_) async => []);
when(() => albumMediaRepository.getAll()).thenAnswer((_) async => []);
when(() => albumRepository.count(local: true)).thenAnswer((_) async => 1);
when(() => syncService.removeAllLocalAlbumsAndAssets()).thenAnswer((_) async => true);
final result = await sut.refreshDeviceAlbums();
expect(result, false);
verify(() => syncService.removeAllLocalAlbumsAndAssets());
});
test('one selected albums, two on device', () async {
when(() => backupRepository.getIdsBySelection(BackupSelection.exclude)).thenAnswer((_) async => []);
when(
() => backupRepository.getIdsBySelection(BackupSelection.select),
).thenAnswer((_) async => [AlbumStub.oneAsset.localId!]);
when(() => albumMediaRepository.getAll()).thenAnswer((_) async => [AlbumStub.oneAsset, AlbumStub.twoAsset]);
when(() => syncService.syncLocalAlbumAssetsToDb(any(), any())).thenAnswer((_) async => true);
final result = await sut.refreshDeviceAlbums();
expect(result, true);
verify(() => syncService.syncLocalAlbumAssetsToDb([AlbumStub.oneAsset], null)).called(1);
verifyNoMoreInteractions(syncService);
});
});
group('refreshRemoteAlbums', () {
test('is working', () async {
when(() => syncService.getUsersFromServer()).thenAnswer((_) async => []);
when(() => syncService.syncUsersFromServer(any())).thenAnswer((_) async => true);
when(() => albumApiRepository.getAll(shared: true)).thenAnswer((_) async => [AlbumStub.sharedWithUser]);
when(
() => albumApiRepository.getAll(shared: null),
).thenAnswer((_) async => [AlbumStub.oneAsset, AlbumStub.twoAsset]);
when(
() => syncService.syncRemoteAlbumsToDb([AlbumStub.twoAsset, AlbumStub.oneAsset, AlbumStub.sharedWithUser]),
).thenAnswer((_) async => true);
final result = await sut.refreshRemoteAlbums();
expect(result, true);
verify(() => syncService.getUsersFromServer()).called(1);
verify(() => syncService.syncUsersFromServer([])).called(1);
verify(() => albumApiRepository.getAll(shared: true)).called(1);
verify(() => albumApiRepository.getAll(shared: null)).called(1);
verify(
() => syncService.syncRemoteAlbumsToDb([AlbumStub.twoAsset, AlbumStub.oneAsset, AlbumStub.sharedWithUser]),
).called(1);
verifyNoMoreInteractions(userService);
verifyNoMoreInteractions(albumApiRepository);
verifyNoMoreInteractions(syncService);
});
});
group('createAlbum', () {
test('shared with assets', () async {
when(
() => albumApiRepository.create(
"name",
assetIds: any(named: "assetIds"),
sharedUserIds: any(named: "sharedUserIds"),
),
).thenAnswer((_) async => AlbumStub.oneAsset);
when(
() => entityService.fillAlbumWithDatabaseEntities(AlbumStub.oneAsset),
).thenAnswer((_) async => AlbumStub.oneAsset);
when(() => albumRepository.create(AlbumStub.oneAsset)).thenAnswer((_) async => AlbumStub.twoAsset);
final result = await sut.createAlbum("name", [AssetStub.image1], [UserStub.user1]);
expect(result, AlbumStub.twoAsset);
verify(
() => albumApiRepository.create(
"name",
assetIds: [AssetStub.image1.remoteId!],
sharedUserIds: [UserStub.user1.id],
),
).called(1);
verify(() => entityService.fillAlbumWithDatabaseEntities(AlbumStub.oneAsset)).called(1);
});
});
group('addAdditionalAssetToAlbum', () {
test('one added, one duplicate', () async {
when(
() => albumApiRepository.addAssets(AlbumStub.oneAsset.remoteId!, any()),
).thenAnswer((_) async => (added: [AssetStub.image2.remoteId!], duplicates: [AssetStub.image1.remoteId!]));
when(() => albumRepository.get(AlbumStub.oneAsset.id)).thenAnswer((_) async => AlbumStub.oneAsset);
when(() => albumRepository.addAssets(AlbumStub.oneAsset, [AssetStub.image2])).thenAnswer((_) async {});
when(() => albumRepository.removeAssets(AlbumStub.oneAsset, [])).thenAnswer((_) async {});
when(() => albumRepository.recalculateMetadata(AlbumStub.oneAsset)).thenAnswer((_) async => AlbumStub.oneAsset);
when(() => albumRepository.update(AlbumStub.oneAsset)).thenAnswer((_) async => AlbumStub.oneAsset);
final result = await sut.addAssets(AlbumStub.oneAsset, [AssetStub.image1, AssetStub.image2]);
expect(result != null, true);
expect(result!.alreadyInAlbum, [AssetStub.image1.remoteId!]);
expect(result.successfullyAdded, 1);
});
});
group('addAdditionalUserToAlbum', () {
test('one added', () async {
when(
() => albumApiRepository.addUsers(AlbumStub.emptyAlbum.remoteId!, any()),
).thenAnswer((_) async => AlbumStub.sharedWithUser);
when(
() => albumRepository.addUsers(
AlbumStub.emptyAlbum,
AlbumStub.emptyAlbum.sharedUsers.map((u) => u.toDto()).toList(),
),
).thenAnswer((_) async => AlbumStub.emptyAlbum);
when(() => albumRepository.update(AlbumStub.emptyAlbum)).thenAnswer((_) async => AlbumStub.emptyAlbum);
final result = await sut.addUsers(AlbumStub.emptyAlbum, [UserStub.user2.id]);
expect(result, true);
});
});
}
@@ -1,103 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/services/asset.service.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
import '../api.mocks.dart';
import '../domain/service.mock.dart';
import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
import '../service.mocks.dart';
class FakeAssetBulkUpdateDto extends Fake implements AssetBulkUpdateDto {}
void main() {
late AssetService sut;
late MockAssetRepository assetRepository;
late MockAssetApiRepository assetApiRepository;
late MockExifInfoRepository exifInfoRepository;
late MockETagRepository eTagRepository;
late MockBackupAlbumRepository backupAlbumRepository;
late MockIsarUserRepository userRepository;
late MockAssetMediaRepository assetMediaRepository;
late MockApiService apiService;
late MockSyncService syncService;
late MockAlbumService albumService;
late MockBackupService backupService;
late MockUserService userService;
setUp(() {
assetRepository = MockAssetRepository();
assetApiRepository = MockAssetApiRepository();
exifInfoRepository = MockExifInfoRepository();
userRepository = MockIsarUserRepository();
eTagRepository = MockETagRepository();
backupAlbumRepository = MockBackupAlbumRepository();
apiService = MockApiService();
assetMediaRepository = MockAssetMediaRepository();
syncService = MockSyncService();
userService = MockUserService();
albumService = MockAlbumService();
backupService = MockBackupService();
sut = AssetService(
assetApiRepository,
assetRepository,
exifInfoRepository,
userRepository,
eTagRepository,
backupAlbumRepository,
apiService,
syncService,
backupService,
albumService,
userService,
assetMediaRepository,
);
registerFallbackValue(FakeAssetBulkUpdateDto());
});
group("Edit ExifInfo", () {
late AssetsApi assetsApi;
setUp(() {
assetsApi = MockAssetsApi();
when(() => apiService.assetsApi).thenReturn(assetsApi);
when(() => assetsApi.updateAssets(any())).thenAnswer((_) async => Future.value());
});
test("asset is updated with DateTime", () async {
final assets = [AssetStub.image1, AssetStub.image2];
final dateTime = DateTime.utc(2025, 6, 4, 2, 57);
await sut.changeDateTime(assets, dateTime.toIso8601String());
verify(() => assetsApi.updateAssets(any())).called(1);
final upsertExifCallback = verify(() => syncService.upsertAssetsWithExif(captureAny()));
upsertExifCallback.called(1);
final receivedAssets = upsertExifCallback.captured.firstOrNull as List<Object>? ?? [];
final receivedDatetime = receivedAssets.cast<Asset>().map((a) => a.exifInfo?.dateTimeOriginal ?? DateTime(0));
expect(receivedDatetime.every((d) => d == dateTime), isTrue);
});
test("asset is updated with LatLng", () async {
final assets = [AssetStub.image1, AssetStub.image2];
final latLng = const LatLng(37.7749, -122.4194);
await sut.changeLocation(assets, latLng);
verify(() => assetsApi.updateAssets(any())).called(1);
final upsertExifCallback = verify(() => syncService.upsertAssetsWithExif(captureAny()));
upsertExifCallback.called(1);
final receivedAssets = upsertExifCallback.captured.firstOrNull as List<Object>? ?? [];
final receivedCoords = receivedAssets.cast<Asset>().map(
(a) => LatLng(a.exifInfo?.latitude ?? 0, a.exifInfo?.longitude ?? 0),
);
expect(receivedCoords.every((l) => l == latLng), isTrue);
});
});
}
+11 -13
View File
@@ -1,18 +1,19 @@
import 'package:drift/drift.dart' hide isNull;
import 'package:drift/native.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
import 'package:isar/isar.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
import '../domain/service.mock.dart';
import '../repository.mocks.dart';
import '../service.mocks.dart';
import '../test_utils.dart';
void main() {
late AuthService sut;
@@ -22,7 +23,7 @@ void main() {
late MockNetworkService networkService;
late MockBackgroundSyncManager backgroundSyncManager;
late MockAppSettingService appSettingsService;
late Isar db;
late Drift db;
setUp(() async {
authApiRepository = MockAuthApiRepository();
@@ -45,19 +46,16 @@ void main() {
});
setUpAll(() async {
db = await TestUtils.initIsar();
db.writeTxnSync(() => db.clearSync());
await StoreService.init(storeRepository: IsarStoreRepository(db));
WidgetsFlutterBinding.ensureInitialized();
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
});
tearDownAll(() async {
await db.close();
});
group('validateServerUrl', () {
setUpAll(() async {
WidgetsFlutterBinding.ensureInitialized();
final db = await TestUtils.initIsar();
db.writeTxnSync(() => db.clearSync());
await StoreService.init(storeRepository: IsarStoreRepository(db));
});
test('Should resolve HTTP endpoint', () async {
const testUrl = 'http://ip:2283';
const resolvedUrl = 'http://ip:2283/api';
@@ -1,76 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/services/entity.service.dart';
import 'package:mocktail/mocktail.dart';
import '../fixtures/asset.stub.dart';
import '../fixtures/user.stub.dart';
import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
void main() {
late EntityService sut;
late MockAssetRepository assetRepository;
late MockIsarUserRepository userRepository;
setUp(() {
assetRepository = MockAssetRepository();
userRepository = MockIsarUserRepository();
sut = EntityService(assetRepository, userRepository);
});
group('fillAlbumWithDatabaseEntities', () {
test('remote album with owner, thumbnail, sharedUsers and assets', () async {
final Album album =
Album(
name: "album-with-two-assets-and-two-users",
localId: "album-with-two-assets-and-two-users-local",
remoteId: "album-with-two-assets-and-two-users-remote",
createdAt: DateTime(2001),
modifiedAt: DateTime(2010),
shared: true,
activityEnabled: true,
startDate: DateTime(2019),
endDate: DateTime(2020),
)
..remoteThumbnailAssetId = AssetStub.image1.remoteId
..assets.addAll([AssetStub.image1, AssetStub.image1])
..owner.value = User.fromDto(UserStub.user1)
..sharedUsers.addAll([User.fromDto(UserStub.admin), User.fromDto(UserStub.admin)]);
when(() => userRepository.getByUserId(any())).thenAnswer((_) async => UserStub.admin);
when(() => userRepository.getByUserId(any())).thenAnswer((_) async => UserStub.admin);
when(() => assetRepository.getByRemoteId(AssetStub.image1.remoteId!)).thenAnswer((_) async => AssetStub.image1);
when(() => userRepository.getByUserIds(any())).thenAnswer((_) async => [UserStub.user1, UserStub.user2]);
when(() => assetRepository.getAllByRemoteId(any())).thenAnswer((_) async => [AssetStub.image1, AssetStub.image2]);
await sut.fillAlbumWithDatabaseEntities(album);
expect(album.owner.value?.toDto(), UserStub.admin);
expect(album.thumbnail.value, AssetStub.image1);
expect(album.remoteUsers.map((u) => u.toDto()).toSet(), {UserStub.user1, UserStub.user2});
expect(album.remoteAssets.toSet(), {AssetStub.image1, AssetStub.image2});
});
test('remote album without any info', () async {
makeEmptyAlbum() => Album(
name: "album-without-info",
localId: "album-without-info-local",
remoteId: "album-without-info-remote",
createdAt: DateTime(2001),
modifiedAt: DateTime(2010),
shared: false,
activityEnabled: false,
);
final album = makeEmptyAlbum();
await sut.fillAlbumWithDatabaseEntities(album);
verifyNoMoreInteractions(assetRepository);
verifyNoMoreInteractions(userRepository);
expect(album, makeEmptyAlbum());
});
});
}
-349
View File
@@ -1,349 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:file/memory.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/device_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/device_asset.repository.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/hash.service.dart';
import 'package:mocktail/mocktail.dart';
import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart';
import '../service.mocks.dart';
import '../mocks/asset_entity.mock.dart';
class MockAsset extends Mock implements Asset {}
void main() {
late HashService sut;
late BackgroundService mockBackgroundService;
late IsarDeviceAssetRepository mockDeviceAssetRepository;
setUp(() {
mockBackgroundService = MockBackgroundService();
mockDeviceAssetRepository = MockDeviceAssetRepository();
sut = HashService(deviceAssetRepository: mockDeviceAssetRepository, backgroundService: mockBackgroundService);
when(() => mockDeviceAssetRepository.transaction<Null>(any())).thenAnswer((_) async {
final capturedCallback = verify(() => mockDeviceAssetRepository.transaction<Null>(captureAny())).captured;
// Invoke the transaction callback
await (capturedCallback.firstOrNull as Future<Null> Function()?)?.call();
});
when(() => mockDeviceAssetRepository.updateAll(any())).thenAnswer((_) async => true);
when(() => mockDeviceAssetRepository.deleteIds(any())).thenAnswer((_) async => true);
});
group("HashService: No DeviceAsset entry", () {
test("hash successfully", () async {
final (mockAsset, file, deviceAsset, hash) = await _createAssetMock(AssetStub.image1);
when(() => mockBackgroundService.digestFiles([file.path])).thenAnswer((_) async => [hash]);
// No DB entries for this asset
when(() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!])).thenAnswer((_) async => []);
final result = await sut.hashAssets([mockAsset]);
// Verify we stored the new hash in DB
when(() => mockDeviceAssetRepository.transaction<Null>(any())).thenAnswer((_) async {
final capturedCallback = verify(() => mockDeviceAssetRepository.transaction<Null>(captureAny())).captured;
// Invoke the transaction callback
await (capturedCallback.firstOrNull as Future<Null> Function()?)?.call();
verify(
() => mockDeviceAssetRepository.updateAll([
deviceAsset.copyWith(modifiedTime: AssetStub.image1.fileModifiedAt),
]),
).called(1);
verify(() => mockDeviceAssetRepository.deleteIds([])).called(1);
});
expect(result, [AssetStub.image1.copyWith(checksum: base64.encode(hash))]);
});
});
group("HashService: Has DeviceAsset entry", () {
test("when the asset is not modified", () async {
final hash = utf8.encode("image1-hash");
when(() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!])).thenAnswer(
(_) async => [
DeviceAsset(assetId: AssetStub.image1.localId!, hash: hash, modifiedTime: AssetStub.image1.fileModifiedAt),
],
);
final result = await sut.hashAssets([AssetStub.image1]);
verifyNever(() => mockBackgroundService.digestFiles(any()));
verifyNever(() => mockBackgroundService.digestFile(any()));
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
verifyNever(() => mockDeviceAssetRepository.deleteIds(any()));
expect(result, [AssetStub.image1.copyWith(checksum: base64.encode(hash))]);
});
test("hashed successful when asset is modified", () async {
final (mockAsset, file, deviceAsset, hash) = await _createAssetMock(AssetStub.image1);
when(() => mockBackgroundService.digestFiles([file.path])).thenAnswer((_) async => [hash]);
when(
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
).thenAnswer((_) async => [deviceAsset]);
final result = await sut.hashAssets([mockAsset]);
when(() => mockDeviceAssetRepository.transaction<Null>(any())).thenAnswer((_) async {
final capturedCallback = verify(() => mockDeviceAssetRepository.transaction<Null>(captureAny())).captured;
// Invoke the transaction callback
await (capturedCallback.firstOrNull as Future<Null> Function()?)?.call();
verify(
() => mockDeviceAssetRepository.updateAll([
deviceAsset.copyWith(modifiedTime: AssetStub.image1.fileModifiedAt),
]),
).called(1);
verify(() => mockDeviceAssetRepository.deleteIds([])).called(1);
});
verify(() => mockBackgroundService.digestFiles([file.path])).called(1);
expect(result, [AssetStub.image1.copyWith(checksum: base64.encode(hash))]);
});
});
group("HashService: Cleanup", () {
late Asset mockAsset;
late Uint8List hash;
late DeviceAsset deviceAsset;
late File file;
setUp(() async {
(mockAsset, file, deviceAsset, hash) = await _createAssetMock(AssetStub.image1);
when(() => mockBackgroundService.digestFiles([file.path])).thenAnswer((_) async => [hash]);
when(
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
).thenAnswer((_) async => [deviceAsset]);
});
test("cleanups DeviceAsset when local file cannot be obtained", () async {
when(() => mockAsset.local).thenThrow(Exception("File not found"));
final result = await sut.hashAssets([mockAsset]);
verifyNever(() => mockBackgroundService.digestFiles(any()));
verifyNever(() => mockBackgroundService.digestFile(any()));
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
verify(() => mockDeviceAssetRepository.deleteIds([AssetStub.image1.localId!])).called(1);
expect(result, isEmpty);
});
test("cleanups DeviceAsset when hashing failed", () async {
when(() => mockDeviceAssetRepository.transaction<Null>(any())).thenAnswer((_) async {
final capturedCallback = verify(() => mockDeviceAssetRepository.transaction<Null>(captureAny())).captured;
// Invoke the transaction callback
await (capturedCallback.firstOrNull as Future<Null> Function()?)?.call();
// Verify the callback inside the transaction because, doing it outside results
// in a small delay before the callback is invoked, resulting in other LOCs getting executed
// resulting in an incorrect state
//
// i.e, consider the following piece of code
// await _deviceAssetRepository.transaction(() async {
// await _deviceAssetRepository.updateAll(toBeAdded);
// await _deviceAssetRepository.deleteIds(toBeDeleted);
// });
// toBeDeleted.clear();
// since the transaction method is mocked, the callback is not invoked until it is captured
// and executed manually in the next event loop. However, the toBeDeleted.clear() is executed
// immediately once the transaction stub is executed, resulting in the deleteIds method being
// called with an empty list.
//
// To avoid this, we capture the callback and execute it within the transaction stub itself
// and verify the results inside the transaction stub
verify(() => mockDeviceAssetRepository.updateAll([])).called(1);
verify(() => mockDeviceAssetRepository.deleteIds([AssetStub.image1.localId!])).called(1);
});
when(() => mockBackgroundService.digestFiles([file.path])).thenAnswer(
// Invalid hash, length != 20
(_) async => [Uint8List.fromList(hash.slice(2).toList())],
);
final result = await sut.hashAssets([mockAsset]);
verify(() => mockBackgroundService.digestFiles([file.path])).called(1);
expect(result, isEmpty);
});
});
group("HashService: Batch processing", () {
test("processes assets in batches when size limit is reached", () async {
// Setup multiple assets with large file sizes
final (mock1, mock2, mock3) = await (
_createAssetMock(AssetStub.image1),
_createAssetMock(AssetStub.image2),
_createAssetMock(AssetStub.image3),
).wait;
final (asset1, file1, deviceAsset1, hash1) = mock1;
final (asset2, file2, deviceAsset2, hash2) = mock2;
final (asset3, file3, deviceAsset3, hash3) = mock3;
when(() => mockDeviceAssetRepository.getByIds(any())).thenAnswer((_) async => []);
// Setup for multiple batch processing calls
when(() => mockBackgroundService.digestFiles([file1.path, file2.path])).thenAnswer((_) async => [hash1, hash2]);
when(() => mockBackgroundService.digestFiles([file3.path])).thenAnswer((_) async => [hash3]);
final size = await file1.length() + await file2.length();
sut = HashService(
deviceAssetRepository: mockDeviceAssetRepository,
backgroundService: mockBackgroundService,
batchSizeLimit: size,
);
final result = await sut.hashAssets([asset1, asset2, asset3]);
// Verify multiple batch process calls
verify(() => mockBackgroundService.digestFiles([file1.path, file2.path])).called(1);
verify(() => mockBackgroundService.digestFiles([file3.path])).called(1);
expect(result, [
AssetStub.image1.copyWith(checksum: base64.encode(hash1)),
AssetStub.image2.copyWith(checksum: base64.encode(hash2)),
AssetStub.image3.copyWith(checksum: base64.encode(hash3)),
]);
});
test("processes assets in batches when file limit is reached", () async {
// Setup multiple assets with large file sizes
final (mock1, mock2, mock3) = await (
_createAssetMock(AssetStub.image1),
_createAssetMock(AssetStub.image2),
_createAssetMock(AssetStub.image3),
).wait;
final (asset1, file1, deviceAsset1, hash1) = mock1;
final (asset2, file2, deviceAsset2, hash2) = mock2;
final (asset3, file3, deviceAsset3, hash3) = mock3;
when(() => mockDeviceAssetRepository.getByIds(any())).thenAnswer((_) async => []);
when(() => mockBackgroundService.digestFiles([file1.path])).thenAnswer((_) async => [hash1]);
when(() => mockBackgroundService.digestFiles([file2.path])).thenAnswer((_) async => [hash2]);
when(() => mockBackgroundService.digestFiles([file3.path])).thenAnswer((_) async => [hash3]);
sut = HashService(
deviceAssetRepository: mockDeviceAssetRepository,
backgroundService: mockBackgroundService,
batchFileLimit: 1,
);
final result = await sut.hashAssets([asset1, asset2, asset3]);
// Verify multiple batch process calls
verify(() => mockBackgroundService.digestFiles([file1.path])).called(1);
verify(() => mockBackgroundService.digestFiles([file2.path])).called(1);
verify(() => mockBackgroundService.digestFiles([file3.path])).called(1);
expect(result, [
AssetStub.image1.copyWith(checksum: base64.encode(hash1)),
AssetStub.image2.copyWith(checksum: base64.encode(hash2)),
AssetStub.image3.copyWith(checksum: base64.encode(hash3)),
]);
});
test("HashService: Sort & Process different states", () async {
final (asset1, file1, deviceAsset1, hash1) = await _createAssetMock(AssetStub.image1); // Will need rehashing
final (asset2, file2, deviceAsset2, hash2) = await _createAssetMock(AssetStub.image2); // Will have matching hash
final (asset3, file3, deviceAsset3, hash3) = await _createAssetMock(AssetStub.image3); // No DB entry
final asset4 = AssetStub.image3.copyWith(localId: "image4"); // Cannot be hashed
when(() => mockBackgroundService.digestFiles([file1.path, file3.path])).thenAnswer((_) async => [hash1, hash3]);
// DB entries are not sorted and a dummy entry added
when(
() => mockDeviceAssetRepository.getByIds([
AssetStub.image1.localId!,
AssetStub.image2.localId!,
AssetStub.image3.localId!,
asset4.localId!,
]),
).thenAnswer(
(_) async => [
// Same timestamp to reuse deviceAsset
deviceAsset2.copyWith(modifiedTime: asset2.fileModifiedAt),
deviceAsset1,
deviceAsset3.copyWith(assetId: asset4.localId!),
],
);
final result = await sut.hashAssets([asset1, asset2, asset3, asset4]);
// Verify correct processing of all assets
verify(() => mockBackgroundService.digestFiles([file1.path, file3.path])).called(1);
expect(result.length, 3);
expect(result, [
AssetStub.image2.copyWith(checksum: base64.encode(hash2)),
AssetStub.image1.copyWith(checksum: base64.encode(hash1)),
AssetStub.image3.copyWith(checksum: base64.encode(hash3)),
]);
});
group("HashService: Edge cases", () {
test("handles empty list of assets", () async {
when(() => mockDeviceAssetRepository.getByIds(any())).thenAnswer((_) async => []);
final result = await sut.hashAssets([]);
verifyNever(() => mockBackgroundService.digestFiles(any()));
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
verifyNever(() => mockDeviceAssetRepository.deleteIds(any()));
expect(result, isEmpty);
});
test("handles all file access failures", () async {
// No DB entries
when(
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!, AssetStub.image2.localId!]),
).thenAnswer((_) async => []);
final result = await sut.hashAssets([AssetStub.image1, AssetStub.image2]);
verifyNever(() => mockBackgroundService.digestFiles(any()));
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
expect(result, isEmpty);
});
});
});
}
Future<(Asset, File, DeviceAsset, Uint8List)> _createAssetMock(Asset asset) async {
final random = Random();
final hash = Uint8List.fromList(List.generate(20, (i) => random.nextInt(255)));
final mockAsset = MockAsset();
final mockAssetEntity = MockAssetEntity();
final fs = MemoryFileSystem();
final deviceAsset = DeviceAsset(
assetId: asset.localId!,
hash: Uint8List.fromList(hash),
modifiedTime: DateTime.now(),
);
final tmp = await fs.systemTempDirectory.createTemp();
final file = tmp.childFile("${asset.fileName}-path");
await file.writeAsString("${asset.fileName}-content");
when(() => mockAsset.localId).thenReturn(asset.localId);
when(() => mockAsset.fileName).thenReturn(asset.fileName);
when(() => mockAsset.fileCreatedAt).thenReturn(asset.fileCreatedAt);
when(() => mockAsset.fileModifiedAt).thenReturn(asset.fileModifiedAt);
when(
() => mockAsset.copyWith(checksum: any(named: "checksum")),
).thenReturn(asset.copyWith(checksum: base64.encode(hash)));
when(() => mockAsset.local).thenAnswer((_) => mockAssetEntity);
when(() => mockAssetEntity.originFile).thenAnswer((_) async => file);
return (mockAsset, file, deviceAsset, hash);
}
-69
View File
@@ -4,82 +4,13 @@ import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:fake_async/fake_async.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' as domain;
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:isar/isar.dart';
import 'package:mocktail/mocktail.dart';
import 'mock_http_override.dart';
// Listener Mock to test when a provider notifies its listeners
class ListenerMock<T> extends Mock {
void call(T? previous, T next);
}
abstract final class TestUtils {
const TestUtils._();
/// Downloads Isar binaries (if required) and initializes a new Isar db
static Future<Isar> initIsar() async {
await Isar.initializeIsarCore(download: true);
final instance = Isar.getInstance();
if (instance != null) {
return instance;
}
final db = await Isar.open(
[
StoreValueSchema,
ExifInfoSchema,
AssetSchema,
AlbumSchema,
UserSchema,
BackupAlbumSchema,
DuplicatedAssetSchema,
ETagSchema,
AndroidDeviceAssetSchema,
IOSDeviceAssetSchema,
DeviceAssetEntitySchema,
],
directory: "test/",
maxSizeMiB: 1024,
inspector: false,
);
// Clear and close db on test end
addTearDown(() async {
await db.writeTxn(() async => await db.clear());
await db.close();
});
return db;
}
/// Creates a new ProviderContainer to test Riverpod providers
static ProviderContainer createContainer({
ProviderContainer? parent,
List<Override> overrides = const [],
List<ProviderObserver>? observers,
}) {
final container = ProviderContainer(parent: parent, overrides: overrides, observers: observers);
// Dispose on test end
addTearDown(container.dispose);
return container;
}
static void init() {
// Turn off easy localization logging
EasyLocalization.logger.enableBuildModes = [];
@@ -1,7 +1,6 @@
import 'dart:math';
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/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
@@ -10,28 +9,6 @@ class MediumFactory {
const MediumFactory(Drift db) : _db = db;
LocalAsset localAsset({
String? id,
String? name,
AssetType? type,
DateTime? createdAt,
DateTime? updatedAt,
String? checksum,
}) {
final random = Random();
return LocalAsset(
id: id ?? '${random.nextInt(1000000)}',
name: name ?? 'Asset ${random.nextInt(1000000)}',
checksum: checksum ?? '${random.nextInt(1000000)}',
type: type ?? AssetType.image,
createdAt: createdAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)),
updatedAt: updatedAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
}
LocalAlbum localAlbum({
String? id,
String? name,