Source Code added
This commit is contained in:
parent
800376eafd
commit
9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions
186
mobile/test/domain/repositories/sync_stream_repository_test.dart
Normal file
186
mobile/test/domain/repositories/sync_stream_repository_test.dart
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
SyncUserV1 _createUser({String id = 'user-1'}) {
|
||||
return SyncUserV1(
|
||||
id: id,
|
||||
name: 'Test User',
|
||||
email: 'test@test.com',
|
||||
deletedAt: null,
|
||||
avatarColor: null,
|
||||
hasProfileImage: false,
|
||||
profileChangedAt: DateTime(2024, 1, 1),
|
||||
);
|
||||
}
|
||||
|
||||
SyncAssetV1 _createAsset({
|
||||
required String id,
|
||||
required String checksum,
|
||||
required String fileName,
|
||||
String ownerId = 'user-1',
|
||||
int? width,
|
||||
int? height,
|
||||
}) {
|
||||
return SyncAssetV1(
|
||||
id: id,
|
||||
checksum: checksum,
|
||||
originalFileName: fileName,
|
||||
type: AssetTypeEnum.IMAGE,
|
||||
ownerId: ownerId,
|
||||
isFavorite: false,
|
||||
fileCreatedAt: DateTime(2024, 1, 1),
|
||||
fileModifiedAt: DateTime(2024, 1, 1),
|
||||
localDateTime: DateTime(2024, 1, 1),
|
||||
visibility: AssetVisibility.timeline,
|
||||
width: width,
|
||||
height: height,
|
||||
deletedAt: null,
|
||||
duration: null,
|
||||
libraryId: null,
|
||||
livePhotoVideoId: null,
|
||||
stackId: null,
|
||||
thumbhash: null,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
SyncAssetExifV1 _createExif({
|
||||
required String assetId,
|
||||
required int width,
|
||||
required int height,
|
||||
required String orientation,
|
||||
}) {
|
||||
return SyncAssetExifV1(
|
||||
assetId: assetId,
|
||||
exifImageWidth: width,
|
||||
exifImageHeight: height,
|
||||
orientation: orientation,
|
||||
city: null,
|
||||
country: null,
|
||||
dateTimeOriginal: null,
|
||||
description: null,
|
||||
exposureTime: null,
|
||||
fNumber: null,
|
||||
fileSizeInByte: null,
|
||||
focalLength: null,
|
||||
fps: null,
|
||||
iso: null,
|
||||
latitude: null,
|
||||
lensModel: null,
|
||||
longitude: null,
|
||||
make: null,
|
||||
model: null,
|
||||
modifyDate: null,
|
||||
profileDescription: null,
|
||||
projectionType: null,
|
||||
rating: null,
|
||||
state: null,
|
||||
timeZone: null,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late Drift db;
|
||||
late SyncStreamRepository sut;
|
||||
|
||||
setUp(() async {
|
||||
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||
sut = SyncStreamRepository(db);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
group('SyncStreamRepository - Dimension swapping based on orientation', () {
|
||||
test('swaps dimensions for asset with rotated orientation', () async {
|
||||
final flippedOrientations = ['5', '6', '7', '8', '90', '-90'];
|
||||
|
||||
for (final orientation in flippedOrientations) {
|
||||
final assetId = 'asset-$orientation-degrees';
|
||||
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
|
||||
final asset = _createAsset(
|
||||
id: assetId,
|
||||
checksum: 'checksum-$orientation',
|
||||
fileName: 'rotated_$orientation.jpg',
|
||||
);
|
||||
await sut.updateAssetsV1([asset]);
|
||||
|
||||
final exif = _createExif(
|
||||
assetId: assetId,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
orientation: orientation, // EXIF orientation value for 90 degrees CW
|
||||
);
|
||||
await sut.updateAssetsExifV1([exif]);
|
||||
|
||||
final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId));
|
||||
final result = await query.getSingle();
|
||||
|
||||
expect(result.width, equals(1080));
|
||||
expect(result.height, equals(1920));
|
||||
}
|
||||
});
|
||||
|
||||
test('does not swap dimensions for asset with normal orientation', () async {
|
||||
final nonFlippedOrientations = ['1', '2', '3', '4'];
|
||||
for (final orientation in nonFlippedOrientations) {
|
||||
final assetId = 'asset-$orientation-degrees';
|
||||
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
|
||||
final asset = _createAsset(id: assetId, checksum: 'checksum-$orientation', fileName: 'normal_$orientation.jpg');
|
||||
await sut.updateAssetsV1([asset]);
|
||||
|
||||
final exif = _createExif(
|
||||
assetId: assetId,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
orientation: orientation, // EXIF orientation value for normal
|
||||
);
|
||||
await sut.updateAssetsExifV1([exif]);
|
||||
|
||||
final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId));
|
||||
final result = await query.getSingle();
|
||||
|
||||
expect(result.width, equals(1920));
|
||||
expect(result.height, equals(1080));
|
||||
}
|
||||
});
|
||||
|
||||
test('does not update dimensions if asset already has width and height', () async {
|
||||
const assetId = 'asset-with-dimensions';
|
||||
const existingWidth = 1920;
|
||||
const existingHeight = 1080;
|
||||
const exifWidth = 3840;
|
||||
const exifHeight = 2160;
|
||||
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
|
||||
final asset = _createAsset(
|
||||
id: assetId,
|
||||
checksum: 'checksum-with-dims',
|
||||
fileName: 'with_dimensions.jpg',
|
||||
width: existingWidth,
|
||||
height: existingHeight,
|
||||
);
|
||||
await sut.updateAssetsV1([asset]);
|
||||
|
||||
final exif = _createExif(assetId: assetId, width: exifWidth, height: exifHeight, orientation: '6');
|
||||
await sut.updateAssetsExifV1([exif]);
|
||||
|
||||
// Verify the asset still has original dimensions (not updated from EXIF)
|
||||
final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId));
|
||||
final result = await query.getSingle();
|
||||
|
||||
expect(result.width, equals(existingWidth), reason: 'Width should remain as originally set');
|
||||
expect(result.height, equals(existingHeight), reason: 'Height should remain as originally set');
|
||||
});
|
||||
});
|
||||
}
|
||||
20
mobile/test/domain/service.mock.dart
Normal file
20
mobile/test/domain/service.mock.dart
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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 {}
|
||||
|
||||
119
mobile/test/domain/services/album.service_test.dart
Normal file
119
mobile/test/domain/services/album.service_test.dart
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import 'package:flutter_test/flutter_test.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;
|
||||
|
||||
setUp(() {
|
||||
mockRemoteAlbumRepo = MockRemoteAlbumRepository();
|
||||
mockAlbumApiRepo = MockDriftAlbumApiRepository();
|
||||
sut = RemoteAlbumService(mockRemoteAlbumRepo, mockAlbumApiRepo);
|
||||
|
||||
when(() => mockRemoteAlbumRepo.getNewestAssetTimestamp(any())).thenAnswer((invocation) {
|
||||
// Simulate a timestamp for the newest asset in the album
|
||||
final albumID = invocation.positionalArguments[0] as String;
|
||||
|
||||
if (albumID == '1') {
|
||||
return Future.value(DateTime(2023, 1, 1));
|
||||
} else if (albumID == '2') {
|
||||
return Future.value(DateTime(2023, 2, 1));
|
||||
}
|
||||
|
||||
return Future.value(DateTime.fromMillisecondsSinceEpoch(0));
|
||||
});
|
||||
|
||||
when(() => mockRemoteAlbumRepo.getOldestAssetTimestamp(any())).thenAnswer((invocation) {
|
||||
// Simulate a timestamp for the oldest asset in the album
|
||||
final albumID = invocation.positionalArguments[0] as String;
|
||||
|
||||
if (albumID == '1') {
|
||||
return Future.value(DateTime(2019, 1, 1));
|
||||
} else if (albumID == '2') {
|
||||
return Future.value(DateTime(2019, 2, 1));
|
||||
}
|
||||
|
||||
return Future.value(DateTime.fromMillisecondsSinceEpoch(0));
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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, [albumA, albumB]);
|
||||
});
|
||||
|
||||
test('should sort correctly based on updatedAt', () async {
|
||||
final albums = [albumB, albumA];
|
||||
|
||||
final result = await sut.sortAlbums(albums, AlbumSortMode.lastModified);
|
||||
expect(result, [albumA, albumB]);
|
||||
});
|
||||
|
||||
test('should sort correctly based on assetCount', () async {
|
||||
final albums = [albumB, albumA];
|
||||
|
||||
final result = await sut.sortAlbums(albums, AlbumSortMode.assetCount);
|
||||
expect(result, [albumA, albumB]);
|
||||
});
|
||||
|
||||
test('should sort correctly based on newestAssetTimestamp', () async {
|
||||
final albums = [albumB, albumA];
|
||||
|
||||
final result = await sut.sortAlbums(albums, AlbumSortMode.mostRecent);
|
||||
expect(result, [albumA, albumB]);
|
||||
});
|
||||
|
||||
test('should sort correctly based on oldestAssetTimestamp', () async {
|
||||
final albums = [albumB, albumA];
|
||||
|
||||
final result = await sut.sortAlbums(albums, AlbumSortMode.mostOldest);
|
||||
expect(result, [albumB, albumA]);
|
||||
});
|
||||
});
|
||||
}
|
||||
185
mobile/test/domain/services/asset.service_test.dart
Normal file
185
mobile/test/domain/services/asset.service_test.dart
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
194
mobile/test/domain/services/hash_service_test.dart
Normal file
194
mobile/test/domain/services/hash_service_test.dart
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/services/hash.service.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../fixtures/album.stub.dart';
|
||||
import '../../fixtures/asset.stub.dart';
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
import '../service.mock.dart';
|
||||
|
||||
void main() {
|
||||
late HashService sut;
|
||||
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);
|
||||
registerFallbackValue(LocalAssetStub.image1);
|
||||
registerFallbackValue(<String, String>{});
|
||||
|
||||
when(() => mockAssetRepo.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
|
||||
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||
});
|
||||
|
||||
group('HashService hashAssets', () {
|
||||
test('skips albums with no assets to hash', () async {
|
||||
when(
|
||||
() => mockAlbumRepo.getBackupAlbums(),
|
||||
).thenAnswer((_) async => [LocalAlbumStub.recent.copyWith(assetCount: 0)]);
|
||||
when(() => mockAlbumRepo.getAssetsToHash(LocalAlbumStub.recent.id)).thenAnswer((_) async => []);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verifyNever(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
});
|
||||
});
|
||||
|
||||
group('HashService _hashAssets', () {
|
||||
test('skips empty batches', () async {
|
||||
final album = LocalAlbumStub.recent;
|
||||
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => []);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verifyNever(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
});
|
||||
|
||||
test('processes assets when available', () async {
|
||||
final album = LocalAlbumStub.recent;
|
||||
final asset = LocalAssetStub.image1;
|
||||
|
||||
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(
|
||||
() => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: 'test-hash')]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verify(() => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
|
||||
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
expect(captured.length, 1);
|
||||
expect(captured[asset.id], 'test-hash');
|
||||
});
|
||||
|
||||
test('handles failed hashes', () async {
|
||||
final album = LocalAlbumStub.recent;
|
||||
final asset = LocalAssetStub.image1;
|
||||
|
||||
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(
|
||||
() => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
).thenAnswer((_) async => [HashResult(assetId: asset.id, error: 'Failed to hash')]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
expect(captured.length, 0);
|
||||
});
|
||||
|
||||
test('handles null hash results', () async {
|
||||
final album = LocalAlbumStub.recent;
|
||||
final asset = LocalAssetStub.image1;
|
||||
|
||||
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(
|
||||
() => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: null)]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
expect(captured.length, 0);
|
||||
});
|
||||
|
||||
test('batches by size limit', () async {
|
||||
const batchSize = 2;
|
||||
final sut = HashService(
|
||||
localAlbumRepository: mockAlbumRepo,
|
||||
localAssetRepository: mockAssetRepo,
|
||||
nativeSyncApi: mockNativeApi,
|
||||
batchSize: batchSize,
|
||||
trashedLocalAssetRepository: mockTrashedAssetRepo,
|
||||
);
|
||||
|
||||
final album = LocalAlbumStub.recent;
|
||||
final asset1 = LocalAssetStub.image1;
|
||||
final asset2 = LocalAssetStub.image2;
|
||||
final asset3 = LocalAssetStub.image1.copyWith(id: 'image3', name: 'image3.jpg');
|
||||
|
||||
final capturedCalls = <List<String>>[];
|
||||
|
||||
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]);
|
||||
when(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
|
||||
invocation,
|
||||
) async {
|
||||
final assetIds = invocation.positionalArguments[0] as List<String>;
|
||||
capturedCalls.add(List<String>.from(assetIds));
|
||||
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
|
||||
});
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
expect(capturedCalls.length, 2, reason: 'Should make exactly 2 calls to hashAssets');
|
||||
expect(capturedCalls[0], [asset1.id, asset2.id], reason: 'First call should batch the first two assets');
|
||||
expect(capturedCalls[1], [asset3.id], reason: 'Second call should have the remaining asset');
|
||||
|
||||
verify(() => mockAssetRepo.updateHashes(any())).called(2);
|
||||
});
|
||||
|
||||
test('handles mixed success and failure in batch', () async {
|
||||
final album = LocalAlbumStub.recent;
|
||||
final asset1 = LocalAssetStub.image1;
|
||||
final asset2 = LocalAssetStub.image2;
|
||||
|
||||
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
|
||||
when(() => mockNativeApi.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer(
|
||||
(_) async => [
|
||||
HashResult(assetId: asset1.id, hash: 'asset1-hash'),
|
||||
HashResult(assetId: asset2.id, error: 'Failed to hash asset2'),
|
||||
],
|
||||
);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
expect(captured.length, 1);
|
||||
expect(captured[asset1.id], 'asset1-hash');
|
||||
});
|
||||
|
||||
test('uses allowNetworkAccess based on album backup selection', () async {
|
||||
final selectedAlbum = LocalAlbumStub.recent.copyWith(backupSelection: BackupSelection.selected);
|
||||
final nonSelectedAlbum = LocalAlbumStub.recent.copyWith(id: 'album2', backupSelection: BackupSelection.excluded);
|
||||
final asset1 = LocalAssetStub.image1;
|
||||
final asset2 = LocalAssetStub.image2;
|
||||
|
||||
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]);
|
||||
when(() => mockAlbumRepo.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]);
|
||||
when(() => mockAlbumRepo.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]);
|
||||
when(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
|
||||
invocation,
|
||||
) async {
|
||||
final assetIds = invocation.positionalArguments[0] as List<String>;
|
||||
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
|
||||
});
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verify(() => mockNativeApi.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
|
||||
verify(() => mockNativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
226
mobile/test/domain/services/local_sync_service_test.dart
Normal file
226
mobile/test/domain/services/local_sync_service_test.dart
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
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/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/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 DriftLocalAssetRepository mockLocalAssetRepository;
|
||||
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();
|
||||
mockLocalAssetRepository = MockLocalAssetRepository();
|
||||
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,
|
||||
localAssetRepository: mockLocalAssetRepository,
|
||||
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],
|
||||
});
|
||||
|
||||
final trashedSnapshot =
|
||||
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single
|
||||
as Iterable<TrashedAsset>;
|
||||
expect(trashedSnapshot.length, 1);
|
||||
final trashedEntry = trashedSnapshot.single;
|
||||
expect(trashedEntry.albumId, 'album-a');
|
||||
expect(trashedEntry.asset.id, platformAsset.id);
|
||||
expect(trashedEntry.asset.name, platformAsset.name);
|
||||
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({});
|
||||
|
||||
final trashedSnapshot =
|
||||
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single
|
||||
as Iterable<TrashedAsset>;
|
||||
expect(trashedSnapshot, isEmpty);
|
||||
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()));
|
||||
});
|
||||
});
|
||||
|
||||
group('LocalSyncService - PlatformAsset conversion', () {
|
||||
test('toLocalAsset uses correct updatedAt timestamp', () {
|
||||
final platformAsset = PlatformAsset(
|
||||
id: 'test-id',
|
||||
name: 'test.jpg',
|
||||
type: AssetType.image.index,
|
||||
durationInSeconds: 0,
|
||||
orientation: 0,
|
||||
isFavorite: false,
|
||||
createdAt: 1700000000,
|
||||
updatedAt: 1732000000,
|
||||
);
|
||||
|
||||
final localAsset = platformAsset.toLocalAsset();
|
||||
|
||||
expect(localAsset.createdAt.millisecondsSinceEpoch ~/ 1000, 1700000000);
|
||||
expect(localAsset.updatedAt.millisecondsSinceEpoch ~/ 1000, 1732000000);
|
||||
expect(localAsset.updatedAt, isNot(localAsset.createdAt));
|
||||
});
|
||||
});
|
||||
}
|
||||
160
mobile/test/domain/services/log_service_test.dart
Normal file
160
mobile/test/domain/services/log_service_test.dart
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
import '../../test_utils.dart';
|
||||
|
||||
final _kInfoLog = LogMessage(
|
||||
message: '#Info Message',
|
||||
level: LogLevel.info,
|
||||
createdAt: DateTime(2025, 2, 26),
|
||||
logger: 'Info Logger',
|
||||
);
|
||||
|
||||
final _kWarnLog = LogMessage(
|
||||
message: '#Warn Message',
|
||||
level: LogLevel.warning,
|
||||
createdAt: DateTime(2025, 2, 27),
|
||||
logger: 'Warn Logger',
|
||||
);
|
||||
|
||||
void main() {
|
||||
late LogService sut;
|
||||
late LogRepository mockLogRepo;
|
||||
late IsarStoreRepository mockStoreRepo;
|
||||
|
||||
setUp(() async {
|
||||
mockLogRepo = MockLogRepository();
|
||||
mockStoreRepo = MockStoreRepository();
|
||||
|
||||
registerFallbackValue(_kInfoLog);
|
||||
|
||||
when(() => mockLogRepo.truncate(limit: any(named: 'limit'))).thenAnswer((_) async => {});
|
||||
when(() => mockStoreRepo.tryGet<int>(StoreKey.logLevel)).thenAnswer((_) async => LogLevel.fine.index);
|
||||
when(() => mockLogRepo.getAll()).thenAnswer((_) async => []);
|
||||
when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true);
|
||||
when(() => mockLogRepo.insertAll(any())).thenAnswer((_) async => true);
|
||||
|
||||
sut = await LogService.create(logRepository: mockLogRepo, storeRepository: mockStoreRepo);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await sut.dispose();
|
||||
});
|
||||
|
||||
group("Log Service Init:", () {
|
||||
test('Truncates the existing logs on init', () {
|
||||
final limit = verify(() => mockLogRepo.truncate(limit: captureAny(named: 'limit'))).captured.firstOrNull as int?;
|
||||
expect(limit, kLogTruncateLimit);
|
||||
});
|
||||
|
||||
test('Sets log level based on the store setting', () {
|
||||
verify(() => mockStoreRepo.tryGet<int>(StoreKey.logLevel)).called(1);
|
||||
expect(Logger.root.level, Level.FINE);
|
||||
});
|
||||
});
|
||||
|
||||
group("Log Service Set Level:", () {
|
||||
setUp(() async {
|
||||
when(() => mockStoreRepo.upsert<int>(StoreKey.logLevel, any())).thenAnswer((_) async => true);
|
||||
await sut.setLogLevel(LogLevel.shout);
|
||||
});
|
||||
|
||||
test('Updates the log level in store', () {
|
||||
final index = verify(() => mockStoreRepo.upsert<int>(StoreKey.logLevel, captureAny())).captured.firstOrNull;
|
||||
expect(index, LogLevel.shout.index);
|
||||
});
|
||||
|
||||
test('Sets log level on logger', () {
|
||||
expect(Logger.root.level, Level.SHOUT);
|
||||
});
|
||||
});
|
||||
|
||||
group("Log Service Buffer:", () {
|
||||
test('Buffers logs until timer elapses', () {
|
||||
TestUtils.fakeAsync((time) async {
|
||||
sut = await LogService.create(logRepository: mockLogRepo, storeRepository: mockStoreRepo, shouldBuffer: true);
|
||||
|
||||
final logger = Logger(_kInfoLog.logger!);
|
||||
logger.info(_kInfoLog.message);
|
||||
expect(await sut.getMessages(), hasLength(1));
|
||||
logger.warning(_kWarnLog.message);
|
||||
expect(await sut.getMessages(), hasLength(2));
|
||||
time.elapse(const Duration(seconds: 6));
|
||||
expect(await sut.getMessages(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('Batch inserts all logs on timer', () {
|
||||
TestUtils.fakeAsync((time) async {
|
||||
sut = await LogService.create(logRepository: mockLogRepo, storeRepository: mockStoreRepo, shouldBuffer: true);
|
||||
|
||||
final logger = Logger(_kInfoLog.logger!);
|
||||
logger.info(_kInfoLog.message);
|
||||
time.elapse(const Duration(seconds: 6));
|
||||
final insert = verify(() => mockLogRepo.insertAll(captureAny()));
|
||||
insert.called(1);
|
||||
final captured = insert.captured.firstOrNull as List<LogMessage>;
|
||||
expect(captured.firstOrNull?.message, _kInfoLog.message);
|
||||
expect(captured.firstOrNull?.logger, _kInfoLog.logger);
|
||||
|
||||
verifyNever(() => mockLogRepo.insert(captureAny()));
|
||||
});
|
||||
});
|
||||
|
||||
test('Does not buffer when off', () {
|
||||
TestUtils.fakeAsync((time) async {
|
||||
sut = await LogService.create(logRepository: mockLogRepo, storeRepository: mockStoreRepo, shouldBuffer: false);
|
||||
|
||||
final logger = Logger(_kInfoLog.logger!);
|
||||
logger.info(_kInfoLog.message);
|
||||
// Ensure nothing gets buffer. This works because we mock log repo getAll to return nothing
|
||||
expect(await sut.getMessages(), isEmpty);
|
||||
|
||||
final insert = verify(() => mockLogRepo.insert(captureAny()));
|
||||
insert.called(1);
|
||||
final captured = insert.captured.firstOrNull as LogMessage;
|
||||
expect(captured.message, _kInfoLog.message);
|
||||
expect(captured.logger, _kInfoLog.logger);
|
||||
|
||||
verifyNever(() => mockLogRepo.insertAll(captureAny()));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group("Log Service Get messages:", () {
|
||||
setUp(() {
|
||||
when(() => mockLogRepo.getAll()).thenAnswer((_) async => [_kInfoLog]);
|
||||
});
|
||||
|
||||
test('Fetches result from DB', () async {
|
||||
expect(await sut.getMessages(), hasLength(1));
|
||||
verify(() => mockLogRepo.getAll()).called(1);
|
||||
});
|
||||
|
||||
test('Combines result from both DB + Buffer', () {
|
||||
TestUtils.fakeAsync((time) async {
|
||||
sut = await LogService.create(logRepository: mockLogRepo, storeRepository: mockStoreRepo, shouldBuffer: true);
|
||||
|
||||
final logger = Logger(_kWarnLog.logger!);
|
||||
logger.warning(_kWarnLog.message);
|
||||
expect(await sut.getMessages(), hasLength(2)); // 1 - DB, 1 - Buff
|
||||
|
||||
final messages = await sut.getMessages();
|
||||
// Logged time is assigned in the service for messages in the buffer, so compare manually
|
||||
expect(messages.firstOrNull?.message, _kWarnLog.message);
|
||||
expect(messages.firstOrNull?.logger, _kWarnLog.logger);
|
||||
|
||||
expect(messages.elementAtOrNull(1), _kInfoLog);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
175
mobile/test/domain/services/store_service_test.dart
Normal file
175
mobile/test/domain/services/store_service_test.dart
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import 'dart:async';
|
||||
|
||||
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/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
|
||||
const _kAccessToken = '#ThisIsAToken';
|
||||
const _kBackgroundBackup = false;
|
||||
const _kGroupAssetsBy = 2;
|
||||
final _kBackupFailedSince = DateTime.utc(2023);
|
||||
|
||||
void main() {
|
||||
late StoreService sut;
|
||||
late 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);
|
||||
registerFallbackValue(StoreKey.backupTriggerDelay);
|
||||
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),
|
||||
const StoreDto(StoreKey.backgroundBackup, _kBackgroundBackup),
|
||||
const StoreDto(StoreKey.groupAssetsBy, _kGroupAssetsBy),
|
||||
StoreDto(StoreKey.backupFailedSince, _kBackupFailedSince),
|
||||
],
|
||||
);
|
||||
when(() => mockDriftStoreRepo.watchAll()).thenAnswer((_) => controller.stream);
|
||||
|
||||
sut = await StoreService.create(storeRepository: mockStoreRepo);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
unawaited(sut.dispose());
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
group("Store Service Init:", () {
|
||||
test('Populates the internal cache on init', () {
|
||||
verify(() => mockStoreRepo.getAll()).called(1);
|
||||
expect(sut.tryGet(StoreKey.accessToken), _kAccessToken);
|
||||
expect(sut.tryGet(StoreKey.backgroundBackup), _kBackgroundBackup);
|
||||
expect(sut.tryGet(StoreKey.groupAssetsBy), _kGroupAssetsBy);
|
||||
expect(sut.tryGet(StoreKey.backupFailedSince), _kBackupFailedSince);
|
||||
// Other keys should be null
|
||||
expect(sut.tryGet(StoreKey.currentUser), isNull);
|
||||
});
|
||||
|
||||
test('Listens to stream of store updates', () async {
|
||||
final event = StoreDto(StoreKey.accessToken, _kAccessToken.toUpperCase());
|
||||
controller.add([event]);
|
||||
|
||||
await pumpEventQueue();
|
||||
|
||||
verify(() => mockStoreRepo.watchAll()).called(1);
|
||||
expect(sut.tryGet(StoreKey.accessToken), _kAccessToken.toUpperCase());
|
||||
});
|
||||
});
|
||||
|
||||
group('Store Service get:', () {
|
||||
test('Returns the stored value for the given key', () {
|
||||
expect(sut.get(StoreKey.accessToken), _kAccessToken);
|
||||
});
|
||||
|
||||
test('Throws StoreKeyNotFoundException for nonexistent keys', () {
|
||||
expect(() => sut.get(StoreKey.currentUser), throwsA(isA<StoreKeyNotFoundException>()));
|
||||
});
|
||||
|
||||
test('Returns the stored value for the given key or the defaultValue', () {
|
||||
expect(sut.get(StoreKey.currentUser, 5), 5);
|
||||
});
|
||||
});
|
||||
|
||||
group('Store Service put:', () {
|
||||
setUp(() {
|
||||
when(() => mockStoreRepo.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()));
|
||||
});
|
||||
|
||||
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);
|
||||
expect(sut.tryGet(StoreKey.accessToken), newAccessToken);
|
||||
});
|
||||
});
|
||||
|
||||
group('Store Service watch:', () {
|
||||
late StreamController<String?> valueController;
|
||||
|
||||
setUp(() {
|
||||
valueController = StreamController<String?>.broadcast();
|
||||
when(() => mockStoreRepo.watch<String>(any<StoreKey<String>>())).thenAnswer((_) => valueController.stream);
|
||||
when(() => mockDriftStoreRepo.watch<String>(any<StoreKey<String>>())).thenAnswer((_) => valueController.stream);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await valueController.close();
|
||||
});
|
||||
|
||||
test('Watches a specific key for changes', () async {
|
||||
final stream = sut.watch(StoreKey.accessToken);
|
||||
final events = <String?>[_kAccessToken, _kAccessToken.toUpperCase(), null, _kAccessToken.toLowerCase()];
|
||||
|
||||
unawaited(expectLater(stream, emitsInOrder(events)));
|
||||
|
||||
for (final event in events) {
|
||||
valueController.add(event);
|
||||
}
|
||||
|
||||
await pumpEventQueue();
|
||||
verify(() => mockStoreRepo.watch<String>(StoreKey.accessToken)).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('Store Service delete:', () {
|
||||
setUp(() {
|
||||
when(() => mockStoreRepo.delete<String>(any<StoreKey<String>>())).thenAnswer((_) async => true);
|
||||
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);
|
||||
});
|
||||
|
||||
test('Removes the value from the cache', () async {
|
||||
await sut.delete(StoreKey.accessToken);
|
||||
expect(sut.tryGet(StoreKey.accessToken), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('Store Service clear:', () {
|
||||
setUp(() {
|
||||
when(() => mockStoreRepo.deleteAll()).thenAnswer((_) async => true);
|
||||
when(() => mockDriftStoreRepo.deleteAll()).thenAnswer((_) async => true);
|
||||
});
|
||||
|
||||
test('Clears all values from the store', () async {
|
||||
await sut.clear();
|
||||
verify(() => mockStoreRepo.deleteAll()).called(1);
|
||||
expect(sut.tryGet(StoreKey.accessToken), isNull);
|
||||
expect(sut.tryGet(StoreKey.backgroundBackup), isNull);
|
||||
expect(sut.tryGet(StoreKey.groupAssetsBy), isNull);
|
||||
expect(sut.tryGet(StoreKey.backupFailedSince), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
578
mobile/test/domain/services/sync_stream_service_test.dart
Normal file
578
mobile/test/domain/services/sync_stream_service_test.dart
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
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 'package:openapi/api.dart';
|
||||
|
||||
import '../../api.mocks.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';
|
||||
import '../../service.mocks.dart';
|
||||
|
||||
class _AbortCallbackWrapper {
|
||||
const _AbortCallbackWrapper();
|
||||
|
||||
bool call() => false;
|
||||
}
|
||||
|
||||
class _MockAbortCallbackWrapper extends Mock implements _AbortCallbackWrapper {}
|
||||
|
||||
class _CancellationWrapper {
|
||||
const _CancellationWrapper();
|
||||
|
||||
bool call() => false;
|
||||
}
|
||||
|
||||
class _MockCancellationWrapper extends Mock implements _CancellationWrapper {}
|
||||
|
||||
void main() {
|
||||
late SyncStreamService sut;
|
||||
late SyncStreamRepository mockSyncStreamRepo;
|
||||
late SyncApiRepository mockSyncApiRepo;
|
||||
late DriftLocalAssetRepository mockLocalAssetRepo;
|
||||
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
|
||||
late LocalFilesManagerRepository mockLocalFilesManagerRepo;
|
||||
late StorageRepository mockStorageRepo;
|
||||
late MockApiService mockApi;
|
||||
late MockServerApi mockServerApi;
|
||||
late MockSyncMigrationRepository mockSyncMigrationRepo;
|
||||
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(() async {
|
||||
mockSyncStreamRepo = MockSyncStreamRepository();
|
||||
mockSyncApiRepo = MockSyncApiRepository();
|
||||
mockLocalAssetRepo = MockLocalAssetRepository();
|
||||
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
|
||||
mockLocalFilesManagerRepo = MockLocalFilesManagerRepository();
|
||||
mockStorageRepo = MockStorageRepository();
|
||||
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
|
||||
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
|
||||
mockApi = MockApiService();
|
||||
mockServerApi = MockServerApi();
|
||||
mockSyncMigrationRepo = MockSyncMigrationRepository();
|
||||
|
||||
when(() => mockAbortCallbackWrapper()).thenReturn(false);
|
||||
|
||||
when(() => mockSyncApiRepo.streamChanges(any())).thenAnswer((invocation) async {
|
||||
handleEventsCallback = invocation.positionalArguments.first;
|
||||
});
|
||||
|
||||
when(() => mockSyncApiRepo.streamChanges(any(), onReset: any(named: 'onReset'))).thenAnswer((invocation) async {
|
||||
handleEventsCallback = invocation.positionalArguments.first;
|
||||
});
|
||||
|
||||
when(() => mockSyncApiRepo.ack(any())).thenAnswer((_) async => {});
|
||||
when(() => mockSyncApiRepo.deleteSyncAck(any())).thenAnswer((_) async => {});
|
||||
|
||||
when(() => mockApi.serverInfoApi).thenReturn(mockServerApi);
|
||||
when(() => mockServerApi.getServerVersion()).thenAnswer(
|
||||
(_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0),
|
||||
);
|
||||
|
||||
when(() => mockSyncStreamRepo.updateUsersV1(any())).thenAnswer(successHandler);
|
||||
when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer(successHandler);
|
||||
when(() => mockSyncStreamRepo.updatePartnerV1(any())).thenAnswer(successHandler);
|
||||
when(() => mockSyncStreamRepo.deletePartnerV1(any())).thenAnswer(successHandler);
|
||||
when(() => mockSyncStreamRepo.updateAssetsV1(any())).thenAnswer(successHandler);
|
||||
when(
|
||||
() => mockSyncStreamRepo.updateAssetsV1(any(), debugLabel: any(named: 'debugLabel')),
|
||||
).thenAnswer(successHandler);
|
||||
when(() => mockSyncStreamRepo.deleteAssetsV1(any())).thenAnswer(successHandler);
|
||||
when(
|
||||
() => mockSyncStreamRepo.deleteAssetsV1(any(), debugLabel: any(named: 'debugLabel')),
|
||||
).thenAnswer(successHandler);
|
||||
when(() => mockSyncStreamRepo.updateAssetsExifV1(any())).thenAnswer(successHandler);
|
||||
when(
|
||||
() => mockSyncStreamRepo.updateAssetsExifV1(any(), debugLabel: any(named: 'debugLabel')),
|
||||
).thenAnswer(successHandler);
|
||||
when(() => mockSyncStreamRepo.updateMemoriesV1(any())).thenAnswer(successHandler);
|
||||
when(() => mockSyncStreamRepo.deleteMemoriesV1(any())).thenAnswer(successHandler);
|
||||
when(() => mockSyncStreamRepo.updateMemoryAssetsV1(any())).thenAnswer(successHandler);
|
||||
when(() => mockSyncStreamRepo.deleteMemoryAssetsV1(any())).thenAnswer(successHandler);
|
||||
when(
|
||||
() => mockSyncStreamRepo.updateStacksV1(any(), debugLabel: any(named: 'debugLabel')),
|
||||
).thenAnswer(successHandler);
|
||||
when(
|
||||
() => mockSyncStreamRepo.deleteStacksV1(any(), debugLabel: any(named: 'debugLabel')),
|
||||
).thenAnswer(successHandler);
|
||||
when(() => mockSyncStreamRepo.updateUserMetadatasV1(any())).thenAnswer(successHandler);
|
||||
when(() => mockSyncStreamRepo.deleteUserMetadatasV1(any())).thenAnswer(successHandler);
|
||||
when(() => mockSyncStreamRepo.updatePeopleV1(any())).thenAnswer(successHandler);
|
||||
when(() => mockSyncStreamRepo.deletePeopleV1(any())).thenAnswer(successHandler);
|
||||
when(() => mockSyncStreamRepo.updateAssetFacesV1(any())).thenAnswer(successHandler);
|
||||
when(() => mockSyncStreamRepo.deleteAssetFacesV1(any())).thenAnswer(successHandler);
|
||||
when(() => mockSyncMigrationRepo.v20260128CopyExifWidthHeightToAsset()).thenAnswer(successHandler);
|
||||
|
||||
sut = SyncStreamService(
|
||||
syncApiRepository: mockSyncApiRepo,
|
||||
syncStreamRepository: mockSyncStreamRepo,
|
||||
localAssetRepository: mockLocalAssetRepo,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||
localFilesManager: mockLocalFilesManagerRepo,
|
||||
storageRepository: mockStorageRepo,
|
||||
api: mockApi,
|
||||
syncMigrationRepository: mockSyncMigrationRepo,
|
||||
);
|
||||
|
||||
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 {
|
||||
await sut.sync();
|
||||
await handleEventsCallback(events, mockAbortCallbackWrapper.call, mockResetCallbackWrapper.call);
|
||||
}
|
||||
|
||||
group("SyncStreamService - _handleEvents", () {
|
||||
test("processes events and acks successfully when handlers succeed", () async {
|
||||
final events = [
|
||||
SyncStreamStub.userDeleteV1,
|
||||
SyncStreamStub.userV1Admin,
|
||||
SyncStreamStub.userV1User,
|
||||
SyncStreamStub.partnerDeleteV1,
|
||||
SyncStreamStub.partnerV1,
|
||||
];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verifyInOrder([
|
||||
() => mockSyncStreamRepo.deleteUsersV1(any()),
|
||||
() => mockSyncApiRepo.ack(["2"]),
|
||||
() => mockSyncStreamRepo.updateUsersV1(any()),
|
||||
() => mockSyncApiRepo.ack(["5"]),
|
||||
() => mockSyncStreamRepo.deletePartnerV1(any()),
|
||||
() => mockSyncApiRepo.ack(["4"]),
|
||||
() => mockSyncStreamRepo.updatePartnerV1(any()),
|
||||
() => mockSyncApiRepo.ack(["3"]),
|
||||
]);
|
||||
verifyNever(() => mockAbortCallbackWrapper());
|
||||
});
|
||||
|
||||
test("processes final batch correctly", () async {
|
||||
final events = [SyncStreamStub.userDeleteV1, SyncStreamStub.userV1Admin];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verifyInOrder([
|
||||
() => mockSyncStreamRepo.deleteUsersV1(any()),
|
||||
() => mockSyncApiRepo.ack(["2"]),
|
||||
() => mockSyncStreamRepo.updateUsersV1(any()),
|
||||
() => mockSyncApiRepo.ack(["1"]),
|
||||
]);
|
||||
verifyNever(() => mockAbortCallbackWrapper());
|
||||
});
|
||||
|
||||
test("does not process or ack when event list is empty", () async {
|
||||
await simulateEvents([]);
|
||||
|
||||
verifyNever(() => mockSyncStreamRepo.updateUsersV1(any()));
|
||||
verifyNever(() => mockSyncStreamRepo.deleteUsersV1(any()));
|
||||
verifyNever(() => mockSyncStreamRepo.updatePartnerV1(any()));
|
||||
verifyNever(() => mockSyncStreamRepo.deletePartnerV1(any()));
|
||||
verifyNever(() => mockAbortCallbackWrapper());
|
||||
verifyNever(() => mockSyncApiRepo.ack(any()));
|
||||
});
|
||||
|
||||
test("aborts and stops processing if cancelled during iteration", () async {
|
||||
final cancellationChecker = _MockCancellationWrapper();
|
||||
when(() => cancellationChecker()).thenReturn(false);
|
||||
|
||||
sut = SyncStreamService(
|
||||
syncApiRepository: mockSyncApiRepo,
|
||||
syncStreamRepository: mockSyncStreamRepo,
|
||||
localAssetRepository: mockLocalAssetRepo,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||
localFilesManager: mockLocalFilesManagerRepo,
|
||||
storageRepository: mockStorageRepo,
|
||||
cancelChecker: cancellationChecker.call,
|
||||
api: mockApi,
|
||||
syncMigrationRepository: mockSyncMigrationRepo,
|
||||
);
|
||||
await sut.sync();
|
||||
|
||||
final events = [SyncStreamStub.userDeleteV1, SyncStreamStub.userV1Admin, SyncStreamStub.partnerDeleteV1];
|
||||
|
||||
when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer((_) async {
|
||||
when(() => cancellationChecker()).thenReturn(true);
|
||||
});
|
||||
|
||||
await handleEventsCallback(events, mockAbortCallbackWrapper.call, mockResetCallbackWrapper.call);
|
||||
|
||||
verify(() => mockSyncStreamRepo.deleteUsersV1(any())).called(1);
|
||||
verifyNever(() => mockSyncStreamRepo.updateUsersV1(any()));
|
||||
verifyNever(() => mockSyncStreamRepo.deletePartnerV1(any()));
|
||||
|
||||
verify(() => mockAbortCallbackWrapper()).called(1);
|
||||
|
||||
verify(() => mockSyncApiRepo.ack(["2"])).called(1);
|
||||
});
|
||||
|
||||
test("aborts and stops processing if cancelled before processing batch", () async {
|
||||
final cancellationChecker = _MockCancellationWrapper();
|
||||
when(() => cancellationChecker()).thenReturn(false);
|
||||
|
||||
final processingCompleter = Completer<void>();
|
||||
bool handler1Started = false;
|
||||
when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer((_) async {
|
||||
handler1Started = true;
|
||||
return processingCompleter.future;
|
||||
});
|
||||
|
||||
sut = SyncStreamService(
|
||||
syncApiRepository: mockSyncApiRepo,
|
||||
syncStreamRepository: mockSyncStreamRepo,
|
||||
localAssetRepository: mockLocalAssetRepo,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||
localFilesManager: mockLocalFilesManagerRepo,
|
||||
storageRepository: mockStorageRepo,
|
||||
cancelChecker: cancellationChecker.call,
|
||||
api: mockApi,
|
||||
syncMigrationRepository: mockSyncMigrationRepo,
|
||||
);
|
||||
|
||||
await sut.sync();
|
||||
|
||||
final events = [SyncStreamStub.userDeleteV1, SyncStreamStub.userV1Admin, SyncStreamStub.partnerDeleteV1];
|
||||
|
||||
final processingFuture = handleEventsCallback(
|
||||
events,
|
||||
mockAbortCallbackWrapper.call,
|
||||
mockResetCallbackWrapper.call,
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(handler1Started, isTrue);
|
||||
|
||||
// Signal cancellation while handler 1 is waiting
|
||||
when(() => cancellationChecker()).thenReturn(true);
|
||||
await pumpEventQueue();
|
||||
|
||||
processingCompleter.complete();
|
||||
await processingFuture;
|
||||
|
||||
verifyNever(() => mockSyncStreamRepo.updateUsersV1(any()));
|
||||
|
||||
verify(() => mockSyncApiRepo.ack(["2"])).called(1);
|
||||
});
|
||||
|
||||
test("processes memory sync events successfully", () async {
|
||||
final events = [
|
||||
SyncStreamStub.memoryV1,
|
||||
SyncStreamStub.memoryDeleteV1,
|
||||
SyncStreamStub.memoryToAssetV1,
|
||||
SyncStreamStub.memoryToAssetDeleteV1,
|
||||
];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verifyInOrder([
|
||||
() => mockSyncStreamRepo.updateMemoriesV1(any()),
|
||||
() => mockSyncApiRepo.ack(["5"]),
|
||||
() => mockSyncStreamRepo.deleteMemoriesV1(any()),
|
||||
() => mockSyncApiRepo.ack(["6"]),
|
||||
() => mockSyncStreamRepo.updateMemoryAssetsV1(any()),
|
||||
() => mockSyncApiRepo.ack(["7"]),
|
||||
() => mockSyncStreamRepo.deleteMemoryAssetsV1(any()),
|
||||
() => mockSyncApiRepo.ack(["8"]),
|
||||
]);
|
||||
verifyNever(() => mockAbortCallbackWrapper());
|
||||
});
|
||||
|
||||
test("processes mixed memory and user events in correct order", () async {
|
||||
final events = [
|
||||
SyncStreamStub.memoryDeleteV1,
|
||||
SyncStreamStub.userV1Admin,
|
||||
SyncStreamStub.memoryToAssetV1,
|
||||
SyncStreamStub.memoryV1,
|
||||
];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verifyInOrder([
|
||||
() => mockSyncStreamRepo.deleteMemoriesV1(any()),
|
||||
() => mockSyncApiRepo.ack(["6"]),
|
||||
() => mockSyncStreamRepo.updateUsersV1(any()),
|
||||
() => mockSyncApiRepo.ack(["1"]),
|
||||
() => mockSyncStreamRepo.updateMemoryAssetsV1(any()),
|
||||
() => mockSyncApiRepo.ack(["7"]),
|
||||
() => mockSyncStreamRepo.updateMemoriesV1(any()),
|
||||
() => mockSyncApiRepo.ack(["5"]),
|
||||
]);
|
||||
verifyNever(() => mockAbortCallbackWrapper());
|
||||
});
|
||||
|
||||
test("handles memory sync failure gracefully", () async {
|
||||
when(() => mockSyncStreamRepo.updateMemoriesV1(any())).thenThrow(Exception("Memory sync failed"));
|
||||
|
||||
final events = [SyncStreamStub.memoryV1, SyncStreamStub.userV1Admin];
|
||||
|
||||
expect(() async => await simulateEvents(events), throwsA(isA<Exception>()));
|
||||
});
|
||||
|
||||
test("processes memory asset events with correct data types", () async {
|
||||
final events = [SyncStreamStub.memoryToAssetV1];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockSyncStreamRepo.updateMemoryAssetsV1(any())).called(1);
|
||||
verify(() => mockSyncApiRepo.ack(["7"])).called(1);
|
||||
});
|
||||
|
||||
test("processes memory delete events with correct data types", () async {
|
||||
final events = [SyncStreamStub.memoryDeleteV1];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockSyncStreamRepo.deleteMemoriesV1(any())).called(1);
|
||||
verify(() => mockSyncApiRepo.ack(["6"])).called(1);
|
||||
});
|
||||
|
||||
test("processes memory create/update events with correct data types", () async {
|
||||
final events = [SyncStreamStub.memoryV1];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockSyncStreamRepo.updateMemoriesV1(any())).called(1);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncStreamService - Sync Migration', () {
|
||||
test('ensure that <2.5.0 migrations run', () async {
|
||||
await Store.put(StoreKey.syncMigrationStatus, "[]");
|
||||
when(
|
||||
() => mockServerApi.getServerVersion(),
|
||||
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1));
|
||||
|
||||
await sut.sync();
|
||||
|
||||
verifyInOrder([
|
||||
() => mockSyncApiRepo.deleteSyncAck([
|
||||
SyncEntityType.assetExifV1,
|
||||
SyncEntityType.partnerAssetExifV1,
|
||||
SyncEntityType.albumAssetExifCreateV1,
|
||||
SyncEntityType.albumAssetExifUpdateV1,
|
||||
]),
|
||||
() => mockSyncMigrationRepo.v20260128CopyExifWidthHeightToAsset(),
|
||||
]);
|
||||
|
||||
// should only run on server >2.5.0
|
||||
verifyNever(
|
||||
() => mockSyncApiRepo.deleteSyncAck([
|
||||
SyncEntityType.assetV1,
|
||||
SyncEntityType.partnerAssetV1,
|
||||
SyncEntityType.albumAssetCreateV1,
|
||||
SyncEntityType.albumAssetUpdateV1,
|
||||
]),
|
||||
);
|
||||
});
|
||||
test('ensure that >=2.5.0 migrations run', () async {
|
||||
await Store.put(StoreKey.syncMigrationStatus, "[]");
|
||||
when(
|
||||
() => mockServerApi.getServerVersion(),
|
||||
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 5, patch_: 0));
|
||||
await sut.sync();
|
||||
|
||||
verifyInOrder([
|
||||
() => mockSyncApiRepo.deleteSyncAck([
|
||||
SyncEntityType.assetExifV1,
|
||||
SyncEntityType.partnerAssetExifV1,
|
||||
SyncEntityType.albumAssetExifCreateV1,
|
||||
SyncEntityType.albumAssetExifUpdateV1,
|
||||
]),
|
||||
() => mockSyncApiRepo.deleteSyncAck([
|
||||
SyncEntityType.assetV1,
|
||||
SyncEntityType.partnerAssetV1,
|
||||
SyncEntityType.albumAssetCreateV1,
|
||||
SyncEntityType.albumAssetUpdateV1,
|
||||
]),
|
||||
]);
|
||||
|
||||
// v20260128_ResetAssetV1 writes that v20260128_CopyExifWidthHeightToAsset has been completed
|
||||
verifyNever(() => mockSyncMigrationRepo.v20260128CopyExifWidthHeightToAsset());
|
||||
});
|
||||
|
||||
test('ensure that migrations do not re-run', () async {
|
||||
await Store.put(
|
||||
StoreKey.syncMigrationStatus,
|
||||
'["${SyncMigrationTask.v20260128_CopyExifWidthHeightToAsset.name}"]',
|
||||
);
|
||||
|
||||
when(
|
||||
() => mockServerApi.getServerVersion(),
|
||||
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1));
|
||||
|
||||
await sut.sync();
|
||||
|
||||
verifyNever(() => mockSyncMigrationRepo.v20260128CopyExifWidthHeightToAsset());
|
||||
});
|
||||
});
|
||||
}
|
||||
130
mobile/test/domain/services/user_service_test.dart
Normal file
130
mobile/test/domain/services/user_service_test.dart
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
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';
|
||||
|
||||
import '../../fixtures/user.stub.dart';
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
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,
|
||||
);
|
||||
|
||||
registerFallbackValue(UserStub.admin);
|
||||
when(() => mockStoreService.get(StoreKey.currentUser)).thenReturn(UserStub.admin);
|
||||
when(() => mockStoreService.tryGet(StoreKey.currentUser)).thenReturn(UserStub.admin);
|
||||
});
|
||||
|
||||
group('getMyUser', () {
|
||||
test('should return user from store', () {
|
||||
final result = sut.getMyUser();
|
||||
expect(result, UserStub.admin);
|
||||
});
|
||||
|
||||
test('should handle user not found scenario', () {
|
||||
when(() => mockStoreService.get(StoreKey.currentUser)).thenThrow(Exception('User not found'));
|
||||
|
||||
expect(() => sut.getMyUser(), throwsA(isA<Exception>()));
|
||||
});
|
||||
});
|
||||
|
||||
group('tryGetMyUser', () {
|
||||
test('should return user from store', () {
|
||||
final result = sut.tryGetMyUser();
|
||||
expect(result, UserStub.admin);
|
||||
});
|
||||
|
||||
test('should return null if user not found', () {
|
||||
when(() => mockStoreService.tryGet(StoreKey.currentUser)).thenReturn(null);
|
||||
final result = sut.tryGetMyUser();
|
||||
expect(result, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('watchMyUser', () {
|
||||
test('should return user stream from store', () {
|
||||
when(() => mockStoreService.watch(StoreKey.currentUser)).thenAnswer((_) => Stream.value(UserStub.admin));
|
||||
final result = sut.watchMyUser();
|
||||
expect(result, emits(UserStub.admin));
|
||||
});
|
||||
|
||||
test('should return an empty stream if user not found', () {
|
||||
when(() => mockStoreService.watch(StoreKey.currentUser)).thenAnswer((_) => const Stream.empty());
|
||||
final result = sut.watchMyUser();
|
||||
expect(result, emitsInOrder([]));
|
||||
});
|
||||
});
|
||||
|
||||
group('refreshMyUser', () {
|
||||
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);
|
||||
});
|
||||
|
||||
test('should return null if user not found', () async {
|
||||
when(() => mockUserApiRepo.getMyUser()).thenAnswer((_) async => null);
|
||||
|
||||
final result = await sut.refreshMyUser();
|
||||
verifyNever(() => mockStoreService.put(StoreKey.currentUser, UserStub.admin));
|
||||
verifyNever(() => mockUserRepo.update(UserStub.admin));
|
||||
expect(result, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('createProfileImage', () {
|
||||
test('should return profile image path', () async {
|
||||
const profileImagePath = 'profile.jpg';
|
||||
final updatedUser = UserStub.admin;
|
||||
|
||||
when(
|
||||
() => 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);
|
||||
});
|
||||
|
||||
test('should return null if profile image creation fails', () async {
|
||||
const profileImagePath = 'profile.jpg';
|
||||
final updatedUser = UserStub.admin;
|
||||
|
||||
when(
|
||||
() => mockUserApiRepo.createProfileImage(name: profileImagePath, data: Uint8List(0)),
|
||||
).thenThrow(Exception('Failed to create profile image'));
|
||||
|
||||
final result = await sut.createProfileImage(profileImagePath, Uint8List(0));
|
||||
verifyNever(() => mockStoreService.put(StoreKey.currentUser, updatedUser));
|
||||
verifyNever(() => mockUserRepo.update(updatedUser));
|
||||
expect(result, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue