diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e27f1ebdf9..41daebd3a7 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -131,7 +131,7 @@ jobs: - device: rocm suffixes: '-rocm' platforms: linux/amd64 - runner-mapping: '{"linux/amd64": "mich"}' + runner-mapping: '{"linux/amd64": "pokedex-giant"}' uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0 permissions: contents: read diff --git a/docs/docs/features/mobile-app.mdx b/docs/docs/features/mobile-app.mdx index 02b5d492f4..59a4844c46 100644 --- a/docs/docs/features/mobile-app.mdx +++ b/docs/docs/features/mobile-app.mdx @@ -66,7 +66,7 @@ Now make sure that the local album is selected in the backup screen (steps 1-2 a - **Keep on device:** You can choose to restrict removal to `Always keep` **All photos** or **All videos**, regardless of other settings. This setting can hamper freeing up space significantly — with 80 GB of videos and 40 GB photos, selecting `Always keep photos` retains thousands of photos on your device. 2. **Scan & Review:** Before any files are removed, you are presented with a review screen to verify which items will be deleted and how much storage is reclamable. -3. **Deletion:** Confirmed items are moved to your device's native Trash/Recycle Bin. +3. **Deletion:** Confirmed items are moved to your device's native Trash/Recycle Bin. For large queues, Immich processes deletion in batches for stability (`2000` assets per batch on Android, `10000` per batch on iOS). :::info reclaim storage To use the reclaimed space right away, you must empty the system/gallery trash manually outside of Immich. diff --git a/docs/docs/install/upgrading.md b/docs/docs/install/upgrading.md index bf788cb680..12e5c9c342 100644 --- a/docs/docs/install/upgrading.md +++ b/docs/docs/install/upgrading.md @@ -26,6 +26,16 @@ docker image prune [breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created [releases]: https://github.com/immich-app/immich/releases +## Versioning Policy + +Immich follows [semantic versioning][semver], which tags releases in the format `..`. We intend for breaking changes to be limited to major version releases. +You can configure your Docker image to point to the current major version by using a metatag, such as `:v2`. + +Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich. +Switching back to an earlier version, even within the same minor release tag, is not supported. + +[semver]: https://semver.org/ + ## Migrating to VectorChord :::info diff --git a/i18n/en.json b/i18n/en.json index 2d3d3680f8..dedbea1bfe 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -997,6 +997,11 @@ "editor_close_without_save_prompt": "The changes will not be saved", "editor_close_without_save_title": "Close editor?", "editor_confirm_reset_all_changes": "Are you sure you want to reset all changes?", + "editor_discard_edits_confirm": "Discard edits", + "editor_discard_edits_prompt": "You have unsaved edits. Are you sure you want to discard them?", + "editor_discard_edits_title": "Discard edits?", + "editor_edits_applied_error": "Failed to apply edits", + "editor_edits_applied_success": "Edits applied successfully", "editor_flip_horizontal": "Flip horizontal", "editor_flip_vertical": "Flip vertical", "editor_orientation": "Orientation", diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index dfc217c118..9db6fd78dd 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -54,7 +54,7 @@ RUN --mount=type=cache,target=/ccache \ --build_wheel \ --update \ --build \ - --parallel 17 \ + --parallel 48 \ --cmake_extra_defines \ ONNXRUNTIME_VERSION="${ONNXRUNTIME_VERSION}" \ CMAKE_HIP_ARCHITECTURES="gfx900;gfx906;gfx908;gfx90a;gfx940;gfx941;gfx942;gfx1030;gfx1100;gfx1101;gfx1102;gfx1200;gfx1201" \ diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index 26c223afad..350f6b80fa 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -1,4 +1,11 @@ -enum SortOrder { asc, desc } +enum SortOrder { + asc, + desc; + + SortOrder reverse() { + return this == SortOrder.asc ? SortOrder.desc : SortOrder.asc; + } +} enum TextSearchType { context, filename, description, ocr } diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index 68c72255b0..0cf3f3e1c1 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; @@ -36,6 +37,7 @@ class RemoteAlbumService { AlbumSortMode sortMode, { bool isReverse = false, }) async { + // list of albums sorted ascendingly according to the selected sort mode final List sorted = switch (sortMode) { AlbumSortMode.created => albums.sortedBy((album) => album.createdAt), AlbumSortMode.title => albums.sortedBy((album) => album.name), @@ -44,8 +46,9 @@ class RemoteAlbumService { AlbumSortMode.mostRecent => await _sortByNewestAsset(albums), AlbumSortMode.mostOldest => await _sortByOldestAsset(albums), }; + final effectiveOrder = isReverse ? sortMode.defaultOrder.reverse() : sortMode.defaultOrder; - return (isReverse ? sorted.reversed : sorted).toList(); + return (effectiveOrder == SortOrder.asc ? sorted : sorted.reversed).toList(); } List searchAlbums( @@ -209,6 +212,6 @@ class RemoteAlbumService { return aDate.compareTo(bDate); }); - return sorted.reversed.toList(); + return sorted; } } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 6ea282cdc3..80c86d2553 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -78,7 +78,7 @@ Future initApp() async { await EasyLocalization.ensureInitialized(); await initializeDateFormatting(); - if (kReleaseMode && Platform.isAndroid) { + if (Platform.isAndroid) { try { await FlutterDisplayMode.setHighRefreshRate(); dPrint(() => "Enabled high refresh mode"); @@ -117,7 +117,7 @@ Future initApp() async { await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false); - await FileDownloader().trackTasks(); + unawaited(FileDownloader().trackTasks()); LicenseRegistry.addLicense(() async* { for (final license in nonPubLicenses.entries) { diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index e35fbf7433..8f3cee9215 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -5,6 +5,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -281,6 +282,8 @@ class _SortButtonState extends ConsumerState<_SortButton> { setState(() { albumSortOption = sortMode; isSorting = true; + // reset sort order to default state when switching option + albumSortIsReverse = false; }); } @@ -293,6 +296,7 @@ class _SortButtonState extends ConsumerState<_SortButton> { @override Widget build(BuildContext context) { + final effectiveOrder = albumSortOption.effectiveOrder(albumSortIsReverse); return MenuAnchor( controller: widget.controller, style: MenuStyle( @@ -307,7 +311,7 @@ class _SortButtonState extends ConsumerState<_SortButton> { .map( (sortMode) => MenuItemButton( leadingIcon: albumSortOption == sortMode - ? albumSortIsReverse + ? effectiveOrder == SortOrder.desc ? Icon( Icons.keyboard_arrow_down, color: albumSortOption == sortMode @@ -355,7 +359,7 @@ class _SortButtonState extends ConsumerState<_SortButton> { children: [ Padding( padding: const EdgeInsets.only(right: 5), - child: albumSortIsReverse + child: effectiveOrder == SortOrder.desc ? Icon(Icons.keyboard_arrow_down, color: context.colorScheme.onSurface) : Icon(Icons.keyboard_arrow_up_rounded, color: context.colorScheme.onSurface), ), diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index be5b8b4189..3c3ed460b4 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -62,6 +62,9 @@ mixin CancellableImageProviderMixin on CancellableImageProvide return; } yield image; + } catch (e) { + PaintingBinding.instance.imageCache.evict(this); + rethrow; } finally { this.request = null; } diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index d7454c0c89..03b9370190 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -31,7 +31,7 @@ class LocalThumbProvider extends CancellableImageProvider DiagnosticsProperty('Id', key.id), DiagnosticsProperty('Size', key.size), ], - onDispose: cancel, + onLastListenerRemoved: cancel, ); } @@ -76,7 +76,7 @@ class LocalFullImageProvider extends CancellableImageProvider('Id', key.id), DiagnosticsProperty('Size', key.size), ], - onDispose: cancel, + onLastListenerRemoved: cancel, ); } diff --git a/mobile/lib/presentation/widgets/images/one_frame_multi_image_stream_completer.dart b/mobile/lib/presentation/widgets/images/one_frame_multi_image_stream_completer.dart index 6d549d4fda..302deca4a7 100644 --- a/mobile/lib/presentation/widgets/images/one_frame_multi_image_stream_completer.dart +++ b/mobile/lib/presentation/widgets/images/one_frame_multi_image_stream_completer.dart @@ -9,7 +9,10 @@ import 'package:flutter/painting.dart'; /// An ImageStreamCompleter with support for loading multiple images. class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { - void Function()? _onDispose; + void Function()? _onLastListenerRemoved; + int _listenerCount = 0; + // True once setImage() has been called at least once. + bool didProvideImage = false; /// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [images] /// should be the primary images to display (typically asynchronously as they load). @@ -19,14 +22,18 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { Stream images, { ImageInfo? initialImage, InformationCollector? informationCollector, - void Function()? onDispose, + void Function()? onLastListenerRemoved, }) { if (initialImage != null) { + didProvideImage = true; setImage(initialImage); } - _onDispose = onDispose; + _onLastListenerRemoved = onLastListenerRemoved; images.listen( - setImage, + (image) { + didProvideImage = true; + setImage(image); + }, onError: (Object error, StackTrace stack) { reportError( context: ErrorDescription('resolving a single-frame image stream'), @@ -40,12 +47,24 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { } @override - void onDisposed() { - final onDispose = _onDispose; - if (onDispose != null) { - _onDispose = null; - onDispose(); + void addListener(ImageStreamListener listener) { + super.addListener(listener); + _listenerCount = _listenerCount + 1; + } + + @override + void removeListener(ImageStreamListener listener) { + super.removeListener(listener); + _listenerCount = _listenerCount - 1; + + final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage; + final bool noListenersAfterImage = _listenerCount == 0 && didProvideImage; + + final onLastListenerRemoved = _onLastListenerRemoved; + + if (onLastListenerRemoved != null && (noListenersAfterImage || onlyCacheListenerLeft)) { + _onLastListenerRemoved = null; + onLastListenerRemoved(); } - super.onDisposed(); } } diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index 6cb68c1442..20db0cc1e1 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -32,7 +32,7 @@ class RemoteImageProvider extends CancellableImageProvider DiagnosticsProperty('Image provider', this), DiagnosticsProperty('URL', key.url), ], - onDispose: cancel, + onLastListenerRemoved: cancel, ); } @@ -76,7 +76,7 @@ class RemoteFullImageProvider extends CancellableImageProvider('Image provider', this), DiagnosticsProperty('Asset Id', key.assetId), ], - onDispose: cancel, + onLastListenerRemoved: cancel, ); } diff --git a/mobile/lib/presentation/widgets/images/thumb_hash_provider.dart b/mobile/lib/presentation/widgets/images/thumb_hash_provider.dart index fcd2fca72f..7076febe3b 100644 --- a/mobile/lib/presentation/widgets/images/thumb_hash_provider.dart +++ b/mobile/lib/presentation/widgets/images/thumb_hash_provider.dart @@ -17,7 +17,7 @@ class ThumbHashProvider extends CancellableImageProvider @override ImageStreamCompleter loadImage(ThumbHashProvider key, ImageDecoderCallback decode) { - return OneFramePlaceholderImageStreamCompleter(_loadCodec(key, decode), onDispose: cancel); + return OneFramePlaceholderImageStreamCompleter(_loadCodec(key, decode), onLastListenerRemoved: cancel); } Stream _loadCodec(ThumbHashProvider key, ImageDecoderCallback decode) { diff --git a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart index d35dd181db..70a9057e12 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -233,16 +233,6 @@ class _ThumbnailState extends State with SingleTickerProviderStateMix @override void dispose() { - final imageProvider = widget.imageProvider; - if (imageProvider is CancellableImageProvider) { - imageProvider.cancel(); - } - - final thumbhashProvider = widget.thumbhashProvider; - if (thumbhashProvider is CancellableImageProvider) { - thumbhashProvider.cancel(); - } - _fadeController.removeStatusListener(_onAnimationStatusChanged); _fadeController.dispose(); _stopListeningToStream(); diff --git a/mobile/lib/providers/album/album_sort_by_options.provider.dart b/mobile/lib/providers/album/album_sort_by_options.provider.dart index 3dd09f1282..c969dbd37d 100644 --- a/mobile/lib/providers/album/album_sort_by_options.provider.dart +++ b/mobile/lib/providers/album/album_sort_by_options.provider.dart @@ -1,4 +1,5 @@ import 'package:collection/collection.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -73,18 +74,21 @@ class _AlbumSortHandlers { // Store index allows us to re-arrange the values without affecting the saved prefs enum AlbumSortMode { - title(1, "library_page_sort_title", _AlbumSortHandlers.title), - assetCount(4, "library_page_sort_asset_count", _AlbumSortHandlers.assetCount), - lastModified(3, "library_page_sort_last_modified", _AlbumSortHandlers.lastModified), - created(0, "library_page_sort_created", _AlbumSortHandlers.created), - mostRecent(2, "sort_recent", _AlbumSortHandlers.mostRecent), - mostOldest(5, "sort_oldest", _AlbumSortHandlers.mostOldest); + title(1, "library_page_sort_title", _AlbumSortHandlers.title, SortOrder.asc), + assetCount(4, "library_page_sort_asset_count", _AlbumSortHandlers.assetCount, SortOrder.desc), + lastModified(3, "library_page_sort_last_modified", _AlbumSortHandlers.lastModified, SortOrder.desc), + created(0, "library_page_sort_created", _AlbumSortHandlers.created, SortOrder.desc), + mostRecent(2, "sort_recent", _AlbumSortHandlers.mostRecent, SortOrder.desc), + mostOldest(5, "sort_oldest", _AlbumSortHandlers.mostOldest, SortOrder.asc); final int storeIndex; final String label; final AlbumSortFn sortFn; + final SortOrder defaultOrder; - const AlbumSortMode(this.storeIndex, this.label, this.sortFn); + const AlbumSortMode(this.storeIndex, this.label, this.sortFn, this.defaultOrder); + + SortOrder effectiveOrder(bool isReverse) => isReverse ? defaultOrder.reverse() : defaultOrder; } @riverpod diff --git a/mobile/lib/services/cleanup.service.dart b/mobile/lib/services/cleanup.service.dart index 86ccac8067..fca5584859 100644 --- a/mobile/lib/services/cleanup.service.dart +++ b/mobile/lib/services/cleanup.service.dart @@ -1,5 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; @@ -9,6 +10,8 @@ final cleanupServiceProvider = Provider((ref) { }); class CleanupService { + static final int _deleteBatchSize = CurrentPlatform.isAndroid ? 2000 : 10000; + final DriftLocalAssetRepository _localAssetRepository; final AssetMediaRepository _assetMediaRepository; @@ -35,13 +38,20 @@ class CleanupService { return 0; } - final deletedIds = await _assetMediaRepository.deleteAll(localIds); - if (deletedIds.isNotEmpty) { - await _localAssetRepository.delete(deletedIds); - return deletedIds.length; + int deletedCount = 0; + + for (int index = 0; index < localIds.length; index += _deleteBatchSize) { + final end = index + _deleteBatchSize < localIds.length ? index + _deleteBatchSize : localIds.length; + final batch = localIds.sublist(index, end); + + final deletedIds = await _assetMediaRepository.deleteAll(batch); + if (deletedIds.isNotEmpty) { + await _localAssetRepository.delete(deletedIds); + deletedCount += deletedIds.length; + } } - return 0; + return deletedCount; } /// Returns album IDs that should be kept by default (e.g., messaging app albums) diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart index 84a9ab52e1..d63a92ba37 100644 --- a/mobile/lib/utils/bootstrap.dart +++ b/mobile/lib/utils/bootstrap.dart @@ -27,19 +27,17 @@ import 'package:isar/isar.dart'; import 'package:path_provider/path_provider.dart'; void configureFileDownloaderNotifications() { - final fileName = 'file_name'.t(args: {'file_name': '{filename}'}); - FileDownloader().configureNotificationForGroup( kDownloadGroupImage, - running: TaskNotification('downloading_media'.t(), fileName), - complete: TaskNotification('download_finished'.t(), fileName), + running: TaskNotification('downloading_media'.t(), '${'file_name_text'.t()}: {filename}'), + complete: TaskNotification('download_finished'.t(), '${'file_name_text'.t()}: {filename}'), progressBar: true, ); FileDownloader().configureNotificationForGroup( kDownloadGroupVideo, - running: TaskNotification('downloading_media'.t(), fileName), - complete: TaskNotification('download_finished'.t(), fileName), + running: TaskNotification('downloading_media'.t(), '${'file_name_text'.t()}: {filename}'), + complete: TaskNotification('download_finished'.t(), '${'file_name_text'.t()}: {filename}'), progressBar: true, ); diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 4278dfa29d..95622c1e5a 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -128,6 +128,9 @@ class _ProfileIndicator extends ConsumerWidget { const widgetSize = 30.0; + // TODO: remove this when update Flutter version newer than 3.35.7 + final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile; + void toggleReadonlyMode() { final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); ref.read(readonlyModeProvider.notifier).toggleReadonlyMode(); @@ -144,7 +147,12 @@ class _ProfileIndicator extends ConsumerWidget { } return InkWell( - onTap: () => showDialog(context: context, useRootNavigator: false, builder: (ctx) => const ImmichAppBarDialog()), + onTap: () => showDialog( + context: context, + useRootNavigator: false, + barrierDismissible: !isIpad, + builder: (ctx) => const ImmichAppBarDialog(), + ), onLongPress: () => toggleReadonlyMode(), borderRadius: const BorderRadius.all(Radius.circular(12)), child: Badge( diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart index 0e6d6761e3..352d686e7c 100644 --- a/mobile/lib/widgets/common/user_circle_avatar.dart +++ b/mobile/lib/widgets/common/user_circle_avatar.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; @@ -20,7 +18,7 @@ class UserCircleAvatar extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final userAvatarColor = user.avatarColor.toColor(); final profileImageUrl = - '${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${Random().nextInt(1024)}'; + '${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}'; final textIcon = DefaultTextStyle( style: TextStyle( diff --git a/mobile/test/domain/services/album.service_test.dart b/mobile/test/domain/services/album.service_test.dart index b86819536d..1a36a811c3 100644 --- a/mobile/test/domain/services/album.service_test.dart +++ b/mobile/test/domain/services/album.service_test.dart @@ -85,35 +85,47 @@ void main() { final albums = [albumB, albumA]; final result = await sut.sortAlbums(albums, AlbumSortMode.created); - expect(result, [albumA, albumB]); + expect(result, [albumB, albumA]); }); test('should sort correctly based on updatedAt', () async { final albums = [albumB, albumA]; final result = await sut.sortAlbums(albums, AlbumSortMode.lastModified); - expect(result, [albumA, albumB]); + expect(result, [albumB, albumA]); }); test('should sort correctly based on assetCount', () async { final albums = [albumB, albumA]; final result = await sut.sortAlbums(albums, AlbumSortMode.assetCount); - expect(result, [albumA, albumB]); + expect(result, [albumB, albumA]); }); test('should sort correctly based on newestAssetTimestamp', () async { final albums = [albumB, albumA]; final result = await sut.sortAlbums(albums, AlbumSortMode.mostRecent); - expect(result, [albumA, albumB]); + expect(result, [albumB, albumA]); }); test('should sort correctly based on oldestAssetTimestamp', () async { final albums = [albumB, albumA]; final result = await sut.sortAlbums(albums, AlbumSortMode.mostOldest); - expect(result, [albumB, albumA]); + expect(result, [albumA, albumB]); + }); + + test('should flip order when isReverse is true for all modes', () async { + final albums = [albumB, albumA]; + + for (final mode in AlbumSortMode.values) { + final normal = await sut.sortAlbums(albums, mode, isReverse: false); + final reversed = await sut.sortAlbums(albums, mode, isReverse: true); + + // reversed should be the exact inverse of normal + expect(reversed, normal.reversed.toList(), reason: 'Mode: $mode'); + } }); }); } diff --git a/mobile/test/services/cleanup.service_test.dart b/mobile/test/services/cleanup.service_test.dart new file mode 100644 index 0000000000..2038941ecb --- /dev/null +++ b/mobile/test/services/cleanup.service_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; +import 'package:immich_mobile/services/cleanup.service.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../infrastructure/repository.mock.dart'; +import '../repository.mocks.dart'; + +void main() { + late CleanupService sut; + + late MockDriftLocalAssetRepository localAssetRepository; + late MockAssetMediaRepository assetMediaRepository; + + setUp(() { + localAssetRepository = MockDriftLocalAssetRepository(); + assetMediaRepository = MockAssetMediaRepository(); + sut = CleanupService(localAssetRepository, assetMediaRepository); + }); + + group('CleanupService.deleteLocalAssets', () { + test('returns 0 and does nothing for empty input', () async { + final result = await sut.deleteLocalAssets([]); + + expect(result, 0); + verifyNever(() => assetMediaRepository.deleteAll(any())); + verifyNever(() => localAssetRepository.delete(any())); + }); + + test('deletes in a single batch when under limit', () async { + final ids = List.generate(999, (i) => 'asset-$i'); + + when(() => assetMediaRepository.deleteAll(any())).thenAnswer((invocation) async { + return (invocation.positionalArguments.first as List).toList(); + }); + when(() => localAssetRepository.delete(any())).thenAnswer((_) async {}); + + final result = await sut.deleteLocalAssets(ids); + + expect(result, ids.length); + verify(() => assetMediaRepository.deleteAll(ids)).called(1); + verify(() => localAssetRepository.delete(ids)).called(1); + }); + + test('deletes in platform-specific batches when over limit', () async { + final batchSize = CurrentPlatform.isAndroid ? 2000 : 10000; + final ids = List.generate(batchSize * 2 + 501, (i) => 'asset-$i'); + final capturedBatches = >[]; + + when(() => assetMediaRepository.deleteAll(any())).thenAnswer((invocation) async { + final batch = (invocation.positionalArguments.first as List).toList(); + capturedBatches.add(batch); + return batch; + }); + when(() => localAssetRepository.delete(any())).thenAnswer((_) async {}); + + final result = await sut.deleteLocalAssets(ids); + + expect(result, ids.length); + expect(capturedBatches.length, 3); + expect(capturedBatches[0].length, batchSize); + expect(capturedBatches[1].length, batchSize); + expect(capturedBatches[2].length, 501); + expect(capturedBatches[0].first, 'asset-0'); + expect(capturedBatches[0].last, 'asset-${batchSize - 1}'); + expect(capturedBatches[1].first, 'asset-$batchSize'); + expect(capturedBatches[1].last, 'asset-${batchSize * 2 - 1}'); + expect(capturedBatches[2].first, 'asset-${batchSize * 2}'); + expect(capturedBatches[2].last, 'asset-${batchSize * 2 + 500}'); + verify(() => localAssetRepository.delete(any())).called(3); + }); + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2030bbc08c..b0a78ab795 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -741,8 +741,8 @@ importers: specifier: file:../open-api/typescript-sdk version: link:../open-api/typescript-sdk '@immich/ui': - specifier: ^0.61.3 - version: 0.61.3(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0) + specifier: ^0.61.4 + version: 0.61.4(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -3131,8 +3131,8 @@ packages: peerDependencies: svelte: ^5.0.0 - '@immich/ui@0.61.3': - resolution: {integrity: sha512-9cz/7kc/CSmJ37gH5nIZNiHxw5OlBCGbdlSGkCOtaMJ458wmcdUFVmF5arjGioaOa4NMwseOVyln7rMhkNU7ww==} + '@immich/ui@0.61.4': + resolution: {integrity: sha512-32nrY7GW6BdBQ12ZI/E4VgrgY40Yn2K31vSO6GPiOvmNgt8h3s3TSKUXbh7pY4yJgfnr7f2QL2EYRV9KkjRybQ==} peerDependencies: svelte: ^5.0.0 @@ -15762,7 +15762,7 @@ snapshots: node-emoji: 2.2.0 svelte: 5.48.0 - '@immich/ui@0.61.3(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)': + '@immich/ui@0.61.4(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)': dependencies: '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.48.0) '@internationalized/date': 3.10.0 diff --git a/server/src/dtos/album-response.dto.spec.ts b/server/src/dtos/album-response.dto.spec.ts index dd8642598f..d3536a3482 100644 --- a/server/src/dtos/album-response.dto.spec.ts +++ b/server/src/dtos/album-response.dto.spec.ts @@ -1,15 +1,18 @@ import { mapAlbum } from 'src/dtos/album.dto'; -import { albumStub } from 'test/fixtures/album.stub'; +import { AlbumFactory } from 'test/factories/album.factory'; describe('mapAlbum', () => { it('should set start and end dates', () => { - const dto = mapAlbum(albumStub.twoAssets, false); - expect(dto.startDate).toEqual(new Date('2020-12-31T23:59:00.000Z')); - expect(dto.endDate).toEqual(new Date('2025-01-01T01:02:03.456Z')); + const startDate = new Date('2023-02-22T05:06:29.716Z'); + const endDate = new Date('2025-01-01T01:02:03.456Z'); + const album = AlbumFactory.from().asset({ localDateTime: endDate }).asset({ localDateTime: startDate }).build(); + const dto = mapAlbum(album, false); + expect(dto.startDate).toEqual(startDate); + expect(dto.endDate).toEqual(endDate); }); it('should not set start and end dates for empty assets', () => { - const dto = mapAlbum(albumStub.empty, false); + const dto = mapAlbum(AlbumFactory.create(), false); expect(dto.startDate).toBeUndefined(); expect(dto.endDate).toBeUndefined(); }); diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index f62e769a17..e3d7436c30 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -58,7 +58,7 @@ select from ( select - * + "shared_link".* from "shared_link" where @@ -243,7 +243,7 @@ select from ( select - * + "shared_link".* from "shared_link" where @@ -316,7 +316,7 @@ select from ( select - * + "shared_link".* from "shared_link" where diff --git a/server/src/queries/album.user.repository.sql b/server/src/queries/album.user.repository.sql index a758ba1cf4..fc4a52bae2 100644 --- a/server/src/queries/album.user.repository.sql +++ b/server/src/queries/album.user.repository.sql @@ -17,8 +17,6 @@ set where "userId" = $2 and "albumId" = $3 -returning - * -- AlbumUserRepository.delete delete from "album_user" diff --git a/server/src/repositories/album-user.repository.ts b/server/src/repositories/album-user.repository.ts index 1a1e58a77d..558a0c05d7 100644 --- a/server/src/repositories/album-user.repository.ts +++ b/server/src/repositories/album-user.repository.ts @@ -25,14 +25,13 @@ export class AlbumUserRepository { } @GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }, { role: AlbumUserRole.Viewer }] }) - update({ userId, albumId }: AlbumPermissionId, dto: Updateable) { - return this.db + async update({ userId, albumId }: AlbumPermissionId, dto: Updateable) { + await this.db .updateTable('album_user') .set(dto) .where('userId', '=', userId) .where('albumId', '=', albumId) - .returningAll() - .executeTakeFirstOrThrow(); + .execute(); } @GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] }) diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index 100ab908c0..cf132a023d 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -44,9 +44,9 @@ const withAlbumUsers = (eb: ExpressionBuilder) => { }; const withSharedLink = (eb: ExpressionBuilder) => { - return jsonArrayFrom(eb.selectFrom('shared_link').selectAll().whereRef('shared_link.albumId', '=', 'album.id')).as( - 'sharedLinks', - ); + return jsonArrayFrom( + eb.selectFrom('shared_link').selectAll('shared_link').whereRef('shared_link.albumId', '=', 'album.id'), + ).as('sharedLinks'); }; const withAssets = (eb: ExpressionBuilder) => { @@ -283,7 +283,7 @@ export class AlbumRepository { return tx .selectFrom('album') - .selectAll() + .selectAll('album') .where('id', '=', newAlbum.id) .select(withOwner) .select(withAssets) diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index 8fab087156..37a5bca718 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -260,7 +260,7 @@ export class SharedLinkRepository { .selectAll('asset') .innerJoinLateral( (eb) => - eb.selectFrom('asset_exif').whereRef('asset_exif.assetId', '=', 'asset.id').selectAll().as('exif'), + eb.selectFrom('asset_exif').whereRef('asset_exif.assetId', '=', 'asset.id').selectAll().as('exifInfo'), (join) => join.onTrue(), ) .as('assets'), diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 03be834354..d21185bd35 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -3,9 +3,13 @@ import _ from 'lodash'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { AlbumUserRole, AssetOrder, UserMetadataKey } from 'src/enum'; import { AlbumService } from 'src/services/album.service'; -import { albumStub } from 'test/fixtures/album.stub'; +import { AlbumUserFactory } from 'test/factories/album-user.factory'; +import { AlbumFactory } from 'test/factories/album.factory'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { authStub } from 'test/fixtures/auth.stub'; -import { userStub } from 'test/fixtures/user.stub'; +import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(AlbumService.name, () => { @@ -39,17 +43,19 @@ describe(AlbumService.name, () => { describe('getAll', () => { it('gets list of albums for auth user', async () => { - mocks.album.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]); + const album = AlbumFactory.from().albumUser().build(); + const sharedWithUserAlbum = AlbumFactory.from().owner(album.owner).albumUser().build(); + mocks.album.getOwned.mockResolvedValue([album, sharedWithUserAlbum]); mocks.album.getMetadataForIds.mockResolvedValue([ { - albumId: albumStub.empty.id, + albumId: album.id, assetCount: 0, startDate: null, endDate: null, lastModifiedAssetTimestamp: null, }, { - albumId: albumStub.sharedWithUser.id, + albumId: sharedWithUserAlbum.id, assetCount: 0, startDate: null, endDate: null, @@ -57,17 +63,18 @@ describe(AlbumService.name, () => { }, ]); - const result = await sut.getAll(authStub.admin, {}); + const result = await sut.getAll(AuthFactory.create(album.owner), {}); expect(result).toHaveLength(2); - expect(result[0].id).toEqual(albumStub.empty.id); - expect(result[1].id).toEqual(albumStub.sharedWithUser.id); + expect(result[0].id).toEqual(album.id); + expect(result[1].id).toEqual(sharedWithUserAlbum.id); }); it('gets list of albums that have a specific asset', async () => { - mocks.album.getByAssetId.mockResolvedValue([albumStub.oneAsset]); + const album = AlbumFactory.from().owner({ isAdmin: true }).albumUser().asset().asset().build(); + mocks.album.getByAssetId.mockResolvedValue([album]); mocks.album.getMetadataForIds.mockResolvedValue([ { - albumId: albumStub.oneAsset.id, + albumId: album.id, assetCount: 1, startDate: new Date('1970-01-01'), endDate: new Date('1970-01-01'), @@ -75,17 +82,18 @@ describe(AlbumService.name, () => { }, ]); - const result = await sut.getAll(authStub.admin, { assetId: albumStub.oneAsset.id }); + const result = await sut.getAll(AuthFactory.create(album.owner), { assetId: album.assets[0].id }); expect(result).toHaveLength(1); - expect(result[0].id).toEqual(albumStub.oneAsset.id); + expect(result[0].id).toEqual(album.id); expect(mocks.album.getByAssetId).toHaveBeenCalledTimes(1); }); it('gets list of albums that are shared', async () => { - mocks.album.getShared.mockResolvedValue([albumStub.sharedWithUser]); + const album = AlbumFactory.from().albumUser().build(); + mocks.album.getShared.mockResolvedValue([album]); mocks.album.getMetadataForIds.mockResolvedValue([ { - albumId: albumStub.sharedWithUser.id, + albumId: album.id, assetCount: 0, startDate: null, endDate: null, @@ -93,17 +101,18 @@ describe(AlbumService.name, () => { }, ]); - const result = await sut.getAll(authStub.admin, { shared: true }); + const result = await sut.getAll(AuthFactory.create(album.owner), { shared: true }); expect(result).toHaveLength(1); - expect(result[0].id).toEqual(albumStub.sharedWithUser.id); + expect(result[0].id).toEqual(album.id); expect(mocks.album.getShared).toHaveBeenCalledTimes(1); }); it('gets list of albums that are NOT shared', async () => { - mocks.album.getNotShared.mockResolvedValue([albumStub.empty]); + const album = AlbumFactory.create(); + mocks.album.getNotShared.mockResolvedValue([album]); mocks.album.getMetadataForIds.mockResolvedValue([ { - albumId: albumStub.empty.id, + albumId: album.id, assetCount: 0, startDate: null, endDate: null, @@ -111,18 +120,19 @@ describe(AlbumService.name, () => { }, ]); - const result = await sut.getAll(authStub.admin, { shared: false }); + const result = await sut.getAll(AuthFactory.create(album.owner), { shared: false }); expect(result).toHaveLength(1); - expect(result[0].id).toEqual(albumStub.empty.id); + expect(result[0].id).toEqual(album.id); expect(mocks.album.getNotShared).toHaveBeenCalledTimes(1); }); }); it('counts assets correctly', async () => { - mocks.album.getOwned.mockResolvedValue([albumStub.oneAsset]); + const album = AlbumFactory.create(); + mocks.album.getOwned.mockResolvedValue([album]); mocks.album.getMetadataForIds.mockResolvedValue([ { - albumId: albumStub.oneAsset.id, + albumId: album.id, assetCount: 1, startDate: new Date('1970-01-01'), endDate: new Date('1970-01-01'), @@ -130,8 +140,7 @@ describe(AlbumService.name, () => { }, ]); - const result = await sut.getAll(authStub.admin, {}); - + const result = await sut.getAll(AuthFactory.create(album.owner), {}); expect(result).toHaveLength(1); expect(result[0].assetCount).toEqual(1); expect(mocks.album.getOwned).toHaveBeenCalledTimes(1); @@ -139,42 +148,52 @@ describe(AlbumService.name, () => { describe('create', () => { it('creates album', async () => { - mocks.album.create.mockResolvedValue(albumStub.empty); - mocks.user.get.mockResolvedValue(userStub.user1); - mocks.user.getMetadata.mockResolvedValue([]); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['123'])); + const assetId = newUuid(); + const albumUser = { userId: newUuid(), role: AlbumUserRole.Editor }; + const album = AlbumFactory.from({ albumName: 'test', description: 'description' }) + .asset({ id: assetId }, (asset) => asset.exif()) + .albumUser(albumUser) + .build(); - await sut.create(authStub.admin, { - albumName: 'Empty album', - albumUsers: [{ userId: 'user-id', role: AlbumUserRole.Editor }], - description: '', - assetIds: ['123'], + mocks.album.create.mockResolvedValue(album); + mocks.user.get.mockResolvedValue(UserFactory.create(album.albumUsers[0].user)); + mocks.user.getMetadata.mockResolvedValue([]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId])); + + await sut.create(AuthFactory.create(album.owner), { + albumName: 'test', + albumUsers: [albumUser], + description: 'description', + assetIds: [assetId], }); expect(mocks.album.create).toHaveBeenCalledWith( { - ownerId: authStub.admin.user.id, - albumName: albumStub.empty.albumName, - description: albumStub.empty.description, - order: 'desc', - albumThumbnailAssetId: '123', + ownerId: album.owner.id, + albumName: 'test', + description: 'description', + order: album.order, + albumThumbnailAssetId: assetId, }, - ['123'], - [{ userId: 'user-id', role: AlbumUserRole.Editor }], + [assetId], + [{ userId: albumUser.userId, role: AlbumUserRole.Editor }], ); - expect(mocks.user.get).toHaveBeenCalledWith('user-id', {}); - expect(mocks.user.getMetadata).toHaveBeenCalledWith(authStub.admin.user.id); - expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']), false); - expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { - id: albumStub.empty.id, - userId: 'user-id', - }); + expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {}); + expect(mocks.user.getMetadata).toHaveBeenCalledWith(album.owner.id); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(album.owner.id, new Set([assetId]), false); + expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { id: album.id, userId: albumUser.userId }); }); it('creates album with assetOrder from user preferences', async () => { - mocks.album.create.mockResolvedValue(albumStub.empty); - mocks.user.get.mockResolvedValue(userStub.user1); + const assetId = newUuid(); + const albumUser = { userId: newUuid(), role: AlbumUserRole.Editor }; + const album = AlbumFactory.from() + .asset({ id: assetId }, (asset) => asset.exif()) + .albumUser(albumUser) + .build(); + mocks.album.create.mockResolvedValue(album); + mocks.user.get.mockResolvedValue(album.albumUsers[0].user); mocks.user.getMetadata.mockResolvedValue([ { key: UserMetadataKey.Preferences, @@ -185,84 +204,87 @@ describe(AlbumService.name, () => { }, }, ]); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId])); - await sut.create(authStub.admin, { - albumName: 'Empty album', - albumUsers: [{ userId: 'user-id', role: AlbumUserRole.Editor }], - description: '', - assetIds: ['123'], + await sut.create(AuthFactory.create(album.owner), { + albumName: album.albumName, + albumUsers: [albumUser], + description: album.description, + assetIds: [assetId], }); expect(mocks.album.create).toHaveBeenCalledWith( { - ownerId: authStub.admin.user.id, - albumName: albumStub.empty.albumName, - description: albumStub.empty.description, + ownerId: album.owner.id, + albumName: album.albumName, + description: album.description, order: 'asc', - albumThumbnailAssetId: '123', + albumThumbnailAssetId: assetId, }, - ['123'], - [{ userId: 'user-id', role: AlbumUserRole.Editor }], + [assetId], + [albumUser], ); - expect(mocks.user.get).toHaveBeenCalledWith('user-id', {}); - expect(mocks.user.getMetadata).toHaveBeenCalledWith(authStub.admin.user.id); - expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']), false); - expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { - id: albumStub.empty.id, - userId: 'user-id', - }); + expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {}); + expect(mocks.user.getMetadata).toHaveBeenCalledWith(album.owner.id); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(album.owner.id, new Set([assetId]), false); + expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { id: album.id, userId: albumUser.userId }); }); it('should require valid userIds', async () => { mocks.user.get.mockResolvedValue(void 0); await expect( - sut.create(authStub.admin, { + sut.create(AuthFactory.create(), { albumName: 'Empty album', - albumUsers: [{ userId: 'user-3', role: AlbumUserRole.Editor }], + albumUsers: [{ userId: 'unknown-user', role: AlbumUserRole.Editor }], }), ).rejects.toBeInstanceOf(BadRequestException); - expect(mocks.user.get).toHaveBeenCalledWith('user-3', {}); + expect(mocks.user.get).toHaveBeenCalledWith('unknown-user', {}); expect(mocks.album.create).not.toHaveBeenCalled(); }); it('should only add assets the user is allowed to access', async () => { - mocks.user.get.mockResolvedValue(userStub.user1); - mocks.album.create.mockResolvedValue(albumStub.oneAsset); + const assetId = newUuid(); + const album = AlbumFactory.from() + .asset({ id: assetId }, (asset) => asset.exif()) + .albumUser() + .build(); + mocks.user.get.mockResolvedValue(album.albumUsers[0].user); + mocks.album.create.mockResolvedValue(album); mocks.user.getMetadata.mockResolvedValue([]); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId])); - await sut.create(authStub.admin, { - albumName: 'Test album', - description: '', - assetIds: ['asset-1', 'asset-2'], + await sut.create(AuthFactory.create(album.owner), { + albumName: album.albumName, + description: album.description, + assetIds: [assetId, 'asset-2'], }); expect(mocks.album.create).toHaveBeenCalledWith( { - ownerId: authStub.admin.user.id, - albumName: 'Test album', - description: '', + ownerId: album.owner.id, + albumName: album.albumName, + description: album.description, order: 'desc', - albumThumbnailAssetId: 'asset-1', + albumThumbnailAssetId: assetId, }, - ['asset-1'], + [assetId], [], ); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( - authStub.admin.user.id, - new Set(['asset-1', 'asset-2']), + album.owner.id, + new Set([assetId, 'asset-2']), false, ); }); it('should throw an error if the userId is the ownerId', async () => { - mocks.user.get.mockResolvedValue(userStub.admin); + const album = AlbumFactory.create(); + mocks.user.get.mockResolvedValue(album.owner); await expect( - sut.create(authStub.admin, { + sut.create(AuthFactory.create(album.owner), { albumName: 'Empty album', - albumUsers: [{ userId: userStub.admin.id, role: AlbumUserRole.Editor }], + albumUsers: [{ userId: album.owner.id, role: AlbumUserRole.Editor }], }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.album.create).not.toHaveBeenCalled(); @@ -271,11 +293,12 @@ describe(AlbumService.name, () => { describe('update', () => { it('should prevent updating an album that does not exist', async () => { + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set()); mocks.album.getById.mockResolvedValue(void 0); await expect( - sut.update(authStub.user1, 'invalid-id', { - albumName: 'new album name', + sut.update(AuthFactory.create(), 'invalid-id', { + albumName: 'Album', }), ).rejects.toBeInstanceOf(BadRequestException); @@ -283,139 +306,138 @@ describe(AlbumService.name, () => { }); it('should prevent updating a not owned album (shared with auth user)', async () => { + const album = AlbumFactory.from().albumUser().build(); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set()); await expect( - sut.update(authStub.admin, albumStub.sharedWithAdmin.id, { - albumName: 'new album name', - }), + sut.update(AuthFactory.create(album.owner), album.id, { albumName: 'new album name' }), ).rejects.toBeInstanceOf(BadRequestException); }); it('should require a valid thumbnail asset id', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4'])); - mocks.album.getById.mockResolvedValue(albumStub.oneAsset); - mocks.album.update.mockResolvedValue(albumStub.oneAsset); + const album = AlbumFactory.create(); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.album.getById.mockResolvedValue(album); mocks.album.getAssetIds.mockResolvedValue(new Set()); await expect( - sut.update(authStub.admin, albumStub.oneAsset.id, { - albumThumbnailAssetId: 'not-in-album', - }), + sut.update(AuthFactory.create(album.owner), album.id, { albumThumbnailAssetId: 'not-in-album' }), ).rejects.toBeInstanceOf(BadRequestException); - expect(mocks.album.getAssetIds).toHaveBeenCalledWith('album-4', ['not-in-album']); + expect(mocks.album.getAssetIds).toHaveBeenCalledWith(album.id, ['not-in-album']); expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should allow the owner to update the album', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4'])); + const album = AlbumFactory.create(); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.album.getById.mockResolvedValue(album); + mocks.album.update.mockResolvedValue(album); - mocks.album.getById.mockResolvedValue(albumStub.oneAsset); - mocks.album.update.mockResolvedValue(albumStub.oneAsset); - - await sut.update(authStub.admin, albumStub.oneAsset.id, { - albumName: 'new album name', - }); + await sut.update(AuthFactory.create(album.owner), album.id, { albumName: 'new album name' }); expect(mocks.album.update).toHaveBeenCalledTimes(1); - expect(mocks.album.update).toHaveBeenCalledWith('album-4', { - id: 'album-4', - albumName: 'new album name', - }); + expect(mocks.album.update).toHaveBeenCalledWith(album.id, { id: album.id, albumName: 'new album name' }); }); }); describe('delete', () => { - it('should throw an error for an album not found', async () => { + it('should require permissions', async () => { + const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set()); - await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf( - BadRequestException, - ); + await expect(sut.delete(AuthFactory.create(album.owner), album.id)).rejects.toBeInstanceOf(BadRequestException); expect(mocks.album.delete).not.toHaveBeenCalled(); }); it('should not let a shared user delete the album', async () => { - mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin); + const album = AlbumFactory.create(); + mocks.album.getById.mockResolvedValue(album); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set()); - await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf( - BadRequestException, - ); + await expect(sut.delete(AuthFactory.create(album.owner), album.id)).rejects.toBeInstanceOf(BadRequestException); expect(mocks.album.delete).not.toHaveBeenCalled(); }); it('should let the owner delete an album', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.empty.id])); - mocks.album.getById.mockResolvedValue(albumStub.empty); + const album = AlbumFactory.create(); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.album.getById.mockResolvedValue(album); - await sut.delete(authStub.admin, albumStub.empty.id); + await sut.delete(AuthFactory.create(album.owner), album.id); expect(mocks.album.delete).toHaveBeenCalledTimes(1); - expect(mocks.album.delete).toHaveBeenCalledWith(albumStub.empty.id); + expect(mocks.album.delete).toHaveBeenCalledWith(album.id); }); }); describe('addUsers', () => { it('should throw an error if the auth user is not the owner', async () => { + const album = AlbumFactory.create(); + const user = UserFactory.create(); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set()); await expect( - sut.addUsers(authStub.admin, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: 'user-1' }] }), + sut.addUsers(AuthFactory.create(user), album.id, { albumUsers: [{ userId: newUuid() }] }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should throw an error if the userId is already added', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); - mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin); + const userId = newUuid(); + const album = AlbumFactory.from().albumUser({ userId }).build(); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.album.getById.mockResolvedValue(album); await expect( - sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { - albumUsers: [{ userId: authStub.admin.user.id }], - }), + sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId }] }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.album.update).not.toHaveBeenCalled(); + expect(mocks.user.get).not.toHaveBeenCalled(); }); it('should throw an error if the userId does not exist', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); - mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin); + const album = AlbumFactory.create(); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.album.getById.mockResolvedValue(album); mocks.user.get.mockResolvedValue(void 0); await expect( - sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: 'user-3' }] }), + sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId: 'unknown-user' }] }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.album.update).not.toHaveBeenCalled(); + expect(mocks.user.get).toHaveBeenCalledWith('unknown-user', {}); }); it('should throw an error if the userId is the ownerId', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); - mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin); + const album = AlbumFactory.create(); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.album.getById.mockResolvedValue(album); await expect( - sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { - albumUsers: [{ userId: userStub.user1.id }], + sut.addUsers(AuthFactory.create(album.owner), album.id, { + albumUsers: [{ userId: album.owner.id }], }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.album.update).not.toHaveBeenCalled(); + expect(mocks.user.get).not.toHaveBeenCalled(); }); it('should add valid shared users', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); - mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin)); - mocks.album.update.mockResolvedValue(albumStub.sharedWithAdmin); - mocks.user.get.mockResolvedValue(userStub.user2); - mocks.albumUser.create.mockResolvedValue({ - userId: userStub.user2.id, - albumId: albumStub.sharedWithAdmin.id, - role: AlbumUserRole.Editor, - }); - await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { - albumUsers: [{ userId: authStub.user2.user.id }], - }); + const album = AlbumFactory.create(); + const user = UserFactory.create(); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.album.getById.mockResolvedValue(album); + mocks.album.update.mockResolvedValue(album); + mocks.user.get.mockResolvedValue(user); + mocks.albumUser.create.mockResolvedValue(AlbumUserFactory.from().album(album).user(user).build()); + + await sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId: user.id }] }); + expect(mocks.albumUser.create).toHaveBeenCalledWith({ - userId: authStub.user2.user.id, - albumId: albumStub.sharedWithAdmin.id, + userId: user.id, + albumId: album.id, }); expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { - id: albumStub.sharedWithAdmin.id, - userId: userStub.user2.id, + id: album.id, + userId: user.id, }); }); }); @@ -424,71 +446,69 @@ describe(AlbumService.name, () => { it('should require a valid album id', async () => { mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1'])); mocks.album.getById.mockResolvedValue(void 0); - await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.removeUser(AuthFactory.create(), 'album-1', 'user-1')).rejects.toBeInstanceOf( + BadRequestException, + ); expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should remove a shared user from an owned album', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithUser.id])); - mocks.album.getById.mockResolvedValue(albumStub.sharedWithUser); + const userId = newUuid(); + const album = AlbumFactory.from().albumUser({ userId }).build(); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.album.getById.mockResolvedValue(album); mocks.albumUser.delete.mockResolvedValue(); - await expect( - sut.removeUser(authStub.admin, albumStub.sharedWithUser.id, userStub.user1.id), - ).resolves.toBeUndefined(); + await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, userId)).resolves.toBeUndefined(); expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); - expect(mocks.albumUser.delete).toHaveBeenCalledWith({ - albumId: albumStub.sharedWithUser.id, - userId: userStub.user1.id, - }); - expect(mocks.album.getById).toHaveBeenCalledWith(albumStub.sharedWithUser.id, { withAssets: false }); + expect(mocks.albumUser.delete).toHaveBeenCalledWith({ albumId: album.id, userId }); + expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false }); }); it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => { - mocks.album.getById.mockResolvedValue(albumStub.sharedWithMultiple); + const user1 = UserFactory.create(); + const user2 = UserFactory.create(); + const album = AlbumFactory.from().albumUser({ userId: user1.id }).albumUser({ userId: user2.id }).build(); + mocks.album.getById.mockResolvedValue(album); - await expect( - sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.user.id), - ).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.removeUser(AuthFactory.create(user1), album.id, user2.id)).rejects.toBeInstanceOf( + BadRequestException, + ); expect(mocks.albumUser.delete).not.toHaveBeenCalled(); - expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith( - authStub.user1.user.id, - new Set([albumStub.sharedWithMultiple.id]), - ); + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(user1.id, new Set([album.id])); }); it('should allow a shared user to remove themselves', async () => { - mocks.album.getById.mockResolvedValue(albumStub.sharedWithUser); + const user1 = UserFactory.create(); + const album = AlbumFactory.from().albumUser({ userId: user1.id }).build(); + mocks.album.getById.mockResolvedValue(album); mocks.albumUser.delete.mockResolvedValue(); - await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, authStub.user1.user.id); + await sut.removeUser(AuthFactory.create(user1), album.id, user1.id); expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); - expect(mocks.albumUser.delete).toHaveBeenCalledWith({ - albumId: albumStub.sharedWithUser.id, - userId: authStub.user1.user.id, - }); + expect(mocks.albumUser.delete).toHaveBeenCalledWith({ albumId: album.id, userId: user1.id }); }); it('should allow a shared user to remove themselves using "me"', async () => { - mocks.album.getById.mockResolvedValue(albumStub.sharedWithUser); + const user = UserFactory.create(); + const album = AlbumFactory.from().albumUser({ userId: user.id }).build(); + mocks.album.getById.mockResolvedValue(album); mocks.albumUser.delete.mockResolvedValue(); - await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, 'me'); + await sut.removeUser(AuthFactory.create(user), album.id, 'me'); expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); - expect(mocks.albumUser.delete).toHaveBeenCalledWith({ - albumId: albumStub.sharedWithUser.id, - userId: authStub.user1.user.id, - }); + expect(mocks.albumUser.delete).toHaveBeenCalledWith({ albumId: album.id, userId: user.id }); }); it('should not allow the owner to be removed', async () => { - mocks.album.getById.mockResolvedValue(albumStub.empty); + const album = AlbumFactory.from().albumUser().build(); + mocks.album.getById.mockResolvedValue(album); - await expect(sut.removeUser(authStub.admin, albumStub.empty.id, authStub.admin.user.id)).rejects.toBeInstanceOf( + await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, album.owner.id)).rejects.toBeInstanceOf( BadRequestException, ); @@ -496,9 +516,10 @@ describe(AlbumService.name, () => { }); it('should throw an error for a user not in the album', async () => { - mocks.album.getById.mockResolvedValue(albumStub.empty); + const album = AlbumFactory.from().albumUser().build(); + mocks.album.getById.mockResolvedValue(album); - await expect(sut.removeUser(authStub.admin, albumStub.empty.id, 'user-3')).rejects.toBeInstanceOf( + await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, 'user-3')).rejects.toBeInstanceOf( BadRequestException, ); @@ -508,26 +529,28 @@ describe(AlbumService.name, () => { describe('updateUser', () => { it('should update user role', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); - mocks.albumUser.update.mockResolvedValue(null as any); + const user = UserFactory.create(); + const album = AlbumFactory.from().albumUser({ userId: user.id }).build(); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.albumUser.update.mockResolvedValue(); + + await sut.updateUser(AuthFactory.create(album.owner), album.id, user.id, { role: AlbumUserRole.Viewer }); - await sut.updateUser(authStub.user1, albumStub.sharedWithAdmin.id, userStub.admin.id, { - role: AlbumUserRole.Editor, - }); expect(mocks.albumUser.update).toHaveBeenCalledWith( - { albumId: albumStub.sharedWithAdmin.id, userId: userStub.admin.id }, - { role: AlbumUserRole.Editor }, + { albumId: album.id, userId: user.id }, + { role: AlbumUserRole.Viewer }, ); }); }); describe('getAlbumInfo', () => { it('should get a shared album', async () => { - mocks.album.getById.mockResolvedValue(albumStub.oneAsset); - mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); + const album = AlbumFactory.from().albumUser().build(); + mocks.album.getById.mockResolvedValue(album); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.album.getMetadataForIds.mockResolvedValue([ { - albumId: albumStub.oneAsset.id, + albumId: album.id, assetCount: 1, startDate: new Date('1970-01-01'), endDate: new Date('1970-01-01'), @@ -535,21 +558,19 @@ describe(AlbumService.name, () => { }, ]); - await sut.get(authStub.admin, albumStub.oneAsset.id, {}); + await sut.get(AuthFactory.create(album.owner), album.id, {}); - expect(mocks.album.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: true }); - expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith( - authStub.admin.user.id, - new Set([albumStub.oneAsset.id]), - ); + expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true }); + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(album.owner.id, new Set([album.id])); }); it('should get a shared album via a shared link', async () => { - mocks.album.getById.mockResolvedValue(albumStub.oneAsset); - mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); + const album = AlbumFactory.from().albumUser().build(); + mocks.album.getById.mockResolvedValue(album); + mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([album.id])); mocks.album.getMetadataForIds.mockResolvedValue([ { - albumId: albumStub.oneAsset.id, + albumId: album.id, assetCount: 1, startDate: new Date('1970-01-01'), endDate: new Date('1970-01-01'), @@ -557,21 +578,21 @@ describe(AlbumService.name, () => { }, ]); - await sut.get(authStub.adminSharedLink, 'album-123', {}); + const auth = AuthFactory.from().sharedLink().build(); + await sut.get(auth, album.id, {}); - expect(mocks.album.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); - expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith( - authStub.adminSharedLink.sharedLink?.id, - new Set(['album-123']), - ); + expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true }); + expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith(auth.sharedLink!.id, new Set([album.id])); }); it('should get a shared album via shared with user', async () => { - mocks.album.getById.mockResolvedValue(albumStub.oneAsset); - mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); + const user = UserFactory.create(); + const album = AlbumFactory.from().albumUser({ userId: user.id }).build(); + mocks.album.getById.mockResolvedValue(album); + mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id])); mocks.album.getMetadataForIds.mockResolvedValue([ { - albumId: albumStub.oneAsset.id, + albumId: album.id, assetCount: 1, startDate: new Date('1970-01-01'), endDate: new Date('1970-01-01'), @@ -579,22 +600,23 @@ describe(AlbumService.name, () => { }, ]); - await sut.get(authStub.user1, 'album-123', {}); + await sut.get(AuthFactory.create(user), album.id, {}); - expect(mocks.album.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); + expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true }); expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith( - authStub.user1.user.id, - new Set(['album-123']), + user.id, + new Set([album.id]), AlbumUserRole.Viewer, ); }); it('should throw an error for no access', async () => { - await expect(sut.get(authStub.admin, 'album-123', {})).rejects.toBeInstanceOf(BadRequestException); + const auth = AuthFactory.create(); + await expect(sut.get(auth, 'album-123', {})).rejects.toBeInstanceOf(BadRequestException); - expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-123'])); + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['album-123'])); expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith( - authStub.admin.user.id, + auth.user.id, new Set(['album-123']), AlbumUserRole.Viewer, ); @@ -603,173 +625,189 @@ describe(AlbumService.name, () => { describe('addAssets', () => { it('should allow the owner to add assets', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + const owner = UserFactory.create({ isAdmin: true }); + const album = AlbumFactory.from({ ownerId: owner.id }).owner(owner).build(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); + mocks.album.getById.mockResolvedValue(album); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect( - sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), + sut.addAssets(AuthFactory.create(owner), album.id, { ids: [asset1.id, asset2.id, asset3.id] }), ).resolves.toEqual([ - { success: true, id: 'asset-1' }, - { success: true, id: 'asset-2' }, - { success: true, id: 'asset-3' }, + { success: true, id: asset1.id }, + { success: true, id: asset2.id }, + { success: true, id: asset3.id }, ]); - expect(mocks.album.update).toHaveBeenCalledWith('album-123', { - id: 'album-123', + expect(mocks.album.update).toHaveBeenCalledWith(album.id, { + id: album.id, updatedAt: expect.any(Date), - albumThumbnailAssetId: 'asset-1', + albumThumbnailAssetId: asset1.id, }); - expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith(album.id, [asset1.id, asset2.id, asset3.id]); }); it('should not set the thumbnail if the album has one already', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - mocks.album.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' })); + const [asset1, asset2] = [AssetFactory.create(), AssetFactory.create()]; + const album = AlbumFactory.from({ albumThumbnailAssetId: asset1.id }).build(); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset2.id])); + mocks.album.getById.mockResolvedValue(album); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); - await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ - { success: true, id: 'asset-1' }, + await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset2.id] })).resolves.toEqual([ + { success: true, id: asset2.id }, ]); - expect(mocks.album.update).toHaveBeenCalledWith('album-123', { - id: 'album-123', + expect(mocks.album.update).toHaveBeenCalledWith(album.id, { + id: album.id, updatedAt: expect.any(Date), - albumThumbnailAssetId: 'asset-id', + albumThumbnailAssetId: asset1.id, }); expect(mocks.album.addAssetIds).toHaveBeenCalled(); }); it('should allow a shared user to add assets', async () => { - mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); + const user = UserFactory.create(); + const album = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Editor }).build(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; + mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); + mocks.album.getById.mockResolvedValue(album); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect( - sut.addAssets(authStub.user1, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), + sut.addAssets(AuthFactory.create(user), album.id, { ids: [asset1.id, asset2.id, asset3.id] }), ).resolves.toEqual([ - { success: true, id: 'asset-1' }, - { success: true, id: 'asset-2' }, - { success: true, id: 'asset-3' }, + { success: true, id: asset1.id }, + { success: true, id: asset2.id }, + { success: true, id: asset3.id }, ]); - expect(mocks.album.update).toHaveBeenCalledWith('album-123', { - id: 'album-123', + expect(mocks.album.update).toHaveBeenCalledWith(album.id, { + id: album.id, updatedAt: expect.any(Date), - albumThumbnailAssetId: 'asset-1', + albumThumbnailAssetId: asset1.id, }); - expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith(album.id, [asset1.id, asset2.id, asset3.id]); expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { - id: 'album-123', - recipientId: 'admin_id', + id: album.id, + recipientId: album.ownerId, }); }); it('should not allow a shared user with viewer access to add assets', async () => { + const user = UserFactory.create(); + const album = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Viewer }).build(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set()); - mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); + mocks.album.getById.mockResolvedValue(album); await expect( - sut.addAssets(authStub.user2, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), + sut.addAssets(AuthFactory.create(user), album.id, { ids: [asset1.id, asset2.id, asset3.id] }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should allow a shared link user to add assets', async () => { - mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + const album = AlbumFactory.create(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; + const auth = AuthFactory.from(album.owner).sharedLink({ allowUpload: true, userId: album.ownerId }).build(); + mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([album.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); + mocks.album.getById.mockResolvedValue(album); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); - await expect( - sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), - ).resolves.toEqual([ - { success: true, id: 'asset-1' }, - { success: true, id: 'asset-2' }, - { success: true, id: 'asset-3' }, + await expect(sut.addAssets(auth, album.id, { ids: [asset1.id, asset2.id, asset3.id] })).resolves.toEqual([ + { success: true, id: asset1.id }, + { success: true, id: asset2.id }, + { success: true, id: asset3.id }, ]); - expect(mocks.album.update).toHaveBeenCalledWith('album-123', { - id: 'album-123', + expect(mocks.album.update).toHaveBeenCalledWith(album.id, { + id: album.id, updatedAt: expect.any(Date), - albumThumbnailAssetId: 'asset-1', + albumThumbnailAssetId: asset1.id, }); - expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith(album.id, [asset1.id, asset2.id, asset3.id]); - expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith( - authStub.adminSharedLink.sharedLink?.id, - new Set(['album-123']), - ); + expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith(auth.sharedLink?.id, new Set([album.id])); }); it('should allow adding assets shared via partner sharing', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1'])); - mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + const album = AlbumFactory.create(); + const asset = AssetFactory.create(); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.album.getById.mockResolvedValue(album); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); - await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ - { success: true, id: 'asset-1' }, + await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ + { success: true, id: asset.id }, ]); - expect(mocks.album.update).toHaveBeenCalledWith('album-123', { - id: 'album-123', + expect(mocks.album.update).toHaveBeenCalledWith(album.id, { + id: album.id, updatedAt: expect.any(Date), - albumThumbnailAssetId: 'asset-1', + albumThumbnailAssetId: asset.id, }); - expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(album.ownerId, new Set([asset.id])); }); it('should skip duplicate assets', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); - mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - mocks.album.getAssetIds.mockResolvedValueOnce(new Set(['asset-id'])); + const asset = AssetFactory.create(); + const album = AlbumFactory.create(); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.album.getById.mockResolvedValue(album); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set([asset.id])); - await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ - { success: false, id: 'asset-id', error: BulkIdErrorReason.DUPLICATE }, + await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ + { success: false, id: asset.id, error: BulkIdErrorReason.DUPLICATE }, ]); expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should skip assets not shared with user', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - mocks.album.getById.mockResolvedValue(albumStub.oneAsset); + const asset = AssetFactory.create(); + const album = AlbumFactory.create(); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.album.getById.mockResolvedValue(album); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); - await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ - { success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION }, + await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ + { success: false, id: asset.id, error: BulkIdErrorReason.NO_PERMISSION }, ]); - expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( - authStub.admin.user.id, - new Set(['asset-1']), - false, - ); - expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(album.ownerId, new Set([asset.id]), false); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(album.ownerId, new Set([asset.id])); }); it('should not allow unauthorized access to the album', async () => { - mocks.album.getById.mockResolvedValue(albumStub.oneAsset); + const user = UserFactory.create(); + const album = AlbumFactory.create(); + const asset = AssetFactory.create({ ownerId: user.id }); + mocks.album.getById.mockResolvedValue(album); - await expect( - sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), - ).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.addAssets(AuthFactory.create(user), album.id, { ids: [asset.id] })).rejects.toBeInstanceOf( + BadRequestException, + ); expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalled(); expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalled(); }); it('should not allow unauthorized shared link access to the album', async () => { - mocks.album.getById.mockResolvedValue(albumStub.oneAsset); + const album = AlbumFactory.create(); + const asset = AssetFactory.create(); + mocks.album.getById.mockResolvedValue(album); await expect( - sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), + sut.addAssets(AuthFactory.from().sharedLink({ allowUpload: true }).build(), album.id, { ids: [asset.id] }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalled(); @@ -778,133 +816,140 @@ describe(AlbumService.name, () => { describe('addAssetsToAlbums', () => { it('should allow the owner to add assets', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321'])); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - mocks.album.getById - .mockResolvedValueOnce(_.cloneDeep(albumStub.empty)) - .mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset)); + const album1 = AlbumFactory.create(); + const album2 = AlbumFactory.create(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; + mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); + mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( - sut.addAssetsToAlbums(authStub.admin, { - albumIds: ['album-123', 'album-321'], - assetIds: ['asset-1', 'asset-2', 'asset-3'], + sut.addAssetsToAlbums(AuthFactory.create(album1.owner), { + albumIds: [album1.id, album2.id], + assetIds: [asset1.id, asset2.id, asset3.id], }), ).resolves.toEqual({ success: true, error: undefined }); expect(mocks.album.update).toHaveBeenCalledTimes(2); - expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { - id: 'album-123', + expect(mocks.album.update).toHaveBeenNthCalledWith(1, album1.id, { + id: album1.id, updatedAt: expect.any(Date), - albumThumbnailAssetId: 'asset-1', + albumThumbnailAssetId: asset1.id, }); - expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', { - id: 'album-321', + expect(mocks.album.update).toHaveBeenNthCalledWith(2, album2.id, { + id: album2.id, updatedAt: expect.any(Date), - albumThumbnailAssetId: 'asset-1', + albumThumbnailAssetId: asset1.id, }); expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([ - { albumId: 'album-123', assetId: 'asset-1' }, - { albumId: 'album-123', assetId: 'asset-2' }, - { albumId: 'album-123', assetId: 'asset-3' }, - { albumId: 'album-321', assetId: 'asset-1' }, - { albumId: 'album-321', assetId: 'asset-2' }, - { albumId: 'album-321', assetId: 'asset-3' }, + { albumId: album1.id, assetId: asset1.id }, + { albumId: album1.id, assetId: asset2.id }, + { albumId: album1.id, assetId: asset3.id }, + { albumId: album2.id, assetId: asset1.id }, + { albumId: album2.id, assetId: asset2.id }, + { albumId: album2.id, assetId: asset3.id }, ]); }); it('should not set the thumbnail if the album has one already', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321'])); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - mocks.album.getById - .mockResolvedValueOnce(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' })) - .mockResolvedValueOnce(_.cloneDeep({ ...albumStub.oneAsset, albumThumbnailAssetId: 'asset-id' })); + const asset = AssetFactory.create(); + const album1 = AlbumFactory.from({ albumThumbnailAssetId: asset.id }).build(); + const album2 = AlbumFactory.from({ albumThumbnailAssetId: asset.id }).build(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; + mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); + mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( - sut.addAssetsToAlbums(authStub.admin, { - albumIds: ['album-123', 'album-321'], - assetIds: ['asset-1', 'asset-2', 'asset-3'], + sut.addAssetsToAlbums(AuthFactory.create(album1.owner), { + albumIds: [album1.id, album2.id], + assetIds: [asset1.id, asset2.id, asset3.id], }), ).resolves.toEqual({ success: true, error: undefined }); expect(mocks.album.update).toHaveBeenCalledTimes(2); - expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { - id: 'album-123', + expect(mocks.album.update).toHaveBeenNthCalledWith(1, album1.id, { + id: album1.id, updatedAt: expect.any(Date), - albumThumbnailAssetId: 'asset-id', + albumThumbnailAssetId: asset.id, }); - expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', { - id: 'album-321', + expect(mocks.album.update).toHaveBeenNthCalledWith(2, album2.id, { + id: album2.id, updatedAt: expect.any(Date), - albumThumbnailAssetId: 'asset-id', + albumThumbnailAssetId: asset.id, }); expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([ - { albumId: 'album-123', assetId: 'asset-1' }, - { albumId: 'album-123', assetId: 'asset-2' }, - { albumId: 'album-123', assetId: 'asset-3' }, - { albumId: 'album-321', assetId: 'asset-1' }, - { albumId: 'album-321', assetId: 'asset-2' }, - { albumId: 'album-321', assetId: 'asset-3' }, + { albumId: album1.id, assetId: asset1.id }, + { albumId: album1.id, assetId: asset2.id }, + { albumId: album1.id, assetId: asset3.id }, + { albumId: album2.id, assetId: asset1.id }, + { albumId: album2.id, assetId: asset2.id }, + { albumId: album2.id, assetId: asset3.id }, ]); }); it('should allow a shared user to add assets', async () => { - mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321'])); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - mocks.album.getById - .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser)) - .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple)); + const user = UserFactory.create(); + const album1 = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Editor }).build(); + const album2 = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Editor }).build(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; + mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set([album1.id, album2.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); + mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( - sut.addAssetsToAlbums(authStub.user1, { - albumIds: ['album-123', 'album-321'], - assetIds: ['asset-1', 'asset-2', 'asset-3'], + sut.addAssetsToAlbums(AuthFactory.create(user), { + albumIds: [album1.id, album2.id], + assetIds: [asset1.id, asset2.id, asset3.id], }), ).resolves.toEqual({ success: true, error: undefined }); expect(mocks.album.update).toHaveBeenCalledTimes(2); - expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { - id: 'album-123', + expect(mocks.album.update).toHaveBeenNthCalledWith(1, album1.id, { + id: album1.id, updatedAt: expect.any(Date), - albumThumbnailAssetId: 'asset-1', + albumThumbnailAssetId: asset1.id, }); - expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', { - id: 'album-321', + expect(mocks.album.update).toHaveBeenNthCalledWith(2, album2.id, { + id: album2.id, updatedAt: expect.any(Date), - albumThumbnailAssetId: 'asset-1', + albumThumbnailAssetId: asset1.id, }); expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([ - { albumId: 'album-123', assetId: 'asset-1' }, - { albumId: 'album-123', assetId: 'asset-2' }, - { albumId: 'album-123', assetId: 'asset-3' }, - { albumId: 'album-321', assetId: 'asset-1' }, - { albumId: 'album-321', assetId: 'asset-2' }, - { albumId: 'album-321', assetId: 'asset-3' }, + { albumId: album1.id, assetId: asset1.id }, + { albumId: album1.id, assetId: asset2.id }, + { albumId: album1.id, assetId: asset3.id }, + { albumId: album2.id, assetId: asset1.id }, + { albumId: album2.id, assetId: asset2.id }, + { albumId: album2.id, assetId: asset3.id }, ]); expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { - id: 'album-123', - recipientId: 'admin_id', + id: album1.id, + recipientId: album1.ownerId, }); expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { - id: 'album-321', - recipientId: 'admin_id', + id: album2.id, + recipientId: album2.ownerId, }); }); it('should not allow a shared user with viewer access to add assets', async () => { + const user = UserFactory.create(); + const album1 = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Viewer }).build(); + const album2 = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Viewer }).build(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - mocks.album.getById - .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser)) - .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithAdmin)); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); + mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( - sut.addAssetsToAlbums(authStub.user2, { - albumIds: ['album-123', 'album-321'], - assetIds: ['asset-1', 'asset-2', 'asset-3'], + sut.addAssetsToAlbums(AuthFactory.create(user), { + albumIds: [album1.id, album2.id], + assetIds: [asset1.id, asset2.id, asset3.id], }), ).resolves.toEqual({ success: false, @@ -915,125 +960,131 @@ describe(AlbumService.name, () => { }); it('should not allow a shared link user to add assets to multiple albums', async () => { - mocks.access.album.checkSharedLinkAccess.mockResolvedValueOnce(new Set(['album-123'])); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - mocks.album.getById - .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser)) - .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple)); + const album1 = AlbumFactory.create(); + const album2 = AlbumFactory.create(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; + mocks.access.album.checkSharedLinkAccess.mockResolvedValueOnce(new Set([album1.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); + mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); + const auth = AuthFactory.from(album1.owner).sharedLink({ allowUpload: true }).build(); await expect( - sut.addAssetsToAlbums(authStub.adminSharedLink, { - albumIds: ['album-123', 'album-321'], - assetIds: ['asset-1', 'asset-2', 'asset-3'], + sut.addAssetsToAlbums(auth, { + albumIds: [album1.id, album2.id], + assetIds: [asset1.id, asset2.id, asset3.id], }), ).resolves.toEqual({ success: true, error: undefined }); expect(mocks.album.update).toHaveBeenCalledTimes(1); - expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { - id: 'album-123', + expect(mocks.album.update).toHaveBeenNthCalledWith(1, album1.id, { + id: album1.id, updatedAt: expect.any(Date), - albumThumbnailAssetId: 'asset-1', + albumThumbnailAssetId: asset1.id, }); expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([ - { albumId: 'album-123', assetId: 'asset-1' }, - { albumId: 'album-123', assetId: 'asset-2' }, - { albumId: 'album-123', assetId: 'asset-3' }, + { albumId: album1.id, assetId: asset1.id }, + { albumId: album1.id, assetId: asset2.id }, + { albumId: album1.id, assetId: asset3.id }, ]); - expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { - id: 'album-123', - recipientId: 'user-id', - }); expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith( - authStub.adminSharedLink.sharedLink?.id, - new Set(['album-123', 'album-321']), + auth.sharedLink?.id, + new Set([album1.id, album2.id]), ); }); it('should allow adding assets shared via partner sharing', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321'])); - mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - mocks.album.getById - .mockResolvedValueOnce(_.cloneDeep(albumStub.empty)) - .mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset)); + const user = UserFactory.create(); + const album1 = AlbumFactory.create(); + const album2 = AlbumFactory.create(); + const [asset1, asset2, asset3] = [ + AssetFactory.create({ ownerId: user.id }), + AssetFactory.create({ ownerId: user.id }), + AssetFactory.create({ ownerId: user.id }), + ]; + mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id])); + mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); + mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( - sut.addAssetsToAlbums(authStub.admin, { - albumIds: ['album-123', 'album-321'], - assetIds: ['asset-1', 'asset-2', 'asset-3'], + sut.addAssetsToAlbums(AuthFactory.create(album1.owner), { + albumIds: [album1.id, album2.id], + assetIds: [asset1.id, asset2.id, asset3.id], }), ).resolves.toEqual({ success: true, error: undefined }); expect(mocks.album.update).toHaveBeenCalledTimes(2); - expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { - id: 'album-123', + expect(mocks.album.update).toHaveBeenNthCalledWith(1, album1.id, { + id: album1.id, updatedAt: expect.any(Date), - albumThumbnailAssetId: 'asset-1', + albumThumbnailAssetId: asset1.id, }); - expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', { - id: 'album-321', + expect(mocks.album.update).toHaveBeenNthCalledWith(2, album2.id, { + id: album2.id, updatedAt: expect.any(Date), - albumThumbnailAssetId: 'asset-1', + albumThumbnailAssetId: asset1.id, }); expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([ - { albumId: 'album-123', assetId: 'asset-1' }, - { albumId: 'album-123', assetId: 'asset-2' }, - { albumId: 'album-123', assetId: 'asset-3' }, - { albumId: 'album-321', assetId: 'asset-1' }, - { albumId: 'album-321', assetId: 'asset-2' }, - { albumId: 'album-321', assetId: 'asset-3' }, + { albumId: album1.id, assetId: asset1.id }, + { albumId: album1.id, assetId: asset2.id }, + { albumId: album1.id, assetId: asset3.id }, + { albumId: album2.id, assetId: asset1.id }, + { albumId: album2.id, assetId: asset2.id }, + { albumId: album2.id, assetId: asset3.id }, ]); expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith( - authStub.admin.user.id, - new Set(['asset-1', 'asset-2', 'asset-3']), + album1.ownerId, + new Set([asset1.id, asset2.id, asset3.id]), ); }); it('should skip some duplicate assets', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321'])); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; + const album1 = AlbumFactory.create(); + const album2 = AlbumFactory.create(); + mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); mocks.album.getAssetIds - .mockResolvedValueOnce(new Set(['asset-1', 'asset-2', 'asset-3'])) + .mockResolvedValueOnce(new Set([asset1.id, asset2.id, asset3.id])) .mockResolvedValueOnce(new Set()); - mocks.album.getById - .mockResolvedValueOnce(_.cloneDeep(albumStub.empty)) - .mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); await expect( - sut.addAssetsToAlbums(authStub.admin, { - albumIds: ['album-123', 'album-321'], - assetIds: ['asset-1', 'asset-2', 'asset-3'], + sut.addAssetsToAlbums(AuthFactory.create(album1.owner), { + albumIds: [album1.id, album2.id], + assetIds: [asset1.id, asset2.id, asset3.id], }), ).resolves.toEqual({ success: true, error: undefined }); expect(mocks.album.update).toHaveBeenCalledTimes(1); - expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-321', { - id: 'album-321', + expect(mocks.album.update).toHaveBeenNthCalledWith(1, album2.id, { + id: album2.id, updatedAt: expect.any(Date), - albumThumbnailAssetId: 'asset-1', + albumThumbnailAssetId: asset1.id, }); expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([ - { albumId: 'album-321', assetId: 'asset-1' }, - { albumId: 'album-321', assetId: 'asset-2' }, - { albumId: 'album-321', assetId: 'asset-3' }, + { albumId: album2.id, assetId: asset1.id }, + { albumId: album2.id, assetId: asset2.id }, + { albumId: album2.id, assetId: asset3.id }, ]); }); it('should skip all duplicate assets', async () => { + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; + const album1 = AlbumFactory.create(); + const album2 = AlbumFactory.create(); mocks.access.album.checkOwnerAccess - .mockResolvedValueOnce(new Set(['album-123'])) - .mockResolvedValueOnce(new Set(['album-321'])); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - mocks.album.getById - .mockResolvedValueOnce(_.cloneDeep(albumStub.empty)) - .mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset)); - mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + .mockResolvedValueOnce(new Set([album1.id])) + .mockResolvedValueOnce(new Set([album2.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); + mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); + mocks.album.getAssetIds.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); await expect( - sut.addAssetsToAlbums(authStub.admin, { - albumIds: ['album-123', 'album-321'], - assetIds: ['asset-1', 'asset-2'], + sut.addAssetsToAlbums(AuthFactory.create(album1.owner), { + albumIds: [album1.id, album2.id], + assetIds: [asset1.id, asset2.id, asset3.id], }), ).resolves.toEqual({ success: false, @@ -1045,18 +1096,24 @@ describe(AlbumService.name, () => { }); it('should skip assets not shared with user', async () => { + const user = UserFactory.create(); + const album1 = AlbumFactory.create(); + const album2 = AlbumFactory.create(); + const [asset1, asset2, asset3] = [ + AssetFactory.create({ ownerId: user.id }), + AssetFactory.create({ ownerId: user.id }), + AssetFactory.create({ ownerId: user.id }), + ]; mocks.access.album.checkSharedAlbumAccess - .mockResolvedValueOnce(new Set(['album-123'])) - .mockResolvedValueOnce(new Set(['album-321'])); - mocks.album.getById - .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser)) - .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple)); + .mockResolvedValueOnce(new Set([album1.id])) + .mockResolvedValueOnce(new Set([album2.id])); + mocks.album.getById.mockResolvedValueOnce(_.cloneDeep(album1)).mockResolvedValueOnce(_.cloneDeep(album2)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( - sut.addAssetsToAlbums(authStub.admin, { - albumIds: ['album-123', 'album-321'], - assetIds: ['asset-1', 'asset-2', 'asset-3'], + sut.addAssetsToAlbums(AuthFactory.create(album1.owner), { + albumIds: [album1.id, album2.id], + assetIds: [asset1.id, asset2.id, asset3.id], }), ).resolves.toEqual({ success: false, @@ -1066,25 +1123,27 @@ describe(AlbumService.name, () => { expect(mocks.album.update).not.toHaveBeenCalled(); expect(mocks.album.addAssetIds).not.toHaveBeenCalled(); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( - authStub.admin.user.id, - new Set(['asset-1', 'asset-2', 'asset-3']), + album1.ownerId, + new Set([asset1.id, asset2.id, asset3.id]), false, ); expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith( - authStub.admin.user.id, - new Set(['asset-1', 'asset-2', 'asset-3']), + album1.ownerId, + new Set([asset1.id, asset2.id, asset3.id]), ); }); it('should not allow unauthorized access to the albums', async () => { - mocks.album.getById - .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser)) - .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple)); + const user = UserFactory.create(); + const album1 = AlbumFactory.create(); + const album2 = AlbumFactory.create(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; + mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); await expect( - sut.addAssetsToAlbums(authStub.admin, { - albumIds: ['album-123', 'album-321'], - assetIds: ['asset-1', 'asset-2', 'asset-3'], + sut.addAssetsToAlbums(AuthFactory.create(user), { + albumIds: [album1.id, album2.id], + assetIds: [asset1.id, asset2.id, asset3.id], }), ).resolves.toEqual({ success: false, @@ -1098,14 +1157,15 @@ describe(AlbumService.name, () => { }); it('should not allow unauthorized shared link access to the album', async () => { - mocks.album.getById - .mockResolvedValueOnce(_.cloneDeep(albumStub.empty)) - .mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset)); + const album1 = AlbumFactory.create(); + const album2 = AlbumFactory.create(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; + mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); await expect( - sut.addAssetsToAlbums(authStub.adminSharedLink, { - albumIds: ['album-123', 'album-321'], - assetIds: ['asset-1', 'asset-2', 'asset-3'], + sut.addAssetsToAlbums(AuthFactory.from().sharedLink({ allowUpload: true }).build(), { + albumIds: [album1.id, album2.id], + assetIds: [asset1.id, asset2.id, asset3.id], }), ).resolves.toEqual({ success: false, @@ -1118,48 +1178,57 @@ describe(AlbumService.name, () => { describe('removeAssets', () => { it('should allow the owner to remove assets', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); - mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-id'])); + const asset = AssetFactory.create(); + const album = AlbumFactory.create(); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.album.getById.mockResolvedValue(album); + mocks.album.getAssetIds.mockResolvedValue(new Set([asset.id])); - await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ - { success: true, id: 'asset-id' }, + await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ + { success: true, id: asset.id }, ]); - expect(mocks.album.removeAssetIds).toHaveBeenCalledWith('album-123', ['asset-id']); + expect(mocks.album.removeAssetIds).toHaveBeenCalledWith(album.id, [asset.id]); }); it('should skip assets not in the album', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.empty)); + const asset = AssetFactory.create(); + const album = AlbumFactory.create(); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.album.getById.mockResolvedValue(album); mocks.album.getAssetIds.mockResolvedValue(new Set()); - await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ - { success: false, id: 'asset-id', error: BulkIdErrorReason.NOT_FOUND }, + await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ + { success: false, id: asset.id, error: BulkIdErrorReason.NOT_FOUND }, ]); expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should allow owner to remove all assets from the album', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-id'])); + const asset = AssetFactory.create(); + const album = AlbumFactory.create(); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.album.getById.mockResolvedValue(album); + mocks.album.getAssetIds.mockResolvedValue(new Set([asset.id])); - await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ - { success: true, id: 'asset-id' }, + await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ + { success: true, id: asset.id }, ]); }); it('should reset the thumbnail if it is removed', async () => { - mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); - mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets)); - mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-id'])); + const asset1 = AssetFactory.create(); + const asset2 = AssetFactory.create(); + const album = AlbumFactory.from({ albumThumbnailAssetId: asset1.id }).build(); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id])); + mocks.album.getById.mockResolvedValue(album); + mocks.album.getAssetIds.mockResolvedValue(new Set([asset1.id, asset2.id])); - await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ - { success: true, id: 'asset-id' }, + await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset1.id] })).resolves.toEqual([ + { success: true, id: asset1.id }, ]); expect(mocks.album.updateThumbnails).toHaveBeenCalled(); diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 707faa326d..ff4dfa96ff 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -6,9 +6,10 @@ import { AssetEditAction } from 'src/dtos/editing.dto'; import { AssetMetadataKey, AssetStatus, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum'; import { AssetStats } from 'src/repositories/asset.repository'; import { AssetService } from 'src/services/asset.service'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { userStub } from 'test/fixtures/user.stub'; import { factory } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -45,35 +46,33 @@ describe(AssetService.name, () => { describe('getStatistics', () => { it('should get the statistics for a user, excluding archived assets', async () => { + const auth = AuthFactory.create(); mocks.asset.getStatistics.mockResolvedValue(stats); - await expect(sut.getStatistics(authStub.admin, { visibility: AssetVisibility.Timeline })).resolves.toEqual( - statResponse, - ); - expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { - visibility: AssetVisibility.Timeline, - }); + await expect(sut.getStatistics(auth, { visibility: AssetVisibility.Timeline })).resolves.toEqual(statResponse); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(auth.user.id, { visibility: AssetVisibility.Timeline }); }); it('should get the statistics for a user for archived assets', async () => { + const auth = AuthFactory.create(); mocks.asset.getStatistics.mockResolvedValue(stats); - await expect(sut.getStatistics(authStub.admin, { visibility: AssetVisibility.Archive })).resolves.toEqual( - statResponse, - ); - expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { + await expect(sut.getStatistics(auth, { visibility: AssetVisibility.Archive })).resolves.toEqual(statResponse); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(auth.user.id, { visibility: AssetVisibility.Archive, }); }); it('should get the statistics for a user for favorite assets', async () => { + const auth = AuthFactory.create(); mocks.asset.getStatistics.mockResolvedValue(stats); - await expect(sut.getStatistics(authStub.admin, { isFavorite: true })).resolves.toEqual(statResponse); - expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isFavorite: true }); + await expect(sut.getStatistics(auth, { isFavorite: true })).resolves.toEqual(statResponse); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(auth.user.id, { isFavorite: true }); }); it('should get the statistics for a user for all assets', async () => { + const auth = AuthFactory.create(); mocks.asset.getStatistics.mockResolvedValue(stats); - await expect(sut.getStatistics(authStub.admin, {})).resolves.toEqual(statResponse); - expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, {}); + await expect(sut.getStatistics(auth, {})).resolves.toEqual(statResponse); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(auth.user.id, {}); }); }); @@ -249,10 +248,11 @@ describe(AssetService.name, () => { }); it('should fail linking a live video if the motion part could not be found', async () => { + const auth = AuthFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); await expect( - sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { + sut.update(auth, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }), ).rejects.toBeInstanceOf(BadRequestException); @@ -267,11 +267,12 @@ describe(AssetService.name, () => { }); expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', { assetId: assetStub.livePhotoMotionAsset.id, - userId: userStub.admin.id, + userId: auth.user.id, }); }); it('should fail linking a live video if the motion part is not a video', async () => { + const auth = AuthFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); @@ -291,16 +292,17 @@ describe(AssetService.name, () => { }); expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', { assetId: assetStub.livePhotoMotionAsset.id, - userId: userStub.admin.id, + userId: auth.user.id, }); }); it('should fail linking a live video if the motion part has a different owner', async () => { + const auth = AuthFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); await expect( - sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { + sut.update(auth, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }), ).rejects.toBeInstanceOf(BadRequestException); @@ -315,52 +317,41 @@ describe(AssetService.name, () => { }); expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', { assetId: assetStub.livePhotoMotionAsset.id, - userId: userStub.admin.id, + userId: auth.user.id, }); }); it('should link a live video', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); - mocks.asset.getById.mockResolvedValueOnce({ - ...assetStub.livePhotoMotionAsset, - ownerId: authStub.admin.user.id, - visibility: AssetVisibility.Timeline, - }); - mocks.asset.getById.mockResolvedValueOnce(assetStub.image); - mocks.asset.update.mockResolvedValue(assetStub.image); + const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Timeline }); + const stillAsset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([stillAsset.id])); + mocks.asset.getById.mockResolvedValueOnce(motionAsset); + mocks.asset.getById.mockResolvedValueOnce(stillAsset); + mocks.asset.update.mockResolvedValue(stillAsset); + const auth = AuthFactory.from(motionAsset.owner).build(); - await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { - livePhotoVideoId: assetStub.livePhotoMotionAsset.id, - }); + await sut.update(auth, stillAsset.id, { livePhotoVideoId: motionAsset.id }); - expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.livePhotoMotionAsset.id, - visibility: AssetVisibility.Hidden, - }); - expect(mocks.event.emit).toHaveBeenCalledWith('AssetHide', { - assetId: assetStub.livePhotoMotionAsset.id, - userId: userStub.admin.id, - }); - expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.livePhotoStillAsset.id, - livePhotoVideoId: assetStub.livePhotoMotionAsset.id, - }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, visibility: AssetVisibility.Hidden }); + expect(mocks.event.emit).toHaveBeenCalledWith('AssetHide', { assetId: motionAsset.id, userId: auth.user.id }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, livePhotoVideoId: motionAsset.id }); }); it('should throw an error if asset could not be found after update', async () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - await expect(sut.update(authStub.admin, 'asset-1', { isFavorite: true })).rejects.toBeInstanceOf( + await expect(sut.update(AuthFactory.create(), 'asset-1', { isFavorite: true })).rejects.toBeInstanceOf( BadRequestException, ); }); it('should unlink a live video', async () => { + const auth = AuthFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset); mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); mocks.asset.update.mockResolvedValueOnce(assetStub.image); - await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }); + await sut.update(auth, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }); expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, @@ -372,7 +363,7 @@ describe(AssetService.name, () => { }); expect(mocks.event.emit).toHaveBeenCalledWith('AssetShow', { assetId: assetStub.livePhotoMotionAsset.id, - userId: userStub.admin.id, + userId: auth.user.id, }); }); @@ -392,17 +383,15 @@ describe(AssetService.name, () => { describe('updateAll', () => { it('should require asset write access for all ids', async () => { - await expect( - sut.updateAll(authStub.admin, { - ids: ['asset-1'], - }), - ).rejects.toBeInstanceOf(BadRequestException); + const auth = AuthFactory.create(); + await expect(sut.updateAll(auth, { ids: ['asset-1'] })).rejects.toBeInstanceOf(BadRequestException); }); it('should update all assets', async () => { + const auth = AuthFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], visibility: AssetVisibility.Archive }); + await sut.updateAll(auth, { ids: ['asset-1', 'asset-2'], visibility: AssetVisibility.Archive }); expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { visibility: AssetVisibility.Archive, @@ -410,9 +399,10 @@ describe(AssetService.name, () => { }); it('should not update Assets table if no relevant fields are provided', async () => { + const auth = AuthFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - await sut.updateAll(authStub.admin, { + await sut.updateAll(auth, { ids: ['asset-1'], latitude: 0, longitude: 0, diff --git a/server/src/services/download.service.spec.ts b/server/src/services/download.service.spec.ts index 86d0bda7f8..7721b12ffc 100644 --- a/server/src/services/download.service.spec.ts +++ b/server/src/services/download.service.spec.ts @@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common'; import { Readable } from 'node:stream'; import { DownloadResponseDto } from 'src/dtos/download.dto'; import { DownloadService } from 'src/services/download.service'; +import { AssetFactory } from 'test/factories/asset.factory'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -60,22 +61,22 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + const asset1 = AssetFactory.create(); + const asset2 = AssetFactory.create(); + + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id])); mocks.storage.realpath.mockRejectedValue(new Error('Could not read file')); - mocks.asset.getByIds.mockResolvedValue([ - { ...assetStub.noResizePath, id: 'asset-1' }, - { ...assetStub.noWebpPath, id: 'asset-2' }, - ]); + mocks.asset.getByIds.mockResolvedValue([asset1, asset2]); mocks.storage.createZipStream.mockReturnValue(archiveMock); - await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ + await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({ stream: archiveMock.stream, }); expect(mocks.logger.warn).toHaveBeenCalledTimes(2); expect(archiveMock.addFile).toHaveBeenCalledTimes(2); - expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, '/data/library/IMG_123.jpg', 'IMG_123.jpg'); - expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, '/data/library/IMG_456.jpg', 'IMG_456.jpg'); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, asset1.originalPath, asset1.originalFileName); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, asset2.originalPath, asset2.originalFileName); }); it('should download an archive', async () => { @@ -85,20 +86,20 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - mocks.asset.getByIds.mockResolvedValue([ - { ...assetStub.noResizePath, id: 'asset-1' }, - { ...assetStub.noWebpPath, id: 'asset-2' }, - ]); + const asset1 = AssetFactory.create(); + const asset2 = AssetFactory.create(); + + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id])); + mocks.asset.getByIds.mockResolvedValue([asset1, asset2]); mocks.storage.createZipStream.mockReturnValue(archiveMock); - await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ + await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({ stream: archiveMock.stream, }); expect(archiveMock.addFile).toHaveBeenCalledTimes(2); - expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, '/data/library/IMG_123.jpg', 'IMG_123.jpg'); - expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, '/data/library/IMG_456.jpg', 'IMG_456.jpg'); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, asset1.originalPath, asset1.originalFileName); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, asset2.originalPath, asset2.originalFileName); }); it('should handle duplicate file names', async () => { diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 75812e2fcb..bee1ed67d9 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -18,6 +18,7 @@ import { } from 'src/enum'; import { MediaService } from 'src/services/media.service'; import { JobCounts, RawImageInfo } from 'src/types'; +import { AssetFactory } from 'test/factories/asset.factory'; import { assetStub, previewFile } from 'test/fixtures/asset.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { probeStub } from 'test/fixtures/media.stub'; @@ -139,33 +140,30 @@ describe(MediaService.name, () => { expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); - it('should queue all assets with missing webp path', async () => { - mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.noWebpPath])); + it('should queue all assets with missing preview', async () => { + const asset = AssetFactory.create(); + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { - name: JobName.AssetGenerateThumbnails, - data: { id: assetStub.image.id }, - }, + { name: JobName.AssetGenerateThumbnails, data: { id: asset.id } }, ]); - expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); it('should queue all assets with missing thumbhash', async () => { - mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.noThumbhash])); + const asset = AssetFactory.from({ thumbhash: null }) + .files([AssetFileType.Thumbnail, AssetFileType.Preview]) + .build(); + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { - name: JobName.AssetGenerateThumbnails, - data: { id: assetStub.image.id }, - }, + { name: JobName.AssetGenerateThumbnails, data: { id: asset.id } }, ]); expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); @@ -1052,12 +1050,19 @@ describe(MediaService.name, () => { mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); // HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari. - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif); + const asset = AssetFactory.from({ originalFileName: 'image.hif' }) + .exif({ + fileSizeInByte: 5000, + profileDescription: 'Adobe RGB', + bitsPerSample: 14, + }) + .build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageHif.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, }); @@ -1107,12 +1112,19 @@ describe(MediaService.name, () => { mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); mocks.media.copyTagGroup.mockResolvedValue(true); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.panoramaTif); + const asset = AssetFactory.from({ originalFileName: 'panorama.tif' }) + .exif({ + fileSizeInByte: 5000, + projectionType: 'EQUIRECTANGULAR', + }) + .build(); + + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.panoramaTif.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.Srgb, orientation: undefined, processInvalidImages: false, @@ -1135,11 +1147,7 @@ describe(MediaService.name, () => { ); expect(mocks.media.copyTagGroup).toHaveBeenCalledTimes(2); - expect(mocks.media.copyTagGroup).toHaveBeenCalledWith( - 'XMP-GPano', - assetStub.panoramaTif.originalPath, - expect.any(String), - ); + expect(mocks.media.copyTagGroup).toHaveBeenCalledWith('XMP-GPano', asset.originalPath, expect.any(String)); }); it('should respect encoding options when generating full-size preview', async () => { @@ -1149,12 +1157,19 @@ describe(MediaService.name, () => { mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); // HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari. - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif); + const asset = AssetFactory.from({ originalFileName: 'image.hif' }) + .exif({ + fileSizeInByte: 5000, + profileDescription: 'Adobe RGB', + bitsPerSample: 14, + }) + .build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageHif.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, }); @@ -1181,9 +1196,16 @@ describe(MediaService.name, () => { }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif); + const asset = AssetFactory.from({ originalFileName: 'image.hif' }) + .exif({ + fileSizeInByte: 5000, + profileDescription: 'Adobe RGB', + bitsPerSample: 14, + }) + .build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( @@ -1263,30 +1285,25 @@ describe(MediaService.name, () => { }); it('should clean up edited files if an asset has no edits', async () => { - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ - ...assetStub.withoutEdits, - }); + const asset = AssetFactory.from({ thumbhash: factory.buffer() }) + .exif() + .files([ + { type: AssetFileType.Preview, path: 'edited1.jpg', isEdited: true }, + { type: AssetFileType.Thumbnail, path: 'edited2.jpg', isEdited: true }, + { type: AssetFileType.FullSize, path: 'edited3.jpg', isEdited: true }, + ]) + .build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + + const status = await sut.handleAssetEditThumbnailGeneration({ id: asset.id }); - const status = await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FileDelete, data: { - files: expect.arrayContaining([ - '/uploads/user-id/fullsize/path_edited.jpg', - '/uploads/user-id/preview/path_edited.jpg', - '/uploads/user-id/thumbnail/path_edited.jpg', - ]), + files: expect.arrayContaining(['edited1.jpg', 'edited2.jpg', 'edited3.jpg']), }, }); - expect(mocks.asset.deleteFiles).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ path: '/uploads/user-id/preview/path_edited.jpg' }), - expect.objectContaining({ path: '/uploads/user-id/thumbnail/path_edited.jpg' }), - expect.objectContaining({ path: '/uploads/user-id/fullsize/path_edited.jpg' }), - ]), - ); - expect(status).toBe(JobStatus.Success); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); @@ -1320,11 +1337,9 @@ describe(MediaService.name, () => { }); it('should generate the original thumbhash if no edits exist', async () => { - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ - ...assetStub.withoutEdits, - }); - const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); - mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); + const asset = AssetFactory.from().exif().build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.media.generateThumbhash.mockResolvedValue(factory.buffer()); await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id, source: 'upload' }); @@ -1335,18 +1350,14 @@ describe(MediaService.name, () => { mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ ...assetStub.withCropEdit, }); - const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + const thumbhashBuffer = factory.buffer(); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.person.getFaces.mockResolvedValue([]); mocks.ocr.getByAssetId.mockResolvedValue([]); await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); - expect(mocks.asset.update).toHaveBeenCalledWith( - expect.objectContaining({ - thumbhash: thumbhashBuffer, - }), - ); + expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ thumbhash: thumbhashBuffer })); }); }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 00bd0305dd..2c4005f436 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -185,7 +185,7 @@ export class MediaService extends BaseService { const generated = await this.generateEditedThumbnails(asset, config); await this.syncFiles( - asset.files.filter((asset) => asset.isEdited), + asset.files.filter((file) => file.isEdited), generated?.files ?? [], ); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index eda4e1a063..d94de020e0 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -16,6 +16,7 @@ import { } from 'src/enum'; import { ImmichTags } from 'src/repositories/metadata.repository'; import { firstDateTime, MetadataService } from 'src/services/metadata.service'; +import { AssetFactory } from 'test/factories/asset.factory'; import { assetStub } from 'test/fixtures/asset.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { probeStub } from 'test/fixtures/media.stub'; @@ -24,13 +25,6 @@ import { tagStub } from 'test/fixtures/tag.stub'; import { factory } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; -const removeNonSidecarFiles = (asset: any) => { - return { - ...asset, - files: asset.files.filter((file: any) => file.type === AssetFileType.Sidecar), - }; -}; - const forSidecarJob = ( asset: { id?: string; @@ -182,17 +176,18 @@ describe(MetadataService.name, () => { it('should handle a date in a sidecar file', async () => { const originalDate = new Date('2023-11-21T16:13:17.517Z'); const sidecarDate = new Date('2022-01-01T00:00:00.000Z'); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar)); + const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.sidecar.id); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate }), { lockedPropertiesBehavior: 'skip', }); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ - id: assetStub.image.id, + id: asset.id, duration: null, fileCreatedAt: sidecarDate, localDateTime: sidecarDate, @@ -203,7 +198,8 @@ describe(MetadataService.name, () => { it('should take the file modification date when missing exif and earlier than creation date', async () => { const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z'); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.storage.stat.mockResolvedValue({ size: 123_456, mtime: fileModifiedAt, @@ -212,14 +208,14 @@ describe(MetadataService.name, () => { } as Stats); mockReadTags(); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ dateTimeOriginal: fileModifiedAt }), { lockedPropertiesBehavior: 'skip' }, ); expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.image.id, + id: asset.id, duration: null, fileCreatedAt: fileModifiedAt, fileModifiedAt, @@ -232,7 +228,8 @@ describe(MetadataService.name, () => { it('should take the file creation date when missing exif and earlier than modification date', async () => { const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z'); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.storage.stat.mockResolvedValue({ size: 123_456, mtime: fileModifiedAt, @@ -241,14 +238,14 @@ describe(MetadataService.name, () => { } as Stats); mockReadTags(); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ dateTimeOriginal: fileCreatedAt }), { lockedPropertiesBehavior: 'skip' }, ); expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.image.id, + id: asset.id, duration: null, fileCreatedAt, fileModifiedAt, @@ -260,10 +257,11 @@ describe(MetadataService.name, () => { it('should determine dateTimeOriginal regardless of the server time zone', async () => { process.env.TZ = 'America/Los_Angeles'; - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar)); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ dateTimeOriginal: new Date('2022-01-01T00:00:00.000Z'), @@ -279,16 +277,15 @@ describe(MetadataService.name, () => { }); it('should handle lists of numbers', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.storage.stat.mockResolvedValue({ size: 123_456, - mtime: assetStub.image.fileModifiedAt, - mtimeMs: assetStub.image.fileModifiedAt.valueOf(), - birthtimeMs: assetStub.image.fileCreatedAt.valueOf(), + mtime: asset.fileModifiedAt, + mtimeMs: asset.fileModifiedAt.valueOf(), + birthtimeMs: asset.fileCreatedAt.valueOf(), } as Stats); - mockReadTags({ - ISO: [160], - }); + mockReadTags({ ISO: [160] }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); @@ -296,11 +293,11 @@ describe(MetadataService.name, () => { lockedPropertiesBehavior: 'skip', }); expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.image.id, + id: asset.id, duration: null, - fileCreatedAt: assetStub.image.fileCreatedAt, - fileModifiedAt: assetStub.image.fileCreatedAt, - localDateTime: assetStub.image.fileCreatedAt, + fileCreatedAt: asset.fileCreatedAt, + fileModifiedAt: asset.fileCreatedAt, + localDateTime: asset.fileCreatedAt, width: null, height: null, }); @@ -308,77 +305,77 @@ describe(MetadataService.name, () => { it('should not delete latituide and longitude without reverse geocode', async () => { // regression test for issue 17511 - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.withLocation); + const asset = AssetFactory.from().exif().build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: false } }); mocks.storage.stat.mockResolvedValue({ size: 123_456, - mtime: assetStub.withLocation.fileModifiedAt, - mtimeMs: assetStub.withLocation.fileModifiedAt.valueOf(), - birthtimeMs: assetStub.withLocation.fileCreatedAt.valueOf(), + mtime: asset.fileModifiedAt, + mtimeMs: asset.fileModifiedAt.valueOf(), + birthtimeMs: asset.fileCreatedAt.valueOf(), } as Stats); mockReadTags({ - GPSLatitude: assetStub.withLocation.exifInfo!.latitude!, - GPSLongitude: assetStub.withLocation.exifInfo!.longitude!, + GPSLatitude: asset.exifInfo.latitude!, + GPSLongitude: asset.exifInfo.longitude!, }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ city: null, state: null, country: null }), { lockedPropertiesBehavior: 'skip' }, ); expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.withLocation.id, + id: asset.id, duration: null, - fileCreatedAt: assetStub.withLocation.fileCreatedAt, - fileModifiedAt: assetStub.withLocation.fileModifiedAt, - localDateTime: new Date('2023-02-22T05:06:29.716Z'), + fileCreatedAt: asset.fileCreatedAt, + fileModifiedAt: asset.fileModifiedAt, + localDateTime: asset.localDateTime, width: null, height: null, }); }); it('should apply reverse geocoding', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.withLocation)); + const asset = AssetFactory.from().exif({ latitude: 10, longitude: 20 }).build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } }); mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); mocks.storage.stat.mockResolvedValue({ size: 123_456, - mtime: assetStub.withLocation.fileModifiedAt, - mtimeMs: assetStub.withLocation.fileModifiedAt.valueOf(), - birthtimeMs: assetStub.withLocation.fileCreatedAt.valueOf(), + mtime: asset.fileModifiedAt, + mtimeMs: asset.fileModifiedAt.valueOf(), + birthtimeMs: asset.fileCreatedAt.valueOf(), } as Stats); - mockReadTags({ - GPSLatitude: assetStub.withLocation.exifInfo!.latitude!, - GPSLongitude: assetStub.withLocation.exifInfo!.longitude!, - }); + mockReadTags({ GPSLatitude: 10, GPSLongitude: 20 }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }), { lockedPropertiesBehavior: 'skip' }, ); expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.withLocation.id, + id: asset.id, duration: null, - fileCreatedAt: assetStub.withLocation.fileCreatedAt, - fileModifiedAt: assetStub.withLocation.fileModifiedAt, - localDateTime: new Date('2023-02-22T05:06:29.716Z'), + fileCreatedAt: asset.fileCreatedAt, + fileModifiedAt: asset.fileModifiedAt, + localDateTime: asset.localDateTime, width: null, height: null, }); }); it('should discard latitude and longitude on null island', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.withLocation)); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ GPSLatitude: 0, GPSLongitude: 0, }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ latitude: null, longitude: null }), { lockedPropertiesBehavior: 'skip' }, @@ -386,19 +383,25 @@ describe(MetadataService.name, () => { }); it('should extract tags from TagsList', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) }); + const asset = AssetFactory.from() + .exif({ tags: ['Parent'] }) + .build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset); mockReadTags({ TagsList: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined }); }); it('should extract hierarchy from TagsList', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent/Child'] }) }); + const asset = AssetFactory.from() + .exif({ tags: ['Parent/Child'] }) + .build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset); mockReadTags({ TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); @@ -406,135 +409,147 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { - userId: 'user-id', + userId: asset.ownerId, value: 'Parent', parentId: undefined, }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { - userId: 'user-id', + userId: asset.ownerId, value: 'Parent/Child', parentId: 'tag-parent', }); }); it('should extract tags from Keywords as a string', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) }); + const asset = AssetFactory.from() + .exif({ tags: ['Parent'] }) + .build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset); mockReadTags({ Keywords: 'Parent' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined }); }); it('should extract tags from Keywords as a list', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) }); + const asset = AssetFactory.from() + .exif({ tags: ['Parent'] }) + .build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset); mockReadTags({ Keywords: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined }); }); it('should extract tags from Keywords as a list with a number', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ - ...factory.asset(), - exifInfo: factory.exif({ tags: ['Parent', '2024'] }), - }); + const asset = AssetFactory.from() + .exif({ tags: ['Parent', '2024'] }) + .build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset); mockReadTags({ Keywords: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); - expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: '2024', parent: undefined }); }); it('should extract hierarchal tags from Keywords', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent/Child'] }) }); + const asset = AssetFactory.from() + .exif({ tags: ['Parent/Child'] }) + .build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset); mockReadTags({ Keywords: 'Parent/Child' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); - + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { - userId: 'user-id', + userId: asset.ownerId, value: 'Parent', parentId: undefined, }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { - userId: 'user-id', + userId: asset.ownerId, value: 'Parent/Child', parentId: 'tag-parent', }); }); it('should ignore Keywords when TagsList is present', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ - ...factory.asset(), - exifInfo: factory.exif({ tags: ['Parent/Child', 'Child'] }), - }); + const asset = AssetFactory.from() + .exif({ tags: ['Parent/Child', 'Child'] }) + .build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset); mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { - userId: 'user-id', + userId: asset.ownerId, value: 'Parent', parentId: undefined, }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { - userId: 'user-id', + userId: asset.ownerId, value: 'Parent/Child', parentId: 'tag-parent', }); }); it('should extract hierarchy from HierarchicalSubject', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ - ...factory.asset(), - exifInfo: factory.exif({ tags: ['Parent/Child', 'TagA'] }), - }); + const asset = AssetFactory.from() + .exif({ tags: ['Parent/Child', 'TagA'] }) + .build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset); mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { - userId: 'user-id', + userId: asset.ownerId, value: 'Parent', parentId: undefined, }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { - userId: 'user-id', + userId: asset.ownerId, value: 'Parent/Child', parentId: 'tag-parent', }); - expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(3, { userId: 'user-id', value: 'TagA', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(3, { + userId: asset.ownerId, + value: 'TagA', + parent: undefined, + }); }); it('should extract tags from HierarchicalSubject as a list with a number', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ - ...factory.asset(), - exifInfo: factory.exif({ tags: ['Parent', '2024'] }), - }); + const asset = AssetFactory.from() + .exif({ tags: ['Parent', '2024'] }) + .build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset); mockReadTags({ HierarchicalSubject: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); - expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: '2024', parent: undefined }); }); it('should extract ignore / characters in a HierarchicalSubject tag', async () => { @@ -1646,31 +1661,23 @@ describe(MetadataService.name, () => { describe('handleQueueSidecar', () => { it('should queue assets with sidecar files', async () => { - mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([asset])); await sut.handleQueueSidecar({ force: true }); - expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(true); - expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { - name: JobName.SidecarCheck, - data: { id: assetStub.sidecar.id }, - }, - ]); + expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(true); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarCheck, data: { id: asset.id } }]); }); it('should queue assets without sidecar files', async () => { - mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([asset])); await sut.handleQueueSidecar({ force: false }); expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(false); - expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { - name: JobName.SidecarCheck, - data: { id: assetStub.image.id }, - }, - ]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarCheck, data: { id: asset.id } }]); }); }); diff --git a/server/src/services/stack.service.spec.ts b/server/src/services/stack.service.spec.ts index 5517cf17f8..1dc87f4348 100644 --- a/server/src/services/stack.service.spec.ts +++ b/server/src/services/stack.service.spec.ts @@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common'; import { StackService } from 'src/services/stack.service'; import { assetStub, stackStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; +import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(StackService.name, () => { @@ -204,9 +205,9 @@ describe(StackService.name, () => { mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); mocks.stack.getForAssetRemoval.mockResolvedValue({ id: null, primaryAssetId: null }); - await expect( - sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: assetStub.imageFrom2015.id }), - ).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: newUuid() })).rejects.toBeInstanceOf( + BadRequestException, + ); expect(mocks.asset.update).not.toHaveBeenCalled(); expect(mocks.event.emit).not.toHaveBeenCalled(); diff --git a/server/test/factories/album-user.factory.ts b/server/test/factories/album-user.factory.ts new file mode 100644 index 0000000000..6e2f8cb832 --- /dev/null +++ b/server/test/factories/album-user.factory.ts @@ -0,0 +1,54 @@ +import { Selectable } from 'kysely'; +import { AlbumUserRole } from 'src/enum'; +import { AlbumUserTable } from 'src/schema/tables/album-user.table'; +import { AlbumFactory } from 'test/factories/album.factory'; +import { build } from 'test/factories/builder.factory'; +import { AlbumUserLike, FactoryBuilder, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class AlbumUserFactory { + #user!: UserFactory; + + private constructor(private readonly value: Selectable) { + value.userId ??= newUuid(); + this.#user = UserFactory.from({ id: value.userId }); + } + + static create(dto: AlbumUserLike = {}) { + return AlbumUserFactory.from(dto).build(); + } + + static from(dto: AlbumUserLike = {}) { + return new AlbumUserFactory({ + albumId: newUuid(), + userId: newUuid(), + role: AlbumUserRole.Editor, + createId: newUuidV7(), + createdAt: newDate(), + updateId: newUuidV7(), + updatedAt: newDate(), + ...dto, + }); + } + + album(dto: AlbumUserLike = {}, builder?: FactoryBuilder) { + const album = build(AlbumFactory.from(dto), builder); + this.value.albumId = album.build().id; + return this; + } + + user(dto: UserLike = {}, builder?: FactoryBuilder) { + const user = build(UserFactory.from(dto), builder); + this.value.userId = user.build().id; + this.#user = user; + return this; + } + + build() { + return { + ...this.value, + user: this.#user.build(), + }; + } +} diff --git a/server/test/factories/album.factory.ts b/server/test/factories/album.factory.ts new file mode 100644 index 0000000000..f401cd343d --- /dev/null +++ b/server/test/factories/album.factory.ts @@ -0,0 +1,87 @@ +import { Selectable } from 'kysely'; +import { AssetOrder } from 'src/enum'; +import { AlbumTable } from 'src/schema/tables/album.table'; +import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; +import { AlbumUserFactory } from 'test/factories/album-user.factory'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { build } from 'test/factories/builder.factory'; +import { AlbumLike, AlbumUserLike, AssetLike, FactoryBuilder, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class AlbumFactory { + #owner: UserFactory; + #sharedLinks: Selectable[] = []; + #albumUsers: AlbumUserFactory[] = []; + #assets: AssetFactory[] = []; + + private constructor(private readonly value: Selectable) { + value.ownerId ??= newUuid(); + this.#owner = UserFactory.from({ id: value.ownerId }); + } + + static create(dto: AlbumLike = {}) { + return AlbumFactory.from(dto).build(); + } + + static from(dto: AlbumLike = {}) { + return new AlbumFactory({ + id: newUuid(), + ownerId: newUuid(), + albumName: 'My Album', + albumThumbnailAssetId: null, + createdAt: newDate(), + deletedAt: null, + description: 'Album description', + isActivityEnabled: false, + order: AssetOrder.Desc, + updatedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }).owner(); + } + + owner(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#owner = build(UserFactory.from(dto), builder); + this.value.ownerId = this.#owner.build().id; + return this; + } + + sharedLinks() { + this.#sharedLinks = []; + return this; + } + + albumUser(dto: AlbumUserLike = {}, builder?: FactoryBuilder) { + const albumUser = build(AlbumUserFactory.from(dto).album(this.value), builder); + this.#albumUsers.push(albumUser); + return this; + } + + asset(dto: AssetLike = {}, builder?: FactoryBuilder) { + const asset = build(AssetFactory.from(dto), builder); + + // use album owner by default + if (!dto.ownerId) { + asset.owner(this.#owner.build()); + } + + if (!this.#assets) { + this.#assets = []; + } + + this.#assets.push(asset); + + return this; + } + + build() { + return { + ...this.value, + owner: this.#owner.build(), + assets: this.#assets.map((asset) => asset.build()), + albumUsers: this.#albumUsers.map((albumUser) => albumUser.build()), + sharedLinks: this.#sharedLinks, + }; + } +} diff --git a/server/test/factories/asset-edit.factory.ts b/server/test/factories/asset-edit.factory.ts new file mode 100644 index 0000000000..e16b0c2e4b --- /dev/null +++ b/server/test/factories/asset-edit.factory.ts @@ -0,0 +1,38 @@ +import { Selectable } from 'kysely'; +import { AssetEditAction } from 'src/dtos/editing.dto'; +import { AssetEditTable } from 'src/schema/tables/asset-edit.table'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { build } from 'test/factories/builder.factory'; +import { AssetEditLike, AssetLike, FactoryBuilder } from 'test/factories/types'; +import { newUuid } from 'test/small.factory'; + +export class AssetEditFactory { + private constructor(private readonly value: Selectable) {} + + static create(dto: AssetEditLike = {}) { + return AssetEditFactory.from(dto).build(); + } + + static from(dto: AssetEditLike = {}) { + const id = dto.id ?? newUuid(); + + return new AssetEditFactory({ + id, + assetId: newUuid(), + action: AssetEditAction.Crop, + parameters: { x: 5, y: 6, width: 200, height: 100 }, + sequence: 1, + ...dto, + }); + } + + asset(dto: AssetLike = {}, builder?: FactoryBuilder) { + const asset = build(AssetFactory.from(dto), builder); + this.value.assetId = asset.build().id; + return this; + } + + build() { + return { ...this.value } as Selectable>; + } +} diff --git a/server/test/factories/asset-exif.factory.ts b/server/test/factories/asset-exif.factory.ts new file mode 100644 index 0000000000..da4d689ebf --- /dev/null +++ b/server/test/factories/asset-exif.factory.ts @@ -0,0 +1,55 @@ +import { Selectable } from 'kysely'; +import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; +import { AssetExifLike } from 'test/factories/types'; +import { factory } from 'test/small.factory'; + +export class AssetExifFactory { + private constructor(private readonly value: Selectable) {} + + static create(dto: AssetExifLike = {}) { + return AssetExifFactory.from(dto).build(); + } + + static from(dto: AssetExifLike = {}) { + return new AssetExifFactory({ + updatedAt: factory.date(), + updateId: factory.uuid(), + assetId: factory.uuid(), + autoStackId: null, + bitsPerSample: null, + city: 'Austin', + colorspace: null, + country: 'United States of America', + dateTimeOriginal: factory.date(), + description: '', + exifImageHeight: 420, + exifImageWidth: 42, + exposureTime: null, + fileSizeInByte: 69, + fNumber: 1.7, + focalLength: 4.38, + fps: null, + iso: 947, + latitude: 30.267_334_570_570_195, + longitude: -97.789_833_534_282_07, + lensModel: null, + livePhotoCID: null, + make: 'Google', + model: 'Pixel 7', + modifyDate: factory.date(), + orientation: '1', + profileDescription: null, + projectionType: null, + rating: 4, + lockedProperties: [], + state: 'Texas', + tags: ['parent/child'], + timeZone: 'UTC-6', + ...dto, + }); + } + + build() { + return { ...this.value }; + } +} diff --git a/server/test/factories/asset-file.factory.ts b/server/test/factories/asset-file.factory.ts new file mode 100644 index 0000000000..109cd5adc4 --- /dev/null +++ b/server/test/factories/asset-file.factory.ts @@ -0,0 +1,43 @@ +import { Selectable } from 'kysely'; +import { AssetFileType } from 'src/enum'; +import { AssetFileTable } from 'src/schema/tables/asset-file.table'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { build } from 'test/factories/builder.factory'; +import { AssetFileLike, AssetLike, FactoryBuilder } from 'test/factories/types'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class AssetFileFactory { + private constructor(private readonly value: Selectable) {} + + static create(dto: AssetFileLike = {}) { + return AssetFileFactory.from(dto).build(); + } + + static from(dto: AssetFileLike = {}) { + const id = dto.id ?? newUuid(); + const isEdited = dto.isEdited ?? false; + + return new AssetFileFactory({ + id, + assetId: newUuid(), + createdAt: newDate(), + updatedAt: newDate(), + type: AssetFileType.Thumbnail, + path: `/data/12/34/thumbs/${id.slice(0, 2)}/${id.slice(2, 4)}/${id}${isEdited ? '_edited' : ''}.jpg`, + updateId: newUuidV7(), + isProgressive: false, + isEdited, + ...dto, + }); + } + + asset(dto: AssetLike = {}, builder?: FactoryBuilder) { + const asset = build(AssetFactory.from(dto), builder); + this.value.assetId = asset.build().id; + return this; + } + + build() { + return { ...this.value }; + } +} diff --git a/server/test/factories/asset.factory.ts b/server/test/factories/asset.factory.ts new file mode 100644 index 0000000000..8cbf704abf --- /dev/null +++ b/server/test/factories/asset.factory.ts @@ -0,0 +1,126 @@ +import { Selectable } from 'kysely'; +import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { AssetEditFactory } from 'test/factories/asset-edit.factory'; +import { AssetExifFactory } from 'test/factories/asset-exif.factory'; +import { AssetFileFactory } from 'test/factories/asset-file.factory'; +import { build } from 'test/factories/builder.factory'; +import { AssetEditLike, AssetExifLike, AssetFileLike, AssetLike, FactoryBuilder, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { newDate, newSha1, newUuid, newUuidV7 } from 'test/small.factory'; + +export class AssetFactory { + #owner!: UserFactory; + #assetExif?: AssetExifFactory; + #files: AssetFileFactory[] = []; + #edits: AssetEditFactory[] = []; + + private constructor(private readonly value: Selectable) { + value.ownerId ??= newUuid(); + this.#owner = UserFactory.from({ id: value.ownerId }); + } + + static create(dto: AssetLike = {}) { + return AssetFactory.from(dto).build(); + } + + static from(dto: AssetLike = {}) { + const id = dto.id ?? newUuid(); + + const originalFileName = dto.originalFileName ?? `IMG_${id}.jpg`; + + return new AssetFactory({ + id, + createdAt: newDate(), + updatedAt: newDate(), + deletedAt: null, + updateId: newUuidV7(), + status: AssetStatus.Active, + checksum: newSha1(), + deviceAssetId: '', + deviceId: '', + duplicateId: null, + duration: null, + encodedVideoPath: null, + fileCreatedAt: newDate(), + fileModifiedAt: newDate(), + isExternal: false, + isFavorite: false, + isOffline: false, + libraryId: null, + livePhotoVideoId: null, + localDateTime: newDate(), + originalFileName, + originalPath: `/data/library/${originalFileName}`, + ownerId: newUuid(), + stackId: null, + thumbhash: null, + type: AssetType.Image, + visibility: AssetVisibility.Timeline, + width: null, + height: null, + isEdited: false, + ...dto, + }); + } + + owner(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#owner = build(UserFactory.from(dto), builder); + this.value.ownerId = this.#owner.build().id; + return this; + } + + exif(dto: AssetExifLike = {}, builder?: FactoryBuilder) { + this.#assetExif = build(AssetExifFactory.from(dto), builder); + return this; + } + + edit(dto: AssetEditLike = {}, builder?: FactoryBuilder) { + this.#edits.push(build(AssetEditFactory.from(dto).asset(this.value), builder)); + this.value.isEdited = true; + return this; + } + + file(dto: AssetFileLike = {}, builder?: FactoryBuilder) { + this.#files.push(build(AssetFileFactory.from(dto).asset(this.value), builder)); + return this; + } + + files(dto?: 'edits'): AssetFactory; + files(items: AssetFileLike[], builder?: FactoryBuilder): AssetFactory; + files(items: AssetFileType[], builder?: FactoryBuilder): AssetFactory; + files(dto?: 'edits' | AssetFileLike[] | AssetFileType[], builder?: FactoryBuilder): AssetFactory { + const items: AssetFileLike[] = []; + + if (dto === undefined || dto === 'edits') { + items.push(...Object.values(AssetFileType).map((type) => ({ type }))); + + if (dto === 'edits') { + items.push(...Object.values(AssetFileType).map((type) => ({ type, isEdited: true }))); + } + } else { + for (const item of dto) { + items.push(typeof item === 'string' ? { type: item as AssetFileType } : item); + } + } + for (const item of items) { + this.file(item, builder); + } + + return this; + } + + build() { + const exif = this.#assetExif?.build(); + + return { + ...this.value, + owner: this.#owner.build(), + exifInfo: exif as NonNullable, + files: this.#files.map((file) => file.build()), + edits: this.#edits.map((edit) => edit.build()), + faces: [] as Selectable[], + }; + } +} diff --git a/server/test/factories/auth.factory.ts b/server/test/factories/auth.factory.ts new file mode 100644 index 0000000000..9c738aabac --- /dev/null +++ b/server/test/factories/auth.factory.ts @@ -0,0 +1,48 @@ +import { AuthDto } from 'src/dtos/auth.dto'; +import { build } from 'test/factories/builder.factory'; +import { SharedLinkFactory } from 'test/factories/shared-link.factory'; +import { FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; + +export class AuthFactory { + #user: UserFactory; + #sharedLink?: SharedLinkFactory; + + private constructor(user: UserFactory) { + this.#user = user; + } + + static create(dto: UserLike = {}) { + return AuthFactory.from(dto).build(); + } + + static from(dto: UserLike = {}) { + return new AuthFactory(UserFactory.from(dto)); + } + + apiKey() { + // TODO + return this; + } + + sharedLink(dto: SharedLinkLike = {}, builder?: FactoryBuilder) { + this.#sharedLink = build(SharedLinkFactory.from(dto), builder); + return this; + } + + build(): AuthDto { + const { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes } = this.#user.build(); + + return { + user: { + id, + isAdmin, + name, + email, + quotaUsageInBytes, + quotaSizeInBytes, + }, + sharedLink: this.#sharedLink?.build(), + }; + } +} diff --git a/server/test/factories/builder.factory.ts b/server/test/factories/builder.factory.ts new file mode 100644 index 0000000000..4efa7a498f --- /dev/null +++ b/server/test/factories/builder.factory.ts @@ -0,0 +1,5 @@ +import { FactoryBuilder } from 'test/factories/types'; + +export const build = (factory: T, builder?: FactoryBuilder) => { + return builder ? builder(factory) : factory; +}; diff --git a/server/test/factories/shared-link.factory.ts b/server/test/factories/shared-link.factory.ts new file mode 100644 index 0000000000..585b43dd84 --- /dev/null +++ b/server/test/factories/shared-link.factory.ts @@ -0,0 +1,63 @@ +import { Selectable } from 'kysely'; +import { SharedLinkType } from 'src/enum'; +import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; +import { AlbumFactory } from 'test/factories/album.factory'; +import { build } from 'test/factories/builder.factory'; +import { AlbumLike, FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { factory, newDate, newUuid } from 'test/small.factory'; + +export class SharedLinkFactory { + #owner: UserFactory; + #album?: AlbumFactory; + + private constructor(private readonly value: Selectable) { + value.userId ??= newUuid(); + this.#owner = UserFactory.from({ id: value.userId }); + } + + static create(dto: SharedLinkLike = {}) { + return SharedLinkFactory.from(dto).build(); + } + + static from(dto: SharedLinkLike = {}) { + const type = dto.type ?? SharedLinkType.Individual; + const albumId = (dto.albumId ?? type === SharedLinkType.Album) ? newUuid() : null; + + return new SharedLinkFactory({ + id: factory.uuid(), + description: 'Shared link description', + userId: newUuid(), + key: factory.buffer(), + type, + albumId, + createdAt: newDate(), + expiresAt: null, + allowUpload: true, + allowDownload: true, + showExif: true, + password: null, + slug: null, + ...dto, + }); + } + + owner(dto: UserLike = {}, builder?: FactoryBuilder): SharedLinkFactory { + this.#owner = build(UserFactory.from(dto), builder); + return this; + } + + album(dto: AlbumLike = {}, builder?: FactoryBuilder) { + this.#album = build(AlbumFactory.from(dto), builder); + return this; + } + + build() { + return { + ...this.value, + owner: this.#owner.build(), + album: this.#album?.build(), + assets: [], + }; + } +} diff --git a/server/test/factories/types.ts b/server/test/factories/types.ts new file mode 100644 index 0000000000..534e290f59 --- /dev/null +++ b/server/test/factories/types.ts @@ -0,0 +1,20 @@ +import { Selectable } from 'kysely'; +import { AlbumUserTable } from 'src/schema/tables/album-user.table'; +import { AlbumTable } from 'src/schema/tables/album.table'; +import { AssetEditTable } from 'src/schema/tables/asset-edit.table'; +import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; +import { AssetFileTable } from 'src/schema/tables/asset-file.table'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; +import { UserTable } from 'src/schema/tables/user.table'; + +export type FactoryBuilder = (builder: T) => R; + +export type AssetLike = Partial>; +export type AssetExifLike = Partial>; +export type AssetEditLike = Partial>; +export type AssetFileLike = Partial>; +export type AlbumLike = Partial>; +export type AlbumUserLike = Partial>; +export type SharedLinkLike = Partial>; +export type UserLike = Partial>; diff --git a/server/test/factories/user.factory.ts b/server/test/factories/user.factory.ts new file mode 100644 index 0000000000..e6e84d94a1 --- /dev/null +++ b/server/test/factories/user.factory.ts @@ -0,0 +1,46 @@ +import { Selectable } from 'kysely'; +import { UserStatus } from 'src/enum'; +import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; +import { UserTable } from 'src/schema/tables/user.table'; +import { UserLike } from 'test/factories/types'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class UserFactory { + private constructor(private value: Selectable) {} + + static create(dto: UserLike = {}) { + return UserFactory.from(dto).build(); + } + + static from(dto: UserLike = {}) { + return new UserFactory({ + id: newUuid(), + email: 'test@immich.cloud', + password: '', + pinCode: null, + createdAt: newDate(), + profileImagePath: '', + isAdmin: false, + shouldChangePassword: false, + avatarColor: null, + deletedAt: null, + oauthId: '', + updatedAt: newDate(), + storageLabel: null, + name: 'Test User', + quotaSizeInBytes: null, + quotaUsageInBytes: 0, + status: UserStatus.Active, + profileChangedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }); + } + + build() { + return { + ...this.value, + metadata: [] as UserMetadataTable[], + }; + } +} diff --git a/server/test/fixtures/album.stub.ts b/server/test/fixtures/album.stub.ts index d36989bbcf..9480fdd5ab 100644 --- a/server/test/fixtures/album.stub.ts +++ b/server/test/fixtures/album.stub.ts @@ -45,56 +45,6 @@ export const albumStub = { order: AssetOrder.Desc, updateId: '42', }), - sharedWithMultiple: Object.freeze({ - id: 'album-3', - albumName: 'Empty album shared with users', - description: '', - ownerId: authStub.admin.user.id, - owner: userStub.admin, - assets: [], - albumThumbnailAsset: null, - albumThumbnailAssetId: null, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - sharedLinks: [], - albumUsers: [ - { - user: userStub.user1, - role: AlbumUserRole.Editor, - }, - { - user: userStub.user2, - role: AlbumUserRole.Editor, - }, - ], - isActivityEnabled: true, - order: AssetOrder.Desc, - updateId: '42', - }), - sharedWithAdmin: Object.freeze({ - id: 'album-3', - albumName: 'Empty album shared with admin', - description: '', - ownerId: authStub.user1.user.id, - owner: userStub.user1, - assets: [], - albumThumbnailAsset: null, - albumThumbnailAssetId: null, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - sharedLinks: [], - albumUsers: [ - { - user: userStub.admin, - role: AlbumUserRole.Editor, - }, - ], - isActivityEnabled: true, - order: AssetOrder.Desc, - updateId: '42', - }), oneAsset: Object.freeze({ id: 'album-4', albumName: 'Album with one asset', @@ -113,24 +63,6 @@ export const albumStub = { order: AssetOrder.Desc, updateId: '42', }), - twoAssets: Object.freeze({ - id: 'album-4a', - albumName: 'Album with two assets', - description: '', - ownerId: authStub.admin.user.id, - owner: userStub.admin, - assets: [assetStub.image, assetStub.withLocation], - albumThumbnailAsset: assetStub.image, - albumThumbnailAssetId: assetStub.image.id, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - sharedLinks: [], - albumUsers: [], - isActivityEnabled: true, - order: AssetOrder.Desc, - updateId: '42', - }), emptyWithValidThumbnail: Object.freeze({ id: 'album-5', albumName: 'Empty album with valid thumbnail', diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 05219c92e7..3c89056f37 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -20,45 +20,8 @@ const fullsizeFile = factory.assetFile({ path: '/uploads/user-id/fullsize/path.webp', }); -const sidecarFileWithExt = factory.assetFile({ - type: AssetFileType.Sidecar, - path: '/original/path.ext.xmp', -}); - -const sidecarFileWithoutExt = factory.assetFile({ - type: AssetFileType.Sidecar, - path: '/original/path.xmp', -}); - -const editedPreviewFile = factory.assetFile({ - type: AssetFileType.Preview, - path: '/uploads/user-id/preview/path_edited.jpg', - isEdited: true, -}); - -const editedThumbnailFile = factory.assetFile({ - type: AssetFileType.Thumbnail, - path: '/uploads/user-id/thumbnail/path_edited.jpg', - isEdited: true, -}); - -const editedFullsizeFile = factory.assetFile({ - type: AssetFileType.FullSize, - path: '/uploads/user-id/fullsize/path_edited.jpg', - isEdited: true, -}); - const files = [fullsizeFile, previewFile, thumbnailFile]; -const editedFiles = [ - fullsizeFile, - previewFile, - thumbnailFile, - editedFullsizeFile, - editedPreviewFile, - editedThumbnailFile, -]; - export const stackStub = (stackId: string, assets: (MapAsset & { exifInfo: Exif })[]) => { return { id: stackId, @@ -132,87 +95,6 @@ export const assetStub = { isEdited: false, }), - noWebpPath: Object.freeze({ - id: 'asset-id', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/data/library/IMG_456.jpg', - files: [previewFile], - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - originalFileName: 'IMG_456.jpg', - faces: [], - isExternal: false, - exifInfo: { - fileSizeInByte: 123_000, - } as Exif, - deletedAt: null, - duplicateId: null, - isOffline: false, - libraryId: null, - stackId: null, - updateId: '42', - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - - noThumbhash: Object.freeze({ - id: 'asset-id', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.ext', - files, - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - thumbhash: null, - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - duration: null, - isExternal: false, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - originalFileName: 'asset-id.ext', - faces: [], - deletedAt: null, - duplicateId: null, - isOffline: false, - libraryId: null, - stackId: null, - updateId: '42', - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - primaryImage: Object.freeze({ id: 'primary-asset-id', status: AssetStatus.Active, @@ -526,48 +408,6 @@ export const assetStub = { isEdited: false, }), - imageFrom2015: Object.freeze({ - id: 'asset-id-2015', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2015-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2015-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.ext', - files, - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2015-02-23T05:06:29.716Z'), - updatedAt: new Date('2015-02-23T05:06:29.716Z'), - localDateTime: new Date('2015-02-23T05:06:29.716Z'), - isFavorite: true, - isExternal: false, - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - updateId: 'foo', - libraryId: null, - stackId: null, - sharedLinks: [], - originalFileName: 'asset-id.ext', - faces: [], - exifInfo: { - fileSizeInByte: 5000, - } as Exif, - deletedAt: null, - duplicateId: null, - isOffline: false, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - video: Object.freeze({ id: 'asset-id', status: AssetStatus.Active, @@ -736,81 +576,6 @@ export const assetStub = { isEdited: false, }), - sidecar: Object.freeze({ - id: 'asset-id', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.ext', - thumbhash: null, - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - files: [previewFile, sidecarFileWithExt], - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isExternal: false, - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - originalFileName: 'asset-id.ext', - faces: [], - deletedAt: null, - duplicateId: null, - isOffline: false, - updateId: 'foo', - libraryId: null, - stackId: null, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - - sidecarWithoutExt: Object.freeze({ - id: 'asset-id', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.ext', - thumbhash: null, - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - files: [previewFile, sidecarFileWithoutExt], - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isExternal: false, - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - originalFileName: 'asset-id.ext', - faces: [], - deletedAt: null, - duplicateId: null, - isOffline: false, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - hasEncodedVideo: Object.freeze({ id: 'asset-id', status: AssetStatus.Active, @@ -854,46 +619,6 @@ export const assetStub = { isEdited: false, }), - hasFileExtension: Object.freeze({ - id: 'asset-id', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/data/user1/photo.jpg', - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - files, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isExternal: true, - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - libraryId: 'library-id', - sharedLinks: [], - originalFileName: 'photo.jpg', - faces: [], - deletedAt: null, - exifInfo: { - fileSizeInByte: 5000, - } as Exif, - duplicateId: null, - isOffline: false, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - imageDng: Object.freeze({ id: 'asset-id', status: AssetStatus.Active, @@ -938,93 +663,6 @@ export const assetStub = { isEdited: false, }), - imageHif: Object.freeze({ - id: 'asset-id', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.hif', - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - files, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - duration: null, - isExternal: false, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - originalFileName: 'asset-id.hif', - faces: [], - deletedAt: null, - exifInfo: { - fileSizeInByte: 5000, - profileDescription: 'Adobe RGB', - bitsPerSample: 14, - } as Exif, - duplicateId: null, - isOffline: false, - updateId: '42', - libraryId: null, - stackId: null, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - - panoramaTif: Object.freeze({ - id: 'asset-id', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.tif', - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - files, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - duration: null, - isExternal: false, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - originalFileName: 'asset-id.tif', - faces: [], - deletedAt: null, - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - projectionType: 'EQUIRECTANGULAR', - } as Exif, - duplicateId: null, - isOffline: false, - updateId: '42', - libraryId: null, - stackId: null, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - }), - withCropEdit: Object.freeze({ id: 'asset-id', status: AssetStatus.Active, @@ -1082,52 +720,4 @@ export const assetStub = { ] as AssetEditActionItem[], isEdited: true, }), - - withoutEdits: Object.freeze({ - id: 'asset-id', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.jpg', - files: editedFiles, - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2025-01-01T01:02:03.456Z'), - isFavorite: true, - duration: null, - isExternal: false, - livePhotoVideo: null, - livePhotoVideoId: null, - updateId: 'foo', - libraryId: null, - stackId: null, - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - deletedAt: null, - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - exifImageHeight: 3840, - exifImageWidth: 2160, - } as Exif, - duplicateId: null, - isOffline: false, - stack: null, - orientation: '', - projectionType: null, - height: 3840, - width: 2160, - visibility: AssetVisibility.Timeline, - edits: [], - isEdited: false, - }), }; diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index 807da5197f..21b49ab899 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -38,21 +38,4 @@ export const userStub = { quotaSizeInBytes: null, quotaUsageInBytes: 0, }, - user2: { - ...authStub.user2.user, - status: UserStatus.Active, - profileChangedAt: new Date('2021-01-01'), - metadata: [], - name: 'immich_name', - storageLabel: null, - oauthId: '', - shouldChangePassword: false, - avatarColor: null, - profileImagePath: '', - createdAt: new Date('2021-01-01'), - deletedAt: null, - updatedAt: new Date('2021-01-01'), - quotaSizeInBytes: null, - quotaUsageInBytes: 0, - }, }; diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 579da1a2d8..4169c6e9bd 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -1,5 +1,6 @@ import { Activity, + Album, ApiKey, AssetFace, AssetFile, @@ -23,6 +24,7 @@ import { AssetEditAction, AssetEditActionItem, MirrorAxis } from 'src/dtos/editi import { QueueStatisticsDto } from 'src/dtos/queue.dto'; import { AssetFileType, + AssetOrder, AssetStatus, AssetType, AssetVisibility, @@ -506,6 +508,24 @@ const personFactory = (person?: Partial): Person => ({ ...person, }); +const albumFactory = (album?: Partial>) => ({ + albumName: 'My Album', + albumThumbnailAssetId: null, + albumUsers: [], + assets: [], + createdAt: newDate(), + deletedAt: null, + description: 'Album description', + id: newUuid(), + isActivityEnabled: false, + order: AssetOrder.Desc, + ownerId: newUuid(), + sharedLinks: [], + updatedAt: newDate(), + updateId: newUuidV7(), + ...album, +}); + export const factory = { activity: activityFactory, apiKey: apiKeyFactory, @@ -532,6 +552,7 @@ export const factory = { person: personFactory, assetEdit: assetEditFactory, tag: tagFactory, + album: albumFactory, uuid: newUuid, buffer: () => Buffer.from('this is a fake buffer'), date: newDate, diff --git a/web/package.json b/web/package.json index 971becec09..7af0474ba1 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "@formatjs/icu-messageformat-parser": "^3.0.0", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.61.3", + "@immich/ui": "^0.61.4", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.14.0", diff --git a/web/src/lib/components/QueueGraph.svelte b/web/src/lib/components/QueueGraph.svelte index 7dc553c29a..f2a23216df 100644 --- a/web/src/lib/components/QueueGraph.svelte +++ b/web/src/lib/components/QueueGraph.svelte @@ -75,6 +75,7 @@ show: false, }, width: 2, + pxAlign: 0, }; const options: uPlot.Options = { @@ -91,7 +92,7 @@ width: 200, height: 200, ms: 1, - pxAlign: true, + pxAlign: 0, scales: { y: { distr: 1, diff --git a/web/src/lib/components/faces-page/manage-people-visibility.svelte b/web/src/lib/components/faces-page/manage-people-visibility.svelte index 77696b5983..619a908507 100644 --- a/web/src/lib/components/faces-page/manage-people-visibility.svelte +++ b/web/src/lib/components/faces-page/manage-people-visibility.svelte @@ -105,72 +105,74 @@ -
-
- -
-

{$t('show_and_hide_people')}

-

({totalPeopleCount.toLocaleString($locale)})

-
-
-
-
+
+
+
overrides.clear()} - /> - +
+

{$t('show_and_hide_people')}

+

({totalPeopleCount.toLocaleString($locale)})

+
- -
-
- -
- - {#snippet children({ person })} - {@const hidden = overrides.get(person.id) ?? person.isHidden} - - {/snippet} - + +
+ +
+
+ +
+ + {#snippet children({ person })} + {@const hidden = overrides.get(person.id) ?? person.isHidden} + + {/snippet} + +
diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte index 09334ff71e..6bcede36c3 100644 --- a/web/src/lib/components/faces-page/person-side-panel.svelte +++ b/web/src/lib/components/faces-page/person-side-panel.svelte @@ -1,6 +1,7 @@
{ - isTransitioned = true; - }} - onoutrostart={() => { - isTransitioned = false; - }} >