Merge remote-tracking branch 'origin/main' into feature/rearrange-buttons-2

This commit is contained in:
idubnori
2025-11-17 09:35:03 +09:00
505 changed files with 34122 additions and 8163 deletions

View File

@@ -17,3 +17,4 @@ class MockNativeSyncApi extends Mock implements NativeSyncApi {}
class MockAppSettingsService extends Mock implements AppSettingsService {}
class MockUploadService extends Mock implements UploadService {}

View File

@@ -14,16 +14,19 @@ void main() {
late MockLocalAlbumRepository mockAlbumRepo;
late MockLocalAssetRepository mockAssetRepo;
late MockNativeSyncApi mockNativeApi;
late MockTrashedLocalAssetRepository mockTrashedAssetRepo;
setUp(() {
mockAlbumRepo = MockLocalAlbumRepository();
mockAssetRepo = MockLocalAssetRepository();
mockNativeApi = MockNativeSyncApi();
mockTrashedAssetRepo = MockTrashedLocalAssetRepository();
sut = HashService(
localAlbumRepository: mockAlbumRepo,
localAssetRepository: mockAssetRepo,
nativeSyncApi: mockNativeApi,
trashedLocalAssetRepository: mockTrashedAssetRepo,
);
registerFallbackValue(LocalAlbumStub.recent);
@@ -114,6 +117,7 @@ void main() {
localAssetRepository: mockAssetRepo,
nativeSyncApi: mockNativeApi,
batchSize: batchSize,
trashedLocalAssetRepository: mockTrashedAssetRepo,
);
final album = LocalAlbumStub.recent;
@@ -186,4 +190,5 @@ void main() {
verify(() => mockNativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
});
});
}

View File

@@ -0,0 +1,190 @@
import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/local_sync.service.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/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:mocktail/mocktail.dart';
import '../../domain/service.mock.dart';
import '../../fixtures/asset.stub.dart';
import '../../infrastructure/repository.mock.dart';
import '../../mocks/asset_entity.mock.dart';
import '../../repository.mocks.dart';
void main() {
late LocalSyncService sut;
late DriftLocalAlbumRepository mockLocalAlbumRepository;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
late LocalFilesManagerRepository mockLocalFilesManager;
late StorageRepository mockStorageRepository;
late MockNativeSyncApi mockNativeSyncApi;
late Drift db;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
debugDefaultTargetPlatformOverride = TargetPlatform.android;
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
});
tearDownAll(() async {
debugDefaultTargetPlatformOverride = null;
await Store.clear();
await db.close();
});
setUp(() async {
mockLocalAlbumRepository = MockLocalAlbumRepository();
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
mockLocalFilesManager = MockLocalFilesManagerRepository();
mockStorageRepository = MockStorageRepository();
mockNativeSyncApi = MockNativeSyncApi();
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
when(() => mockNativeSyncApi.getMediaChanges()).thenAnswer(
(_) async => SyncDelta(
hasChanges: false,
updates: const [],
deletes: const [],
assetAlbums: const {},
),
);
when(() => mockNativeSyncApi.getTrashedAssets()).thenAnswer((_) async => {});
when(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {});
when(() => mockLocalFilesManager.moveToTrash(any<List<String>>())).thenAnswer((_) async => true);
sut = LocalSyncService(
localAlbumRepository: mockLocalAlbumRepository,
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
localFilesManager: mockLocalFilesManager,
storageRepository: mockStorageRepository,
nativeSyncApi: mockNativeSyncApi,
);
await Store.put(StoreKey.manageLocalMediaAndroid, false);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
});
group('LocalSyncService - syncTrashedAssets gating', () {
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
await sut.sync();
verify(() => mockNativeSyncApi.getTrashedAssets()).called(1);
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1);
});
test('skips syncTrashedAssets when store flag disabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, false);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
await sut.sync();
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
});
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
await sut.sync();
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
});
test('skips syncTrashedAssets on non-Android platforms', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
await sut.sync();
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
});
});
group('LocalSyncService - syncTrashedAssets behavior', () {
test('processes trashed snapshot, restores assets, and trashes local files', () async {
final platformAsset = PlatformAsset(
id: 'remote-id',
name: 'remote.jpg',
type: AssetType.image.index,
durationInSeconds: 0,
orientation: 0,
isFavorite: false,
);
final assetsToRestore = [LocalAssetStub.image1];
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => assetsToRestore);
final restoredIds = ['image1'];
when(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
final Iterable<LocalAsset> requested = invocation.positionalArguments.first as Iterable<LocalAsset>;
expect(requested, orderedEquals(assetsToRestore));
return restoredIds;
});
final localAssetToTrash = LocalAssetStub.image2.copyWith(id: 'local-trash', checksum: 'checksum-trash');
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {'album-a': [localAssetToTrash]});
final assetEntity = MockAssetEntity();
when(() => assetEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-trash');
when(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).thenAnswer((_) async => assetEntity);
await sut.processTrashedAssets({'album-a': [platformAsset]});
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1);
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
verify(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).called(1);
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
verify(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).called(1);
final moveArgs =
verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List<String>;
expect(moveArgs, ['content://local-trash']);
final trashArgs =
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
as Map<String, List<LocalAsset>>;
expect(trashArgs.keys, ['album-a']);
expect(trashArgs['album-a'], [localAssetToTrash]);
});
test('does not attempt restore when repository has no assets to restore', () async {
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
await sut.processTrashedAssets({});
verifyNever(() => mockLocalFilesManager.restoreAssetsFromTrash(any()));
verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any()));
});
test('does not move local assets when repository finds nothing to trash', () async {
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
await sut.processTrashedAssets({});
verifyNever(() => mockLocalFilesManager.moveToTrash(any()));
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
});
});
}

View File

@@ -1,14 +1,30 @@
import 'dart:async';
import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/services/sync_stream.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/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.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/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:mocktail/mocktail.dart';
import '../../fixtures/asset.stub.dart';
import '../../fixtures/sync_stream.stub.dart';
import '../../infrastructure/repository.mock.dart';
import '../../mocks/asset_entity.mock.dart';
import '../../repository.mocks.dart';
class _AbortCallbackWrapper {
const _AbortCallbackWrapper();
@@ -30,15 +46,40 @@ void main() {
late SyncStreamService sut;
late SyncStreamRepository mockSyncStreamRepo;
late SyncApiRepository mockSyncApiRepo;
late DriftLocalAssetRepository mockLocalAssetRepo;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
late LocalFilesManagerRepository mockLocalFilesManagerRepo;
late StorageRepository mockStorageRepo;
late Future<void> Function(List<SyncEvent>, Function(), Function()) handleEventsCallback;
late _MockAbortCallbackWrapper mockAbortCallbackWrapper;
late _MockAbortCallbackWrapper mockResetCallbackWrapper;
late Drift db;
late bool hasManageMediaPermission;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
debugDefaultTargetPlatformOverride = TargetPlatform.android;
registerFallbackValue(LocalAssetStub.image1);
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
});
tearDownAll(() async {
debugDefaultTargetPlatformOverride = null;
await Store.clear();
await db.close();
});
successHandler(Invocation _) async => true;
setUp(() {
setUp(() async {
mockSyncStreamRepo = MockSyncStreamRepository();
mockSyncApiRepo = MockSyncApiRepository();
mockLocalAssetRepo = MockLocalAssetRepository();
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
mockLocalFilesManagerRepo = MockLocalFilesManagerRepository();
mockStorageRepo = MockStorageRepository();
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
@@ -87,7 +128,25 @@ void main() {
when(() => mockSyncStreamRepo.updateAssetFacesV1(any())).thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deleteAssetFacesV1(any())).thenAnswer(successHandler);
sut = SyncStreamService(syncApiRepository: mockSyncApiRepo, syncStreamRepository: mockSyncStreamRepo);
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,
syncStreamRepository: mockSyncStreamRepo,
localAssetRepository: mockLocalAssetRepo,
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
);
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((_) async => {});
when(() => mockTrashedLocalAssetRepo.trashLocalAsset(any())).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []);
when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {});
hasManageMediaPermission = false;
when(() => mockLocalFilesManagerRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((_) async => true);
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
when(() => mockStorageRepo.getAssetEntityForAsset(any())).thenAnswer((_) async => null);
await Store.put(StoreKey.manageLocalMediaAndroid, false);
});
Future<void> simulateEvents(List<SyncEvent> events) async {
@@ -152,6 +211,10 @@ void main() {
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,
syncStreamRepository: mockSyncStreamRepo,
localAssetRepository: mockLocalAssetRepo,
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
cancelChecker: cancellationChecker.call,
);
await sut.sync();
@@ -187,6 +250,10 @@ void main() {
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,
syncStreamRepository: mockSyncStreamRepo,
localAssetRepository: mockLocalAssetRepo,
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
cancelChecker: cancellationChecker.call,
);
@@ -296,4 +363,127 @@ void main() {
verify(() => mockSyncApiRepo.ack(["5"])).called(1);
});
});
group("SyncStreamService - remote trash & restore", () {
setUp(() async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
hasManageMediaPermission = true;
});
tearDown(() async {
await Store.put(StoreKey.manageLocalMediaAndroid, false);
hasManageMediaPermission = false;
});
test("moves backed up local and merged assets to device trash when remote trash events are received", () async {
final localAsset = LocalAssetStub.image1.copyWith(id: 'local-only', checksum: 'checksum-local', remoteId: null);
final mergedAsset = LocalAssetStub.image2.copyWith(
id: 'merged-local',
checksum: 'checksum-merged',
remoteId: 'remote-merged',
);
final assetsByAlbum = {
'album-a': [localAsset],
'album-b': [mergedAsset],
};
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
final Iterable<String> requestedChecksums = invocation.positionalArguments.first as Iterable<String>;
expect(requestedChecksums.toSet(), equals({'checksum-local', 'checksum-merged', 'checksum-remote-only'}));
return assetsByAlbum;
});
final localEntity = MockAssetEntity();
when(() => localEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-only');
when(() => mockStorageRepo.getAssetEntityForAsset(localAsset)).thenAnswer((_) async => localEntity);
final mergedEntity = MockAssetEntity();
when(() => mergedEntity.getMediaUrl()).thenAnswer((_) async => 'content://merged-local');
when(() => mockStorageRepo.getAssetEntityForAsset(mergedAsset)).thenAnswer((_) async => mergedEntity);
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((invocation) async {
final urls = invocation.positionalArguments.first as List<String>;
expect(urls, unorderedEquals(['content://local-only', 'content://merged-local']));
return true;
});
final events = [
SyncStreamStub.assetTrashed(
id: 'remote-1',
checksum: localAsset.checksum!,
ack: 'asset-remote-local-1',
trashedAt: DateTime(2025, 5, 1),
),
SyncStreamStub.assetTrashed(
id: 'remote-2',
checksum: mergedAsset.checksum!,
ack: 'asset-remote-merged-2',
trashedAt: DateTime(2025, 5, 2),
),
SyncStreamStub.assetTrashed(
id: 'remote-3',
checksum: 'checksum-remote-only',
ack: 'asset-remote-only-3',
trashedAt: DateTime(2025, 5, 3),
),
];
await simulateEvents(events);
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(assetsByAlbum)).called(1);
verify(() => mockSyncApiRepo.ack(['asset-remote-only-3'])).called(1);
});
test("skips device trashing when no local assets match the remote trash payload", () async {
final events = [
SyncStreamStub.assetTrashed(
id: 'remote-only',
checksum: 'checksum-only',
ack: 'asset-remote-only-9',
trashedAt: DateTime(2025, 6, 1),
),
];
await simulateEvents(events);
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
});
test("does not request local deletions for permanent remote delete events", () async {
final events = [SyncStreamStub.assetDeleteV1];
await simulateEvents(events);
verifyNever(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any()));
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
});
test("restores trashed local assets once the matching remote assets leave the trash", () async {
final trashedAssets = [
LocalAssetStub.image1.copyWith(id: 'trashed-1', checksum: 'checksum-trash', remoteId: 'remote-1'),
];
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => trashedAssets);
final restoredIds = ['trashed-1'];
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
final Iterable<LocalAsset> requestedAssets = invocation.positionalArguments.first as Iterable<LocalAsset>;
expect(requestedAssets, orderedEquals(trashedAssets));
return restoredIds;
});
final events = [
SyncStreamStub.assetModified(
id: 'remote-1',
checksum: 'checksum-trash',
ack: 'asset-remote-1-11',
),
];
await simulateEvents(events);
verify(() => mockTrashedLocalAssetRepo.applyRestoredAssets(restoredIds)).called(1);
});
});
}

View File

@@ -15,6 +15,7 @@ import 'schema_v9.dart' as v9;
import 'schema_v10.dart' as v10;
import 'schema_v11.dart' as v11;
import 'schema_v12.dart' as v12;
import 'schema_v13.dart' as v13;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -44,10 +45,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v11.DatabaseAtV11(db);
case 12:
return v12.DatabaseAtV12(db);
case 13:
return v13.DatabaseAtV13(db);
default:
throw MissingSchemaException(version, versions);
}
}
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
}

File diff suppressed because it is too large Load Diff

View File

@@ -81,4 +81,67 @@ abstract final class SyncStreamStub {
data: SyncMemoryAssetDeleteV1(assetId: "asset-2", memoryId: "memory-1"),
ack: "8",
);
static final assetDeleteV1 = SyncEvent(
type: SyncEntityType.assetDeleteV1,
data: SyncAssetDeleteV1(assetId: "remote-asset"),
ack: "asset-delete-ack",
);
static SyncEvent assetTrashed({
required String id,
required String checksum,
required String ack,
DateTime? trashedAt,
}) {
return _assetV1(
id: id,
checksum: checksum,
deletedAt: trashedAt ?? DateTime(2025, 1, 1),
ack: ack,
);
}
static SyncEvent assetModified({
required String id,
required String checksum,
required String ack,
}) {
return _assetV1(
id: id,
checksum: checksum,
deletedAt: null,
ack: ack,
);
}
static SyncEvent _assetV1({
required String id,
required String checksum,
required DateTime? deletedAt,
required String ack,
}) {
return SyncEvent(
type: SyncEntityType.assetV1,
data: SyncAssetV1(
checksum: checksum,
deletedAt: deletedAt,
duration: '0',
fileCreatedAt: DateTime(2025),
fileModifiedAt: DateTime(2025, 1, 2),
id: id,
isFavorite: false,
libraryId: null,
livePhotoVideoId: null,
localDateTime: DateTime(2025, 1, 3),
originalFileName: '$id.jpg',
ownerId: 'owner',
stackId: null,
thumbhash: null,
type: AssetTypeEnum.IMAGE,
visibility: AssetVisibility.timeline,
),
ack: ack,
);
}
}

View File

@@ -8,6 +8,7 @@ import 'package:immich_mobile/infrastructure/repositories/storage.repository.dar
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.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';
@@ -34,6 +35,8 @@ class MockLocalAssetRepository extends Mock implements DriftLocalAssetRepository
class MockDriftLocalAssetRepository extends Mock implements DriftLocalAssetRepository {}
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
class MockStorageRepository extends Mock implements StorageRepository {}
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}

View File

@@ -0,0 +1,4 @@
import 'package:mocktail/mocktail.dart';
import 'package:photo_manager/photo_manager.dart';
class MockAssetEntity extends Mock implements AssetEntity {}

View File

@@ -33,6 +33,7 @@ final _activities = [
void main() {
late ActivityServiceMock activityMock;
late ActivityStatisticsMock activityStatisticsMock;
late ActivityStatisticsMock albumActivityStatisticsMock;
late ProviderContainer container;
late AlbumActivityProvider provider;
late ListenerMock<AsyncValue<List<Activity>>> listener;
@@ -44,17 +45,23 @@ void main() {
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');
@@ -89,6 +96,10 @@ void main() {
() => 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'));
@@ -99,6 +110,11 @@ void main() {
// 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 {
@@ -107,6 +123,10 @@ void main() {
() => 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'));
@@ -114,6 +134,12 @@ void main() {
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)));
});
});
@@ -130,6 +156,7 @@ void main() {
expect(activities, isNot(anyElement(predicate((Activity a) => a.id == '3'))));
verifyNever(() => activityStatisticsMock.removeActivity());
verifyNever(() => albumActivityStatisticsMock.removeActivity());
});
test('Remove Like failed', () async {
@@ -140,6 +167,9 @@ void main() {
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 {
@@ -151,23 +181,35 @@ void main() {
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()', () {
late ActivityStatisticsMock albumActivityStatisticsMock;
setUp(() {
albumActivityStatisticsMock = ActivityStatisticsMock();
container = TestUtils.createContainer(
overrides: [
activityServiceProvider.overrideWith((ref) => activityMock),
activityStatisticsProvider('test-album', 'test-asset').overrideWith(() => activityStatisticsMock),
activityStatisticsProvider('test-album').overrideWith(() => albumActivityStatisticsMock),
],
);
});
test('Comment successfully added', () async {
final comment = Activity(
id: '5',
@@ -178,6 +220,10 @@ void main() {
assetId: 'test-asset',
);
final albumProvider = albumActivityProvider('test-album');
container.read(albumProvider.notifier);
await container.read(albumProvider.future);
when(
() => activityMock.addActivity(
'test-album',
@@ -206,6 +252,10 @@ void main() {
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 {
@@ -225,6 +275,8 @@ void main() {
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(
@@ -258,6 +310,10 @@ void main() {
),
).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);
@@ -266,6 +322,10 @@ void main() {
verifyNever(() => activityStatisticsMock.addActivity());
verifyNever(() => albumActivityStatisticsMock.addActivity());
final albumActivities = container.read(albumProvider).requireValue;
expect(albumActivities, hasLength(4));
expect(albumActivities, isNot(contains(comment)));
});
});
}

View File

@@ -12,16 +12,14 @@ import 'package:immich_mobile/infrastructure/repositories/device_asset.repositor
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/hash.service.dart';
import 'package:mocktail/mocktail.dart';
import 'package:photo_manager/photo_manager.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 {}
class MockAssetEntity extends Mock implements AssetEntity {}
void main() {
late HashService sut;
late BackgroundService mockBackgroundService;

View File

@@ -12,14 +12,12 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:mocktail/mocktail.dart';
import 'package:photo_manager/photo_manager.dart';
import '../domain/service.mock.dart';
import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
class MockAssetEntity extends Mock implements AssetEntity {}
import '../mocks/asset_entity.mock.dart';
void main() {
late UploadService sut;

View File

@@ -0,0 +1,92 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/semver.dart';
void main() {
group('SemVer', () {
test('Parses valid semantic version strings correctly', () {
final version = SemVer.fromString('1.2.3');
expect(version.major, 1);
expect(version.minor, 2);
expect(version.patch, 3);
});
test('Throws FormatException for invalid version strings', () {
expect(() => SemVer.fromString('1.2'), throwsFormatException);
expect(() => SemVer.fromString('a.b.c'), throwsFormatException);
expect(() => SemVer.fromString('1.2.3.4'), throwsFormatException);
});
test('Compares equal versons correctly', () {
final v1 = SemVer.fromString('1.2.3');
final v2 = SemVer.fromString('1.2.3');
expect(v1 == v2, isTrue);
expect(v1 > v2, isFalse);
expect(v1 < v2, isFalse);
});
test('Compares major version correctly', () {
final v1 = SemVer.fromString('2.0.0');
final v2 = SemVer.fromString('1.9.9');
expect(v1 == v2, isFalse);
expect(v1 > v2, isTrue);
expect(v1 < v2, isFalse);
});
test('Compares minor version correctly', () {
final v1 = SemVer.fromString('1.3.0');
final v2 = SemVer.fromString('1.2.9');
expect(v1 == v2, isFalse);
expect(v1 > v2, isTrue);
expect(v1 < v2, isFalse);
});
test('Compares patch version correctly', () {
final v1 = SemVer.fromString('1.2.4');
final v2 = SemVer.fromString('1.2.3');
expect(v1 == v2, isFalse);
expect(v1 > v2, isTrue);
expect(v1 < v2, isFalse);
});
test('Gives correct major difference type', () {
final v1 = SemVer.fromString('2.0.0');
final v2 = SemVer.fromString('1.9.9');
expect(v1.differenceType(v2), SemVerType.major);
});
test('Gives correct minor difference type', () {
final v1 = SemVer.fromString('1.3.0');
final v2 = SemVer.fromString('1.2.9');
expect(v1.differenceType(v2), SemVerType.minor);
});
test('Gives correct patch difference type', () {
final v1 = SemVer.fromString('1.2.4');
final v2 = SemVer.fromString('1.2.3');
expect(v1.differenceType(v2), SemVerType.patch);
});
test('Gives null difference type for equal versions', () {
final v1 = SemVer.fromString('1.2.3');
final v2 = SemVer.fromString('1.2.3');
expect(v1.differenceType(v2), isNull);
});
test('toString returns correct format', () {
final version = SemVer.fromString('1.2.3');
expect(version.toString(), '1.2.3');
});
test('Parses versions with leading v correctly', () {
final version1 = SemVer.fromString('v1.2.3');
expect(version1.major, 1);
expect(version1.minor, 2);
expect(version1.patch, 3);
final version2 = SemVer.fromString('V1.2.3');
expect(version2.major, 1);
expect(version2.minor, 2);
expect(version2.patch, 3);
});
});
}