Compare commits

..

1 Commits

Author SHA1 Message Date
Jonathan Jogenfors 00ec2bd525 feat: add job ids 2025-02-08 00:44:47 +01:00
138 changed files with 4796 additions and 5741 deletions
+1 -1
View File
@@ -88,7 +88,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' }} type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push image - name: Build and push image
uses: docker/build-push-action@v6.13.0 uses: docker/build-push-action@v6.12.0
with: with:
file: cli/Dockerfile file: cli/Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
+2 -2
View File
@@ -174,7 +174,7 @@ jobs:
fi fi
- name: Build and push image - name: Build and push image
uses: docker/build-push-action@v6.13.0 uses: docker/build-push-action@v6.12.0
with: with:
context: ${{ env.context }} context: ${{ env.context }}
file: ${{ env.file }} file: ${{ env.file }}
@@ -265,7 +265,7 @@ jobs:
fi fi
- name: Build and push image - name: Build and push image
uses: docker/build-push-action@v6.13.0 uses: docker/build-push-action@v6.12.0
with: with:
context: ${{ env.context }} context: ${{ env.context }}
file: ${{ env.file }} file: ${{ env.file }}
+8 -8
View File
@@ -1,12 +1,12 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.50", "version": "2.2.48",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.50", "version": "2.2.48",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
@@ -24,7 +24,7 @@
"@types/cli-progress": "^3.11.0", "@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.13.1", "@types/node": "^22.10.9",
"@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0", "@typescript-eslint/parser": "^8.15.0",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
@@ -52,14 +52,14 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.126.1", "version": "1.125.7",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.13.1", "@types/node": "^22.10.9",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },
@@ -1482,9 +1482,9 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.13.1", "version": "22.10.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.9.tgz",
"integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", "integrity": "sha512-Ir6hwgsKyNESl/gLOcEz3krR4CBGgliDqBQ2ma4wIhEx0w+xnoeTq3tdrNw15kU3SxogDjOgv9sqdtLW8mIHaw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.50", "version": "2.2.48",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",
@@ -20,7 +20,7 @@
"@types/cli-progress": "^3.11.0", "@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.13.1", "@types/node": "^22.10.9",
"@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0", "@typescript-eslint/parser": "^8.15.0",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
+1 -1
View File
@@ -103,7 +103,7 @@ services:
command: ['./run.sh', '-disable-reporting'] command: ['./run.sh', '-disable-reporting']
ports: ports:
- 3000:3000 - 3000:3000
image: grafana/grafana:11.5.1-ubuntu@sha256:9a4ab78cec1a2ec7d1ca5dfd5aacec6412706a1bc9e971fc7184e2f6696a63f5 image: grafana/grafana:11.5.0-ubuntu@sha256:3c9e2b202eb933a22da5f2b5a22c98a665493f603b452263d9d6f242a87f60d7
volumes: volumes:
- grafana-data:/var/lib/grafana - grafana-data:/var/lib/grafana
+1 -1
View File
@@ -49,7 +49,7 @@ export default function VersionSwitcher(): JSX.Element {
mobile={windowSize === 'mobile'} mobile={windowSize === 'mobile'}
items={versions.map(({ label, url }) => ({ items={versions.map(({ label, url }) => ({
label, label,
to: url + location.pathname + location.hash, to: url + location.pathname,
target: '_self', target: '_self',
}))} }))}
/> />
+1 -1
View File
@@ -53,7 +53,7 @@ function HomepageHeader() {
<Link <Link
className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary/10 dark:bg-gray-300 rounded-xl hover:no-underline text-immich-primary dark:text-immich-dark-bg font-bold uppercase" className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary/10 dark:bg-gray-300 rounded-xl hover:no-underline text-immich-primary dark:text-immich-dark-bg font-bold uppercase"
to="https://immich.store" to="https://demo.immich.app/"
> >
Buy Merch Buy Merch
</Link> </Link>
-8
View File
@@ -1,12 +1,4 @@
[ [
{
"label": "v1.126.1",
"url": "https://v1.126.1.archive.immich.app"
},
{
"label": "v1.126.0",
"url": "https://v1.126.0.archive.immich.app"
},
{ {
"label": "v1.125.7", "label": "v1.125.7",
"url": "https://v1.125.7.archive.immich.app" "url": "https://v1.125.7.archive.immich.app"
+10 -10
View File
@@ -1,12 +1,12 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.126.1", "version": "1.125.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.126.1", "version": "1.125.7",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",
@@ -15,7 +15,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^22.13.1", "@types/node": "^22.10.9",
"@types/oidc-provider": "^8.5.1", "@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0", "@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
@@ -45,7 +45,7 @@
}, },
"../cli": { "../cli": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.50", "version": "2.2.48",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
@@ -64,7 +64,7 @@
"@types/cli-progress": "^3.11.0", "@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.13.1", "@types/node": "^22.10.9",
"@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0", "@typescript-eslint/parser": "^8.15.0",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
@@ -92,14 +92,14 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.126.1", "version": "1.125.7",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.13.1", "@types/node": "^22.10.9",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },
@@ -1666,9 +1666,9 @@
"dev": true "dev": true
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.13.1", "version": "22.10.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.9.tgz",
"integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", "integrity": "sha512-Ir6hwgsKyNESl/gLOcEz3krR4CBGgliDqBQ2ma4wIhEx0w+xnoeTq3tdrNw15kU3SxogDjOgv9sqdtLW8mIHaw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.126.1", "version": "1.125.7",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -25,7 +25,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^22.13.1", "@types/node": "^22.10.9",
"@types/oidc-provider": "^8.5.1", "@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0", "@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
-24
View File
@@ -150,30 +150,6 @@ describe('/shared-links', () => {
); );
}); });
it('should filter on albumId', async () => {
const { status, body } = await request(app)
.get(`/shared-links?albumId=${album.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(2);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: linkWithAlbum.id }),
expect.objectContaining({ id: linkWithPassword.id }),
]),
);
});
it('should find 0 albums', async () => {
const { status, body } = await request(app)
.get(`/shared-links?albumId=${uuidDto.notFound}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(0);
});
it('should not get shared links created by other users', async () => { it('should not get shared links created by other users', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/shared-links') .get('/shared-links')
+437 -437
View File
@@ -312,157 +312,157 @@
"admin_password": "رمز عبور مدیر", "admin_password": "رمز عبور مدیر",
"administration": "مدیریت", "administration": "مدیریت",
"advanced": "پیشرفته", "advanced": "پیشرفته",
"album_added": "آلبوم اضافه شد", "album_added": "",
"album_added_notification_setting_description": "", "album_added_notification_setting_description": "",
"album_cover_updated": "جلد آلبوم به‌روزرسانی شد", "album_cover_updated": "",
"album_info_updated": "اطلاعات آلبوم به‌روزرسانی شد", "album_info_updated": "",
"album_name": "نام آلبوم", "album_name": "",
"album_options": "گزینه‌های آلبوم", "album_options": "",
"album_updated": "آلبوم به‌روزرسانی شد", "album_updated": "",
"album_updated_setting_description": "", "album_updated_setting_description": "",
"albums": "آلبوم‌ها", "albums": "",
"albums_count": "", "albums_count": "",
"all": "همه", "all": "",
"all_people": "همه افراد", "all_people": "",
"allow_dark_mode": "اجازه دادن به حالت تاریک", "allow_dark_mode": "",
"allow_edits": "اجازه ویرایش", "allow_edits": "",
"api_key": "کلید API", "api_key": "",
"api_keys": "کلیدهای API", "api_keys": "",
"app_settings": "تنظیمات برنامه", "app_settings": "",
"appears_in": "ظاهر می‌شود در", "appears_in": "",
"archive": "بایگانی", "archive": "",
"archive_or_unarchive_photo": "", "archive_or_unarchive_photo": "",
"archive_size": "اندازه بایگانی", "archive_size": "",
"archive_size_description": "", "archive_size_description": "",
"asset_offline": "محتوا آفلاین", "asset_offline": "",
"assets": "محتواها", "assets": "",
"authorized_devices": "دستگاه‌های مجاز", "authorized_devices": "",
"back": "بازگشت", "back": "",
"backward": "عقب", "backward": "",
"blurred_background": "پس‌زمینه محو", "blurred_background": "",
"bulk_delete_duplicates_confirmation": "", "bulk_delete_duplicates_confirmation": "",
"bulk_keep_duplicates_confirmation": "", "bulk_keep_duplicates_confirmation": "",
"bulk_trash_duplicates_confirmation": "", "bulk_trash_duplicates_confirmation": "",
"camera": "دوربین", "camera": "",
"camera_brand": "برند دوربین", "camera_brand": "",
"camera_model": "مدل دوربین", "camera_model": "",
"cancel": "لغو", "cancel": "",
"cancel_search": "لغو جستجو", "cancel_search": "",
"cannot_merge_people": "نمی‌توان افراد را ادغام کرد", "cannot_merge_people": "",
"cannot_update_the_description": "نمی‌توان توضیحات را به‌روزرسانی کرد", "cannot_update_the_description": "",
"change_date": "تغییر تاریخ", "change_date": "",
"change_expiration_time": "تغییر زمان انقضا", "change_expiration_time": "",
"change_location": "تغییر مکان", "change_location": "",
"change_name": "تغییر نام", "change_name": "",
"change_name_successfully": "نام با موفقیت تغییر یافت", "change_name_successfully": "",
"change_password": "تغییر رمز عبور", "change_password": "",
"change_your_password": "رمز عبور خود را تغییر دهید", "change_your_password": "",
"changed_visibility_successfully": "", "changed_visibility_successfully": "",
"check_all": "انتخاب همه", "check_all": "",
"check_logs": "بررسی لاگ‌ها", "check_logs": "",
"choose_matching_people_to_merge": "", "choose_matching_people_to_merge": "",
"city": "شهر", "city": "",
"clear": "پاک کردن", "clear": "",
"clear_all": "پاک کردن همه", "clear_all": "",
"clear_message": "پاک کردن پیام", "clear_message": "",
"clear_value": "پاک کردن مقدار", "clear_value": "",
"close": "بستن", "close": "",
"collapse_all": "جمع کردن همه", "collapse_all": "",
"color_theme": "تم رنگ", "color_theme": "",
"comment_options": "گزینه‌های نظر", "comment_options": "",
"comments_are_disabled": "نظرات غیرفعال هستند", "comments_are_disabled": "",
"confirm": "تأیید", "confirm": "",
"confirm_admin_password": "تأیید رمز عبور مدیر", "confirm_admin_password": "",
"confirm_delete_shared_link": "", "confirm_delete_shared_link": "",
"confirm_password": "تأیید رمز عبور", "confirm_password": "",
"contain": "شامل", "contain": "",
"context": "زمینه", "context": "",
"continue": "ادامه", "continue": "",
"copied_image_to_clipboard": "تصویر به کلیپ‌بورد کپی شد.", "copied_image_to_clipboard": "",
"copied_to_clipboard": "به کلیپ‌بورد کپی شد!", "copied_to_clipboard": "",
"copy_error": "خطا در کپی", "copy_error": "",
"copy_file_path": "کپی مسیر فایل", "copy_file_path": "",
"copy_image": "کپی تصویر", "copy_image": "",
"copy_link": "کپی لینک", "copy_link": "",
"copy_link_to_clipboard": "کپی لینک به کلیپ‌بورد", "copy_link_to_clipboard": "",
"copy_password": "کپی رمز عبور", "copy_password": "",
"copy_to_clipboard": "کپی به کلیپ‌بورد", "copy_to_clipboard": "",
"country": "کشور", "country": "",
"cover": "جلد", "cover": "",
"covers": "جلدها", "covers": "",
"create": "ایجاد", "create": "",
"create_album": "ایجاد آلبوم", "create_album": "",
"create_library": "ایجاد کتابخانه", "create_library": "",
"create_link": "ایجاد لینک", "create_link": "",
"create_link_to_share": "ایجاد لینک برای اشتراک‌گذاری", "create_link_to_share": "",
"create_new_person": "ایجاد فرد جدید", "create_new_person": "",
"create_new_user": "ایجاد کاربر جدید", "create_new_user": "",
"create_user": "ایجاد کاربر", "create_user": "",
"created": "ایجاد شد", "created": "",
"current_device": "دستگاه فعلی", "current_device": "",
"custom_locale": "", "custom_locale": "",
"custom_locale_description": "", "custom_locale_description": "",
"dark": "تاریک", "dark": "",
"date_after": "تاریخ پس از", "date_after": "",
"date_and_time": "تاریخ و زمان", "date_and_time": "",
"date_before": "تاریخ قبل از", "date_before": "",
"date_range": "بازه زمانی", "date_range": "",
"day": "روز", "day": "",
"deduplicate_all": "حذف تکراری‌ها به صورت کامل", "deduplicate_all": "",
"default_locale": "", "default_locale": "",
"default_locale_description": "", "default_locale_description": "",
"delete": "حذف", "delete": "",
"delete_album": "حذف آلبوم", "delete_album": "",
"delete_api_key_prompt": "", "delete_api_key_prompt": "",
"delete_duplicates_confirmation": "", "delete_duplicates_confirmation": "",
"delete_key": "حذف کلید", "delete_key": "",
"delete_library": "حذف کتابخانه", "delete_library": "",
"delete_link": "حذف لینک", "delete_link": "",
"delete_shared_link": "حذف لینک اشتراکی", "delete_shared_link": "",
"delete_user": "حذف کاربر", "delete_user": "",
"deleted_shared_link": "لینک اشتراکی حذف شد", "deleted_shared_link": "",
"description": "توضیحات", "description": "",
"details": "جزئیات", "details": "",
"direction": "جهت", "direction": "",
"disabled": "غیرفعال", "disabled": "",
"disallow_edits": "عدم اجازه ویرایش", "disallow_edits": "",
"discover": "کشف کردن", "discover": "",
"dismiss_all_errors": "رد تمام خطاها", "dismiss_all_errors": "",
"dismiss_error": "رد خطا", "dismiss_error": "",
"display_options": "گزینه‌های نمایش", "display_options": "",
"display_order": "ترتیب نمایش", "display_order": "",
"display_original_photos": "نمایش عکس‌های اصلی", "display_original_photos": "",
"display_original_photos_setting_description": "", "display_original_photos_setting_description": "",
"done": "انجام شد", "done": "",
"download": "دانلود", "download": "",
"download_settings": "تنظیمات دانلود", "download_settings": "",
"download_settings_description": "مدیریت تنظیمات مرتبط با دانلود محتوا", "download_settings_description": "",
"downloading": "در حال دانلود", "downloading": "",
"duplicates": "تکراری‌ها", "duplicates": "",
"duplicates_description": "", "duplicates_description": "",
"duration": "مدت زمان", "duration": "",
"edit_album": "ویرایش آلبوم", "edit_album": "",
"edit_avatar": "ویرایش آواتار", "edit_avatar": "",
"edit_date": "ویرایش تاریخ", "edit_date": "",
"edit_date_and_time": "ویرایش تاریخ و زمان", "edit_date_and_time": "",
"edit_exclusion_pattern": "ویرایش الگوی استثناء", "edit_exclusion_pattern": "",
"edit_faces": "ویرایش چهره‌ها", "edit_faces": "",
"edit_import_path": "", "edit_import_path": "",
"edit_import_paths": "", "edit_import_paths": "",
"edit_key": "ویرایش کلید", "edit_key": "",
"edit_link": "ویرایش لینک", "edit_link": "",
"edit_location": "ویرایش مکان", "edit_location": "",
"edit_name": "ویرایش نام", "edit_name": "",
"edit_people": "ویرایش افراد", "edit_people": "",
"edit_title": "ویرایش عنوان", "edit_title": "",
"edit_user": "ویرایش کاربر", "edit_user": "",
"edited": "ویرایش شد", "edited": "",
"editor": "ویرایشگر", "editor": "",
"email": "ایمیل", "email": "",
"empty_trash": "خالی کردن سطل زباله", "empty_trash": "",
"end_date": "تاریخ پایان", "end_date": "",
"error": "خطا", "error": "",
"error_loading_image": "خطا در بارگذاری تصویر", "error_loading_image": "",
"errors": { "errors": {
"exclusion_pattern_already_exists": "", "exclusion_pattern_already_exists": "",
"import_path_already_exists": "", "import_path_already_exists": "",
@@ -530,400 +530,400 @@
"unable_to_update_timeline_display_status": "", "unable_to_update_timeline_display_status": "",
"unable_to_update_user": "" "unable_to_update_user": ""
}, },
"exit_slideshow": "خروج از نمایش اسلاید", "exit_slideshow": "",
"expand_all": "باز کردن همه", "expand_all": "",
"expire_after": "منقضی شدن بعد از", "expire_after": "",
"expired": "منقضی شده", "expired": "",
"explore": "کاوش کردن", "explore": "",
"export": "صادر کردن", "export": "",
"export_as_json": "صادر کردن به‌صورت JSON", "export_as_json": "",
"extension": "پسوند", "extension": "",
"external": "خارجی", "external": "",
"external_libraries": "کتابخانه‌های خارجی", "external_libraries": "",
"favorite": "علاقه‌مندی", "favorite": "",
"favorite_or_unfavorite_photo": "", "favorite_or_unfavorite_photo": "",
"favorites": "علاقه‌مندی‌ها", "favorites": "",
"feature_photo_updated": "", "feature_photo_updated": "",
"file_name": "نام فایل", "file_name": "",
"file_name_or_extension": "نام فایل یا پسوند", "file_name_or_extension": "",
"filename": "نام فایل", "filename": "",
"filetype": "نوع فایل", "filetype": "",
"filter_people": "فیلتر افراد", "filter_people": "",
"find_them_fast": "", "find_them_fast": "",
"fix_incorrect_match": "رفع تطابق نادرست", "fix_incorrect_match": "",
"forward": "جلو", "forward": "",
"general": "عمومی", "general": "",
"get_help": "دریافت کمک", "get_help": "",
"getting_started": "شروع به کار", "getting_started": "",
"go_back": "بازگشت", "go_back": "",
"go_to_search": "رفتن به جستجو", "go_to_search": "",
"group_albums_by": "گروه‌بندی آلبوم‌ها براساس...", "group_albums_by": "",
"has_quota": "دارای سهمیه", "has_quota": "",
"hide_gallery": "پنهان کردن گالری", "hide_gallery": "",
"hide_password": "پنهان کردن رمز عبور", "hide_password": "",
"hide_person": "پنهان کردن فرد", "hide_person": "",
"host": "میزبان", "host": "",
"hour": "ساعت", "hour": "",
"image": "تصویر", "image": "",
"immich_logo": "لوگوی Immich", "immich_logo": "",
"immich_web_interface": "رابط وب Immich", "immich_web_interface": "",
"import_from_json": "وارد کردن از JSON", "import_from_json": "",
"import_path": "مسیر وارد کردن", "import_path": "",
"in_albums": "", "in_albums": "",
"in_archive": "در بایگانی", "in_archive": "",
"include_archived": "شامل بایگانی شده‌ها", "include_archived": "",
"include_shared_albums": "شامل آلبوم‌های اشتراکی", "include_shared_albums": "",
"include_shared_partner_assets": "", "include_shared_partner_assets": "",
"individual_share": "اشتراک فردی", "individual_share": "",
"info": "اطلاعات", "info": "",
"interval": { "interval": {
"day_at_onepm": "", "day_at_onepm": "",
"hours": "", "hours": "",
"night_at_midnight": "", "night_at_midnight": "",
"night_at_twoam": "" "night_at_twoam": ""
}, },
"invite_people": "دعوت افراد", "invite_people": "",
"invite_to_album": "دعوت به آلبوم", "invite_to_album": "",
"jobs": "وظایف", "jobs": "",
"keep": "نگه داشتن", "keep": "",
"keep_all": "نگه داشتن همه", "keep_all": "",
"keyboard_shortcuts": "میانبرهای صفحه‌کلید", "keyboard_shortcuts": "",
"language": "زبان", "language": "",
"language_setting_description": "انتخاب زبان دلخواه شما", "language_setting_description": "",
"last_seen": "آخرین مشاهده", "last_seen": "",
"leave": "ترک کردن", "leave": "",
"let_others_respond": "اجازه به دیگران برای پاسخ‌گویی", "let_others_respond": "",
"level": "سطح", "level": "",
"library": "کتابخانه", "library": "",
"library_options": "گزینه‌های کتابخانه", "library_options": "",
"light": "روشن", "light": "",
"link_options": "گزینه‌های لینک", "link_options": "",
"link_to_oauth": "اتصال به OAuth", "link_to_oauth": "",
"linked_oauth_account": "حساب OAuth متصل شده", "linked_oauth_account": "",
"list": "لیست", "list": "",
"loading": "در حال بارگذاری", "loading": "",
"loading_search_results_failed": "بارگذاری نتایج جستجو ناموفق بود", "loading_search_results_failed": "",
"log_out": "خروج از سیستم", "log_out": "",
"log_out_all_devices": "خروج از همه دستگاه‌ها", "log_out_all_devices": "",
"login_has_been_disabled": "ورود غیرفعال شده است.", "login_has_been_disabled": "",
"look": "نگاه کردن", "look": "",
"loop_videos": "پخش مداوم ویدئوها", "loop_videos": "",
"loop_videos_description": "", "loop_videos_description": "",
"make": "ساختن", "make": "",
"manage_shared_links": "مدیریت لینک‌های اشتراکی", "manage_shared_links": "",
"manage_sharing_with_partners": "", "manage_sharing_with_partners": "",
"manage_the_app_settings": "مدیریت تنظیمات برنامه", "manage_the_app_settings": "",
"manage_your_account": "مدیریت حساب کاربری شما", "manage_your_account": "",
"manage_your_api_keys": "مدیریت کلیدهای API شما", "manage_your_api_keys": "",
"manage_your_devices": "مدیریت دستگاه‌های متصل", "manage_your_devices": "",
"manage_your_oauth_connection": "مدیریت اتصال OAuth شما", "manage_your_oauth_connection": "",
"map": "نقشه", "map": "",
"map_marker_with_image": "", "map_marker_with_image": "",
"map_settings": "تنظیمات نقشه", "map_settings": "",
"matches": "تطابق‌ها", "matches": "",
"media_type": "نوع رسانه", "media_type": "",
"memories": "خاطرات", "memories": "",
"memories_setting_description": "", "memories_setting_description": "",
"memory": "خاطره", "memory": "",
"menu": "منو", "menu": "",
"merge": "ادغام", "merge": "",
"merge_people": "ادغام افراد", "merge_people": "",
"merge_people_limit": "", "merge_people_limit": "",
"merge_people_prompt": "", "merge_people_prompt": "",
"merge_people_successfully": "ادغام افراد با موفقیت انجام شد", "merge_people_successfully": "",
"minimize": "کوچک کردن", "minimize": "",
"minute": "دقیقه", "minute": "",
"missing": "گمشده", "missing": "",
"model": "مدل", "model": "",
"month": "ماه", "month": "",
"more": "بیشتر", "more": "",
"moved_to_trash": "به سطل زباله منتقل شد", "moved_to_trash": "",
"my_albums": "آلبوم‌های من", "my_albums": "",
"name": "نام", "name": "",
"name_or_nickname": "نام یا لقب", "name_or_nickname": "",
"never": "هرگز", "never": "",
"new_api_key": "کلید API جدید", "new_api_key": "",
"new_password": "رمز عبور جدید", "new_password": "",
"new_person": "فرد جدید", "new_person": "",
"new_user_created": "کاربر جدید ایجاد شد", "new_user_created": "",
"newest_first": "جدیدترین ابتدا", "newest_first": "",
"next": "بعدی", "next": "",
"next_memory": "خاطره بعدی", "next_memory": "",
"no": "خیر", "no": "",
"no_albums_message": "", "no_albums_message": "",
"no_archived_assets_message": "", "no_archived_assets_message": "",
"no_assets_message": "", "no_assets_message": "",
"no_duplicates_found": "هیچ تکراری یافت نشد.", "no_duplicates_found": "",
"no_exif_info_available": "اطلاعات EXIF موجود نیست", "no_exif_info_available": "",
"no_explore_results_message": "", "no_explore_results_message": "",
"no_favorites_message": "", "no_favorites_message": "",
"no_libraries_message": "", "no_libraries_message": "",
"no_name": "بدون نام", "no_name": "",
"no_places": "مکانی یافت نشد", "no_places": "",
"no_results": "نتیجه‌ای یافت نشد", "no_results": "",
"no_shared_albums_message": "", "no_shared_albums_message": "",
"not_in_any_album": "در هیچ آلبومی نیست", "not_in_any_album": "",
"note_apply_storage_label_to_previously_uploaded assets": "", "note_apply_storage_label_to_previously_uploaded assets": "",
"note_unlimited_quota": "", "note_unlimited_quota": "",
"notes": "یادداشت‌ها", "notes": "",
"notification_toggle_setting_description": "اعلان‌های ایمیلی را فعال کنید", "notification_toggle_setting_description": "",
"notifications": "اعلان‌ها", "notifications": "",
"notifications_setting_description": "مدیریت اعلان‌ها", "notifications_setting_description": "",
"oauth": "OAuth", "oauth": "",
"offline": "آفلاین", "offline": "",
"offline_paths": "مسیرهای آفلاین", "offline_paths": "",
"offline_paths_description": "", "offline_paths_description": "",
"ok": "تأیید", "ok": "",
"oldest_first": "قدیمی‌ترین ابتدا", "oldest_first": "",
"online": "آنلاین", "online": "",
"only_favorites": "فقط علاقه‌مندی‌ها", "only_favorites": "",
"open_the_search_filters": "باز کردن فیلترهای جستجو", "open_the_search_filters": "",
"options": "گزینه‌ها", "options": "",
"organize_your_library": "کتابخانه خود را سازماندهی کنید", "organize_your_library": "",
"other": "دیگر", "other": "",
"other_devices": "دستگاه‌های دیگر", "other_devices": "",
"other_variables": "متغیرهای دیگر", "other_variables": "",
"owned": "مالکیت", "owned": "",
"owner": "مالک", "owner": "",
"partner": "شریک", "partner": "",
"partner_can_access": "{partner} می‌تواند دسترسی داشته باشد", "partner_can_access": "",
"partner_can_access_assets": "", "partner_can_access_assets": "",
"partner_can_access_location": "مکان‌هایی که عکس‌های شما گرفته شده‌اند", "partner_can_access_location": "",
"partner_sharing": "اشتراک‌گذاری با شریک", "partner_sharing": "",
"partners": "شرکا", "partners": "",
"password": "رمز عبور", "password": "",
"password_does_not_match": "رمز عبور مطابقت ندارد", "password_does_not_match": "",
"password_required": "رمز عبور مورد نیاز است", "password_required": "",
"password_reset_success": "بازنشانی رمز عبور موفقیت‌آمیز بود", "password_reset_success": "",
"past_durations": { "past_durations": {
"days": "", "days": "",
"hours": "", "hours": "",
"years": "" "years": ""
}, },
"path": "مسیر", "path": "",
"pattern": "الگو", "pattern": "",
"pause": "توقف", "pause": "",
"pause_memories": "توقف خاطرات", "pause_memories": "",
"paused": "متوقف شده", "paused": "",
"pending": "در انتظار", "pending": "",
"people": "افراد", "people": "",
"people_sidebar_description": "", "people_sidebar_description": "",
"permanent_deletion_warning": "هشدار حذف دائمی", "permanent_deletion_warning": "",
"permanent_deletion_warning_setting_description": "نمایش هشدار هنگام حذف دائمی محتواها", "permanent_deletion_warning_setting_description": "",
"permanently_delete": "حذف دائمی", "permanently_delete": "",
"permanently_deleted_asset": "محتوای حذف شده دائمی", "permanently_deleted_asset": "",
"person": "فرد", "person": "",
"photos": "عکس‌ها", "photos": "",
"photos_count": "", "photos_count": "",
"photos_from_previous_years": "عکس‌های سال‌های گذشته", "photos_from_previous_years": "",
"pick_a_location": "یک مکان انتخاب کنید", "pick_a_location": "",
"place": "مکان", "place": "",
"places": "مکان‌ها", "places": "",
"play": "پخش", "play": "",
"play_memories": "پخش خاطرات", "play_memories": "",
"play_motion_photo": "پخش عکس متحرک", "play_motion_photo": "",
"play_or_pause_video": "پخش یا توقف ویدیو", "play_or_pause_video": "",
"port": "پورت", "port": "",
"preset": "پیش‌فرض", "preset": "",
"preview": "پیش‌نمایش", "preview": "",
"previous": "قبلی", "previous": "",
"previous_memory": "خاطره قبلی", "previous_memory": "",
"previous_or_next_photo": "عکس قبلی یا بعدی", "previous_or_next_photo": "",
"primary": "اصلی", "primary": "",
"profile_picture_set": "تصویر پروفایل تنظیم شد.", "profile_picture_set": "",
"public_share": "اشتراک عمومی", "public_share": "",
"reaction_options": "گزینه‌های واکنش", "reaction_options": "",
"read_changelog": "مطالعه تغییرات نسخه", "read_changelog": "",
"recent": "اخیر", "recent": "",
"recent_searches": "جستجوهای اخیر", "recent_searches": "",
"refresh": "تازه سازی", "refresh": "",
"refreshed": "تازه سازی شد", "refreshed": "",
"refreshes_every_file": "", "refreshes_every_file": "",
"remove": "حذف", "remove": "",
"remove_deleted_assets": "حذف محتواهای حذف‌شده", "remove_deleted_assets": "",
"remove_from_album": "حذف از آلبوم", "remove_from_album": "",
"remove_from_favorites": "حذف از علاقه‌مندی‌ها", "remove_from_favorites": "",
"remove_from_shared_link": "", "remove_from_shared_link": "",
"removed_api_key": "", "removed_api_key": "",
"rename": "تغییر نام", "rename": "",
"repair": "تعمیر", "repair": "",
"repair_no_results_message": "", "repair_no_results_message": "",
"replace_with_upload": "جایگزینی با آپلود", "replace_with_upload": "",
"require_password": "", "require_password": "",
"require_user_to_change_password_on_first_login": "", "require_user_to_change_password_on_first_login": "",
"reset": "بازنشانی", "reset": "",
"reset_password": "بازنشانی رمز عبور", "reset_password": "",
"reset_people_visibility": "", "reset_people_visibility": "",
"resolved_all_duplicates": "", "resolved_all_duplicates": "",
"restore": "بازیابی", "restore": "",
"restore_all": "بازیابی همه", "restore_all": "",
"restore_user": "بازیابی کاربر", "restore_user": "",
"resume": "ادامه", "resume": "",
"retry_upload": "", "retry_upload": "",
"review_duplicates": "بررسی تکراری‌ها", "review_duplicates": "",
"role": "نقش", "role": "",
"save": "ذخیره", "save": "",
"saved_api_key": "", "saved_api_key": "",
"saved_profile": "پروفایل ذخیره شد", "saved_profile": "",
"saved_settings": "تنظیمات ذخیره شد", "saved_settings": "",
"say_something": "چیزی بگویید", "say_something": "",
"scan_all_libraries": "اسکن همه کتابخانه‌ها", "scan_all_libraries": "",
"scan_settings": "تنظیمات اسکن", "scan_settings": "",
"scanning_for_album": "", "scanning_for_album": "",
"search": "جستجو", "search": "",
"search_albums": "جستجوی آلبوم‌ها", "search_albums": "",
"search_by_context": "جستجو براساس زمینه", "search_by_context": "",
"search_camera_make": "جستجوی برند دوربین...", "search_camera_make": "",
"search_camera_model": "جستجوی مدل دوربین...", "search_camera_model": "",
"search_city": "جستجوی شهر...", "search_city": "",
"search_country": "جستجوی کشور...", "search_country": "",
"search_for_existing_person": "جستجوی فرد موجود", "search_for_existing_person": "",
"search_people": "جستجوی افراد", "search_people": "",
"search_places": "جستجوی مکان‌ها", "search_places": "",
"search_state": "جستجوی ایالت...", "search_state": "",
"search_timezone": "جستجوی منطقه زمانی...", "search_timezone": "",
"search_type": "نوع جستجو", "search_type": "",
"search_your_photos": "", "search_your_photos": "",
"searching_locales": "", "searching_locales": "",
"second": "ثانیه", "second": "",
"select_album_cover": "انتخاب جلد آلبوم", "select_album_cover": "",
"select_all": "انتخاب همه", "select_all": "",
"select_avatar_color": "انتخاب رنگ آواتار", "select_avatar_color": "",
"select_face": "انتخاب چهره", "select_face": "",
"select_featured_photo": "انتخاب عکس ویژه", "select_featured_photo": "",
"select_keep_all": "انتخاب نگهداری همه", "select_keep_all": "",
"select_library_owner": "انتخاب مالک کتابخانه", "select_library_owner": "",
"select_new_face": "انتخاب چهره جدید", "select_new_face": "",
"select_photos": "انتخاب عکس‌ها", "select_photos": "",
"select_trash_all": "", "select_trash_all": "",
"selected": "انتخاب شده", "selected": "",
"send_message": "ارسال پیام", "send_message": "",
"send_welcome_email": "ارسال ایمیل خوش‌آمدگویی", "send_welcome_email": "",
"server_stats": "آمار سرور", "server_stats": "",
"set": "تنظیم", "set": "",
"set_as_album_cover": "", "set_as_album_cover": "",
"set_as_profile_picture": "", "set_as_profile_picture": "",
"set_date_of_birth": "تنظیم تاریخ تولد", "set_date_of_birth": "",
"set_profile_picture": "تنظیم تصویر پروفایل", "set_profile_picture": "",
"set_slideshow_to_fullscreen": "", "set_slideshow_to_fullscreen": "",
"settings": "تنظیمات", "settings": "",
"settings_saved": "تنظیمات ذخیره شد", "settings_saved": "",
"share": "اشتراک‌گذاری", "share": "",
"shared": "مشترک", "shared": "",
"shared_by": "مشترک توسط", "shared_by": "",
"shared_by_you": "", "shared_by_you": "",
"shared_from_partner": "عکس‌ها از {partner}", "shared_from_partner": "",
"shared_links": "لینک‌های اشتراکی", "shared_links": "",
"shared_photos_and_videos_count": "", "shared_photos_and_videos_count": "",
"shared_with_partner": "مشترک با {partner}", "shared_with_partner": "",
"sharing": "اشتراک‌گذاری", "sharing": "",
"sharing_sidebar_description": "", "sharing_sidebar_description": "",
"show_album_options": "نمایش گزینه‌های آلبوم", "show_album_options": "",
"show_and_hide_people": "", "show_and_hide_people": "",
"show_file_location": "نمایش مسیر فایل", "show_file_location": "",
"show_gallery": "نمایش گالری", "show_gallery": "",
"show_hidden_people": "نمایش افراد پنهان", "show_hidden_people": "",
"show_in_timeline": "", "show_in_timeline": "",
"show_in_timeline_setting_description": "", "show_in_timeline_setting_description": "",
"show_keyboard_shortcuts": "", "show_keyboard_shortcuts": "",
"show_metadata": "نمایش اطلاعات متا", "show_metadata": "",
"show_or_hide_info": "", "show_or_hide_info": "",
"show_password": "نمایش رمز عبور", "show_password": "",
"show_person_options": "", "show_person_options": "",
"show_progress_bar": "نمایش نوار پیشرفت", "show_progress_bar": "",
"show_search_options": "نمایش گزینه‌های جستجو", "show_search_options": "",
"shuffle": "تصادفی", "shuffle": "",
"sign_out": "خروج", "sign_out": "",
"sign_up": "ثبت‌نام", "sign_up": "",
"size": "اندازه", "size": "",
"skip_to_content": "رفتن به محتوا", "skip_to_content": "",
"slideshow": "نمایش اسلاید", "slideshow": "",
"slideshow_settings": "تنظیمات نمایش اسلاید", "slideshow_settings": "",
"sort_albums_by": "", "sort_albums_by": "",
"stack": "پشته", "stack": "",
"stack_selected_photos": "", "stack_selected_photos": "",
"stacktrace": "", "stacktrace": "",
"start": "شروع", "start": "",
"start_date": "تاریخ شروع", "start_date": "",
"state": "ایالت", "state": "",
"status": "وضعیت", "status": "",
"stop_motion_photo": "توقف عکس متحرک", "stop_motion_photo": "",
"stop_photo_sharing": "", "stop_photo_sharing": "",
"stop_photo_sharing_description": "", "stop_photo_sharing_description": "",
"stop_sharing_photos_with_user": "", "stop_sharing_photos_with_user": "",
"storage": "فضای ذخیره‌سازی", "storage": "",
"storage_label": "برچسب فضای ذخیره‌سازی", "storage_label": "",
"storage_usage": "", "storage_usage": "",
"submit": "ارسال", "submit": "",
"suggestions": "پیشنهادات", "suggestions": "",
"sunrise_on_the_beach": "", "sunrise_on_the_beach": "",
"swap_merge_direction": "تغییر جهت ادغام", "swap_merge_direction": "",
"sync": "همگام‌سازی", "sync": "",
"template": "الگو", "template": "",
"theme": "تم", "theme": "",
"theme_selection": "انتخاب تم", "theme_selection": "",
"theme_selection_description": "", "theme_selection_description": "",
"time_based_memories": "", "time_based_memories": "",
"timezone": "منطقه زمانی", "timezone": "",
"to_archive": "بایگانی", "to_archive": "",
"to_favorite": "به علاقه‌مندی‌ها", "to_favorite": "",
"to_trash": "", "to_trash": "",
"toggle_settings": "تغییر تنظیمات", "toggle_settings": "",
"toggle_theme": "تغییر تم تاریک", "toggle_theme": "",
"total_usage": "استفاده کلی", "total_usage": "",
"trash": "سطل زباله", "trash": "",
"trash_all": "", "trash_all": "",
"trash_count": "", "trash_count": "",
"trash_no_results_message": "", "trash_no_results_message": "",
"trashed_items_will_be_permanently_deleted_after": "", "trashed_items_will_be_permanently_deleted_after": "",
"type": "نوع", "type": "",
"unarchive": "", "unarchive": "",
"unfavorite": "حذف از علاقه‌مندی‌ها", "unfavorite": "",
"unhide_person": "آشکار کردن فرد", "unhide_person": "",
"unknown": "ناشناخته", "unknown": "",
"unknown_year": "سال نامشخص", "unknown_year": "",
"unlimited": "نامحدود", "unlimited": "",
"unlink_oauth": "لغو اتصال OAuth", "unlink_oauth": "",
"unlinked_oauth_account": "", "unlinked_oauth_account": "",
"unnamed_album": "آلبوم بدون نام", "unnamed_album": "",
"unnamed_share": "اشتراک بدون نام", "unnamed_share": "",
"unselect_all": "لغو انتخاب همه", "unselect_all": "",
"unstack": "", "unstack": "",
"untracked_files": "", "untracked_files": "",
"untracked_files_decription": "", "untracked_files_decription": "",
"up_next": "مورد بعدی", "up_next": "",
"updated_password": "", "updated_password": "",
"upload": "آپلود", "upload": "",
"upload_concurrency": "تعداد آپلود همزمان", "upload_concurrency": "",
"url": "آدرس", "url": "",
"usage": "استفاده", "usage": "",
"user": "کاربر", "user": "",
"user_id": "شناسه کاربر", "user_id": "",
"user_usage_detail": "جزئیات استفاده کاربر", "user_usage_detail": "",
"username": "نام کاربری", "username": "",
"users": "کاربران", "users": "",
"utilities": "ابزارها", "utilities": "",
"validate": "اعتبارسنجی", "validate": "",
"variables": "متغیرها", "variables": "",
"version": "نسخه", "version": "",
"version_announcement_message": "", "version_announcement_message": "",
"video": "ویدیو", "video": "",
"video_hover_setting": "", "video_hover_setting": "",
"video_hover_setting_description": "", "video_hover_setting_description": "",
"videos": "ویدیوها", "videos": "",
"videos_count": "", "videos_count": "",
"view": "مشاهده", "view": "",
"view_all": "مشاهده همه", "view_all": "",
"view_all_users": "مشاهده همه کاربران", "view_all_users": "",
"view_links": "مشاهده لینک‌ها", "view_links": "",
"view_next_asset": "مشاهده محتوای بعدی", "view_next_asset": "",
"view_previous_asset": "مشاهده محتوای قبلی", "view_previous_asset": "",
"waiting": "در انتظار", "waiting": "",
"week": "هفته", "week": "",
"welcome": "خوش آمدید", "welcome": "",
"welcome_to_immich": "", "welcome_to_immich": "",
"year": "سال", "year": "",
"yes": "بله", "yes": "",
"you_dont_have_any_shared_links": "", "you_dont_have_any_shared_links": "",
"zoom_image": "بزرگنمایی تصویر" "zoom_image": "بزرگنمایی تصویر"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "machine-learning" name = "machine-learning"
version = "1.126.1" version = "1.125.7"
description = "" description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"] authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md" readme = "README.md"
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 184, "android.injected.version.code" => 182,
"android.injected.version.name" => "1.126.1", "android.injected.version.name" => "1.125.7",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+6 -6
View File
@@ -541,7 +541,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 193; CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -685,7 +685,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 193; CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -715,7 +715,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 193; CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -748,7 +748,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 193; CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -791,7 +791,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 193; CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -831,7 +831,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 193; CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
+2 -2
View File
@@ -78,7 +78,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.126.0</string> <string>1.125.2</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -93,7 +93,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>193</string> <string>190</string>
<key>FLTEnableImpeller</key> <key>FLTEnableImpeller</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
+1 -1
View File
@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release" desc "iOS Release"
lane :release do lane :release do
increment_version_number( increment_version_number(
version_number: "1.126.1" version_number: "1.125.7"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,
-1
View File
@@ -10,7 +10,6 @@ dynamic upgradeDto(dynamic value, String targetType) {
addDefault(value, 'ratings', RatingsResponse().toJson()); addDefault(value, 'ratings', RatingsResponse().toJson());
addDefault(value, 'people', PeopleResponse().toJson()); addDefault(value, 'people', PeopleResponse().toJson());
addDefault(value, 'tags', TagsResponse().toJson()); addDefault(value, 'tags', TagsResponse().toJson());
addDefault(value, 'sharedLinks', SharedLinksResponse().toJson());
} }
break; break;
case 'ServerConfigDto': case 'ServerConfigDto':
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.126.1 - API version: 1.125.7
- Generator version: 7.8.0 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen
+3 -13
View File
@@ -127,10 +127,7 @@ class SharedLinksApi {
} }
/// Performs an HTTP 'GET /shared-links' operation and returns the [Response]. /// Performs an HTTP 'GET /shared-links' operation and returns the [Response].
/// Parameters: Future<Response> getAllSharedLinksWithHttpInfo() async {
///
/// * [String] albumId:
Future<Response> getAllSharedLinksWithHttpInfo({ String? albumId, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final path = r'/shared-links'; final path = r'/shared-links';
@@ -141,10 +138,6 @@ class SharedLinksApi {
final headerParams = <String, String>{}; final headerParams = <String, String>{};
final formParams = <String, String>{}; final formParams = <String, String>{};
if (albumId != null) {
queryParams.addAll(_queryParams('', 'albumId', albumId));
}
const contentTypes = <String>[]; const contentTypes = <String>[];
@@ -159,11 +152,8 @@ class SharedLinksApi {
); );
} }
/// Parameters: Future<List<SharedLinkResponseDto>?> getAllSharedLinks() async {
/// final response = await getAllSharedLinksWithHttpInfo();
/// * [String] albumId:
Future<List<SharedLinkResponseDto>?> getAllSharedLinks({ String? albumId, }) async {
final response = await getAllSharedLinksWithHttpInfo( albumId: albumId, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 1.126.1+184 version: 1.125.7+182
environment: environment:
sdk: '>=3.3.0 <4.0.0' sdk: '>=3.3.0 <4.0.0'
+2 -12
View File
@@ -5230,17 +5230,7 @@
"/shared-links": { "/shared-links": {
"get": { "get": {
"operationId": "getAllSharedLinks", "operationId": "getAllSharedLinks",
"parameters": [ "parameters": [],
{
"name": "albumId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": { "responses": {
"200": { "200": {
"content": { "content": {
@@ -7468,7 +7458,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "1.126.1", "version": "1.125.7",
"contact": {} "contact": {}
}, },
"tags": [], "tags": [],
+6 -6
View File
@@ -1,18 +1,18 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.126.1", "version": "1.125.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.126.1", "version": "1.125.7",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.13.1", "@types/node": "^22.10.9",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },
@@ -22,9 +22,9 @@
"integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g==" "integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g=="
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.13.1", "version": "22.10.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.9.tgz",
"integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", "integrity": "sha512-Ir6hwgsKyNESl/gLOcEz3krR4CBGgliDqBQ2ma4wIhEx0w+xnoeTq3tdrNw15kU3SxogDjOgv9sqdtLW8mIHaw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.126.1", "version": "1.125.7",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",
@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.13.1", "@types/node": "^22.10.9",
"typescript": "^5.3.3" "typescript": "^5.3.3"
}, },
"repository": { "repository": {
+3 -7
View File
@@ -1,6 +1,6 @@
/** /**
* Immich * Immich
* 1.126.1 * 1.125.7
* DO NOT MODIFY - This file has been generated using oazapfts. * DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts * See https://www.npmjs.com/package/oazapfts
*/ */
@@ -2762,15 +2762,11 @@ export function deleteSession({ id }: {
method: "DELETE" method: "DELETE"
})); }));
} }
export function getAllSharedLinks({ albumId }: { export function getAllSharedLinks(opts?: Oazapfts.RequestOpts) {
albumId?: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: SharedLinkResponseDto[]; data: SharedLinkResponseDto[];
}>(`/shared-links${QS.query(QS.explode({ }>("/shared-links", {
albumId
}))}`, {
...opts ...opts
})); }));
} }
+59 -1022
View File
File diff suppressed because it is too large Load Diff
+2 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "1.126.1", "version": "1.125.7",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -35,13 +35,10 @@
"email:dev": "email dev -p 3050 --dir src/emails" "email:dev": "email dev -p 3050 --dir src/emails"
}, },
"dependencies": { "dependencies": {
"@apollo/server": "^4.11.3",
"@nestjs/apollo": "^13.0.2",
"@nestjs/bullmq": "^11.0.1", "@nestjs/bullmq": "^11.0.1",
"@nestjs/common": "^11.0.4", "@nestjs/common": "^11.0.4",
"@nestjs/core": "^11.0.4", "@nestjs/core": "^11.0.4",
"@nestjs/event-emitter": "^3.0.0", "@nestjs/event-emitter": "^3.0.0",
"@nestjs/graphql": "^13.0.2",
"@nestjs/platform-express": "^11.0.4", "@nestjs/platform-express": "^11.0.4",
"@nestjs/platform-socket.io": "^11.0.4", "@nestjs/platform-socket.io": "^11.0.4",
"@nestjs/schedule": "^5.0.0", "@nestjs/schedule": "^5.0.0",
@@ -66,7 +63,6 @@
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0", "geo-tz": "^8.0.0",
"graphql": "^16.10.0",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"i18n-iso-countries": "^7.6.0", "i18n-iso-countries": "^7.6.0",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
@@ -116,7 +112,7 @@
"@types/lodash": "^4.14.197", "@types/lodash": "^4.14.197",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^22.13.1", "@types/node": "^22.10.9",
"@types/nodemailer": "^6.4.14", "@types/nodemailer": "^6.4.14",
"@types/picomatch": "^3.0.0", "@types/picomatch": "^3.0.0",
"@types/pngjs": "^6.0.5", "@types/pngjs": "^6.0.5",
+2 -25
View File
@@ -1,15 +1,12 @@
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { BullModule } from '@nestjs/bullmq'; import { BullModule } from '@nestjs/bullmq';
import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common'; import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@nestjs/core'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@nestjs/core';
import { GraphQLModule } from '@nestjs/graphql';
import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { PostgresJSDialect } from 'kysely-postgres-js'; import { PostgresJSDialect } from 'kysely-postgres-js';
import { ClsModule } from 'nestjs-cls'; import { ClsModule } from 'nestjs-cls';
import { KyselyModule } from 'nestjs-kysely'; import { KyselyModule } from 'nestjs-kysely';
import { OpenTelemetryModule } from 'nestjs-otel'; import { OpenTelemetryModule } from 'nestjs-otel';
import { join } from 'node:path';
import postgres from 'postgres'; import postgres from 'postgres';
import { commands } from 'src/commands'; import { commands } from 'src/commands';
import { IWorker } from 'src/constants'; import { IWorker } from 'src/constants';
@@ -27,7 +24,6 @@ import { providers, repositories } from 'src/repositories';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository'; import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
import { resolvers } from 'src/resolvers';
import { services } from 'src/services'; import { services } from 'src/services';
import { CliService } from 'src/services/cli.service'; import { CliService } from 'src/services/cli.service';
import { DatabaseService } from 'src/services/database.service'; import { DatabaseService } from 'src/services/database.service';
@@ -108,28 +104,9 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
} }
@Module({ @Module({
imports: [ imports: [...imports, ScheduleModule.forRoot()],
...imports,
ScheduleModule.forRoot(),
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
playground: true,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true,
debug: true,
buildSchemaOptions: {
numberScalarMode: 'integer',
},
}),
],
controllers: [...controllers], controllers: [...controllers],
providers: [ providers: [...common, ...middleware, { provide: IWorker, useValue: ImmichWorker.API }],
//
...common,
...middleware,
...resolvers,
{ provide: IWorker, useValue: ImmichWorker.API },
],
}) })
export class ApiModule extends BaseModule {} export class ApiModule extends BaseModule {}
+1 -1
View File
@@ -33,7 +33,7 @@ export const citiesFile = 'cities500.txt';
export const MOBILE_REDIRECT = 'app.immich:///oauth-callback'; export const MOBILE_REDIRECT = 'app.immich:///oauth-callback';
export const LOGIN_URL = '/auth/login?autoLaunch=0'; export const LOGIN_URL = '/auth/login?autoLaunch=0';
export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico', '/graphql']; export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico'];
export const FACE_THUMBNAIL_SIZE = 250; export const FACE_THUMBNAIL_SIZE = 250;
@@ -9,7 +9,6 @@ import {
SharedLinkEditDto, SharedLinkEditDto,
SharedLinkPasswordDto, SharedLinkPasswordDto,
SharedLinkResponseDto, SharedLinkResponseDto,
SharedLinkSearchDto,
} from 'src/dtos/shared-link.dto'; } from 'src/dtos/shared-link.dto';
import { ImmichCookie, Permission } from 'src/enum'; import { ImmichCookie, Permission } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
@@ -25,8 +24,8 @@ export class SharedLinkController {
@Get() @Get()
@Authenticated({ permission: Permission.SHARED_LINK_READ }) @Authenticated({ permission: Permission.SHARED_LINK_READ })
getAllSharedLinks(@Auth() auth: AuthDto, @Query() dto: SharedLinkSearchDto): Promise<SharedLinkResponseDto[]> { getAllSharedLinks(@Auth() auth: AuthDto): Promise<SharedLinkResponseDto[]> {
return this.service.getAll(auth, dto); return this.service.getAll(auth);
} }
@Get('me') @Get('me')
+2 -16
View File
@@ -9,7 +9,6 @@ import {
Param, Param,
Post, Post,
Put, Put,
Req,
Res, Res,
UploadedFile, UploadedFile,
UseInterceptors, UseInterceptors,
@@ -39,21 +38,8 @@ export class UserController {
@Get() @Get()
@Authenticated() @Authenticated()
async searchUsers(@Req() req: Request): Promise<UserResponseDto[]> { searchUsers(@Auth() auth: AuthDto): Promise<UserResponseDto[]> {
const response = await fetch(`http://localhost:2283/graphql`, { return this.service.search(auth);
method: 'POST',
body: JSON.stringify({
operationName: null,
query: '{ users { id name email } }',
}),
headers: {
...req.headers,
'Content-Type': 'application/json',
},
});
const { data } = await response.json();
return data.users;
} }
@Get('me') @Get('me')
+11 -12
View File
@@ -5,13 +5,12 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { PersonEntity } from 'src/entities/person.entity'; import { PersonEntity } from 'src/entities/person.entity';
import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum'; import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IMoveRepository } from 'src/interfaces/move.interface'; import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { CryptoRepository } from 'src/repositories/crypto.repository'; import { IConfigRepository, ILoggingRepository } from 'src/types';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { getAssetFiles } from 'src/utils/asset.util'; import { getAssetFiles } from 'src/utils/asset.util';
import { getConfig } from 'src/utils/config'; import { getConfig } from 'src/utils/config';
@@ -34,24 +33,24 @@ let instance: StorageCore | null;
export class StorageCore { export class StorageCore {
private constructor( private constructor(
private assetRepository: IAssetRepository, private assetRepository: IAssetRepository,
private configRepository: ConfigRepository, private configRepository: IConfigRepository,
private cryptoRepository: CryptoRepository, private cryptoRepository: ICryptoRepository,
private moveRepository: IMoveRepository, private moveRepository: IMoveRepository,
private personRepository: IPersonRepository, private personRepository: IPersonRepository,
private storageRepository: IStorageRepository, private storageRepository: IStorageRepository,
private systemMetadataRepository: SystemMetadataRepository, private systemMetadataRepository: ISystemMetadataRepository,
private logger: LoggingRepository, private logger: ILoggingRepository,
) {} ) {}
static create( static create(
assetRepository: IAssetRepository, assetRepository: IAssetRepository,
configRepository: ConfigRepository, configRepository: IConfigRepository,
cryptoRepository: CryptoRepository, cryptoRepository: ICryptoRepository,
moveRepository: IMoveRepository, moveRepository: IMoveRepository,
personRepository: IPersonRepository, personRepository: IPersonRepository,
storageRepository: IStorageRepository, storageRepository: IStorageRepository,
systemMetadataRepository: SystemMetadataRepository, systemMetadataRepository: ISystemMetadataRepository,
logger: LoggingRepository, logger: ILoggingRepository,
) { ) {
if (!instance) { if (!instance) {
instance = new StorageCore( instance = new StorageCore(
+2 -2
View File
@@ -1,4 +1,4 @@
import { SessionItem } from 'src/types'; import { SessionEntity } from 'src/entities/session.entity';
export class SessionResponseDto { export class SessionResponseDto {
id!: string; id!: string;
@@ -9,7 +9,7 @@ export class SessionResponseDto {
deviceOS!: string; deviceOS!: string;
} }
export const mapSession = (entity: SessionItem, currentId?: string): SessionResponseDto => ({ export const mapSession = (entity: SessionEntity, currentId?: string): SessionResponseDto => ({
id: entity.id, id: entity.id,
createdAt: entity.createdAt.toISOString(), createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(), updatedAt: entity.updatedAt.toISOString(),
-5
View File
@@ -7,11 +7,6 @@ import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { SharedLinkType } from 'src/enum'; import { SharedLinkType } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
export class SharedLinkSearchDto {
@ValidateUUID({ optional: true })
albumId?: string;
}
export class SharedLinkCreateDto { export class SharedLinkCreateDto {
@IsEnum(SharedLinkType) @IsEnum(SharedLinkType)
@ApiProperty({ enum: SharedLinkType, enumName: 'SharedLinkType' }) @ApiProperty({ enum: SharedLinkType, enumName: 'SharedLinkType' })
-1
View File
@@ -144,7 +144,6 @@ export interface IAssetDeleteJob extends IEntityJob {
} }
export interface ILibraryFileJob extends IEntityJob { export interface ILibraryFileJob extends IEntityJob {
ownerId: string;
assetPath: string; assetPath: string;
} }
@@ -0,0 +1,25 @@
import { ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio } from 'node:child_process';
import { Readable } from 'node:stream';
export interface ImmichReadStream {
stream: Readable;
type?: string;
length?: number;
}
export interface ImmichZipStream extends ImmichReadStream {
addFile: (inputPath: string, filename: string) => void;
finalize: () => Promise<void>;
}
export interface DiskUsage {
available: number;
free: number;
total: number;
}
export const IProcessRepository = 'IProcessRepository';
export interface IProcessRepository {
spawn(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams;
}
@@ -0,0 +1,17 @@
import { Insertable, Updateable } from 'kysely';
import { Sessions } from 'src/db';
import { SessionEntity } from 'src/entities/session.entity';
export const ISessionRepository = 'ISessionRepository';
type E = SessionEntity;
export type SessionSearchOptions = { updatedBefore: Date };
export interface ISessionRepository {
search(options: SessionSearchOptions): Promise<SessionEntity[]>;
create(dto: Insertable<Sessions>): Promise<SessionEntity>;
update(id: string, dto: Updateable<Sessions>): Promise<SessionEntity>;
delete(id: string): Promise<void>;
getByToken(token: string): Promise<E | undefined>;
getByUserId(userId: string): Promise<E[]>;
}
@@ -4,13 +4,8 @@ import { SharedLinkEntity } from 'src/entities/shared-link.entity';
export const ISharedLinkRepository = 'ISharedLinkRepository'; export const ISharedLinkRepository = 'ISharedLinkRepository';
export type SharedLinkSearchOptions = {
userId: string;
albumId?: string;
};
export interface ISharedLinkRepository { export interface ISharedLinkRepository {
getAll(options: SharedLinkSearchOptions): Promise<SharedLinkEntity[]>; getAll(userId: string): Promise<SharedLinkEntity[]>;
get(userId: string, id: string): Promise<SharedLinkEntity | undefined>; get(userId: string, id: string): Promise<SharedLinkEntity | undefined>;
getByKey(key: Buffer): Promise<SharedLinkEntity | undefined>; getByKey(key: Buffer): Promise<SharedLinkEntity | undefined>;
create(entity: Insertable<SharedLinks> & { assetIds?: string[] }): Promise<SharedLinkEntity>; create(entity: Insertable<SharedLinks> & { assetIds?: string[] }): Promise<SharedLinkEntity>;
@@ -0,0 +1,10 @@
import { SystemMetadata } from 'src/entities/system-metadata.entity';
export const ISystemMetadataRepository = 'ISystemMetadataRepository';
export interface ISystemMetadataRepository {
get<T extends keyof SystemMetadata>(key: T): Promise<SystemMetadata[T] | null>;
set<T extends keyof SystemMetadata>(key: T, value: SystemMetadata[T]): Promise<void>;
delete<T extends keyof SystemMetadata>(key: T): Promise<void>;
readFile(filename: string): Promise<string>;
}
@@ -5,18 +5,19 @@ import { AssetMediaResponseDto, AssetMediaStatus } from 'src/dtos/asset-media-re
import { ImmichHeader } from 'src/enum'; import { ImmichHeader } from 'src/enum';
import { AuthenticatedRequest } from 'src/middleware/auth.guard'; import { AuthenticatedRequest } from 'src/middleware/auth.guard';
import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetMediaService } from 'src/services/asset-media.service';
import { fromMaybeArray, getReqRes } from 'src/utils/request'; import { fromMaybeArray } from 'src/utils/request';
@Injectable() @Injectable()
export class AssetUploadInterceptor implements NestInterceptor { export class AssetUploadInterceptor implements NestInterceptor {
constructor(private service: AssetMediaService) {} constructor(private service: AssetMediaService) {}
async intercept(context: ExecutionContext, next: CallHandler<any>) { async intercept(context: ExecutionContext, next: CallHandler<any>) {
const { type, req, res } = getReqRes<AuthenticatedRequest, Response<AssetMediaResponseDto>>(context); const req = context.switchToHttp().getRequest<AuthenticatedRequest>();
const res = context.switchToHttp().getResponse<Response<AssetMediaResponseDto>>();
const checksum = fromMaybeArray(req.headers[ImmichHeader.CHECKSUM]); const checksum = fromMaybeArray(req.headers[ImmichHeader.CHECKSUM]);
const response = await this.service.getUploadAssetIdByChecksum(req.user, checksum); const response = await this.service.getUploadAssetIdByChecksum(req.user, checksum);
if (response && type === 'http') { if (response) {
res.status(200); res.status(200);
return of({ status: AssetMediaStatus.DUPLICATE, id: response.id }); return of({ status: AssetMediaStatus.DUPLICATE, id: response.id });
} }
+6 -8
View File
@@ -13,7 +13,6 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { ImmichQuery, MetadataKey, Permission } from 'src/enum'; import { ImmichQuery, MetadataKey, Permission } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { AuthService, LoginDetails } from 'src/services/auth.service'; import { AuthService, LoginDetails } from 'src/services/auth.service';
import { getReqRes } from 'src/utils/request';
import { UAParser } from 'ua-parser-js'; import { UAParser } from 'ua-parser-js';
type AdminRoute = { admin?: true }; type AdminRoute = { admin?: true };
@@ -36,8 +35,7 @@ export const Authenticated = (options?: AuthenticatedOptions): MethodDecorator =
}; };
export const Auth = createParamDecorator((data, context: ExecutionContext): AuthDto => { export const Auth = createParamDecorator((data, context: ExecutionContext): AuthDto => {
const { req } = getReqRes<AuthenticatedRequest>(context); return context.switchToHttp().getRequest<AuthenticatedRequest>().user;
return req.user;
}); });
export const FileResponse = () => export const FileResponse = () =>
@@ -88,12 +86,12 @@ export class AuthGuard implements CanActivate {
sharedLink: sharedLinkRoute, sharedLink: sharedLinkRoute,
permission, permission,
} = { sharedLink: false, admin: false, ...options }; } = { sharedLink: false, admin: false, ...options };
const { req } = getReqRes<AuthenticatedRequest>(context); const request = context.switchToHttp().getRequest<AuthRequest>();
req.user = await this.authService.authenticate({ request.user = await this.authService.authenticate({
headers: req.headers, headers: request.headers,
queryParams: req.query as Record<string, string>, queryParams: request.query as Record<string, string>,
metadata: { adminRoute, sharedLinkRoute, permission, uri: req.path }, metadata: { adminRoute, sharedLinkRoute, permission, uri: request.path },
}); });
return true; return true;
@@ -1,18 +1,8 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common'; import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import { GqlContextType } from '@nestjs/graphql'; import { Response } from 'express';
import { GraphQLError } from 'graphql';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { logGlobalError } from 'src/utils/logger'; import { logGlobalError } from 'src/utils/logger';
import { getReqRes } from 'src/utils/request';
type StructuredError = {
status: number;
body: {
[key: string]: unknown;
message?: string;
};
};
@Catch() @Catch()
export class GlobalExceptionFilter implements ExceptionFilter<Error> { export class GlobalExceptionFilter implements ExceptionFilter<Error> {
@@ -24,20 +14,15 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
} }
catch(error: Error, host: ArgumentsHost) { catch(error: Error, host: ArgumentsHost) {
const { res } = getReqRes(host); const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const { status, body } = this.fromError(error); const { status, body } = this.fromError(error);
const message = { ...body, statusCode: status, correlationId: this.cls.getId() }; if (!response.headersSent) {
response.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() });
if (host.getType<GqlContextType>() === 'graphql') {
throw new GraphQLError(body?.message || 'Error', { extensions: message });
}
if (!res.headersSent) {
res.status(status).json(message);
} }
} }
private fromError(error: Error): StructuredError { private fromError(error: Error) {
logGlobalError(this.logger, error); logGlobalError(this.logger, error);
if (error instanceof HttpException) { if (error instanceof HttpException) {
@@ -49,7 +34,7 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
body = { message: body }; body = { message: body };
} }
return { status, body } as StructuredError; return { status, body };
} }
return { return {
+8 -3
View File
@@ -1,7 +1,7 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Request, Response } from 'express';
import { Observable, finalize } from 'rxjs'; import { Observable, finalize } from 'rxjs';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { getReqRes } from 'src/utils/request';
const maxArrayLength = 100; const maxArrayLength = 100;
const replacer = (key: string, value: unknown) => { const replacer = (key: string, value: unknown) => {
@@ -23,7 +23,10 @@ export class LoggingInterceptor implements NestInterceptor {
} }
intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> { intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {
const { req, res } = getReqRes(context); const handler = context.switchToHttp();
const req = handler.getRequest<Request>();
const res = handler.getResponse<Response>();
const { method, ip, url } = req; const { method, ip, url } = req;
const start = performance.now(); const start = performance.now();
@@ -32,7 +35,9 @@ export class LoggingInterceptor implements NestInterceptor {
finalize(() => { finalize(() => {
const finish = performance.now(); const finish = performance.now();
const duration = (finish - start).toFixed(2); const duration = (finish - start).toFixed(2);
this.logger.debug(`${method} ${url} ${res?.statusCode || ''} ${duration}ms ${ip}`); const { statusCode } = res;
this.logger.debug(`${method} ${url} ${statusCode} ${duration}ms ${ip}`);
if (req.body && Object.keys(req.body).length > 0) { if (req.body && Object.keys(req.body).length > 0) {
this.logger.verbose(JSON.stringify(req.body, replacer)); this.logger.verbose(JSON.stringify(req.body, replacer));
} }
-27
View File
@@ -1,27 +0,0 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { UserAvatarColor } from 'src/enum';
registerEnumType(UserAvatarColor, {
name: 'UserAvatarColor',
});
@ObjectType()
export class User {
@Field()
id!: string;
@Field()
name!: string;
@Field()
email!: string;
@Field(() => UserAvatarColor)
avatarColor!: UserAvatarColor;
@Field()
profileImagePath!: string;
@Field({ nullable: true })
profileChangedAt!: Date;
}
+1 -1
View File
@@ -524,7 +524,7 @@ export class AssetRepository implements IAssetRepository {
.executeTakeFirst() as Promise<AssetEntity | undefined>; .executeTakeFirst() as Promise<AssetEntity | undefined>;
} }
private getMapMarkers(ownerIds: string[], options: MapMarkerSearchOptions = {}): Promise<MapMarker[]> { getMapMarkers(ownerIds: string[], options: MapMarkerSearchOptions = {}): Promise<MapMarker[]> {
const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options; const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options;
return this.db return this.db
+6 -3
View File
@@ -9,10 +9,13 @@ import { IMachineLearningRepository } from 'src/interfaces/machine-learning.inte
import { IMoveRepository } from 'src/interfaces/move.interface'; import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { IProcessRepository } from 'src/interfaces/process.interface';
import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISearchRepository } from 'src/interfaces/search.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { IStackRepository } from 'src/interfaces/stack.interface'; import { IStackRepository } from 'src/interfaces/stack.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface'; import { ITagRepository } from 'src/interfaces/tag.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
@@ -70,10 +73,7 @@ export const repositories = [
MetadataRepository, MetadataRepository,
NotificationRepository, NotificationRepository,
OAuthRepository, OAuthRepository,
ProcessRepository,
SessionRepository,
ServerInfoRepository, ServerInfoRepository,
SystemMetadataRepository,
TelemetryRepository, TelemetryRepository,
TrashRepository, TrashRepository,
ViewRepository, ViewRepository,
@@ -92,10 +92,13 @@ export const providers = [
{ provide: IMoveRepository, useClass: MoveRepository }, { provide: IMoveRepository, useClass: MoveRepository },
{ provide: IPartnerRepository, useClass: PartnerRepository }, { provide: IPartnerRepository, useClass: PartnerRepository },
{ provide: IPersonRepository, useClass: PersonRepository }, { provide: IPersonRepository, useClass: PersonRepository },
{ provide: IProcessRepository, useClass: ProcessRepository },
{ provide: ISearchRepository, useClass: SearchRepository }, { provide: ISearchRepository, useClass: SearchRepository },
{ provide: ISessionRepository, useClass: SessionRepository },
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository }, { provide: ISharedLinkRepository, useClass: SharedLinkRepository },
{ provide: IStackRepository, useClass: StackRepository }, { provide: IStackRepository, useClass: StackRepository },
{ provide: IStorageRepository, useClass: StorageRepository }, { provide: IStorageRepository, useClass: StorageRepository },
{ provide: ISystemMetadataRepository, useClass: SystemMetadataRepository },
{ provide: ITagRepository, useClass: TagRepository }, { provide: ITagRepository, useClass: TagRepository },
{ provide: IUserRepository, useClass: UserRepository }, { provide: IUserRepository, useClass: UserRepository },
]; ];
+16
View File
@@ -216,6 +216,17 @@ export class JobRepository implements IJobRepository {
private getJobOptions(item: JobItem): JobsOptions | null { private getJobOptions(item: JobItem): JobsOptions | null {
switch (item.name) { switch (item.name) {
case JobName.LIBRARY_QUEUE_SYNC_ASSETS:
case JobName.LIBRARY_QUEUE_SYNC_FILES:
case JobName.LIBRARY_SYNC_ASSET:
case JobName.LIBRARY_DELETE:
case JobName.SIDECAR_SYNC:
case JobName.SIDECAR_DISCOVERY: {
return { jobId: `${item.data.id}-${item.name}` };
}
case JobName.LIBRARY_SYNC_FILE: {
return { jobId: `${item.data.id}-${item.data.assetPath}` };
}
case JobName.NOTIFY_ALBUM_UPDATE: { case JobName.NOTIFY_ALBUM_UPDATE: {
return { jobId: item.data.id, delay: item.data?.delay }; return { jobId: item.data.id, delay: item.data?.delay };
} }
@@ -228,6 +239,11 @@ export class JobRepository implements IJobRepository {
case JobName.QUEUE_FACIAL_RECOGNITION: { case JobName.QUEUE_FACIAL_RECOGNITION: {
return { jobId: JobName.QUEUE_FACIAL_RECOGNITION }; return { jobId: JobName.QUEUE_FACIAL_RECOGNITION };
} }
case JobName.LIBRARY_QUEUE_SYNC_ALL:
case JobName.LIBRARY_QUEUE_CLEANUP: {
// These jobs are globally unique and should only have one instance running at a time
return { jobId: item.name };
}
default: { default: {
return null; return null;
} }
@@ -1,14 +1,14 @@
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import { ImmichWorker } from 'src/enum'; import { ImmichWorker } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { IConfigRepository } from 'src/types';
import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(LoggingRepository.name, () => { describe(LoggingRepository.name, () => {
let sut: LoggingRepository; let sut: LoggingRepository;
let configMock: Mocked<ConfigRepository>; let configMock: Mocked<IConfigRepository>;
let clsMock: Mocked<ClsService>; let clsMock: Mocked<ClsService>;
beforeEach(() => { beforeEach(() => {
+3 -3
View File
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { getName } from 'i18n-iso-countries'; import { getName } from 'i18n-iso-countries';
import { Expression, Kysely, sql, SqlBool } from 'kysely'; import { Expression, Kysely, sql, SqlBool } from 'kysely';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
@@ -11,9 +11,9 @@ import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity'; import { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity';
import { LogLevel, SystemMetadataKey } from 'src/enum'; import { LogLevel, SystemMetadataKey } from 'src/enum';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
export interface MapMarkerSearchOptions { export interface MapMarkerSearchOptions {
isArchived?: boolean; isArchived?: boolean;
@@ -48,7 +48,7 @@ interface MapDB extends DB {
export class MapRepository { export class MapRepository {
constructor( constructor(
private configRepository: ConfigRepository, private configRepository: ConfigRepository,
private metadataRepository: SystemMetadataRepository, @Inject(ISystemMetadataRepository) private metadataRepository: ISystemMetadataRepository,
private logger: LoggingRepository, private logger: LoggingRepository,
@InjectKysely() private db: Kysely<MapDB>, @InjectKysely() private db: Kysely<MapDB>,
) { ) {
@@ -1,16 +1,17 @@
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { EmailRenderRequest, EmailTemplate, NotificationRepository } from 'src/repositories/notification.repository'; import { EmailRenderRequest, EmailTemplate, NotificationRepository } from 'src/repositories/notification.repository';
import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; import { ILoggingRepository } from 'src/types';
import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(NotificationRepository.name, () => { describe(NotificationRepository.name, () => {
let sut: NotificationRepository; let sut: NotificationRepository;
let loggerMock: Mocked<LoggingRepository>; let loggerMock: Mocked<ILoggingRepository>;
beforeEach(() => { beforeEach(() => {
loggerMock = newLoggingRepositoryMock() as ILoggingRepository as Mocked<LoggingRepository>; loggerMock = newLoggingRepositoryMock();
sut = new NotificationRepository(loggerMock as LoggingRepository); sut = new NotificationRepository(loggerMock as ILoggingRepository as LoggingRepository);
}); });
describe('renderEmail', () => { describe('renderEmail', () => {
+1 -6
View File
@@ -43,12 +43,7 @@ export class OAuthRepository {
const params = client.callbackParams(url); const params = client.callbackParams(url);
try { try {
const tokens = await client.callback(redirectUrl, params, { state: params.state }); const tokens = await client.callback(redirectUrl, params, { state: params.state });
const profile = await client.userinfo<OAuthProfile>(tokens.access_token || ''); return await client.userinfo<OAuthProfile>(tokens.access_token || '');
if (!profile.sub) {
throw new Error('Unexpected profile response, no `sub`');
}
return profile;
} catch (error: Error | any) { } catch (error: Error | any) {
if (error.message.includes('unexpected JWT alg received')) { if (error.message.includes('unexpected JWT alg received')) {
this.logger.warn( this.logger.warn(
@@ -1,11 +1,13 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ChildProcessWithoutNullStreams, spawn, SpawnOptionsWithoutStdio } from 'node:child_process'; import { ChildProcessWithoutNullStreams, spawn, SpawnOptionsWithoutStdio } from 'node:child_process';
import { IProcessRepository } from 'src/interfaces/process.interface';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
@Injectable() @Injectable()
export class ProcessRepository { export class ProcessRepository implements IProcessRepository {
constructor(private logger: LoggingRepository) { constructor(private logger: LoggingRepository) {
this.logger.setContext(ProcessRepository.name); this.logger.setContext(StorageRepository.name);
} }
spawn(command: string, args: readonly string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams { spawn(command: string, args: readonly string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams {
+20 -15
View File
@@ -3,37 +3,36 @@ import { Insertable, Kysely, Updateable } from 'kysely';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { DB, Sessions } from 'src/db'; import { DB, Sessions } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { withUser } from 'src/entities/session.entity'; import { SessionEntity, withUser } from 'src/entities/session.entity';
import { ISessionRepository, SessionSearchOptions } from 'src/interfaces/session.interface';
import { asUuid } from 'src/utils/database'; import { asUuid } from 'src/utils/database';
export type SessionSearchOptions = { updatedBefore: Date };
@Injectable() @Injectable()
export class SessionRepository { export class SessionRepository implements ISessionRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [{ updatedBefore: DummyValue.DATE }] }) @GenerateSql({ params: [{ updatedBefore: DummyValue.DATE }] })
search(options: SessionSearchOptions) { search(options: SessionSearchOptions): Promise<SessionEntity[]> {
return this.db return this.db
.selectFrom('sessions') .selectFrom('sessions')
.selectAll() .selectAll()
.where('sessions.updatedAt', '<=', options.updatedBefore) .where('sessions.updatedAt', '<=', options.updatedBefore)
.execute(); .execute() as Promise<SessionEntity[]>;
} }
@GenerateSql({ params: [DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.STRING] })
getByToken(token: string) { getByToken(token: string): Promise<SessionEntity | undefined> {
return this.db return this.db
.selectFrom('sessions') .selectFrom('sessions')
.innerJoinLateral(withUser, (join) => join.onTrue()) .innerJoinLateral(withUser, (join) => join.onTrue())
.selectAll('sessions') .selectAll('sessions')
.select((eb) => eb.fn.toJson('user').as('user')) .select((eb) => eb.fn.toJson('user').as('user'))
.where('sessions.token', '=', token) .where('sessions.token', '=', token)
.executeTakeFirst(); .executeTakeFirst() as Promise<SessionEntity | undefined>;
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getByUserId(userId: string) { getByUserId(userId: string): Promise<SessionEntity[]> {
return this.db return this.db
.selectFrom('sessions') .selectFrom('sessions')
.innerJoinLateral(withUser, (join) => join.onTrue()) .innerJoinLateral(withUser, (join) => join.onTrue())
@@ -42,24 +41,30 @@ export class SessionRepository {
.where('sessions.userId', '=', userId) .where('sessions.userId', '=', userId)
.orderBy('sessions.updatedAt', 'desc') .orderBy('sessions.updatedAt', 'desc')
.orderBy('sessions.createdAt', 'desc') .orderBy('sessions.createdAt', 'desc')
.execute(); .execute() as unknown as Promise<SessionEntity[]>;
} }
create(dto: Insertable<Sessions>) { async create(dto: Insertable<Sessions>): Promise<SessionEntity> {
return this.db.insertInto('sessions').values(dto).returningAll().executeTakeFirstOrThrow(); const { id, token, userId, createdAt, updatedAt, deviceType, deviceOS } = await this.db
.insertInto('sessions')
.values(dto)
.returningAll()
.executeTakeFirstOrThrow();
return { id, token, userId, createdAt, updatedAt, deviceType, deviceOS } as SessionEntity;
} }
update(id: string, dto: Updateable<Sessions>) { update(id: string, dto: Updateable<Sessions>): Promise<SessionEntity> {
return this.db return this.db
.updateTable('sessions') .updateTable('sessions')
.set(dto) .set(dto)
.where('sessions.id', '=', asUuid(id)) .where('sessions.id', '=', asUuid(id))
.returningAll() .returningAll()
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow() as Promise<SessionEntity>;
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async delete(id: string) { async delete(id: string): Promise<void> {
await this.db.deleteFrom('sessions').where('id', '=', asUuid(id)).execute(); await this.db.deleteFrom('sessions').where('id', '=', asUuid(id)).execute();
} }
} }
@@ -7,7 +7,7 @@ import { DB, SharedLinks } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { SharedLinkType } from 'src/enum'; import { SharedLinkType } from 'src/enum';
import { ISharedLinkRepository, SharedLinkSearchOptions } from 'src/interfaces/shared-link.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
@Injectable() @Injectable()
export class SharedLinkRepository implements ISharedLinkRepository { export class SharedLinkRepository implements ISharedLinkRepository {
@@ -93,7 +93,7 @@ export class SharedLinkRepository implements ISharedLinkRepository {
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getAll({ userId, albumId }: SharedLinkSearchOptions): Promise<SharedLinkEntity[]> { getAll(userId: string): Promise<SharedLinkEntity[]> {
return this.db return this.db
.selectFrom('shared_links') .selectFrom('shared_links')
.selectAll('shared_links') .selectAll('shared_links')
@@ -149,7 +149,6 @@ export class SharedLinkRepository implements ISharedLinkRepository {
) )
.select((eb) => eb.fn.toJson('album').as('album')) .select((eb) => eb.fn.toJson('album').as('album'))
.where((eb) => eb.or([eb('shared_links.type', '=', SharedLinkType.INDIVIDUAL), eb('album.id', 'is not', null)])) .where((eb) => eb.or([eb('shared_links.type', '=', SharedLinkType.INDIVIDUAL), eb('album.id', 'is not', null)]))
.$if(!!albumId, (eb) => eb.where('shared_links.albumId', '=', albumId!))
.orderBy('shared_links.createdAt', 'desc') .orderBy('shared_links.createdAt', 'desc')
.distinctOn(['shared_links.createdAt']) .distinctOn(['shared_links.createdAt'])
.execute() as unknown as Promise<SharedLinkEntity[]>; .execute() as unknown as Promise<SharedLinkEntity[]>;
@@ -2,8 +2,8 @@ import mockfs from 'mock-fs';
import { CrawlOptionsDto } from 'src/dtos/library.dto'; import { CrawlOptionsDto } from 'src/dtos/library.dto';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { StorageRepository } from 'src/repositories/storage.repository'; import { StorageRepository } from 'src/repositories/storage.repository';
import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; import { ILoggingRepository } from 'src/types';
import { Mocked } from 'vitest'; import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock';
interface Test { interface Test {
test: string; test: string;
@@ -182,11 +182,11 @@ const tests: Test[] = [
describe(StorageRepository.name, () => { describe(StorageRepository.name, () => {
let sut: StorageRepository; let sut: StorageRepository;
let logger: Mocked<ILoggingRepository>; let logger: ILoggingRepository;
beforeEach(() => { beforeEach(() => {
logger = newLoggingRepositoryMock(); logger = newLoggingRepositoryMock();
sut = new StorageRepository(logger as ILoggingRepository as LoggingRepository); sut = new StorageRepository(logger as LoggingRepository);
}); });
afterEach(() => { afterEach(() => {
@@ -5,11 +5,12 @@ import { readFile } from 'node:fs/promises';
import { DB, SystemMetadata as DbSystemMetadata } from 'src/db'; import { DB, SystemMetadata as DbSystemMetadata } from 'src/db';
import { GenerateSql } from 'src/decorators'; import { GenerateSql } from 'src/decorators';
import { SystemMetadata } from 'src/entities/system-metadata.entity'; import { SystemMetadata } from 'src/entities/system-metadata.entity';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
type Upsert = Insertable<DbSystemMetadata>; type Upsert = Insertable<DbSystemMetadata>;
@Injectable() @Injectable()
export class SystemMetadataRepository { export class SystemMetadataRepository implements ISystemMetadataRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: ['metadata_key'] }) @GenerateSql({ params: ['metadata_key'] })
-3
View File
@@ -1,3 +0,0 @@
import { UsersResolver } from 'src/resolvers/user.resolver';
export const resolvers = [UsersResolver];
-22
View File
@@ -1,22 +0,0 @@
import { Args, Int, Query, Resolver } from '@nestjs/graphql';
import { AuthDto } from 'src/dtos/auth.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { User } from 'src/models/user.model';
import { UserService } from 'src/services/user.service';
@Resolver(() => User)
export class UsersResolver {
constructor(private service: UserService) {}
@Authenticated()
@Query(() => User)
async user(@Args('id', { type: () => Int }) id: string) {
return this.service.get(id);
}
@Authenticated()
@Query(() => [User])
async users(@Auth() auth: AuthDto) {
return this.service.search(auth);
}
}
-35
View File
@@ -1,35 +0,0 @@
# ------------------------------------------------------
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------
"""
A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
"""
scalar DateTime
type Query {
user(id: Int!): User!
users: [User!]!
}
type User {
avatarColor: UserAvatarColor!
email: String!
id: String!
name: String!
profileChangedAt: DateTime
profileImagePath: String!
}
enum UserAvatarColor {
AMBER
BLUE
GRAY
GREEN
ORANGE
PINK
PRIMARY
PURPLE
RED
YELLOW
}
+37 -32
View File
@@ -1,16 +1,21 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { ReactionType } from 'src/dtos/activity.dto'; import { ReactionType } from 'src/dtos/activity.dto';
import { ActivityService } from 'src/services/activity.service'; import { ActivityService } from 'src/services/activity.service';
import { IActivityRepository } from 'src/types';
import { activityStub } from 'test/fixtures/activity.stub'; import { activityStub } from 'test/fixtures/activity.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
describe(ActivityService.name, () => { describe(ActivityService.name, () => {
let sut: ActivityService; let sut: ActivityService;
let mocks: ServiceMocks;
let accessMock: IAccessRepositoryMock;
let activityMock: Mocked<IActivityRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(ActivityService)); ({ sut, accessMock, activityMock } = newTestService(ActivityService));
}); });
it('should work', () => { it('should work', () => {
@@ -19,12 +24,12 @@ describe(ActivityService.name, () => {
describe('getAll', () => { describe('getAll', () => {
it('should get all', async () => { it('should get all', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
mocks.activity.search.mockResolvedValue([]); activityMock.search.mockResolvedValue([]);
await expect(sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id' })).resolves.toEqual([]); await expect(sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id' })).resolves.toEqual([]);
expect(mocks.activity.search).toHaveBeenCalledWith({ expect(activityMock.search).toHaveBeenCalledWith({
assetId: 'asset-id', assetId: 'asset-id',
albumId: 'album-id', albumId: 'album-id',
isLiked: undefined, isLiked: undefined,
@@ -32,14 +37,14 @@ describe(ActivityService.name, () => {
}); });
it('should filter by type=like', async () => { it('should filter by type=like', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
mocks.activity.search.mockResolvedValue([]); activityMock.search.mockResolvedValue([]);
await expect( await expect(
sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.LIKE }), sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.LIKE }),
).resolves.toEqual([]); ).resolves.toEqual([]);
expect(mocks.activity.search).toHaveBeenCalledWith({ expect(activityMock.search).toHaveBeenCalledWith({
assetId: 'asset-id', assetId: 'asset-id',
albumId: 'album-id', albumId: 'album-id',
isLiked: true, isLiked: true,
@@ -47,14 +52,14 @@ describe(ActivityService.name, () => {
}); });
it('should filter by type=comment', async () => { it('should filter by type=comment', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
mocks.activity.search.mockResolvedValue([]); activityMock.search.mockResolvedValue([]);
await expect( await expect(
sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.COMMENT }), sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.COMMENT }),
).resolves.toEqual([]); ).resolves.toEqual([]);
expect(mocks.activity.search).toHaveBeenCalledWith({ expect(activityMock.search).toHaveBeenCalledWith({
assetId: 'asset-id', assetId: 'asset-id',
albumId: 'album-id', albumId: 'album-id',
isLiked: false, isLiked: false,
@@ -64,8 +69,8 @@ describe(ActivityService.name, () => {
describe('getStatistics', () => { describe('getStatistics', () => {
it('should get the comment count', async () => { it('should get the comment count', async () => {
mocks.activity.getStatistics.mockResolvedValue(1); activityMock.getStatistics.mockResolvedValue(1);
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([activityStub.oneComment.albumId])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([activityStub.oneComment.albumId]));
await expect( await expect(
sut.getStatistics(authStub.admin, { sut.getStatistics(authStub.admin, {
assetId: 'asset-id', assetId: 'asset-id',
@@ -88,8 +93,8 @@ describe(ActivityService.name, () => {
}); });
it('should create a comment', async () => { it('should create a comment', async () => {
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id'])); accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id']));
mocks.activity.create.mockResolvedValue(activityStub.oneComment); activityMock.create.mockResolvedValue(activityStub.oneComment);
await sut.create(authStub.admin, { await sut.create(authStub.admin, {
albumId: 'album-id', albumId: 'album-id',
@@ -98,7 +103,7 @@ describe(ActivityService.name, () => {
comment: 'comment', comment: 'comment',
}); });
expect(mocks.activity.create).toHaveBeenCalledWith({ expect(activityMock.create).toHaveBeenCalledWith({
userId: 'admin_id', userId: 'admin_id',
albumId: 'album-id', albumId: 'album-id',
assetId: 'asset-id', assetId: 'asset-id',
@@ -108,8 +113,8 @@ describe(ActivityService.name, () => {
}); });
it('should fail because activity is disabled for the album', async () => { it('should fail because activity is disabled for the album', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
mocks.activity.create.mockResolvedValue(activityStub.oneComment); activityMock.create.mockResolvedValue(activityStub.oneComment);
await expect( await expect(
sut.create(authStub.admin, { sut.create(authStub.admin, {
@@ -122,9 +127,9 @@ describe(ActivityService.name, () => {
}); });
it('should create a like', async () => { it('should create a like', async () => {
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id'])); accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id']));
mocks.activity.create.mockResolvedValue(activityStub.liked); activityMock.create.mockResolvedValue(activityStub.liked);
mocks.activity.search.mockResolvedValue([]); activityMock.search.mockResolvedValue([]);
await sut.create(authStub.admin, { await sut.create(authStub.admin, {
albumId: 'album-id', albumId: 'album-id',
@@ -132,7 +137,7 @@ describe(ActivityService.name, () => {
type: ReactionType.LIKE, type: ReactionType.LIKE,
}); });
expect(mocks.activity.create).toHaveBeenCalledWith({ expect(activityMock.create).toHaveBeenCalledWith({
userId: 'admin_id', userId: 'admin_id',
albumId: 'album-id', albumId: 'album-id',
assetId: 'asset-id', assetId: 'asset-id',
@@ -141,9 +146,9 @@ describe(ActivityService.name, () => {
}); });
it('should skip if like exists', async () => { it('should skip if like exists', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id'])); accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id']));
mocks.activity.search.mockResolvedValue([activityStub.liked]); activityMock.search.mockResolvedValue([activityStub.liked]);
await sut.create(authStub.admin, { await sut.create(authStub.admin, {
albumId: 'album-id', albumId: 'album-id',
@@ -151,26 +156,26 @@ describe(ActivityService.name, () => {
type: ReactionType.LIKE, type: ReactionType.LIKE,
}); });
expect(mocks.activity.create).not.toHaveBeenCalled(); expect(activityMock.create).not.toHaveBeenCalled();
}); });
}); });
describe('delete', () => { describe('delete', () => {
it('should require access', async () => { it('should require access', async () => {
await expect(sut.delete(authStub.admin, activityStub.oneComment.id)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.delete(authStub.admin, activityStub.oneComment.id)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.activity.delete).not.toHaveBeenCalled(); expect(activityMock.delete).not.toHaveBeenCalled();
}); });
it('should let the activity owner delete a comment', async () => { it('should let the activity owner delete a comment', async () => {
mocks.access.activity.checkOwnerAccess.mockResolvedValue(new Set(['activity-id'])); accessMock.activity.checkOwnerAccess.mockResolvedValue(new Set(['activity-id']));
await sut.delete(authStub.admin, 'activity-id'); await sut.delete(authStub.admin, 'activity-id');
expect(mocks.activity.delete).toHaveBeenCalledWith('activity-id'); expect(activityMock.delete).toHaveBeenCalledWith('activity-id');
}); });
it('should let the album owner delete a comment', async () => { it('should let the album owner delete a comment', async () => {
mocks.access.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set(['activity-id'])); accessMock.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set(['activity-id']));
await sut.delete(authStub.admin, 'activity-id'); await sut.delete(authStub.admin, 'activity-id');
expect(mocks.activity.delete).toHaveBeenCalledWith('activity-id'); expect(activityMock.delete).toHaveBeenCalledWith('activity-id');
}); });
}); });
}); });
+195 -184
View File
@@ -2,18 +2,29 @@ import { BadRequestException } from '@nestjs/common';
import _ from 'lodash'; import _ from 'lodash';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { AlbumUserRole } from 'src/enum'; import { AlbumUserRole } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { AlbumService } from 'src/services/album.service'; import { AlbumService } from 'src/services/album.service';
import { IAlbumUserRepository } from 'src/types';
import { albumStub } from 'test/fixtures/album.stub'; import { albumStub } from 'test/fixtures/album.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
describe(AlbumService.name, () => { describe(AlbumService.name, () => {
let sut: AlbumService; let sut: AlbumService;
let mocks: ServiceMocks;
let accessMock: IAccessRepositoryMock;
let albumMock: Mocked<IAlbumRepository>;
let albumUserMock: Mocked<IAlbumUserRepository>;
let eventMock: Mocked<IEventRepository>;
let userMock: Mocked<IUserRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(AlbumService)); ({ sut, accessMock, albumMock, albumUserMock, eventMock, userMock } = newTestService(AlbumService));
}); });
it('should work', () => { it('should work', () => {
@@ -22,25 +33,25 @@ describe(AlbumService.name, () => {
describe('getStatistics', () => { describe('getStatistics', () => {
it('should get the album count', async () => { it('should get the album count', async () => {
mocks.album.getOwned.mockResolvedValue([]); albumMock.getOwned.mockResolvedValue([]);
mocks.album.getShared.mockResolvedValue([]); albumMock.getShared.mockResolvedValue([]);
mocks.album.getNotShared.mockResolvedValue([]); albumMock.getNotShared.mockResolvedValue([]);
await expect(sut.getStatistics(authStub.admin)).resolves.toEqual({ await expect(sut.getStatistics(authStub.admin)).resolves.toEqual({
owned: 0, owned: 0,
shared: 0, shared: 0,
notShared: 0, notShared: 0,
}); });
expect(mocks.album.getOwned).toHaveBeenCalledWith(authStub.admin.user.id); expect(albumMock.getOwned).toHaveBeenCalledWith(authStub.admin.user.id);
expect(mocks.album.getShared).toHaveBeenCalledWith(authStub.admin.user.id); expect(albumMock.getShared).toHaveBeenCalledWith(authStub.admin.user.id);
expect(mocks.album.getNotShared).toHaveBeenCalledWith(authStub.admin.user.id); expect(albumMock.getNotShared).toHaveBeenCalledWith(authStub.admin.user.id);
}); });
}); });
describe('getAll', () => { describe('getAll', () => {
it('gets list of albums for auth user', async () => { it('gets list of albums for auth user', async () => {
mocks.album.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]); albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]);
mocks.album.getMetadataForIds.mockResolvedValue([ albumMock.getMetadataForIds.mockResolvedValue([
{ albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null }, { albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null },
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null }, { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null },
]); ]);
@@ -52,8 +63,8 @@ describe(AlbumService.name, () => {
}); });
it('gets list of albums that have a specific asset', async () => { it('gets list of albums that have a specific asset', async () => {
mocks.album.getByAssetId.mockResolvedValue([albumStub.oneAsset]); albumMock.getByAssetId.mockResolvedValue([albumStub.oneAsset]);
mocks.album.getMetadataForIds.mockResolvedValue([ albumMock.getMetadataForIds.mockResolvedValue([
{ {
albumId: albumStub.oneAsset.id, albumId: albumStub.oneAsset.id,
assetCount: 1, assetCount: 1,
@@ -65,37 +76,37 @@ describe(AlbumService.name, () => {
const result = await sut.getAll(authStub.admin, { assetId: albumStub.oneAsset.id }); const result = await sut.getAll(authStub.admin, { assetId: albumStub.oneAsset.id });
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].id).toEqual(albumStub.oneAsset.id); expect(result[0].id).toEqual(albumStub.oneAsset.id);
expect(mocks.album.getByAssetId).toHaveBeenCalledTimes(1); expect(albumMock.getByAssetId).toHaveBeenCalledTimes(1);
}); });
it('gets list of albums that are shared', async () => { it('gets list of albums that are shared', async () => {
mocks.album.getShared.mockResolvedValue([albumStub.sharedWithUser]); albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]);
mocks.album.getMetadataForIds.mockResolvedValue([ albumMock.getMetadataForIds.mockResolvedValue([
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null }, { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null },
]); ]);
const result = await sut.getAll(authStub.admin, { shared: true }); const result = await sut.getAll(authStub.admin, { shared: true });
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].id).toEqual(albumStub.sharedWithUser.id); expect(result[0].id).toEqual(albumStub.sharedWithUser.id);
expect(mocks.album.getShared).toHaveBeenCalledTimes(1); expect(albumMock.getShared).toHaveBeenCalledTimes(1);
}); });
it('gets list of albums that are NOT shared', async () => { it('gets list of albums that are NOT shared', async () => {
mocks.album.getNotShared.mockResolvedValue([albumStub.empty]); albumMock.getNotShared.mockResolvedValue([albumStub.empty]);
mocks.album.getMetadataForIds.mockResolvedValue([ albumMock.getMetadataForIds.mockResolvedValue([
{ albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null }, { albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null },
]); ]);
const result = await sut.getAll(authStub.admin, { shared: false }); const result = await sut.getAll(authStub.admin, { shared: false });
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].id).toEqual(albumStub.empty.id); expect(result[0].id).toEqual(albumStub.empty.id);
expect(mocks.album.getNotShared).toHaveBeenCalledTimes(1); expect(albumMock.getNotShared).toHaveBeenCalledTimes(1);
}); });
}); });
it('counts assets correctly', async () => { it('counts assets correctly', async () => {
mocks.album.getOwned.mockResolvedValue([albumStub.oneAsset]); albumMock.getOwned.mockResolvedValue([albumStub.oneAsset]);
mocks.album.getMetadataForIds.mockResolvedValue([ albumMock.getMetadataForIds.mockResolvedValue([
{ {
albumId: albumStub.oneAsset.id, albumId: albumStub.oneAsset.id,
assetCount: 1, assetCount: 1,
@@ -108,14 +119,14 @@ describe(AlbumService.name, () => {
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].assetCount).toEqual(1); expect(result[0].assetCount).toEqual(1);
expect(mocks.album.getOwned).toHaveBeenCalledTimes(1); expect(albumMock.getOwned).toHaveBeenCalledTimes(1);
}); });
describe('create', () => { describe('create', () => {
it('creates album', async () => { it('creates album', async () => {
mocks.album.create.mockResolvedValue(albumStub.empty); albumMock.create.mockResolvedValue(albumStub.empty);
mocks.user.get.mockResolvedValue(userStub.user1); userMock.get.mockResolvedValue(userStub.user1);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['123'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['123']));
await sut.create(authStub.admin, { await sut.create(authStub.admin, {
albumName: 'Empty album', albumName: 'Empty album',
@@ -124,7 +135,7 @@ describe(AlbumService.name, () => {
assetIds: ['123'], assetIds: ['123'],
}); });
expect(mocks.album.create).toHaveBeenCalledWith( expect(albumMock.create).toHaveBeenCalledWith(
{ {
ownerId: authStub.admin.user.id, ownerId: authStub.admin.user.id,
albumName: albumStub.empty.albumName, albumName: albumStub.empty.albumName,
@@ -136,30 +147,30 @@ describe(AlbumService.name, () => {
[{ userId: 'user-id', role: AlbumUserRole.EDITOR }], [{ userId: 'user-id', role: AlbumUserRole.EDITOR }],
); );
expect(mocks.user.get).toHaveBeenCalledWith('user-id', {}); expect(userMock.get).toHaveBeenCalledWith('user-id', {});
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123'])); expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']));
expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', { expect(eventMock.emit).toHaveBeenCalledWith('album.invite', {
id: albumStub.empty.id, id: albumStub.empty.id,
userId: 'user-id', userId: 'user-id',
}); });
}); });
it('should require valid userIds', async () => { it('should require valid userIds', async () => {
mocks.user.get.mockResolvedValue(void 0); userMock.get.mockResolvedValue(void 0);
await expect( await expect(
sut.create(authStub.admin, { sut.create(authStub.admin, {
albumName: 'Empty album', albumName: 'Empty album',
albumUsers: [{ userId: 'user-3', role: AlbumUserRole.EDITOR }], albumUsers: [{ userId: 'user-3', role: AlbumUserRole.EDITOR }],
}), }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.user.get).toHaveBeenCalledWith('user-3', {}); expect(userMock.get).toHaveBeenCalledWith('user-3', {});
expect(mocks.album.create).not.toHaveBeenCalled(); expect(albumMock.create).not.toHaveBeenCalled();
}); });
it('should only add assets the user is allowed to access', async () => { it('should only add assets the user is allowed to access', async () => {
mocks.user.get.mockResolvedValue(userStub.user1); userMock.get.mockResolvedValue(userStub.user1);
mocks.album.create.mockResolvedValue(albumStub.oneAsset); albumMock.create.mockResolvedValue(albumStub.oneAsset);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
await sut.create(authStub.admin, { await sut.create(authStub.admin, {
albumName: 'Test album', albumName: 'Test album',
@@ -167,7 +178,7 @@ describe(AlbumService.name, () => {
assetIds: ['asset-1', 'asset-2'], assetIds: ['asset-1', 'asset-2'],
}); });
expect(mocks.album.create).toHaveBeenCalledWith( expect(albumMock.create).toHaveBeenCalledWith(
{ {
ownerId: authStub.admin.user.id, ownerId: authStub.admin.user.id,
albumName: 'Test album', albumName: 'Test album',
@@ -178,7 +189,7 @@ describe(AlbumService.name, () => {
['asset-1'], ['asset-1'],
[], [],
); );
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id, authStub.admin.user.id,
new Set(['asset-1', 'asset-2']), new Set(['asset-1', 'asset-2']),
); );
@@ -187,7 +198,7 @@ describe(AlbumService.name, () => {
describe('update', () => { describe('update', () => {
it('should prevent updating an album that does not exist', async () => { it('should prevent updating an album that does not exist', async () => {
mocks.album.getById.mockResolvedValue(void 0); albumMock.getById.mockResolvedValue(void 0);
await expect( await expect(
sut.update(authStub.user1, 'invalid-id', { sut.update(authStub.user1, 'invalid-id', {
@@ -195,7 +206,7 @@ describe(AlbumService.name, () => {
}), }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.update).not.toHaveBeenCalled(); expect(albumMock.update).not.toHaveBeenCalled();
}); });
it('should prevent updating a not owned album (shared with auth user)', async () => { it('should prevent updating a not owned album (shared with auth user)', async () => {
@@ -207,10 +218,10 @@ describe(AlbumService.name, () => {
}); });
it('should require a valid thumbnail asset id', async () => { it('should require a valid thumbnail asset id', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4']));
mocks.album.getById.mockResolvedValue(albumStub.oneAsset); albumMock.getById.mockResolvedValue(albumStub.oneAsset);
mocks.album.update.mockResolvedValue(albumStub.oneAsset); albumMock.update.mockResolvedValue(albumStub.oneAsset);
mocks.album.getAssetIds.mockResolvedValue(new Set()); albumMock.getAssetIds.mockResolvedValue(new Set());
await expect( await expect(
sut.update(authStub.admin, albumStub.oneAsset.id, { sut.update(authStub.admin, albumStub.oneAsset.id, {
@@ -218,22 +229,22 @@ describe(AlbumService.name, () => {
}), }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.getAssetIds).toHaveBeenCalledWith('album-4', ['not-in-album']); expect(albumMock.getAssetIds).toHaveBeenCalledWith('album-4', ['not-in-album']);
expect(mocks.album.update).not.toHaveBeenCalled(); expect(albumMock.update).not.toHaveBeenCalled();
}); });
it('should allow the owner to update the album', async () => { it('should allow the owner to update the album', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4']));
mocks.album.getById.mockResolvedValue(albumStub.oneAsset); albumMock.getById.mockResolvedValue(albumStub.oneAsset);
mocks.album.update.mockResolvedValue(albumStub.oneAsset); albumMock.update.mockResolvedValue(albumStub.oneAsset);
await sut.update(authStub.admin, albumStub.oneAsset.id, { await sut.update(authStub.admin, albumStub.oneAsset.id, {
albumName: 'new album name', albumName: 'new album name',
}); });
expect(mocks.album.update).toHaveBeenCalledTimes(1); expect(albumMock.update).toHaveBeenCalledTimes(1);
expect(mocks.album.update).toHaveBeenCalledWith('album-4', { expect(albumMock.update).toHaveBeenCalledWith('album-4', {
id: 'album-4', id: 'album-4',
albumName: 'new album name', albumName: 'new album name',
}); });
@@ -242,33 +253,33 @@ describe(AlbumService.name, () => {
describe('delete', () => { describe('delete', () => {
it('should throw an error for an album not found', async () => { it('should throw an error for an album not found', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set()); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf( await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
expect(mocks.album.delete).not.toHaveBeenCalled(); expect(albumMock.delete).not.toHaveBeenCalled();
}); });
it('should not let a shared user delete the album', async () => { it('should not let a shared user delete the album', async () => {
mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin); albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf( await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
expect(mocks.album.delete).not.toHaveBeenCalled(); expect(albumMock.delete).not.toHaveBeenCalled();
}); });
it('should let the owner delete an album', async () => { it('should let the owner delete an album', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.empty.id])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.empty.id]));
mocks.album.getById.mockResolvedValue(albumStub.empty); albumMock.getById.mockResolvedValue(albumStub.empty);
await sut.delete(authStub.admin, albumStub.empty.id); await sut.delete(authStub.admin, albumStub.empty.id);
expect(mocks.album.delete).toHaveBeenCalledTimes(1); expect(albumMock.delete).toHaveBeenCalledTimes(1);
expect(mocks.album.delete).toHaveBeenCalledWith(albumStub.empty.id); expect(albumMock.delete).toHaveBeenCalledWith(albumStub.empty.id);
}); });
}); });
@@ -277,47 +288,47 @@ describe(AlbumService.name, () => {
await expect( await expect(
sut.addUsers(authStub.admin, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: 'user-1' }] }), sut.addUsers(authStub.admin, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: 'user-1' }] }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.update).not.toHaveBeenCalled(); expect(albumMock.update).not.toHaveBeenCalled();
}); });
it('should throw an error if the userId is already added', async () => { it('should throw an error if the userId is already added', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin); albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
await expect( await expect(
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, {
albumUsers: [{ userId: authStub.admin.user.id }], albumUsers: [{ userId: authStub.admin.user.id }],
}), }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.update).not.toHaveBeenCalled(); expect(albumMock.update).not.toHaveBeenCalled();
}); });
it('should throw an error if the userId does not exist', async () => { it('should throw an error if the userId does not exist', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin); albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
mocks.user.get.mockResolvedValue(void 0); userMock.get.mockResolvedValue(void 0);
await expect( await expect(
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: 'user-3' }] }), sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: 'user-3' }] }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.update).not.toHaveBeenCalled(); expect(albumMock.update).not.toHaveBeenCalled();
}); });
it('should throw an error if the userId is the ownerId', async () => { it('should throw an error if the userId is the ownerId', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin); albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
await expect( await expect(
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, {
albumUsers: [{ userId: userStub.user1.id }], albumUsers: [{ userId: userStub.user1.id }],
}), }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.update).not.toHaveBeenCalled(); expect(albumMock.update).not.toHaveBeenCalled();
}); });
it('should add valid shared users', async () => { it('should add valid shared users', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin)); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin));
mocks.album.update.mockResolvedValue(albumStub.sharedWithAdmin); albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin);
mocks.user.get.mockResolvedValue(userStub.user2); userMock.get.mockResolvedValue(userStub.user2);
mocks.albumUser.create.mockResolvedValue({ albumUserMock.create.mockResolvedValue({
usersId: userStub.user2.id, usersId: userStub.user2.id,
albumsId: albumStub.sharedWithAdmin.id, albumsId: albumStub.sharedWithAdmin.id,
role: AlbumUserRole.EDITOR, role: AlbumUserRole.EDITOR,
@@ -325,11 +336,11 @@ describe(AlbumService.name, () => {
await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, {
albumUsers: [{ userId: authStub.user2.user.id }], albumUsers: [{ userId: authStub.user2.user.id }],
}); });
expect(mocks.albumUser.create).toHaveBeenCalledWith({ expect(albumUserMock.create).toHaveBeenCalledWith({
usersId: authStub.user2.user.id, usersId: authStub.user2.user.id,
albumsId: albumStub.sharedWithAdmin.id, albumsId: albumStub.sharedWithAdmin.id,
}); });
expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', { expect(eventMock.emit).toHaveBeenCalledWith('album.invite', {
id: albumStub.sharedWithAdmin.id, id: albumStub.sharedWithAdmin.id,
userId: userStub.user2.id, userId: userStub.user2.id,
}); });
@@ -338,94 +349,94 @@ describe(AlbumService.name, () => {
describe('removeUser', () => { describe('removeUser', () => {
it('should require a valid album id', async () => { it('should require a valid album id', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1']));
mocks.album.getById.mockResolvedValue(void 0); albumMock.getById.mockResolvedValue(void 0);
await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.update).not.toHaveBeenCalled(); expect(albumMock.update).not.toHaveBeenCalled();
}); });
it('should remove a shared user from an owned album', async () => { it('should remove a shared user from an owned album', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithUser.id])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithUser.id]));
mocks.album.getById.mockResolvedValue(albumStub.sharedWithUser); albumMock.getById.mockResolvedValue(albumStub.sharedWithUser);
await expect( await expect(
sut.removeUser(authStub.admin, albumStub.sharedWithUser.id, userStub.user1.id), sut.removeUser(authStub.admin, albumStub.sharedWithUser.id, userStub.user1.id),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); expect(albumUserMock.delete).toHaveBeenCalledTimes(1);
expect(mocks.albumUser.delete).toHaveBeenCalledWith({ expect(albumUserMock.delete).toHaveBeenCalledWith({
albumsId: albumStub.sharedWithUser.id, albumsId: albumStub.sharedWithUser.id,
usersId: userStub.user1.id, usersId: userStub.user1.id,
}); });
expect(mocks.album.getById).toHaveBeenCalledWith(albumStub.sharedWithUser.id, { withAssets: false }); expect(albumMock.getById).toHaveBeenCalledWith(albumStub.sharedWithUser.id, { withAssets: false });
}); });
it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => { it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => {
mocks.album.getById.mockResolvedValue(albumStub.sharedWithMultiple); albumMock.getById.mockResolvedValue(albumStub.sharedWithMultiple);
await expect( await expect(
sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.user.id), sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.user.id),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.albumUser.delete).not.toHaveBeenCalled(); expect(albumUserMock.delete).not.toHaveBeenCalled();
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith( expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(
authStub.user1.user.id, authStub.user1.user.id,
new Set([albumStub.sharedWithMultiple.id]), new Set([albumStub.sharedWithMultiple.id]),
); );
}); });
it('should allow a shared user to remove themselves', async () => { it('should allow a shared user to remove themselves', async () => {
mocks.album.getById.mockResolvedValue(albumStub.sharedWithUser); albumMock.getById.mockResolvedValue(albumStub.sharedWithUser);
await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, authStub.user1.user.id); await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, authStub.user1.user.id);
expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); expect(albumUserMock.delete).toHaveBeenCalledTimes(1);
expect(mocks.albumUser.delete).toHaveBeenCalledWith({ expect(albumUserMock.delete).toHaveBeenCalledWith({
albumsId: albumStub.sharedWithUser.id, albumsId: albumStub.sharedWithUser.id,
usersId: authStub.user1.user.id, usersId: authStub.user1.user.id,
}); });
}); });
it('should allow a shared user to remove themselves using "me"', async () => { it('should allow a shared user to remove themselves using "me"', async () => {
mocks.album.getById.mockResolvedValue(albumStub.sharedWithUser); albumMock.getById.mockResolvedValue(albumStub.sharedWithUser);
await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, 'me'); await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, 'me');
expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); expect(albumUserMock.delete).toHaveBeenCalledTimes(1);
expect(mocks.albumUser.delete).toHaveBeenCalledWith({ expect(albumUserMock.delete).toHaveBeenCalledWith({
albumsId: albumStub.sharedWithUser.id, albumsId: albumStub.sharedWithUser.id,
usersId: authStub.user1.user.id, usersId: authStub.user1.user.id,
}); });
}); });
it('should not allow the owner to be removed', async () => { it('should not allow the owner to be removed', async () => {
mocks.album.getById.mockResolvedValue(albumStub.empty); albumMock.getById.mockResolvedValue(albumStub.empty);
await expect(sut.removeUser(authStub.admin, albumStub.empty.id, authStub.admin.user.id)).rejects.toBeInstanceOf( await expect(sut.removeUser(authStub.admin, albumStub.empty.id, authStub.admin.user.id)).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
expect(mocks.album.update).not.toHaveBeenCalled(); expect(albumMock.update).not.toHaveBeenCalled();
}); });
it('should throw an error for a user not in the album', async () => { it('should throw an error for a user not in the album', async () => {
mocks.album.getById.mockResolvedValue(albumStub.empty); albumMock.getById.mockResolvedValue(albumStub.empty);
await expect(sut.removeUser(authStub.admin, albumStub.empty.id, 'user-3')).rejects.toBeInstanceOf( await expect(sut.removeUser(authStub.admin, albumStub.empty.id, 'user-3')).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
expect(mocks.album.update).not.toHaveBeenCalled(); expect(albumMock.update).not.toHaveBeenCalled();
}); });
}); });
describe('updateUser', () => { describe('updateUser', () => {
it('should update user role', async () => { it('should update user role', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
await sut.updateUser(authStub.user1, albumStub.sharedWithAdmin.id, userStub.admin.id, { await sut.updateUser(authStub.user1, albumStub.sharedWithAdmin.id, userStub.admin.id, {
role: AlbumUserRole.EDITOR, role: AlbumUserRole.EDITOR,
}); });
expect(mocks.albumUser.update).toHaveBeenCalledWith( expect(albumUserMock.update).toHaveBeenCalledWith(
{ albumsId: albumStub.sharedWithAdmin.id, usersId: userStub.admin.id }, { albumsId: albumStub.sharedWithAdmin.id, usersId: userStub.admin.id },
{ role: AlbumUserRole.EDITOR }, { role: AlbumUserRole.EDITOR },
); );
@@ -434,9 +445,9 @@ describe(AlbumService.name, () => {
describe('getAlbumInfo', () => { describe('getAlbumInfo', () => {
it('should get a shared album', async () => { it('should get a shared album', async () => {
mocks.album.getById.mockResolvedValue(albumStub.oneAsset); albumMock.getById.mockResolvedValue(albumStub.oneAsset);
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id]));
mocks.album.getMetadataForIds.mockResolvedValue([ albumMock.getMetadataForIds.mockResolvedValue([
{ {
albumId: albumStub.oneAsset.id, albumId: albumStub.oneAsset.id,
assetCount: 1, assetCount: 1,
@@ -447,17 +458,17 @@ describe(AlbumService.name, () => {
await sut.get(authStub.admin, albumStub.oneAsset.id, {}); await sut.get(authStub.admin, albumStub.oneAsset.id, {});
expect(mocks.album.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: true }); expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: true });
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith( expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id, authStub.admin.user.id,
new Set([albumStub.oneAsset.id]), new Set([albumStub.oneAsset.id]),
); );
}); });
it('should get a shared album via a shared link', async () => { it('should get a shared album via a shared link', async () => {
mocks.album.getById.mockResolvedValue(albumStub.oneAsset); albumMock.getById.mockResolvedValue(albumStub.oneAsset);
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123']));
mocks.album.getMetadataForIds.mockResolvedValue([ albumMock.getMetadataForIds.mockResolvedValue([
{ {
albumId: albumStub.oneAsset.id, albumId: albumStub.oneAsset.id,
assetCount: 1, assetCount: 1,
@@ -468,17 +479,17 @@ describe(AlbumService.name, () => {
await sut.get(authStub.adminSharedLink, 'album-123', {}); await sut.get(authStub.adminSharedLink, 'album-123', {});
expect(mocks.album.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true });
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith( expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLink?.id, authStub.adminSharedLink.sharedLink?.id,
new Set(['album-123']), new Set(['album-123']),
); );
}); });
it('should get a shared album via shared with user', async () => { it('should get a shared album via shared with user', async () => {
mocks.album.getById.mockResolvedValue(albumStub.oneAsset); albumMock.getById.mockResolvedValue(albumStub.oneAsset);
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123']));
mocks.album.getMetadataForIds.mockResolvedValue([ albumMock.getMetadataForIds.mockResolvedValue([
{ {
albumId: albumStub.oneAsset.id, albumId: albumStub.oneAsset.id,
assetCount: 1, assetCount: 1,
@@ -489,8 +500,8 @@ describe(AlbumService.name, () => {
await sut.get(authStub.user1, 'album-123', {}); await sut.get(authStub.user1, 'album-123', {});
expect(mocks.album.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true });
expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith( expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith(
authStub.user1.user.id, authStub.user1.user.id,
new Set(['album-123']), new Set(['album-123']),
AlbumUserRole.VIEWER, AlbumUserRole.VIEWER,
@@ -500,8 +511,8 @@ describe(AlbumService.name, () => {
it('should throw an error for no access', async () => { it('should throw an error for no access', async () => {
await expect(sut.get(authStub.admin, 'album-123', {})).rejects.toBeInstanceOf(BadRequestException); await expect(sut.get(authStub.admin, 'album-123', {})).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-123'])); expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-123']));
expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith( expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith(
authStub.admin.user.id, authStub.admin.user.id,
new Set(['album-123']), new Set(['album-123']),
AlbumUserRole.VIEWER, AlbumUserRole.VIEWER,
@@ -511,10 +522,10 @@ describe(AlbumService.name, () => {
describe('addAssets', () => { describe('addAssets', () => {
it('should allow the owner to add assets', async () => { it('should allow the owner to add assets', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); albumMock.getAssetIds.mockResolvedValueOnce(new Set());
await expect( await expect(
sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
@@ -524,37 +535,37 @@ describe(AlbumService.name, () => {
{ success: true, id: 'asset-3' }, { success: true, id: 'asset-3' },
]); ]);
expect(mocks.album.update).toHaveBeenCalledWith('album-123', { expect(albumMock.update).toHaveBeenCalledWith('album-123', {
id: 'album-123', id: 'album-123',
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1', albumThumbnailAssetId: 'asset-1',
}); });
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
}); });
it('should not set the thumbnail if the album has one already', async () => { it('should not set the thumbnail if the album has one already', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
mocks.album.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' })); albumMock.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' }));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); albumMock.getAssetIds.mockResolvedValueOnce(new Set());
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
{ success: true, id: 'asset-1' }, { success: true, id: 'asset-1' },
]); ]);
expect(mocks.album.update).toHaveBeenCalledWith('album-123', { expect(albumMock.update).toHaveBeenCalledWith('album-123', {
id: 'album-123', id: 'album-123',
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-id', albumThumbnailAssetId: 'asset-id',
}); });
expect(mocks.album.addAssetIds).toHaveBeenCalled(); expect(albumMock.addAssetIds).toHaveBeenCalled();
}); });
it('should allow a shared user to add assets', async () => { it('should allow a shared user to add assets', async () => {
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); albumMock.getAssetIds.mockResolvedValueOnce(new Set());
await expect( await expect(
sut.addAssets(authStub.user1, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), sut.addAssets(authStub.user1, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
@@ -564,34 +575,34 @@ describe(AlbumService.name, () => {
{ success: true, id: 'asset-3' }, { success: true, id: 'asset-3' },
]); ]);
expect(mocks.album.update).toHaveBeenCalledWith('album-123', { expect(albumMock.update).toHaveBeenCalledWith('album-123', {
id: 'album-123', id: 'album-123',
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1', albumThumbnailAssetId: 'asset-1',
}); });
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
expect(mocks.event.emit).toHaveBeenCalledWith('album.update', { expect(eventMock.emit).toHaveBeenCalledWith('album.update', {
id: 'album-123', id: 'album-123',
recipientIds: ['admin_id'], recipientIds: ['admin_id'],
}); });
}); });
it('should not allow a shared user with viewer access to add assets', async () => { it('should not allow a shared user with viewer access to add assets', async () => {
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([])); accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set([]));
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser));
await expect( await expect(
sut.addAssets(authStub.user2, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), sut.addAssets(authStub.user2, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.update).not.toHaveBeenCalled(); expect(albumMock.update).not.toHaveBeenCalled();
}); });
it('should allow a shared link user to add assets', async () => { it('should allow a shared link user to add assets', async () => {
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); albumMock.getAssetIds.mockResolvedValueOnce(new Set());
await expect( await expect(
sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
@@ -601,115 +612,115 @@ describe(AlbumService.name, () => {
{ success: true, id: 'asset-3' }, { success: true, id: 'asset-3' },
]); ]);
expect(mocks.album.update).toHaveBeenCalledWith('album-123', { expect(albumMock.update).toHaveBeenCalledWith('album-123', {
id: 'album-123', id: 'album-123',
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1', albumThumbnailAssetId: 'asset-1',
}); });
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith( expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLink?.id, authStub.adminSharedLink.sharedLink?.id,
new Set(['album-123']), new Set(['album-123']),
); );
}); });
it('should allow adding assets shared via partner sharing', async () => { it('should allow adding assets shared via partner sharing', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1'])); accessMock.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1']));
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); albumMock.getAssetIds.mockResolvedValueOnce(new Set());
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
{ success: true, id: 'asset-1' }, { success: true, id: 'asset-1' },
]); ]);
expect(mocks.album.update).toHaveBeenCalledWith('album-123', { expect(albumMock.update).toHaveBeenCalledWith('album-123', {
id: 'album-123', id: 'album-123',
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1', albumThumbnailAssetId: 'asset-1',
}); });
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
}); });
it('should skip duplicate assets', async () => { it('should skip duplicate assets', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id']));
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set(['asset-id'])); albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id']));
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: false, id: 'asset-id', error: BulkIdErrorReason.DUPLICATE }, { success: false, id: 'asset-id', error: BulkIdErrorReason.DUPLICATE },
]); ]);
expect(mocks.album.update).not.toHaveBeenCalled(); expect(albumMock.update).not.toHaveBeenCalled();
}); });
it('should skip assets not shared with user', async () => { it('should skip assets not shared with user', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
mocks.album.getById.mockResolvedValue(albumStub.oneAsset); albumMock.getById.mockResolvedValue(albumStub.oneAsset);
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); albumMock.getAssetIds.mockResolvedValueOnce(new Set());
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
{ success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION }, { success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION },
]); ]);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
}); });
it('should not allow unauthorized access to the album', async () => { it('should not allow unauthorized access to the album', async () => {
mocks.album.getById.mockResolvedValue(albumStub.oneAsset); albumMock.getById.mockResolvedValue(albumStub.oneAsset);
await expect( await expect(
sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalled(); expect(accessMock.album.checkOwnerAccess).toHaveBeenCalled();
expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalled(); expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalled();
}); });
it('should not allow unauthorized shared link access to the album', async () => { it('should not allow unauthorized shared link access to the album', async () => {
mocks.album.getById.mockResolvedValue(albumStub.oneAsset); albumMock.getById.mockResolvedValue(albumStub.oneAsset);
await expect( await expect(
sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalled(); expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalled();
}); });
}); });
describe('removeAssets', () => { describe('removeAssets', () => {
it('should allow the owner to remove assets', async () => { it('should allow the owner to remove assets', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id']));
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-id'])); albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id']));
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: true, id: 'asset-id' }, { success: true, id: 'asset-id' },
]); ]);
expect(mocks.album.removeAssetIds).toHaveBeenCalledWith('album-123', ['asset-id']); expect(albumMock.removeAssetIds).toHaveBeenCalledWith('album-123', ['asset-id']);
}); });
it('should skip assets not in the album', async () => { it('should skip assets not in the album', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.empty)); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.empty));
mocks.album.getAssetIds.mockResolvedValue(new Set()); albumMock.getAssetIds.mockResolvedValue(new Set());
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: false, id: 'asset-id', error: BulkIdErrorReason.NOT_FOUND }, { success: false, id: 'asset-id', error: BulkIdErrorReason.NOT_FOUND },
]); ]);
expect(mocks.album.update).not.toHaveBeenCalled(); expect(albumMock.update).not.toHaveBeenCalled();
}); });
it('should allow owner to remove all assets from the album', async () => { it('should allow owner to remove all assets from the album', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-id'])); albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id']));
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: true, id: 'asset-id' }, { success: true, id: 'asset-id' },
@@ -717,16 +728,16 @@ describe(AlbumService.name, () => {
}); });
it('should reset the thumbnail if it is removed', async () => { it('should reset the thumbnail if it is removed', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id']));
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets)); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets));
mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-id'])); albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id']));
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: true, id: 'asset-id' }, { success: true, id: 'asset-id' },
]); ]);
expect(mocks.album.updateThumbnails).toHaveBeenCalled(); expect(albumMock.updateThumbnails).toHaveBeenCalled();
}); });
}); });
+28 -23
View File
@@ -1,45 +1,50 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { APIKeyService } from 'src/services/api-key.service'; import { APIKeyService } from 'src/services/api-key.service';
import { IApiKeyRepository } from 'src/types';
import { keyStub } from 'test/fixtures/api-key.stub'; import { keyStub } from 'test/fixtures/api-key.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
describe(APIKeyService.name, () => { describe(APIKeyService.name, () => {
let sut: APIKeyService; let sut: APIKeyService;
let mocks: ServiceMocks;
let cryptoMock: Mocked<ICryptoRepository>;
let keyMock: Mocked<IApiKeyRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(APIKeyService)); ({ sut, cryptoMock, keyMock } = newTestService(APIKeyService));
}); });
describe('create', () => { describe('create', () => {
it('should create a new key', async () => { it('should create a new key', async () => {
mocks.apiKey.create.mockResolvedValue(keyStub.admin); keyMock.create.mockResolvedValue(keyStub.admin);
await sut.create(authStub.admin, { name: 'Test Key', permissions: [Permission.ALL] }); await sut.create(authStub.admin, { name: 'Test Key', permissions: [Permission.ALL] });
expect(mocks.apiKey.create).toHaveBeenCalledWith({ expect(keyMock.create).toHaveBeenCalledWith({
key: 'cmFuZG9tLWJ5dGVz (hashed)', key: 'cmFuZG9tLWJ5dGVz (hashed)',
name: 'Test Key', name: 'Test Key',
permissions: [Permission.ALL], permissions: [Permission.ALL],
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
}); });
expect(mocks.crypto.newPassword).toHaveBeenCalled(); expect(cryptoMock.newPassword).toHaveBeenCalled();
expect(mocks.crypto.hashSha256).toHaveBeenCalled(); expect(cryptoMock.hashSha256).toHaveBeenCalled();
}); });
it('should not require a name', async () => { it('should not require a name', async () => {
mocks.apiKey.create.mockResolvedValue(keyStub.admin); keyMock.create.mockResolvedValue(keyStub.admin);
await sut.create(authStub.admin, { permissions: [Permission.ALL] }); await sut.create(authStub.admin, { permissions: [Permission.ALL] });
expect(mocks.apiKey.create).toHaveBeenCalledWith({ expect(keyMock.create).toHaveBeenCalledWith({
key: 'cmFuZG9tLWJ5dGVz (hashed)', key: 'cmFuZG9tLWJ5dGVz (hashed)',
name: 'API Key', name: 'API Key',
permissions: [Permission.ALL], permissions: [Permission.ALL],
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
}); });
expect(mocks.crypto.newPassword).toHaveBeenCalled(); expect(cryptoMock.newPassword).toHaveBeenCalled();
expect(mocks.crypto.hashSha256).toHaveBeenCalled(); expect(cryptoMock.hashSha256).toHaveBeenCalled();
}); });
it('should throw an error if the api key does not have sufficient permissions', async () => { it('should throw an error if the api key does not have sufficient permissions', async () => {
@@ -55,16 +60,16 @@ describe(APIKeyService.name, () => {
BadRequestException, BadRequestException,
); );
expect(mocks.apiKey.update).not.toHaveBeenCalledWith('random-guid'); expect(keyMock.update).not.toHaveBeenCalledWith('random-guid');
}); });
it('should update a key', async () => { it('should update a key', async () => {
mocks.apiKey.getById.mockResolvedValue(keyStub.admin); keyMock.getById.mockResolvedValue(keyStub.admin);
mocks.apiKey.update.mockResolvedValue(keyStub.admin); keyMock.update.mockResolvedValue(keyStub.admin);
await sut.update(authStub.admin, 'random-guid', { name: 'New Name' }); await sut.update(authStub.admin, 'random-guid', { name: 'New Name' });
expect(mocks.apiKey.update).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid', { name: 'New Name' }); expect(keyMock.update).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid', { name: 'New Name' });
}); });
}); });
@@ -72,15 +77,15 @@ describe(APIKeyService.name, () => {
it('should throw an error if the key is not found', async () => { it('should throw an error if the key is not found', async () => {
await expect(sut.delete(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.delete(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.apiKey.delete).not.toHaveBeenCalledWith('random-guid'); expect(keyMock.delete).not.toHaveBeenCalledWith('random-guid');
}); });
it('should delete a key', async () => { it('should delete a key', async () => {
mocks.apiKey.getById.mockResolvedValue(keyStub.admin); keyMock.getById.mockResolvedValue(keyStub.admin);
await sut.delete(authStub.admin, 'random-guid'); await sut.delete(authStub.admin, 'random-guid');
expect(mocks.apiKey.delete).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); expect(keyMock.delete).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid');
}); });
}); });
@@ -88,25 +93,25 @@ describe(APIKeyService.name, () => {
it('should throw an error if the key is not found', async () => { it('should throw an error if the key is not found', async () => {
await expect(sut.getById(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.getById(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.apiKey.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid');
}); });
it('should get a key by id', async () => { it('should get a key by id', async () => {
mocks.apiKey.getById.mockResolvedValue(keyStub.admin); keyMock.getById.mockResolvedValue(keyStub.admin);
await sut.getById(authStub.admin, 'random-guid'); await sut.getById(authStub.admin, 'random-guid');
expect(mocks.apiKey.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid');
}); });
}); });
describe('getAll', () => { describe('getAll', () => {
it('should return all the keys for a user', async () => { it('should return all the keys for a user', async () => {
mocks.apiKey.getByUserId.mockResolvedValue([keyStub.admin]); keyMock.getByUserId.mockResolvedValue([keyStub.admin]);
await expect(sut.getAll(authStub.admin)).resolves.toHaveLength(1); await expect(sut.getAll(authStub.admin)).resolves.toHaveLength(1);
expect(mocks.apiKey.getByUserId).toHaveBeenCalledWith(authStub.admin.user.id); expect(keyMock.getByUserId).toHaveBeenCalledWith(authStub.admin.user.id);
}); });
}); });
}); });
+123 -113
View File
@@ -10,7 +10,10 @@ import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldN
import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
import { AssetFileType, AssetStatus, AssetType, CacheControl } from 'src/enum'; import { AssetFileType, AssetStatus, AssetType, CacheControl } from 'src/enum';
import { JobName } from 'src/interfaces/job.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { AuthRequest } from 'src/middleware/auth.guard'; import { AuthRequest } from 'src/middleware/auth.guard';
import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetMediaService } from 'src/services/asset-media.service';
import { ImmichFileResponse } from 'src/utils/file'; import { ImmichFileResponse } from 'src/utils/file';
@@ -18,7 +21,9 @@ import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub'; import { fileStub } from 'test/fixtures/file.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
@@ -198,10 +203,15 @@ const copiedAsset = Object.freeze({
describe(AssetMediaService.name, () => { describe(AssetMediaService.name, () => {
let sut: AssetMediaService; let sut: AssetMediaService;
let mocks: ServiceMocks;
let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>;
let jobMock: Mocked<IJobRepository>;
let storageMock: Mocked<IStorageRepository>;
let userMock: Mocked<IUserRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(AssetMediaService)); ({ sut, accessMock, assetMock, jobMock, storageMock, userMock } = newTestService(AssetMediaService));
}); });
describe('getUploadAssetIdByChecksum', () => { describe('getUploadAssetIdByChecksum', () => {
@@ -211,25 +221,25 @@ describe(AssetMediaService.name, () => {
it('should handle a non-existent asset', async () => { it('should handle a non-existent asset', async () => {
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toBeUndefined(); await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toBeUndefined();
expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
}); });
it('should find an existing asset', async () => { it('should find an existing asset', async () => {
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toEqual({ await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toEqual({
id: 'asset-id', id: 'asset-id',
status: AssetMediaStatus.DUPLICATE, status: AssetMediaStatus.DUPLICATE,
}); });
expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
}); });
it('should find an existing asset by base64', async () => { it('should find an existing asset by base64', async () => {
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('base64'))).resolves.toEqual({ await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('base64'))).resolves.toEqual({
id: 'asset-id', id: 'asset-id',
status: AssetMediaStatus.DUPLICATE, status: AssetMediaStatus.DUPLICATE,
}); });
expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
}); });
}); });
@@ -298,14 +308,14 @@ describe(AssetMediaService.name, () => {
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual( expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
'upload/profile/admin_id', 'upload/profile/admin_id',
); );
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id');
}); });
it('should return upload for everything else', () => { it('should return upload for everything else', () => {
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual( expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
'upload/upload/admin_id/ra/nd', 'upload/upload/admin_id/ra/nd',
); );
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id/ra/nd'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id/ra/nd');
}); });
}); });
@@ -320,7 +330,7 @@ describe(AssetMediaService.name, () => {
size: 42, size: 42,
}; };
mocks.asset.create.mockResolvedValue(assetEntity); assetMock.create.mockResolvedValue(assetEntity);
await expect( await expect(
sut.uploadAsset( sut.uploadAsset(
@@ -330,9 +340,9 @@ describe(AssetMediaService.name, () => {
), ),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.asset.create).not.toHaveBeenCalled(); expect(assetMock.create).not.toHaveBeenCalled();
expect(mocks.user.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.size); expect(userMock.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.size);
expect(mocks.storage.utimes).not.toHaveBeenCalledWith( expect(storageMock.utimes).not.toHaveBeenCalledWith(
file.originalPath, file.originalPath,
expect.any(Date), expect.any(Date),
new Date(createDto.fileModifiedAt), new Date(createDto.fileModifiedAt),
@@ -349,16 +359,16 @@ describe(AssetMediaService.name, () => {
size: 42, size: 42,
}; };
mocks.asset.create.mockResolvedValue(assetEntity); assetMock.create.mockResolvedValue(assetEntity);
await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({ await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({
id: 'id_1', id: 'id_1',
status: AssetMediaStatus.CREATED, status: AssetMediaStatus.CREATED,
}); });
expect(mocks.asset.create).toHaveBeenCalled(); expect(assetMock.create).toHaveBeenCalled();
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size); expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size);
expect(mocks.storage.utimes).toHaveBeenCalledWith( expect(storageMock.utimes).toHaveBeenCalledWith(
file.originalPath, file.originalPath,
expect.any(Date), expect.any(Date),
new Date(createDto.fileModifiedAt), new Date(createDto.fileModifiedAt),
@@ -377,19 +387,19 @@ describe(AssetMediaService.name, () => {
const error = new Error('unique key violation'); const error = new Error('unique key violation');
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT; (error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
mocks.asset.create.mockRejectedValue(error); assetMock.create.mockRejectedValue(error);
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(assetEntity.id); assetMock.getUploadAssetIdByChecksum.mockResolvedValue(assetEntity.id);
await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({ await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({
id: 'id_1', id: 'id_1',
status: AssetMediaStatus.DUPLICATE, status: AssetMediaStatus.DUPLICATE,
}); });
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.DELETE_FILES, name: JobName.DELETE_FILES,
data: { files: ['fake_path/asset_1.jpeg', undefined] }, data: { files: ['fake_path/asset_1.jpeg', undefined] },
}); });
expect(mocks.user.updateUsage).not.toHaveBeenCalled(); expect(userMock.updateUsage).not.toHaveBeenCalled();
}); });
it('should throw an error if the duplicate could not be found by checksum', async () => { it('should throw an error if the duplicate could not be found by checksum', async () => {
@@ -404,22 +414,22 @@ describe(AssetMediaService.name, () => {
const error = new Error('unique key violation'); const error = new Error('unique key violation');
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT; (error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
mocks.asset.create.mockRejectedValue(error); assetMock.create.mockRejectedValue(error);
await expect(sut.uploadAsset(authStub.user1, createDto, file)).rejects.toBeInstanceOf( await expect(sut.uploadAsset(authStub.user1, createDto, file)).rejects.toBeInstanceOf(
InternalServerErrorException, InternalServerErrorException,
); );
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.DELETE_FILES, name: JobName.DELETE_FILES,
data: { files: ['fake_path/asset_1.jpeg', undefined] }, data: { files: ['fake_path/asset_1.jpeg', undefined] },
}); });
expect(mocks.user.updateUsage).not.toHaveBeenCalled(); expect(userMock.updateUsage).not.toHaveBeenCalled();
}); });
it('should handle a live photo', async () => { it('should handle a live photo', async () => {
mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
mocks.asset.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
await expect( await expect(
sut.uploadAsset( sut.uploadAsset(
@@ -432,13 +442,13 @@ describe(AssetMediaService.name, () => {
id: 'live-photo-still-asset', id: 'live-photo-still-asset',
}); });
expect(mocks.asset.getById).toHaveBeenCalledWith('live-photo-motion-asset'); expect(assetMock.getById).toHaveBeenCalledWith('live-photo-motion-asset');
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalled();
}); });
it('should hide the linked motion asset', async () => { it('should hide the linked motion asset', async () => {
mocks.asset.getById.mockResolvedValueOnce({ ...assetStub.livePhotoMotionAsset, isVisible: true }); assetMock.getById.mockResolvedValueOnce({ ...assetStub.livePhotoMotionAsset, isVisible: true });
mocks.asset.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
await expect( await expect(
sut.uploadAsset( sut.uploadAsset(
@@ -451,25 +461,25 @@ describe(AssetMediaService.name, () => {
id: 'live-photo-still-asset', id: 'live-photo-still-asset',
}); });
expect(mocks.asset.getById).toHaveBeenCalledWith('live-photo-motion-asset'); expect(assetMock.getById).toHaveBeenCalledWith('live-photo-motion-asset');
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'live-photo-motion-asset', isVisible: false }); expect(assetMock.update).toHaveBeenCalledWith({ id: 'live-photo-motion-asset', isVisible: false });
}); });
it('should handle a sidecar file', async () => { it('should handle a sidecar file', async () => {
mocks.asset.getById.mockResolvedValueOnce(assetStub.image); assetMock.getById.mockResolvedValueOnce(assetStub.image);
mocks.asset.create.mockResolvedValueOnce(assetStub.image); assetMock.create.mockResolvedValueOnce(assetStub.image);
await expect(sut.uploadAsset(authStub.user1, createDto, fileStub.photo, fileStub.photoSidecar)).resolves.toEqual({ await expect(sut.uploadAsset(authStub.user1, createDto, fileStub.photo, fileStub.photoSidecar)).resolves.toEqual({
status: AssetMediaStatus.CREATED, status: AssetMediaStatus.CREATED,
id: assetStub.image.id, id: assetStub.image.id,
}); });
expect(mocks.storage.utimes).toHaveBeenCalledWith( expect(storageMock.utimes).toHaveBeenCalledWith(
fileStub.photoSidecar.originalPath, fileStub.photoSidecar.originalPath,
expect.any(Date), expect.any(Date),
new Date(createDto.fileModifiedAt), new Date(createDto.fileModifiedAt),
); );
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalled();
}); });
}); });
@@ -477,22 +487,22 @@ describe(AssetMediaService.name, () => {
it('should require the asset.download permission', async () => { it('should require the asset.download permission', async () => {
await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
}); });
it('should throw an error if the asset is not found', async () => { it('should throw an error if the asset is not found', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(NotFoundException); await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(NotFoundException);
expect(mocks.asset.getById).toHaveBeenCalledWith('asset-1', { files: true }); expect(assetMock.getById).toHaveBeenCalledWith('asset-1', { files: true });
}); });
it('should download a file', async () => { it('should download a file', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
mocks.asset.getById.mockResolvedValue(assetStub.image); assetMock.getById.mockResolvedValue(assetStub.image);
await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).resolves.toEqual( await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).resolves.toEqual(
new ImmichFileResponse({ new ImmichFileResponse({
@@ -508,13 +518,13 @@ describe(AssetMediaService.name, () => {
it('should require asset.view permissions', async () => { it('should require asset.view permissions', async () => {
await expect(sut.viewThumbnail(authStub.admin, 'id', {})).rejects.toBeInstanceOf(BadRequestException); await expect(sut.viewThumbnail(authStub.admin, 'id', {})).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
}); });
it('should throw an error if the asset does not exist', async () => { it('should throw an error if the asset does not exist', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
await expect( await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }), sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
@@ -522,8 +532,8 @@ describe(AssetMediaService.name, () => {
}); });
it('should throw an error if the requested thumbnail file does not exist', async () => { it('should throw an error if the requested thumbnail file does not exist', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.asset.getById.mockResolvedValue({ ...assetStub.image, files: [] }); assetMock.getById.mockResolvedValue({ ...assetStub.image, files: [] });
await expect( await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
@@ -531,8 +541,8 @@ describe(AssetMediaService.name, () => {
}); });
it('should throw an error if the requested preview file does not exist', async () => { it('should throw an error if the requested preview file does not exist', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.asset.getById.mockResolvedValue({ assetMock.getById.mockResolvedValue({
...assetStub.image, ...assetStub.image,
files: [ files: [
{ {
@@ -551,8 +561,8 @@ describe(AssetMediaService.name, () => {
}); });
it('should fall back to preview if the requested thumbnail file does not exist', async () => { it('should fall back to preview if the requested thumbnail file does not exist', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.asset.getById.mockResolvedValue({ assetMock.getById.mockResolvedValue({
...assetStub.image, ...assetStub.image,
files: [ files: [
{ {
@@ -579,8 +589,8 @@ describe(AssetMediaService.name, () => {
}); });
it('should get preview file', async () => { it('should get preview file', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.asset.getById.mockResolvedValue({ ...assetStub.image }); assetMock.getById.mockResolvedValue({ ...assetStub.image });
await expect( await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }), sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
).resolves.toEqual( ).resolves.toEqual(
@@ -594,8 +604,8 @@ describe(AssetMediaService.name, () => {
}); });
it('should get thumbnail file', async () => { it('should get thumbnail file', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.asset.getById.mockResolvedValue({ ...assetStub.image }); assetMock.getById.mockResolvedValue({ ...assetStub.image });
await expect( await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
).resolves.toEqual( ).resolves.toEqual(
@@ -613,27 +623,27 @@ describe(AssetMediaService.name, () => {
it('should require asset.view permissions', async () => { it('should require asset.view permissions', async () => {
await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
}); });
it('should throw an error if the asset does not exist', async () => { it('should throw an error if the asset does not exist', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(NotFoundException); await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(NotFoundException);
}); });
it('should throw an error if the asset is not a video', async () => { it('should throw an error if the asset is not a video', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.asset.getById.mockResolvedValue(assetStub.image); assetMock.getById.mockResolvedValue(assetStub.image);
await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException);
}); });
it('should return the encoded video path if available', async () => { it('should return the encoded video path if available', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.hasEncodedVideo.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.hasEncodedVideo.id]));
mocks.asset.getById.mockResolvedValue(assetStub.hasEncodedVideo); assetMock.getById.mockResolvedValue(assetStub.hasEncodedVideo);
await expect(sut.playbackVideo(authStub.admin, assetStub.hasEncodedVideo.id)).resolves.toEqual( await expect(sut.playbackVideo(authStub.admin, assetStub.hasEncodedVideo.id)).resolves.toEqual(
new ImmichFileResponse({ new ImmichFileResponse({
@@ -645,8 +655,8 @@ describe(AssetMediaService.name, () => {
}); });
it('should fall back to the original path', async () => { it('should fall back to the original path', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.video.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.video.id]));
mocks.asset.getById.mockResolvedValue(assetStub.video); assetMock.getById.mockResolvedValue(assetStub.video);
await expect(sut.playbackVideo(authStub.admin, assetStub.video.id)).resolves.toEqual( await expect(sut.playbackVideo(authStub.admin, assetStub.video.id)).resolves.toEqual(
new ImmichFileResponse({ new ImmichFileResponse({
@@ -660,12 +670,12 @@ describe(AssetMediaService.name, () => {
describe('checkExistingAssets', () => { describe('checkExistingAssets', () => {
it('should get existing asset ids', async () => { it('should get existing asset ids', async () => {
mocks.asset.getByDeviceIds.mockResolvedValue(['42']); assetMock.getByDeviceIds.mockResolvedValue(['42']);
await expect( await expect(
sut.checkExistingAssets(authStub.admin, { deviceId: '420', deviceAssetIds: ['69'] }), sut.checkExistingAssets(authStub.admin, { deviceId: '420', deviceAssetIds: ['69'] }),
).resolves.toEqual({ existingIds: ['42'] }); ).resolves.toEqual({ existingIds: ['42'] });
expect(mocks.asset.getByDeviceIds).toHaveBeenCalledWith(userStub.admin.id, '420', ['69']); expect(assetMock.getByDeviceIds).toHaveBeenCalledWith(userStub.admin.id, '420', ['69']);
}); });
}); });
@@ -675,26 +685,26 @@ describe(AssetMediaService.name, () => {
'Not found or no asset.update access', 'Not found or no asset.update access',
); );
expect(mocks.asset.create).not.toHaveBeenCalled(); expect(assetMock.create).not.toHaveBeenCalled();
}); });
it('should update a photo with no sidecar to photo with no sidecar', async () => { it('should update a photo with no sidecar to photo with no sidecar', async () => {
const updatedFile = fileStub.photo; const updatedFile = fileStub.photo;
const updatedAsset = { ...existingAsset, ...updatedFile }; const updatedAsset = { ...existingAsset, ...updatedFile };
mocks.asset.getById.mockResolvedValueOnce(existingAsset); assetMock.getById.mockResolvedValueOnce(existingAsset);
mocks.asset.getById.mockResolvedValueOnce(updatedAsset); assetMock.getById.mockResolvedValueOnce(updatedAsset);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
// this is the original file size // this is the original file size
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
// this is for the clone call // this is for the clone call
mocks.asset.create.mockResolvedValue(copiedAsset); assetMock.create.mockResolvedValue(copiedAsset);
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({ await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({
status: AssetMediaStatus.REPLACED, status: AssetMediaStatus.REPLACED,
id: 'copied-asset', id: 'copied-asset',
}); });
expect(mocks.asset.update).toHaveBeenCalledWith( expect(assetMock.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
id: existingAsset.id, id: existingAsset.id,
sidecarPath: null, sidecarPath: null,
@@ -702,7 +712,7 @@ describe(AssetMediaService.name, () => {
originalPath: 'fake_path/photo1.jpeg', originalPath: 'fake_path/photo1.jpeg',
}), }),
); );
expect(mocks.asset.create).toHaveBeenCalledWith( expect(assetMock.create).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
sidecarPath: null, sidecarPath: null,
originalFileName: 'existing-filename.jpeg', originalFileName: 'existing-filename.jpeg',
@@ -710,12 +720,12 @@ describe(AssetMediaService.name, () => {
}), }),
); );
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], { expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
deletedAt: expect.any(Date), deletedAt: expect.any(Date),
status: AssetStatus.TRASHED, status: AssetStatus.TRASHED,
}); });
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
expect(mocks.storage.utimes).toHaveBeenCalledWith( expect(storageMock.utimes).toHaveBeenCalledWith(
updatedFile.originalPath, updatedFile.originalPath,
expect.any(Date), expect.any(Date),
new Date(replaceDto.fileModifiedAt), new Date(replaceDto.fileModifiedAt),
@@ -726,13 +736,13 @@ describe(AssetMediaService.name, () => {
const updatedFile = fileStub.photo; const updatedFile = fileStub.photo;
const sidecarFile = fileStub.photoSidecar; const sidecarFile = fileStub.photoSidecar;
const updatedAsset = { ...sidecarAsset, ...updatedFile }; const updatedAsset = { ...sidecarAsset, ...updatedFile };
mocks.asset.getById.mockResolvedValueOnce(existingAsset); assetMock.getById.mockResolvedValueOnce(existingAsset);
mocks.asset.getById.mockResolvedValueOnce(updatedAsset); assetMock.getById.mockResolvedValueOnce(updatedAsset);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
// this is the original file size // this is the original file size
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
// this is for the clone call // this is for the clone call
mocks.asset.create.mockResolvedValue(copiedAsset); assetMock.create.mockResolvedValue(copiedAsset);
await expect( await expect(
sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile, sidecarFile), sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile, sidecarFile),
@@ -741,12 +751,12 @@ describe(AssetMediaService.name, () => {
id: 'copied-asset', id: 'copied-asset',
}); });
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], { expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
deletedAt: expect.any(Date), deletedAt: expect.any(Date),
status: AssetStatus.TRASHED, status: AssetStatus.TRASHED,
}); });
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
expect(mocks.storage.utimes).toHaveBeenCalledWith( expect(storageMock.utimes).toHaveBeenCalledWith(
updatedFile.originalPath, updatedFile.originalPath,
expect.any(Date), expect.any(Date),
new Date(replaceDto.fileModifiedAt), new Date(replaceDto.fileModifiedAt),
@@ -757,25 +767,25 @@ describe(AssetMediaService.name, () => {
const updatedFile = fileStub.photo; const updatedFile = fileStub.photo;
const updatedAsset = { ...sidecarAsset, ...updatedFile }; const updatedAsset = { ...sidecarAsset, ...updatedFile };
mocks.asset.getById.mockResolvedValueOnce(sidecarAsset); assetMock.getById.mockResolvedValueOnce(sidecarAsset);
mocks.asset.getById.mockResolvedValueOnce(updatedAsset); assetMock.getById.mockResolvedValueOnce(updatedAsset);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
// this is the original file size // this is the original file size
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
// this is for the copy call // this is for the copy call
mocks.asset.create.mockResolvedValue(copiedAsset); assetMock.create.mockResolvedValue(copiedAsset);
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({ await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({
status: AssetMediaStatus.REPLACED, status: AssetMediaStatus.REPLACED,
id: 'copied-asset', id: 'copied-asset',
}); });
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], { expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
deletedAt: expect.any(Date), deletedAt: expect.any(Date),
status: AssetStatus.TRASHED, status: AssetStatus.TRASHED,
}); });
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
expect(mocks.storage.utimes).toHaveBeenCalledWith( expect(storageMock.utimes).toHaveBeenCalledWith(
updatedFile.originalPath, updatedFile.originalPath,
expect.any(Date), expect.any(Date),
new Date(replaceDto.fileModifiedAt), new Date(replaceDto.fileModifiedAt),
@@ -787,27 +797,27 @@ describe(AssetMediaService.name, () => {
const error = new Error('unique key violation'); const error = new Error('unique key violation');
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT; (error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
mocks.asset.update.mockRejectedValue(error); assetMock.update.mockRejectedValue(error);
mocks.asset.getById.mockResolvedValueOnce(sidecarAsset); assetMock.getById.mockResolvedValueOnce(sidecarAsset);
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(sidecarAsset.id); assetMock.getUploadAssetIdByChecksum.mockResolvedValue(sidecarAsset.id);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
// this is the original file size // this is the original file size
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
// this is for the clone call // this is for the clone call
mocks.asset.create.mockResolvedValue(copiedAsset); assetMock.create.mockResolvedValue(copiedAsset);
await expect(sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile)).resolves.toEqual({ await expect(sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile)).resolves.toEqual({
status: AssetMediaStatus.DUPLICATE, status: AssetMediaStatus.DUPLICATE,
id: sidecarAsset.id, id: sidecarAsset.id,
}); });
expect(mocks.asset.create).not.toHaveBeenCalled(); expect(assetMock.create).not.toHaveBeenCalled();
expect(mocks.asset.updateAll).not.toHaveBeenCalled(); expect(assetMock.updateAll).not.toHaveBeenCalled();
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.DELETE_FILES, name: JobName.DELETE_FILES,
data: { files: [updatedFile.originalPath, undefined] }, data: { files: [updatedFile.originalPath, undefined] },
}); });
expect(mocks.user.updateUsage).not.toHaveBeenCalled(); expect(userMock.updateUsage).not.toHaveBeenCalled();
}); });
}); });
@@ -816,7 +826,7 @@ describe(AssetMediaService.name, () => {
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex'); const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex');
mocks.asset.getByChecksums.mockResolvedValue([ assetMock.getByChecksums.mockResolvedValue([
{ id: 'asset-1', checksum: file1 } as AssetEntity, { id: 'asset-1', checksum: file1 } as AssetEntity,
{ id: 'asset-2', checksum: file2 } as AssetEntity, { id: 'asset-2', checksum: file2 } as AssetEntity,
]); ]);
@@ -847,14 +857,14 @@ describe(AssetMediaService.name, () => {
], ],
}); });
expect(mocks.asset.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]); expect(assetMock.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]);
}); });
it('should return non-duplicates as well', async () => { it('should return non-duplicates as well', async () => {
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex'); const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex');
mocks.asset.getByChecksums.mockResolvedValue([{ id: 'asset-1', checksum: file1 } as AssetEntity]); assetMock.getByChecksums.mockResolvedValue([{ id: 'asset-1', checksum: file1 } as AssetEntity]);
await expect( await expect(
sut.bulkUploadCheck(authStub.admin, { sut.bulkUploadCheck(authStub.admin, {
@@ -879,7 +889,7 @@ describe(AssetMediaService.name, () => {
], ],
}); });
expect(mocks.asset.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]); expect(assetMock.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]);
}); });
}); });
@@ -900,7 +910,7 @@ describe(AssetMediaService.name, () => {
await sut.onUploadError(request, file); await sut.onUploadError(request, file);
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.DELETE_FILES, name: JobName.DELETE_FILES,
data: { files: ['upload/upload/user-id/ra/nd/random-uuid.jpg'] }, data: { files: ['upload/upload/user-id/ra/nd/random-uuid.jpg'] },
}); });
+152 -137
View File
@@ -4,16 +4,22 @@ import { mapAsset } from 'src/dtos/asset-response.dto';
import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, AssetType } from 'src/enum'; import { AssetStatus, AssetType } from 'src/enum';
import { AssetStats } from 'src/interfaces/asset.interface'; import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface';
import { JobName, JobStatus } from 'src/interfaces/job.interface'; import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IStackRepository } from 'src/interfaces/stack.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { AssetService } from 'src/services/asset.service'; import { AssetService } from 'src/services/asset.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { faceStub } from 'test/fixtures/face.stub'; import { faceStub } from 'test/fixtures/face.stub';
import { partnerStub } from 'test/fixtures/partner.stub'; import { partnerStub } from 'test/fixtures/partner.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { vitest } from 'vitest'; import { newTestService } from 'test/utils';
import { Mocked, vitest } from 'vitest';
const stats: AssetStats = { const stats: AssetStats = {
[AssetType.IMAGE]: 10, [AssetType.IMAGE]: 10,
@@ -30,18 +36,27 @@ const statResponse: AssetStatsResponseDto = {
describe(AssetService.name, () => { describe(AssetService.name, () => {
let sut: AssetService; let sut: AssetService;
let mocks: ServiceMocks;
let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>;
let partnerMock: Mocked<IPartnerRepository>;
let stackMock: Mocked<IStackRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let userMock: Mocked<IUserRepository>;
it('should work', () => { it('should work', () => {
expect(sut).toBeDefined(); expect(sut).toBeDefined();
}); });
const mockGetById = (assets: AssetEntity[]) => { const mockGetById = (assets: AssetEntity[]) => {
mocks.asset.getById.mockImplementation((assetId) => Promise.resolve(assets.find((asset) => asset.id === assetId))); assetMock.getById.mockImplementation((assetId) => Promise.resolve(assets.find((asset) => asset.id === assetId)));
}; };
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(AssetService)); ({ sut, accessMock, assetMock, eventMock, jobMock, partnerMock, stackMock, systemMock, userMock } =
newTestService(AssetService));
mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]); mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]);
}); });
@@ -62,8 +77,8 @@ describe(AssetService.name, () => {
const image3 = { ...assetStub.image, localDateTime: new Date(2015, 1, 15) }; const image3 = { ...assetStub.image, localDateTime: new Date(2015, 1, 15) };
const image4 = { ...assetStub.image, localDateTime: new Date(2009, 1, 15) }; const image4 = { ...assetStub.image, localDateTime: new Date(2009, 1, 15) };
mocks.partner.getAll.mockResolvedValue([]); partnerMock.getAll.mockResolvedValue([]);
mocks.asset.getByDayOfYear.mockResolvedValue([ assetMock.getByDayOfYear.mockResolvedValue([
{ {
yearsAgo: 1, yearsAgo: 1,
assets: [image1, image2], assets: [image1, image2],
@@ -84,16 +99,16 @@ describe(AssetService.name, () => {
{ yearsAgo: 15, title: '15 years ago', assets: [mapAsset(image4)] }, { yearsAgo: 15, title: '15 years ago', assets: [mapAsset(image4)] },
]); ]);
expect(mocks.asset.getByDayOfYear.mock.calls).toEqual([[[authStub.admin.user.id], { day: 15, month: 1 }]]); expect(assetMock.getByDayOfYear.mock.calls).toEqual([[[authStub.admin.user.id], { day: 15, month: 1 }]]);
}); });
it('should get memories with partners with inTimeline enabled', async () => { it('should get memories with partners with inTimeline enabled', async () => {
mocks.partner.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]); partnerMock.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]);
mocks.asset.getByDayOfYear.mockResolvedValue([]); assetMock.getByDayOfYear.mockResolvedValue([]);
await sut.getMemoryLane(authStub.admin, { day: 15, month: 1 }); await sut.getMemoryLane(authStub.admin, { day: 15, month: 1 });
expect(mocks.asset.getByDayOfYear.mock.calls).toEqual([ expect(assetMock.getByDayOfYear.mock.calls).toEqual([
[[authStub.admin.user.id, userStub.user1.id], { day: 15, month: 1 }], [[authStub.admin.user.id, userStub.user1.id], { day: 15, month: 1 }],
]); ]);
}); });
@@ -101,76 +116,76 @@ describe(AssetService.name, () => {
describe('getStatistics', () => { describe('getStatistics', () => {
it('should get the statistics for a user, excluding archived assets', async () => { it('should get the statistics for a user, excluding archived assets', async () => {
mocks.asset.getStatistics.mockResolvedValue(stats); assetMock.getStatistics.mockResolvedValue(stats);
await expect(sut.getStatistics(authStub.admin, { isArchived: false })).resolves.toEqual(statResponse); await expect(sut.getStatistics(authStub.admin, { isArchived: false })).resolves.toEqual(statResponse);
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: false }); expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: false });
}); });
it('should get the statistics for a user for archived assets', async () => { it('should get the statistics for a user for archived assets', async () => {
mocks.asset.getStatistics.mockResolvedValue(stats); assetMock.getStatistics.mockResolvedValue(stats);
await expect(sut.getStatistics(authStub.admin, { isArchived: true })).resolves.toEqual(statResponse); await expect(sut.getStatistics(authStub.admin, { isArchived: true })).resolves.toEqual(statResponse);
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: true }); expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: true });
}); });
it('should get the statistics for a user for favorite assets', async () => { it('should get the statistics for a user for favorite assets', async () => {
mocks.asset.getStatistics.mockResolvedValue(stats); assetMock.getStatistics.mockResolvedValue(stats);
await expect(sut.getStatistics(authStub.admin, { isFavorite: true })).resolves.toEqual(statResponse); await expect(sut.getStatistics(authStub.admin, { isFavorite: true })).resolves.toEqual(statResponse);
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isFavorite: true }); expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isFavorite: true });
}); });
it('should get the statistics for a user for all assets', async () => { it('should get the statistics for a user for all assets', async () => {
mocks.asset.getStatistics.mockResolvedValue(stats); assetMock.getStatistics.mockResolvedValue(stats);
await expect(sut.getStatistics(authStub.admin, {})).resolves.toEqual(statResponse); await expect(sut.getStatistics(authStub.admin, {})).resolves.toEqual(statResponse);
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, {}); expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, {});
}); });
}); });
describe('getRandom', () => { describe('getRandom', () => {
it('should get own random assets', async () => { it('should get own random assets', async () => {
mocks.asset.getRandom.mockResolvedValue([assetStub.image]); assetMock.getRandom.mockResolvedValue([assetStub.image]);
await sut.getRandom(authStub.admin, 1); await sut.getRandom(authStub.admin, 1);
expect(mocks.asset.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1); expect(assetMock.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1);
}); });
it('should not include partner assets if not in timeline', async () => { it('should not include partner assets if not in timeline', async () => {
mocks.asset.getRandom.mockResolvedValue([assetStub.image]); assetMock.getRandom.mockResolvedValue([assetStub.image]);
mocks.partner.getAll.mockResolvedValue([{ ...partnerStub.user1ToAdmin1, inTimeline: false }]); partnerMock.getAll.mockResolvedValue([{ ...partnerStub.user1ToAdmin1, inTimeline: false }]);
await sut.getRandom(authStub.admin, 1); await sut.getRandom(authStub.admin, 1);
expect(mocks.asset.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1); expect(assetMock.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1);
}); });
it('should include partner assets if in timeline', async () => { it('should include partner assets if in timeline', async () => {
mocks.asset.getRandom.mockResolvedValue([assetStub.image]); assetMock.getRandom.mockResolvedValue([assetStub.image]);
mocks.partner.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]); partnerMock.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]);
await sut.getRandom(authStub.admin, 1); await sut.getRandom(authStub.admin, 1);
expect(mocks.asset.getRandom).toHaveBeenCalledWith([userStub.admin.id, userStub.user1.id], 1); expect(assetMock.getRandom).toHaveBeenCalledWith([userStub.admin.id, userStub.user1.id], 1);
}); });
}); });
describe('get', () => { describe('get', () => {
it('should allow owner access', async () => { it('should allow owner access', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.asset.getById.mockResolvedValue(assetStub.image); assetMock.getById.mockResolvedValue(assetStub.image);
await sut.get(authStub.admin, assetStub.image.id); await sut.get(authStub.admin, assetStub.image.id);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id, authStub.admin.user.id,
new Set([assetStub.image.id]), new Set([assetStub.image.id]),
); );
}); });
it('should allow shared link access', async () => { it('should allow shared link access', async () => {
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.asset.getById.mockResolvedValue(assetStub.image); assetMock.getById.mockResolvedValue(assetStub.image);
await sut.get(authStub.adminSharedLink, assetStub.image.id); await sut.get(authStub.adminSharedLink, assetStub.image.id);
expect(mocks.access.asset.checkSharedLinkAccess).toHaveBeenCalledWith( expect(accessMock.asset.checkSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLink?.id, authStub.adminSharedLink.sharedLink?.id,
new Set([assetStub.image.id]), new Set([assetStub.image.id]),
); );
}); });
it('should strip metadata for shared link if exif is disabled', async () => { it('should strip metadata for shared link if exif is disabled', async () => {
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.asset.getById.mockResolvedValue(assetStub.image); assetMock.getById.mockResolvedValue(assetStub.image);
const result = await sut.get( const result = await sut.get(
{ ...authStub.adminSharedLink, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } }, { ...authStub.adminSharedLink, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } },
@@ -179,27 +194,27 @@ describe(AssetService.name, () => {
expect(result).toEqual(expect.objectContaining({ hasMetadata: false })); expect(result).toEqual(expect.objectContaining({ hasMetadata: false }));
expect(result).not.toHaveProperty('exifInfo'); expect(result).not.toHaveProperty('exifInfo');
expect(mocks.access.asset.checkSharedLinkAccess).toHaveBeenCalledWith( expect(accessMock.asset.checkSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLink?.id, authStub.adminSharedLink.sharedLink?.id,
new Set([assetStub.image.id]), new Set([assetStub.image.id]),
); );
}); });
it('should allow partner sharing access', async () => { it('should allow partner sharing access', async () => {
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.asset.checkPartnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.asset.getById.mockResolvedValue(assetStub.image); assetMock.getById.mockResolvedValue(assetStub.image);
await sut.get(authStub.admin, assetStub.image.id); await sut.get(authStub.admin, assetStub.image.id);
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith( expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id, authStub.admin.user.id,
new Set([assetStub.image.id]), new Set([assetStub.image.id]),
); );
}); });
it('should allow shared album access', async () => { it('should allow shared album access', async () => {
mocks.access.asset.checkAlbumAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.asset.checkAlbumAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.asset.getById.mockResolvedValue(assetStub.image); assetMock.getById.mockResolvedValue(assetStub.image);
await sut.get(authStub.admin, assetStub.image.id); await sut.get(authStub.admin, assetStub.image.id);
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith( expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(
authStub.admin.user.id, authStub.admin.user.id,
new Set([assetStub.image.id]), new Set([assetStub.image.id]),
); );
@@ -207,17 +222,17 @@ describe(AssetService.name, () => {
it('should throw an error for no access', async () => { it('should throw an error for no access', async () => {
await expect(sut.get(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.get(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.asset.getById).not.toHaveBeenCalled(); expect(assetMock.getById).not.toHaveBeenCalled();
}); });
it('should throw an error for an invalid shared link', async () => { it('should throw an error for an invalid shared link', async () => {
await expect(sut.get(authStub.adminSharedLink, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.get(authStub.adminSharedLink, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.asset.checkOwnerAccess).not.toHaveBeenCalled(); expect(accessMock.asset.checkOwnerAccess).not.toHaveBeenCalled();
expect(mocks.asset.getById).not.toHaveBeenCalled(); expect(assetMock.getById).not.toHaveBeenCalled();
}); });
it('should throw an error if the asset could not be found', async () => { it('should throw an error if the asset could not be found', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
await expect(sut.get(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.get(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException);
}); });
}); });
@@ -227,40 +242,40 @@ describe(AssetService.name, () => {
await expect(sut.update(authStub.admin, 'asset-1', { isArchived: false })).rejects.toBeInstanceOf( await expect(sut.update(authStub.admin, 'asset-1', { isArchived: false })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalled();
}); });
it('should update the asset', async () => { it('should update the asset', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
mocks.asset.getById.mockResolvedValue(assetStub.image); assetMock.getById.mockResolvedValue(assetStub.image);
mocks.asset.update.mockResolvedValue(assetStub.image); assetMock.update.mockResolvedValue(assetStub.image);
await sut.update(authStub.admin, 'asset-1', { isFavorite: true }); await sut.update(authStub.admin, 'asset-1', { isFavorite: true });
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true }); expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true });
}); });
it('should update the exif description', async () => { it('should update the exif description', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
mocks.asset.getById.mockResolvedValue(assetStub.image); assetMock.getById.mockResolvedValue(assetStub.image);
mocks.asset.update.mockResolvedValue(assetStub.image); assetMock.update.mockResolvedValue(assetStub.image);
await sut.update(authStub.admin, 'asset-1', { description: 'Test description' }); await sut.update(authStub.admin, 'asset-1', { description: 'Test description' });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' }); expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' });
}); });
it('should update the exif rating', async () => { it('should update the exif rating', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
mocks.asset.getById.mockResolvedValueOnce(assetStub.image); assetMock.getById.mockResolvedValueOnce(assetStub.image);
mocks.asset.update.mockResolvedValueOnce(assetStub.image); assetMock.update.mockResolvedValueOnce(assetStub.image);
await sut.update(authStub.admin, 'asset-1', { rating: 3 }); await sut.update(authStub.admin, 'asset-1', { rating: 3 });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', rating: 3 }); expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', rating: 3 });
}); });
it('should fail linking a live video if the motion part could not be found', async () => { it('should fail linking a live video if the motion part could not be found', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
await expect( await expect(
sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, {
@@ -268,20 +283,20 @@ describe(AssetService.name, () => {
}), }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.asset.update).not.toHaveBeenCalledWith({ expect(assetMock.update).not.toHaveBeenCalledWith({
id: assetStub.livePhotoStillAsset.id, id: assetStub.livePhotoStillAsset.id,
livePhotoVideoId: assetStub.livePhotoMotionAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
}); });
expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); expect(assetMock.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true });
expect(mocks.event.emit).not.toHaveBeenCalledWith('asset.show', { expect(eventMock.emit).not.toHaveBeenCalledWith('asset.show', {
assetId: assetStub.livePhotoMotionAsset.id, assetId: assetStub.livePhotoMotionAsset.id,
userId: userStub.admin.id, userId: userStub.admin.id,
}); });
}); });
it('should fail linking a live video if the motion part is not a video', async () => { it('should fail linking a live video if the motion part is not a video', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset);
await expect( await expect(
sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, {
@@ -289,20 +304,20 @@ describe(AssetService.name, () => {
}), }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.asset.update).not.toHaveBeenCalledWith({ expect(assetMock.update).not.toHaveBeenCalledWith({
id: assetStub.livePhotoStillAsset.id, id: assetStub.livePhotoStillAsset.id,
livePhotoVideoId: assetStub.livePhotoMotionAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
}); });
expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); expect(assetMock.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true });
expect(mocks.event.emit).not.toHaveBeenCalledWith('asset.show', { expect(eventMock.emit).not.toHaveBeenCalledWith('asset.show', {
assetId: assetStub.livePhotoMotionAsset.id, assetId: assetStub.livePhotoMotionAsset.id,
userId: userStub.admin.id, userId: userStub.admin.id,
}); });
}); });
it('should fail linking a live video if the motion part has a different owner', async () => { it('should fail linking a live video if the motion part has a different owner', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
await expect( await expect(
sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, {
@@ -310,79 +325,79 @@ describe(AssetService.name, () => {
}), }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.asset.update).not.toHaveBeenCalledWith({ expect(assetMock.update).not.toHaveBeenCalledWith({
id: assetStub.livePhotoStillAsset.id, id: assetStub.livePhotoStillAsset.id,
livePhotoVideoId: assetStub.livePhotoMotionAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
}); });
expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); expect(assetMock.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true });
expect(mocks.event.emit).not.toHaveBeenCalledWith('asset.show', { expect(eventMock.emit).not.toHaveBeenCalledWith('asset.show', {
assetId: assetStub.livePhotoMotionAsset.id, assetId: assetStub.livePhotoMotionAsset.id,
userId: userStub.admin.id, userId: userStub.admin.id,
}); });
}); });
it('should link a live video', async () => { it('should link a live video', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
mocks.asset.getById.mockResolvedValueOnce({ assetMock.getById.mockResolvedValueOnce({
...assetStub.livePhotoMotionAsset, ...assetStub.livePhotoMotionAsset,
ownerId: authStub.admin.user.id, ownerId: authStub.admin.user.id,
isVisible: true, isVisible: true,
}); });
mocks.asset.getById.mockResolvedValueOnce(assetStub.image); assetMock.getById.mockResolvedValueOnce(assetStub.image);
mocks.asset.update.mockResolvedValue(assetStub.image); assetMock.update.mockResolvedValue(assetStub.image);
await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, {
livePhotoVideoId: assetStub.livePhotoMotionAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
}); });
expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false }); expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false });
expect(mocks.event.emit).toHaveBeenCalledWith('asset.hide', { expect(eventMock.emit).toHaveBeenCalledWith('asset.hide', {
assetId: assetStub.livePhotoMotionAsset.id, assetId: assetStub.livePhotoMotionAsset.id,
userId: userStub.admin.id, userId: userStub.admin.id,
}); });
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(assetMock.update).toHaveBeenCalledWith({
id: assetStub.livePhotoStillAsset.id, id: assetStub.livePhotoStillAsset.id,
livePhotoVideoId: assetStub.livePhotoMotionAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
}); });
}); });
it('should throw an error if asset could not be found after update', async () => { it('should throw an error if asset could not be found after update', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
await expect(sut.update(authStub.admin, 'asset-1', { isFavorite: true })).rejects.toBeInstanceOf( await expect(sut.update(authStub.admin, 'asset-1', { isFavorite: true })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
}); });
it('should unlink a live video', async () => { it('should unlink a live video', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset); assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
mocks.asset.update.mockResolvedValueOnce(assetStub.image); assetMock.update.mockResolvedValueOnce(assetStub.image);
await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }); await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null });
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(assetMock.update).toHaveBeenCalledWith({
id: assetStub.livePhotoStillAsset.id, id: assetStub.livePhotoStillAsset.id,
livePhotoVideoId: null, livePhotoVideoId: null,
}); });
expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true });
expect(mocks.event.emit).toHaveBeenCalledWith('asset.show', { expect(eventMock.emit).toHaveBeenCalledWith('asset.show', {
assetId: assetStub.livePhotoMotionAsset.id, assetId: assetStub.livePhotoMotionAsset.id,
userId: userStub.admin.id, userId: userStub.admin.id,
}); });
}); });
it('should fail unlinking a live video if the asset could not be found', async () => { it('should fail unlinking a live video if the asset could not be found', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
// eslint-disable-next-line unicorn/no-useless-undefined // eslint-disable-next-line unicorn/no-useless-undefined
mocks.asset.getById.mockResolvedValueOnce(undefined); assetMock.getById.mockResolvedValueOnce(undefined);
await expect( await expect(
sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }), sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalled();
expect(mocks.event.emit).not.toHaveBeenCalled(); expect(eventMock.emit).not.toHaveBeenCalled();
}); });
}); });
@@ -397,13 +412,13 @@ describe(AssetService.name, () => {
}); });
it('should update all assets', async () => { it('should update all assets', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true }); await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
}); });
it('should not update Assets table if no relevant fields are provided', async () => { it('should not update Assets table if no relevant fields are provided', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
await sut.updateAll(authStub.admin, { await sut.updateAll(authStub.admin, {
ids: ['asset-1'], ids: ['asset-1'],
latitude: 0, latitude: 0,
@@ -413,11 +428,11 @@ describe(AssetService.name, () => {
duplicateId: undefined, duplicateId: undefined,
rating: undefined, rating: undefined,
}); });
expect(mocks.asset.updateAll).not.toHaveBeenCalled(); expect(assetMock.updateAll).not.toHaveBeenCalled();
}); });
it('should update Assets table if isArchived field is provided', async () => { it('should update Assets table if isArchived field is provided', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
await sut.updateAll(authStub.admin, { await sut.updateAll(authStub.admin, {
ids: ['asset-1'], ids: ['asset-1'],
latitude: 0, latitude: 0,
@@ -427,7 +442,7 @@ describe(AssetService.name, () => {
duplicateId: undefined, duplicateId: undefined,
rating: undefined, rating: undefined,
}); });
expect(mocks.asset.updateAll).toHaveBeenCalled(); expect(assetMock.updateAll).toHaveBeenCalled();
}); });
}); });
@@ -441,26 +456,26 @@ describe(AssetService.name, () => {
}); });
it('should force delete a batch of assets', async () => { it('should force delete a batch of assets', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2']));
await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: true }); await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: true });
expect(mocks.event.emit).toHaveBeenCalledWith('assets.delete', { expect(eventMock.emit).toHaveBeenCalledWith('assets.delete', {
assetIds: ['asset1', 'asset2'], assetIds: ['asset1', 'asset2'],
userId: 'user-id', userId: 'user-id',
}); });
}); });
it('should soft delete a batch of assets', async () => { it('should soft delete a batch of assets', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2']));
await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: false }); await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: false });
expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset1', 'asset2'], { expect(assetMock.updateAll).toHaveBeenCalledWith(['asset1', 'asset2'], {
deletedAt: expect.any(Date), deletedAt: expect.any(Date),
status: AssetStatus.TRASHED, status: AssetStatus.TRASHED,
}); });
expect(mocks.job.queue.mock.calls).toEqual([]); expect(jobMock.queue.mock.calls).toEqual([]);
}); });
}); });
@@ -474,27 +489,27 @@ describe(AssetService.name, () => {
}); });
it('should immediately queue assets for deletion if trash is disabled', async () => { it('should immediately queue assets for deletion if trash is disabled', async () => {
mocks.asset.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] });
mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: false } }); systemMock.get.mockResolvedValue({ trash: { enabled: false } });
await expect(sut.handleAssetDeletionCheck()).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleAssetDeletionCheck()).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.asset.getAll).toHaveBeenCalledWith(expect.anything(), { trashedBefore: new Date() }); expect(assetMock.getAll).toHaveBeenCalledWith(expect.anything(), { trashedBefore: new Date() });
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ name: JobName.ASSET_DELETION, data: { id: assetStub.image.id, deleteOnDisk: true } }, { name: JobName.ASSET_DELETION, data: { id: assetStub.image.id, deleteOnDisk: true } },
]); ]);
}); });
it('should queue assets for deletion after trash duration', async () => { it('should queue assets for deletion after trash duration', async () => {
mocks.asset.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] });
mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: true, days: 7 } }); systemMock.get.mockResolvedValue({ trash: { enabled: true, days: 7 } });
await expect(sut.handleAssetDeletionCheck()).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleAssetDeletionCheck()).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.asset.getAll).toHaveBeenCalledWith(expect.anything(), { expect(assetMock.getAll).toHaveBeenCalledWith(expect.anything(), {
trashedBefore: DateTime.now().minus({ days: 7 }).toJSDate(), trashedBefore: DateTime.now().minus({ days: 7 }).toJSDate(),
}); });
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ name: JobName.ASSET_DELETION, data: { id: assetStub.image.id, deleteOnDisk: true } }, { name: JobName.ASSET_DELETION, data: { id: assetStub.image.id, deleteOnDisk: true } },
]); ]);
}); });
@@ -504,11 +519,11 @@ describe(AssetService.name, () => {
it('should remove faces', async () => { it('should remove faces', async () => {
const assetWithFace = { ...assetStub.image, faces: [faceStub.face1, faceStub.mergeFace1] }; const assetWithFace = { ...assetStub.image, faces: [faceStub.face1, faceStub.mergeFace1] };
mocks.asset.getById.mockResolvedValue(assetWithFace); assetMock.getById.mockResolvedValue(assetWithFace);
await sut.handleAssetDeletion({ id: assetWithFace.id, deleteOnDisk: true }); await sut.handleAssetDeletion({ id: assetWithFace.id, deleteOnDisk: true });
expect(mocks.job.queue.mock.calls).toEqual([ expect(jobMock.queue.mock.calls).toEqual([
[ [
{ {
name: JobName.DELETE_FILES, name: JobName.DELETE_FILES,
@@ -525,41 +540,41 @@ describe(AssetService.name, () => {
], ],
]); ]);
expect(mocks.asset.remove).toHaveBeenCalledWith(assetWithFace); expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace);
}); });
it('should update stack primary asset if deleted asset was primary asset in a stack', async () => { it('should update stack primary asset if deleted asset was primary asset in a stack', async () => {
mocks.asset.getById.mockResolvedValue(assetStub.primaryImage as AssetEntity); assetMock.getById.mockResolvedValue(assetStub.primaryImage as AssetEntity);
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true }); await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true });
expect(mocks.stack.update).toHaveBeenCalledWith('stack-1', { expect(stackMock.update).toHaveBeenCalledWith('stack-1', {
id: 'stack-1', id: 'stack-1',
primaryAssetId: 'stack-child-asset-1', primaryAssetId: 'stack-child-asset-1',
}); });
}); });
it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => { it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => {
mocks.asset.getById.mockResolvedValue({ assetMock.getById.mockResolvedValue({
...assetStub.primaryImage, ...assetStub.primaryImage,
stack: { ...assetStub.primaryImage.stack, assets: assetStub.primaryImage.stack!.assets.slice(0, 2) }, stack: { ...assetStub.primaryImage.stack, assets: assetStub.primaryImage.stack!.assets.slice(0, 2) },
} as AssetEntity); } as AssetEntity);
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true }); await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true });
expect(mocks.stack.delete).toHaveBeenCalledWith('stack-1'); expect(stackMock.delete).toHaveBeenCalledWith('stack-1');
}); });
it('should delete a live photo', async () => { it('should delete a live photo', async () => {
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset);
mocks.asset.getLivePhotoCount.mockResolvedValue(0); assetMock.getLivePhotoCount.mockResolvedValue(0);
await sut.handleAssetDeletion({ await sut.handleAssetDeletion({
id: assetStub.livePhotoStillAsset.id, id: assetStub.livePhotoStillAsset.id,
deleteOnDisk: true, deleteOnDisk: true,
}); });
expect(mocks.job.queue.mock.calls).toEqual([ expect(jobMock.queue.mock.calls).toEqual([
[ [
{ {
name: JobName.ASSET_DELETION, name: JobName.ASSET_DELETION,
@@ -581,15 +596,15 @@ describe(AssetService.name, () => {
}); });
it('should not delete a live motion part if it is being used by another asset', async () => { it('should not delete a live motion part if it is being used by another asset', async () => {
mocks.asset.getLivePhotoCount.mockResolvedValue(2); assetMock.getLivePhotoCount.mockResolvedValue(2);
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset);
await sut.handleAssetDeletion({ await sut.handleAssetDeletion({
id: assetStub.livePhotoStillAsset.id, id: assetStub.livePhotoStillAsset.id,
deleteOnDisk: true, deleteOnDisk: true,
}); });
expect(mocks.job.queue.mock.calls).toEqual([ expect(jobMock.queue.mock.calls).toEqual([
[ [
{ {
name: JobName.DELETE_FILES, name: JobName.DELETE_FILES,
@@ -602,9 +617,9 @@ describe(AssetService.name, () => {
}); });
it('should update usage', async () => { it('should update usage', async () => {
mocks.asset.getById.mockResolvedValue(assetStub.image); assetMock.getById.mockResolvedValue(assetStub.image);
await sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true }); await sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true });
expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000); expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000);
}); });
it('should fail if asset could not be found', async () => { it('should fail if asset could not be found', async () => {
@@ -616,27 +631,27 @@ describe(AssetService.name, () => {
describe('run', () => { describe('run', () => {
it('should run the refresh faces job', async () => { it('should run the refresh faces job', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_FACES }); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_FACES });
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.FACE_DETECTION, data: { id: 'asset-1' } }]); expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.FACE_DETECTION, data: { id: 'asset-1' } }]);
}); });
it('should run the refresh metadata job', async () => { it('should run the refresh metadata job', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_METADATA }); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_METADATA });
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } }]); expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } }]);
}); });
it('should run the refresh thumbnails job', async () => { it('should run the refresh thumbnails job', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL }); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL });
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }]); expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }]);
}); });
it('should run the transcode video', async () => { it('should run the transcode video', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.TRANSCODE_VIDEO }); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.TRANSCODE_VIDEO });
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } }]); expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } }]);
}); });
}); });
@@ -644,7 +659,7 @@ describe(AssetService.name, () => {
it('get assets by device id', async () => { it('get assets by device id', async () => {
const assets = [assetStub.image, assetStub.image1]; const assets = [assetStub.image, assetStub.image1];
mocks.asset.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId)); assetMock.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId));
const deviceId = 'device-id'; const deviceId = 'device-id';
const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId); const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId);
+52 -42
View File
@@ -1,18 +1,28 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { FileReportItemDto } from 'src/dtos/audit.dto'; import { FileReportItemDto } from 'src/dtos/audit.dto';
import { AssetFileType, AssetPathType, DatabaseAction, EntityType, PersonPathType, UserPathType } from 'src/enum'; import { AssetFileType, AssetPathType, DatabaseAction, EntityType, PersonPathType, UserPathType } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { JobStatus } from 'src/interfaces/job.interface'; import { JobStatus } from 'src/interfaces/job.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { AuditService } from 'src/services/audit.service'; import { AuditService } from 'src/services/audit.service';
import { IAuditRepository } from 'src/types';
import { auditStub } from 'test/fixtures/audit.stub'; import { auditStub } from 'test/fixtures/audit.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
describe(AuditService.name, () => { describe(AuditService.name, () => {
let sut: AuditService; let sut: AuditService;
let mocks: ServiceMocks; let auditMock: Mocked<IAuditRepository>;
let assetMock: Mocked<IAssetRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let personMock: Mocked<IPersonRepository>;
let userMock: Mocked<IUserRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(AuditService)); ({ sut, auditMock, assetMock, cryptoMock, personMock, userMock } = newTestService(AuditService));
}); });
it('should work', () => { it('should work', () => {
@@ -22,13 +32,13 @@ describe(AuditService.name, () => {
describe('handleCleanup', () => { describe('handleCleanup', () => {
it('should delete old audit entries', async () => { it('should delete old audit entries', async () => {
await expect(sut.handleCleanup()).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleCleanup()).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.audit.removeBefore).toHaveBeenCalledWith(expect.any(Date)); expect(auditMock.removeBefore).toHaveBeenCalledWith(expect.any(Date));
}); });
}); });
describe('getDeletes', () => { describe('getDeletes', () => {
it('should require full sync if the request is older than 100 days', async () => { it('should require full sync if the request is older than 100 days', async () => {
mocks.audit.getAfter.mockResolvedValue([]); auditMock.getAfter.mockResolvedValue([]);
const date = new Date(2022, 0, 1); const date = new Date(2022, 0, 1);
await expect(sut.getDeletes(authStub.admin, { after: date, entityType: EntityType.ASSET })).resolves.toEqual({ await expect(sut.getDeletes(authStub.admin, { after: date, entityType: EntityType.ASSET })).resolves.toEqual({
@@ -36,7 +46,7 @@ describe(AuditService.name, () => {
ids: [], ids: [],
}); });
expect(mocks.audit.getAfter).toHaveBeenCalledWith(date, { expect(auditMock.getAfter).toHaveBeenCalledWith(date, {
action: DatabaseAction.DELETE, action: DatabaseAction.DELETE,
userIds: [authStub.admin.user.id], userIds: [authStub.admin.user.id],
entityType: EntityType.ASSET, entityType: EntityType.ASSET,
@@ -44,7 +54,7 @@ describe(AuditService.name, () => {
}); });
it('should get any new or updated assets and deleted ids', async () => { it('should get any new or updated assets and deleted ids', async () => {
mocks.audit.getAfter.mockResolvedValue([auditStub.delete.entityId]); auditMock.getAfter.mockResolvedValue([auditStub.delete.entityId]);
const date = new Date(); const date = new Date();
await expect(sut.getDeletes(authStub.admin, { after: date, entityType: EntityType.ASSET })).resolves.toEqual({ await expect(sut.getDeletes(authStub.admin, { after: date, entityType: EntityType.ASSET })).resolves.toEqual({
@@ -52,7 +62,7 @@ describe(AuditService.name, () => {
ids: ['asset-deleted'], ids: ['asset-deleted'],
}); });
expect(mocks.audit.getAfter).toHaveBeenCalledWith(date, { expect(auditMock.getAfter).toHaveBeenCalledWith(date, {
action: DatabaseAction.DELETE, action: DatabaseAction.DELETE,
userIds: [authStub.admin.user.id], userIds: [authStub.admin.user.id],
entityType: EntityType.ASSET, entityType: EntityType.ASSET,
@@ -64,7 +74,7 @@ describe(AuditService.name, () => {
it('should fail if the file is not in the immich path', async () => { it('should fail if the file is not in the immich path', async () => {
await expect(sut.getChecksums({ filenames: ['foo/bar'] })).rejects.toBeInstanceOf(BadRequestException); await expect(sut.getChecksums({ filenames: ['foo/bar'] })).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.crypto.hashFile).not.toHaveBeenCalled(); expect(cryptoMock.hashFile).not.toHaveBeenCalled();
}); });
it('should get checksum for valid file', async () => { it('should get checksum for valid file', async () => {
@@ -72,7 +82,7 @@ describe(AuditService.name, () => {
{ filename: './upload/my-file.jpg', checksum: expect.any(String) }, { filename: './upload/my-file.jpg', checksum: expect.any(String) },
]); ]);
expect(mocks.crypto.hashFile).toHaveBeenCalledWith('./upload/my-file.jpg'); expect(cryptoMock.hashFile).toHaveBeenCalledWith('./upload/my-file.jpg');
}); });
}); });
@@ -84,10 +94,10 @@ describe(AuditService.name, () => {
]), ]),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalled();
expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); expect(assetMock.upsertFile).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled(); expect(personMock.update).not.toHaveBeenCalled();
expect(mocks.user.update).not.toHaveBeenCalled(); expect(userMock.update).not.toHaveBeenCalled();
}); });
it('should update encoded video path', async () => { it('should update encoded video path', async () => {
@@ -99,10 +109,10 @@ describe(AuditService.name, () => {
} as FileReportItemDto, } as FileReportItemDto,
]); ]);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', encodedVideoPath: './upload/my-video.mp4' }); expect(assetMock.update).toHaveBeenCalledWith({ id: 'my-id', encodedVideoPath: './upload/my-video.mp4' });
expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); expect(assetMock.upsertFile).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled(); expect(personMock.update).not.toHaveBeenCalled();
expect(mocks.user.update).not.toHaveBeenCalled(); expect(userMock.update).not.toHaveBeenCalled();
}); });
it('should update preview path', async () => { it('should update preview path', async () => {
@@ -114,14 +124,14 @@ describe(AuditService.name, () => {
} as FileReportItemDto, } as FileReportItemDto,
]); ]);
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({ expect(assetMock.upsertFile).toHaveBeenCalledWith({
assetId: 'my-id', assetId: 'my-id',
type: AssetFileType.PREVIEW, type: AssetFileType.PREVIEW,
path: './upload/my-preview.png', path: './upload/my-preview.png',
}); });
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled(); expect(personMock.update).not.toHaveBeenCalled();
expect(mocks.user.update).not.toHaveBeenCalled(); expect(userMock.update).not.toHaveBeenCalled();
}); });
it('should update thumbnail path', async () => { it('should update thumbnail path', async () => {
@@ -133,14 +143,14 @@ describe(AuditService.name, () => {
} as FileReportItemDto, } as FileReportItemDto,
]); ]);
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({ expect(assetMock.upsertFile).toHaveBeenCalledWith({
assetId: 'my-id', assetId: 'my-id',
type: AssetFileType.THUMBNAIL, type: AssetFileType.THUMBNAIL,
path: './upload/my-thumbnail.webp', path: './upload/my-thumbnail.webp',
}); });
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled(); expect(personMock.update).not.toHaveBeenCalled();
expect(mocks.user.update).not.toHaveBeenCalled(); expect(userMock.update).not.toHaveBeenCalled();
}); });
it('should update original path', async () => { it('should update original path', async () => {
@@ -152,10 +162,10 @@ describe(AuditService.name, () => {
} as FileReportItemDto, } as FileReportItemDto,
]); ]);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', originalPath: './upload/my-original.png' }); expect(assetMock.update).toHaveBeenCalledWith({ id: 'my-id', originalPath: './upload/my-original.png' });
expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); expect(assetMock.upsertFile).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled(); expect(personMock.update).not.toHaveBeenCalled();
expect(mocks.user.update).not.toHaveBeenCalled(); expect(userMock.update).not.toHaveBeenCalled();
}); });
it('should update sidecar path', async () => { it('should update sidecar path', async () => {
@@ -167,10 +177,10 @@ describe(AuditService.name, () => {
} as FileReportItemDto, } as FileReportItemDto,
]); ]);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', sidecarPath: './upload/my-sidecar.xmp' }); expect(assetMock.update).toHaveBeenCalledWith({ id: 'my-id', sidecarPath: './upload/my-sidecar.xmp' });
expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); expect(assetMock.upsertFile).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled(); expect(personMock.update).not.toHaveBeenCalled();
expect(mocks.user.update).not.toHaveBeenCalled(); expect(userMock.update).not.toHaveBeenCalled();
}); });
it('should update face path', async () => { it('should update face path', async () => {
@@ -182,10 +192,10 @@ describe(AuditService.name, () => {
} as FileReportItemDto, } as FileReportItemDto,
]); ]);
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'my-id', thumbnailPath: './upload/my-face.jpg' }); expect(personMock.update).toHaveBeenCalledWith({ id: 'my-id', thumbnailPath: './upload/my-face.jpg' });
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalled();
expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); expect(assetMock.upsertFile).not.toHaveBeenCalled();
expect(mocks.user.update).not.toHaveBeenCalled(); expect(userMock.update).not.toHaveBeenCalled();
}); });
it('should update profile path', async () => { it('should update profile path', async () => {
@@ -197,10 +207,10 @@ describe(AuditService.name, () => {
} as FileReportItemDto, } as FileReportItemDto,
]); ]);
expect(mocks.user.update).toHaveBeenCalledWith('my-id', { profileImagePath: './upload/my-profile-pic.jpg' }); expect(userMock.update).toHaveBeenCalledWith('my-id', { profileImagePath: './upload/my-profile-pic.jpg' });
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalled();
expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); expect(assetMock.upsertFile).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled(); expect(personMock.update).not.toHaveBeenCalled();
}); });
}); });
}); });
+146 -129
View File
@@ -3,14 +3,22 @@ import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { AuthType, Permission } from 'src/enum'; import { AuthType, Permission } from 'src/enum';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { AuthService } from 'src/services/auth.service'; import { AuthService } from 'src/services/auth.service';
import { IApiKeyRepository, IOAuthRepository } from 'src/types';
import { keyStub } from 'test/fixtures/api-key.stub'; import { keyStub } from 'test/fixtures/api-key.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { sessionStub } from 'test/fixtures/session.stub'; import { sessionStub } from 'test/fixtures/session.stub';
import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
const oauthResponse = { const oauthResponse = {
accessToken: 'cmFuZG9tLWJ5dGVz', accessToken: 'cmFuZG9tLWJ5dGVz',
@@ -50,14 +58,23 @@ const oauthUserWithDefaultQuota = {
describe('AuthService', () => { describe('AuthService', () => {
let sut: AuthService; let sut: AuthService;
let mocks: ServiceMocks;
let cryptoMock: Mocked<ICryptoRepository>;
let eventMock: Mocked<IEventRepository>;
let keyMock: Mocked<IApiKeyRepository>;
let oauthMock: Mocked<IOAuthRepository>;
let sessionMock: Mocked<ISessionRepository>;
let sharedLinkMock: Mocked<ISharedLinkRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let userMock: Mocked<IUserRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(AuthService)); ({ sut, cryptoMock, eventMock, keyMock, oauthMock, sessionMock, sharedLinkMock, systemMock, userMock } =
newTestService(AuthService));
mocks.oauth.authorize.mockResolvedValue('access-token'); oauthMock.authorize.mockResolvedValue('access-token');
mocks.oauth.getProfile.mockResolvedValue({ sub, email }); oauthMock.getProfile.mockResolvedValue({ sub, email });
mocks.oauth.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint'); oauthMock.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint');
}); });
it('should be defined', () => { it('should be defined', () => {
@@ -67,31 +84,31 @@ describe('AuthService', () => {
describe('onBootstrap', () => { describe('onBootstrap', () => {
it('should init the repo', () => { it('should init the repo', () => {
sut.onBootstrap(); sut.onBootstrap();
expect(mocks.oauth.init).toHaveBeenCalled(); expect(oauthMock.init).toHaveBeenCalled();
}); });
}); });
describe('login', () => { describe('login', () => {
it('should throw an error if password login is disabled', async () => { it('should throw an error if password login is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled); systemMock.get.mockResolvedValue(systemConfigStub.disabled);
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
}); });
it('should check the user exists', async () => { it('should check the user exists', async () => {
mocks.user.getByEmail.mockResolvedValue(void 0); userMock.getByEmail.mockResolvedValue(void 0);
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
}); });
it('should check the user has a password', async () => { it('should check the user has a password', async () => {
mocks.user.getByEmail.mockResolvedValue({} as UserEntity); userMock.getByEmail.mockResolvedValue({} as UserEntity);
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
}); });
it('should successfully log the user in', async () => { it('should successfully log the user in', async () => {
mocks.user.getByEmail.mockResolvedValue(userStub.user1); userMock.getByEmail.mockResolvedValue(userStub.user1);
mocks.session.create.mockResolvedValue(sessionStub.valid); sessionMock.create.mockResolvedValue(sessionStub.valid);
await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({ await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({
accessToken: 'cmFuZG9tLWJ5dGVz', accessToken: 'cmFuZG9tLWJ5dGVz',
userId: 'user-id', userId: 'user-id',
@@ -101,7 +118,7 @@ describe('AuthService', () => {
isAdmin: false, isAdmin: false,
shouldChangePassword: false, shouldChangePassword: false,
}); });
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -110,23 +127,23 @@ describe('AuthService', () => {
const auth = { user: { email: 'test@imimch.com' } } as AuthDto; const auth = { user: { email: 'test@imimch.com' } } as AuthDto;
const dto = { password: 'old-password', newPassword: 'new-password' }; const dto = { password: 'old-password', newPassword: 'new-password' };
mocks.user.getByEmail.mockResolvedValue({ userMock.getByEmail.mockResolvedValue({
email: 'test@immich.com', email: 'test@immich.com',
password: 'hash-password', password: 'hash-password',
} as UserEntity); } as UserEntity);
mocks.user.update.mockResolvedValue(userStub.user1); userMock.update.mockResolvedValue(userStub.user1);
await sut.changePassword(auth, dto); await sut.changePassword(auth, dto);
expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, true); expect(userMock.getByEmail).toHaveBeenCalledWith(auth.user.email, true);
expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password'); expect(cryptoMock.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password');
}); });
it('should throw when auth user email is not found', async () => { it('should throw when auth user email is not found', async () => {
const auth = { user: { email: 'test@imimch.com' } } as AuthDto; const auth = { user: { email: 'test@imimch.com' } } as AuthDto;
const dto = { password: 'old-password', newPassword: 'new-password' }; const dto = { password: 'old-password', newPassword: 'new-password' };
mocks.user.getByEmail.mockResolvedValue(void 0); userMock.getByEmail.mockResolvedValue(void 0);
await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(UnauthorizedException); await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(UnauthorizedException);
}); });
@@ -135,9 +152,9 @@ describe('AuthService', () => {
const auth = { user: { email: 'test@imimch.com' } as UserEntity }; const auth = { user: { email: 'test@imimch.com' } as UserEntity };
const dto = { password: 'old-password', newPassword: 'new-password' }; const dto = { password: 'old-password', newPassword: 'new-password' };
mocks.crypto.compareBcrypt.mockReturnValue(false); cryptoMock.compareBcrypt.mockReturnValue(false);
mocks.user.getByEmail.mockResolvedValue({ userMock.getByEmail.mockResolvedValue({
email: 'test@immich.com', email: 'test@immich.com',
password: 'hash-password', password: 'hash-password',
} as UserEntity); } as UserEntity);
@@ -149,7 +166,7 @@ describe('AuthService', () => {
const auth = { user: { email: 'test@imimch.com' } } as AuthDto; const auth = { user: { email: 'test@imimch.com' } } as AuthDto;
const dto = { password: 'old-password', newPassword: 'new-password' }; const dto = { password: 'old-password', newPassword: 'new-password' };
mocks.user.getByEmail.mockResolvedValue({ userMock.getByEmail.mockResolvedValue({
email: 'test@immich.com', email: 'test@immich.com',
password: '', password: '',
} as UserEntity); } as UserEntity);
@@ -160,7 +177,7 @@ describe('AuthService', () => {
describe('logout', () => { describe('logout', () => {
it('should return the end session endpoint', async () => { it('should return the end session endpoint', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); systemMock.get.mockResolvedValue(systemConfigStub.enabled);
const auth = { user: { id: '123' } } as AuthDto; const auth = { user: { id: '123' } } as AuthDto;
await expect(sut.logout(auth, AuthType.OAUTH)).resolves.toEqual({ await expect(sut.logout(auth, AuthType.OAUTH)).resolves.toEqual({
successful: true, successful: true,
@@ -185,8 +202,8 @@ describe('AuthService', () => {
redirectUri: '/auth/login?autoLaunch=0', redirectUri: '/auth/login?autoLaunch=0',
}); });
expect(mocks.session.delete).toHaveBeenCalledWith('token123'); expect(sessionMock.delete).toHaveBeenCalledWith('token123');
expect(mocks.event.emit).toHaveBeenCalledWith('session.delete', { sessionId: 'token123' }); expect(eventMock.emit).toHaveBeenCalledWith('session.delete', { sessionId: 'token123' });
}); });
it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => { it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => {
@@ -203,14 +220,14 @@ describe('AuthService', () => {
const dto: SignUpDto = { email: 'test@immich.com', password: 'password', name: 'immich admin' }; const dto: SignUpDto = { email: 'test@immich.com', password: 'password', name: 'immich admin' };
it('should only allow one admin', async () => { it('should only allow one admin', async () => {
mocks.user.getAdmin.mockResolvedValue({} as UserEntity); userMock.getAdmin.mockResolvedValue({} as UserEntity);
await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.user.getAdmin).toHaveBeenCalled(); expect(userMock.getAdmin).toHaveBeenCalled();
}); });
it('should sign up the admin', async () => { it('should sign up the admin', async () => {
mocks.user.getAdmin.mockResolvedValue(void 0); userMock.getAdmin.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue({ userMock.create.mockResolvedValue({
...dto, ...dto,
id: 'admin', id: 'admin',
createdAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'),
@@ -223,8 +240,8 @@ describe('AuthService', () => {
email: 'test@immich.com', email: 'test@immich.com',
name: 'immich admin', name: 'immich admin',
}); });
expect(mocks.user.getAdmin).toHaveBeenCalled(); expect(userMock.getAdmin).toHaveBeenCalled();
expect(mocks.user.create).toHaveBeenCalled(); expect(userMock.create).toHaveBeenCalled();
}); });
}); });
@@ -240,8 +257,8 @@ describe('AuthService', () => {
}); });
it('should validate using authorization header', async () => { it('should validate using authorization header', async () => {
mocks.user.get.mockResolvedValue(userStub.user1); userMock.get.mockResolvedValue(userStub.user1);
mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); sessionMock.getByToken.mockResolvedValue(sessionStub.valid);
await expect( await expect(
sut.authenticate({ sut.authenticate({
headers: { authorization: 'Bearer auth_token' }, headers: { authorization: 'Bearer auth_token' },
@@ -267,7 +284,7 @@ describe('AuthService', () => {
}); });
it('should not accept an expired key', async () => { it('should not accept an expired key', async () => {
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired); sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
await expect( await expect(
sut.authenticate({ sut.authenticate({
headers: { 'x-immich-share-key': 'key' }, headers: { 'x-immich-share-key': 'key' },
@@ -278,7 +295,7 @@ describe('AuthService', () => {
}); });
it('should not accept a key on a non-shared route', async () => { it('should not accept a key on a non-shared route', async () => {
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid); sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
await expect( await expect(
sut.authenticate({ sut.authenticate({
headers: { 'x-immich-share-key': 'key' }, headers: { 'x-immich-share-key': 'key' },
@@ -289,8 +306,8 @@ describe('AuthService', () => {
}); });
it('should not accept a key without a user', async () => { it('should not accept a key without a user', async () => {
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired); sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
mocks.user.get.mockResolvedValue(void 0); userMock.get.mockResolvedValue(void 0);
await expect( await expect(
sut.authenticate({ sut.authenticate({
headers: { 'x-immich-share-key': 'key' }, headers: { 'x-immich-share-key': 'key' },
@@ -301,8 +318,8 @@ describe('AuthService', () => {
}); });
it('should accept a base64url key', async () => { it('should accept a base64url key', async () => {
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid); sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
mocks.user.get.mockResolvedValue(userStub.admin); userMock.get.mockResolvedValue(userStub.admin);
await expect( await expect(
sut.authenticate({ sut.authenticate({
headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('base64url') }, headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('base64url') },
@@ -313,12 +330,12 @@ describe('AuthService', () => {
user: userStub.admin, user: userStub.admin,
sharedLink: sharedLinkStub.valid, sharedLink: sharedLinkStub.valid,
}); });
expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); expect(sharedLinkMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
}); });
it('should accept a hex key', async () => { it('should accept a hex key', async () => {
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid); sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
mocks.user.get.mockResolvedValue(userStub.admin); userMock.get.mockResolvedValue(userStub.admin);
await expect( await expect(
sut.authenticate({ sut.authenticate({
headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('hex') }, headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('hex') },
@@ -329,13 +346,13 @@ describe('AuthService', () => {
user: userStub.admin, user: userStub.admin,
sharedLink: sharedLinkStub.valid, sharedLink: sharedLinkStub.valid,
}); });
expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); expect(sharedLinkMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
}); });
}); });
describe('validate - user token', () => { describe('validate - user token', () => {
it('should throw if no token is found', async () => { it('should throw if no token is found', async () => {
mocks.session.getByToken.mockResolvedValue(void 0); sessionMock.getByToken.mockResolvedValue(void 0);
await expect( await expect(
sut.authenticate({ sut.authenticate({
headers: { 'x-immich-user-token': 'auth_token' }, headers: { 'x-immich-user-token': 'auth_token' },
@@ -346,7 +363,7 @@ describe('AuthService', () => {
}); });
it('should return an auth dto', async () => { it('should return an auth dto', async () => {
mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); sessionMock.getByToken.mockResolvedValue(sessionStub.valid);
await expect( await expect(
sut.authenticate({ sut.authenticate({
headers: { cookie: 'immich_access_token=auth_token' }, headers: { cookie: 'immich_access_token=auth_token' },
@@ -360,7 +377,7 @@ describe('AuthService', () => {
}); });
it('should throw if admin route and not an admin', async () => { it('should throw if admin route and not an admin', async () => {
mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); sessionMock.getByToken.mockResolvedValue(sessionStub.valid);
await expect( await expect(
sut.authenticate({ sut.authenticate({
headers: { cookie: 'immich_access_token=auth_token' }, headers: { cookie: 'immich_access_token=auth_token' },
@@ -371,8 +388,8 @@ describe('AuthService', () => {
}); });
it('should update when access time exceeds an hour', async () => { it('should update when access time exceeds an hour', async () => {
mocks.session.getByToken.mockResolvedValue(sessionStub.inactive as any); sessionMock.getByToken.mockResolvedValue(sessionStub.inactive);
mocks.session.update.mockResolvedValue(sessionStub.valid); sessionMock.update.mockResolvedValue(sessionStub.valid);
await expect( await expect(
sut.authenticate({ sut.authenticate({
headers: { cookie: 'immich_access_token=auth_token' }, headers: { cookie: 'immich_access_token=auth_token' },
@@ -380,13 +397,13 @@ describe('AuthService', () => {
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
}), }),
).resolves.toBeDefined(); ).resolves.toBeDefined();
expect(mocks.session.update.mock.calls[0][1]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) }); expect(sessionMock.update.mock.calls[0][1]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) });
}); });
}); });
describe('validate - api key', () => { describe('validate - api key', () => {
it('should throw an error if no api key is found', async () => { it('should throw an error if no api key is found', async () => {
mocks.apiKey.getKey.mockResolvedValue(void 0); keyMock.getKey.mockResolvedValue(void 0);
await expect( await expect(
sut.authenticate({ sut.authenticate({
headers: { 'x-api-key': 'auth_token' }, headers: { 'x-api-key': 'auth_token' },
@@ -394,11 +411,11 @@ describe('AuthService', () => {
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
}), }),
).rejects.toBeInstanceOf(UnauthorizedException); ).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.apiKey.getKey).toHaveBeenCalledWith('auth_token (hashed)'); expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)');
}); });
it('should throw an error if api key has insufficient permissions', async () => { it('should throw an error if api key has insufficient permissions', async () => {
mocks.apiKey.getKey.mockResolvedValue(keyStub.authKey); keyMock.getKey.mockResolvedValue(keyStub.authKey);
await expect( await expect(
sut.authenticate({ sut.authenticate({
headers: { 'x-api-key': 'auth_token' }, headers: { 'x-api-key': 'auth_token' },
@@ -409,7 +426,7 @@ describe('AuthService', () => {
}); });
it('should return an auth dto', async () => { it('should return an auth dto', async () => {
mocks.apiKey.getKey.mockResolvedValue(keyStub.authKey); keyMock.getKey.mockResolvedValue(keyStub.authKey);
await expect( await expect(
sut.authenticate({ sut.authenticate({
headers: { 'x-api-key': 'auth_token' }, headers: { 'x-api-key': 'auth_token' },
@@ -417,7 +434,7 @@ describe('AuthService', () => {
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
}), }),
).resolves.toEqual({ user: userStub.admin, apiKey: keyStub.authKey }); ).resolves.toEqual({ user: userStub.admin, apiKey: keyStub.authKey });
expect(mocks.apiKey.getKey).toHaveBeenCalledWith('auth_token (hashed)'); expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)');
}); });
}); });
@@ -435,14 +452,14 @@ describe('AuthService', () => {
describe('authorize', () => { describe('authorize', () => {
it('should fail if oauth is disabled', async () => { it('should fail if oauth is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ oauth: { enabled: false } }); systemMock.get.mockResolvedValue({ oauth: { enabled: false } });
await expect(sut.authorize({ redirectUri: 'https://demo.immich.app' })).rejects.toBeInstanceOf( await expect(sut.authorize({ redirectUri: 'https://demo.immich.app' })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
}); });
it('should authorize the user', async () => { it('should authorize the user', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); systemMock.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
await sut.authorize({ redirectUri: 'https://demo.immich.app' }); await sut.authorize({ redirectUri: 'https://demo.immich.app' });
}); });
}); });
@@ -453,71 +470,71 @@ describe('AuthService', () => {
}); });
it('should not allow auto registering', async () => { it('should not allow auto registering', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); systemMock.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.user.getByEmail.mockResolvedValue(void 0); userMock.getByEmail.mockResolvedValue(void 0);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
}); });
it('should link an existing user', async () => { it('should link an existing user', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); systemMock.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.user.getByEmail.mockResolvedValue(userStub.user1); userMock.getByEmail.mockResolvedValue(userStub.user1);
mocks.user.update.mockResolvedValue(userStub.user1); userMock.update.mockResolvedValue(userStub.user1);
mocks.session.create.mockResolvedValue(sessionStub.valid); sessionMock.create.mockResolvedValue(sessionStub.valid);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse, oauthResponse,
); );
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { oauthId: sub }); expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { oauthId: sub });
}); });
it('should not link to a user with a different oauth sub', async () => { it('should not link to a user with a different oauth sub', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); systemMock.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.user.getByEmail.mockResolvedValueOnce({ ...userStub.user1, oauthId: 'existing-sub' }); userMock.getByEmail.mockResolvedValueOnce({ ...userStub.user1, oauthId: 'existing-sub' });
mocks.user.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
mocks.user.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toThrow( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toThrow(
BadRequestException, BadRequestException,
); );
expect(mocks.user.update).not.toHaveBeenCalled(); expect(userMock.update).not.toHaveBeenCalled();
expect(mocks.user.create).not.toHaveBeenCalled(); expect(userMock.create).not.toHaveBeenCalled();
}); });
it('should allow auto registering by default', async () => { it('should allow auto registering by default', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); systemMock.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.getByEmail.mockResolvedValue(void 0); userMock.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
mocks.user.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
mocks.session.create.mockResolvedValue(sessionStub.valid); sessionMock.create.mockResolvedValue(sessionStub.valid);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse, oauthResponse,
); );
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create expect(userMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
expect(mocks.user.create).toHaveBeenCalledTimes(1); expect(userMock.create).toHaveBeenCalledTimes(1);
}); });
it('should throw an error if user should be auto registered but the email claim does not exist', async () => { it('should throw an error if user should be auto registered but the email claim does not exist', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); systemMock.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.getByEmail.mockResolvedValue(void 0); userMock.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
mocks.user.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
mocks.session.create.mockResolvedValue(sessionStub.valid); sessionMock.create.mockResolvedValue(sessionStub.valid);
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined }); oauthMock.getProfile.mockResolvedValue({ sub, email: undefined });
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
expect(mocks.user.getByEmail).not.toHaveBeenCalled(); expect(userMock.getByEmail).not.toHaveBeenCalled();
expect(mocks.user.create).not.toHaveBeenCalled(); expect(userMock.create).not.toHaveBeenCalled();
}); });
for (const url of [ for (const url of [
@@ -529,68 +546,68 @@ describe('AuthService', () => {
'app.immich:///oauth-callback?code=abc123', 'app.immich:///oauth-callback?code=abc123',
]) { ]) {
it(`should use the mobile redirect override for a url of ${url}`, async () => { it(`should use the mobile redirect override for a url of ${url}`, async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); systemMock.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
mocks.user.getByOAuthId.mockResolvedValue(userStub.user1); userMock.getByOAuthId.mockResolvedValue(userStub.user1);
mocks.session.create.mockResolvedValue(sessionStub.valid); sessionMock.create.mockResolvedValue(sessionStub.valid);
await sut.callback({ url }, loginDetails); await sut.callback({ url }, loginDetails);
expect(mocks.oauth.getProfile).toHaveBeenCalledWith(expect.objectContaining({}), url, 'http://mobile-redirect'); expect(oauthMock.getProfile).toHaveBeenCalledWith(expect.objectContaining({}), url, 'http://mobile-redirect');
}); });
} }
it('should use the default quota', async () => { it('should use the default quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.user.getByEmail.mockResolvedValue(void 0); userMock.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
mocks.user.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse, oauthResponse,
); );
expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); expect(userMock.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 });
}); });
it('should ignore an invalid storage quota', async () => { it('should ignore an invalid storage quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.user.getByEmail.mockResolvedValue(void 0); userMock.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
mocks.user.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 'abc' }); oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 'abc' });
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse, oauthResponse,
); );
expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); expect(userMock.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 });
}); });
it('should ignore a negative quota', async () => { it('should ignore a negative quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.user.getByEmail.mockResolvedValue(void 0); userMock.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
mocks.user.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: -5 }); oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: -5 });
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse, oauthResponse,
); );
expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); expect(userMock.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 });
}); });
it('should not set quota for 0 quota', async () => { it('should not set quota for 0 quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.user.getByEmail.mockResolvedValue(void 0); userMock.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
mocks.user.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 0 }); oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 0 });
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse, oauthResponse,
); );
expect(mocks.user.create).toHaveBeenCalledWith({ expect(userMock.create).toHaveBeenCalledWith({
email, email,
name: ' ', name: ' ',
oauthId: sub, oauthId: sub,
@@ -600,17 +617,17 @@ describe('AuthService', () => {
}); });
it('should use a valid storage quota', async () => { it('should use a valid storage quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.user.getByEmail.mockResolvedValue(void 0); userMock.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
mocks.user.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 5 }); oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 5 });
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse, oauthResponse,
); );
expect(mocks.user.create).toHaveBeenCalledWith({ expect(userMock.create).toHaveBeenCalledWith({
email, email,
name: ' ', name: ' ',
oauthId: sub, oauthId: sub,
@@ -622,34 +639,34 @@ describe('AuthService', () => {
describe('link', () => { describe('link', () => {
it('should link an account', async () => { it('should link an account', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); systemMock.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.update.mockResolvedValue(userStub.user1); userMock.update.mockResolvedValue(userStub.user1);
await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' }); await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' });
expect(mocks.user.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: sub }); expect(userMock.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: sub });
}); });
it('should not link an already linked oauth.sub', async () => { it('should not link an already linked oauth.sub', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); systemMock.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity); userMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
await expect(sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf( await expect(sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
expect(mocks.user.update).not.toHaveBeenCalled(); expect(userMock.update).not.toHaveBeenCalled();
}); });
}); });
describe('unlink', () => { describe('unlink', () => {
it('should unlink an account', async () => { it('should unlink an account', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); systemMock.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.update.mockResolvedValue(userStub.user1); userMock.update.mockResolvedValue(userStub.user1);
await sut.unlink(authStub.user1); await sut.unlink(authStub.user1);
expect(mocks.user.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: '' }); expect(userMock.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: '' });
}); });
}); });
}); });
+1 -2
View File
@@ -17,7 +17,6 @@ import {
mapLoginResponse, mapLoginResponse,
} from 'src/dtos/auth.dto'; } from 'src/dtos/auth.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { SessionEntity } from 'src/entities/session.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, Permission } from 'src/enum'; import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, Permission } from 'src/enum';
import { OAuthProfile } from 'src/repositories/oauth.repository'; import { OAuthProfile } from 'src/repositories/oauth.repository';
@@ -339,7 +338,7 @@ export class AuthService extends BaseService {
await this.sessionRepository.update(session.id, { id: session.id, updatedAt: new Date() }); await this.sessionRepository.update(session.id, { id: session.id, updatedAt: new Date() });
} }
return { user: session.user as unknown as UserEntity, session: session as unknown as SessionEntity }; return { user: session.user, session };
} }
throw new UnauthorizedException('Invalid user token'); throw new UnauthorizedException('Invalid user token');
+65 -54
View File
@@ -2,18 +2,29 @@ import { PassThrough } from 'node:stream';
import { defaults, SystemConfig } from 'src/config'; import { defaults, SystemConfig } from 'src/config';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { ImmichWorker, StorageFolder } from 'src/enum'; import { ImmichWorker, StorageFolder } from 'src/enum';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { JobStatus } from 'src/interfaces/job.interface'; import { JobStatus } from 'src/interfaces/job.interface';
import { IProcessRepository } from 'src/interfaces/process.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BackupService } from 'src/services/backup.service'; import { BackupService } from 'src/services/backup.service';
import { IConfigRepository, ICronRepository } from 'src/types';
import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { mockSpawn, newTestService, ServiceMocks } from 'test/utils'; import { mockSpawn, newTestService } from 'test/utils';
import { describe } from 'vitest'; import { describe, Mocked } from 'vitest';
describe(BackupService.name, () => { describe(BackupService.name, () => {
let sut: BackupService; let sut: BackupService;
let mocks: ServiceMocks;
let databaseMock: Mocked<IDatabaseRepository>;
let configMock: Mocked<IConfigRepository>;
let cronMock: Mocked<ICronRepository>;
let processMock: Mocked<IProcessRepository>;
let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(BackupService)); ({ sut, cronMock, configMock, databaseMock, processMock, storageMock, systemMock } = newTestService(BackupService));
}); });
it('should work', () => { it('should work', () => {
@@ -22,32 +33,32 @@ describe(BackupService.name, () => {
describe('onBootstrapEvent', () => { describe('onBootstrapEvent', () => {
it('should init cron job and handle config changes', async () => { it('should init cron job and handle config changes', async () => {
mocks.database.tryLock.mockResolvedValue(true); databaseMock.tryLock.mockResolvedValue(true);
await sut.onConfigInit({ newConfig: systemConfigStub.backupEnabled as SystemConfig }); await sut.onConfigInit({ newConfig: systemConfigStub.backupEnabled as SystemConfig });
expect(mocks.cron.create).toHaveBeenCalled(); expect(cronMock.create).toHaveBeenCalled();
}); });
it('should not initialize backup database cron job when lock is taken', async () => { it('should not initialize backup database cron job when lock is taken', async () => {
mocks.database.tryLock.mockResolvedValue(false); databaseMock.tryLock.mockResolvedValue(false);
await sut.onConfigInit({ newConfig: systemConfigStub.backupEnabled as SystemConfig }); await sut.onConfigInit({ newConfig: systemConfigStub.backupEnabled as SystemConfig });
expect(mocks.cron.create).not.toHaveBeenCalled(); expect(cronMock.create).not.toHaveBeenCalled();
}); });
it('should not initialise backup database job when running on microservices', async () => { it('should not initialise backup database job when running on microservices', async () => {
mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); configMock.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES);
await sut.onConfigInit({ newConfig: systemConfigStub.backupEnabled as SystemConfig }); await sut.onConfigInit({ newConfig: systemConfigStub.backupEnabled as SystemConfig });
expect(mocks.cron.create).not.toHaveBeenCalled(); expect(cronMock.create).not.toHaveBeenCalled();
}); });
}); });
describe('onConfigUpdateEvent', () => { describe('onConfigUpdateEvent', () => {
beforeEach(async () => { beforeEach(async () => {
mocks.database.tryLock.mockResolvedValue(true); databaseMock.tryLock.mockResolvedValue(true);
await sut.onConfigInit({ newConfig: defaults }); await sut.onConfigInit({ newConfig: defaults });
}); });
@@ -64,66 +75,66 @@ describe(BackupService.name, () => {
} as SystemConfig, } as SystemConfig,
}); });
expect(mocks.cron.update).toHaveBeenCalledWith({ name: 'backupDatabase', expression: '0 1 * * *', start: true }); expect(cronMock.update).toHaveBeenCalledWith({ name: 'backupDatabase', expression: '0 1 * * *', start: true });
expect(mocks.cron.update).toHaveBeenCalled(); expect(cronMock.update).toHaveBeenCalled();
}); });
it('should do nothing if instance does not have the backup database lock', async () => { it('should do nothing if instance does not have the backup database lock', async () => {
mocks.database.tryLock.mockResolvedValue(false); databaseMock.tryLock.mockResolvedValue(false);
await sut.onConfigInit({ newConfig: defaults }); await sut.onConfigInit({ newConfig: defaults });
sut.onConfigUpdate({ newConfig: systemConfigStub.backupEnabled as SystemConfig, oldConfig: defaults }); sut.onConfigUpdate({ newConfig: systemConfigStub.backupEnabled as SystemConfig, oldConfig: defaults });
expect(mocks.cron.update).not.toHaveBeenCalled(); expect(cronMock.update).not.toHaveBeenCalled();
}); });
}); });
describe('cleanupDatabaseBackups', () => { describe('cleanupDatabaseBackups', () => {
it('should do nothing if not reached keepLastAmount', async () => { it('should do nothing if not reached keepLastAmount', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled);
mocks.storage.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz']); storageMock.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz']);
await sut.cleanupDatabaseBackups(); await sut.cleanupDatabaseBackups();
expect(mocks.storage.unlink).not.toHaveBeenCalled(); expect(storageMock.unlink).not.toHaveBeenCalled();
}); });
it('should remove failed backup files', async () => { it('should remove failed backup files', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled);
mocks.storage.readdir.mockResolvedValue([ storageMock.readdir.mockResolvedValue([
'immich-db-backup-123.sql.gz.tmp', 'immich-db-backup-123.sql.gz.tmp',
'immich-db-backup-234.sql.gz', 'immich-db-backup-234.sql.gz',
'immich-db-backup-345.sql.gz.tmp', 'immich-db-backup-345.sql.gz.tmp',
]); ]);
await sut.cleanupDatabaseBackups(); await sut.cleanupDatabaseBackups();
expect(mocks.storage.unlink).toHaveBeenCalledTimes(2); expect(storageMock.unlink).toHaveBeenCalledTimes(2);
expect(mocks.storage.unlink).toHaveBeenCalledWith( expect(storageMock.unlink).toHaveBeenCalledWith(
`${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-123.sql.gz.tmp`, `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-123.sql.gz.tmp`,
); );
expect(mocks.storage.unlink).toHaveBeenCalledWith( expect(storageMock.unlink).toHaveBeenCalledWith(
`${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-345.sql.gz.tmp`, `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-345.sql.gz.tmp`,
); );
}); });
it('should remove old backup files over keepLastAmount', async () => { it('should remove old backup files over keepLastAmount', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled);
mocks.storage.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz', 'immich-db-backup-2.sql.gz']); storageMock.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz', 'immich-db-backup-2.sql.gz']);
await sut.cleanupDatabaseBackups(); await sut.cleanupDatabaseBackups();
expect(mocks.storage.unlink).toHaveBeenCalledTimes(1); expect(storageMock.unlink).toHaveBeenCalledTimes(1);
expect(mocks.storage.unlink).toHaveBeenCalledWith( expect(storageMock.unlink).toHaveBeenCalledWith(
`${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-1.sql.gz`, `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-1.sql.gz`,
); );
}); });
it('should remove old backup files over keepLastAmount and failed backups', async () => { it('should remove old backup files over keepLastAmount and failed backups', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled);
mocks.storage.readdir.mockResolvedValue([ storageMock.readdir.mockResolvedValue([
'immich-db-backup-1.sql.gz.tmp', 'immich-db-backup-1.sql.gz.tmp',
'immich-db-backup-2.sql.gz', 'immich-db-backup-2.sql.gz',
'immich-db-backup-3.sql.gz', 'immich-db-backup-3.sql.gz',
]); ]);
await sut.cleanupDatabaseBackups(); await sut.cleanupDatabaseBackups();
expect(mocks.storage.unlink).toHaveBeenCalledTimes(2); expect(storageMock.unlink).toHaveBeenCalledTimes(2);
expect(mocks.storage.unlink).toHaveBeenCalledWith( expect(storageMock.unlink).toHaveBeenCalledWith(
`${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-1.sql.gz.tmp`, `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-1.sql.gz.tmp`,
); );
expect(mocks.storage.unlink).toHaveBeenCalledWith( expect(storageMock.unlink).toHaveBeenCalledWith(
`${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-2.sql.gz`, `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-2.sql.gz`,
); );
}); });
@@ -131,57 +142,57 @@ describe(BackupService.name, () => {
describe('handleBackupDatabase', () => { describe('handleBackupDatabase', () => {
beforeEach(() => { beforeEach(() => {
mocks.storage.readdir.mockResolvedValue([]); storageMock.readdir.mockResolvedValue([]);
mocks.process.spawn.mockReturnValue(mockSpawn(0, 'data', '')); processMock.spawn.mockReturnValue(mockSpawn(0, 'data', ''));
mocks.storage.rename.mockResolvedValue(); storageMock.rename.mockResolvedValue();
mocks.storage.unlink.mockResolvedValue(); storageMock.unlink.mockResolvedValue();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled);
mocks.storage.createWriteStream.mockReturnValue(new PassThrough()); storageMock.createWriteStream.mockReturnValue(new PassThrough());
}); });
it('should run a database backup successfully', async () => { it('should run a database backup successfully', async () => {
const result = await sut.handleBackupDatabase(); const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.SUCCESS); expect(result).toBe(JobStatus.SUCCESS);
expect(mocks.storage.createWriteStream).toHaveBeenCalled(); expect(storageMock.createWriteStream).toHaveBeenCalled();
}); });
it('should rename file on success', async () => { it('should rename file on success', async () => {
const result = await sut.handleBackupDatabase(); const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.SUCCESS); expect(result).toBe(JobStatus.SUCCESS);
expect(mocks.storage.rename).toHaveBeenCalled(); expect(storageMock.rename).toHaveBeenCalled();
}); });
it('should fail if pg_dumpall fails', async () => { it('should fail if pg_dumpall fails', async () => {
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
const result = await sut.handleBackupDatabase(); const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.FAILED); expect(result).toBe(JobStatus.FAILED);
}); });
it('should not rename file if pgdump fails and gzip succeeds', async () => { it('should not rename file if pgdump fails and gzip succeeds', async () => {
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
const result = await sut.handleBackupDatabase(); const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.FAILED); expect(result).toBe(JobStatus.FAILED);
expect(mocks.storage.rename).not.toHaveBeenCalled(); expect(storageMock.rename).not.toHaveBeenCalled();
}); });
it('should fail if gzip fails', async () => { it('should fail if gzip fails', async () => {
mocks.process.spawn.mockReturnValueOnce(mockSpawn(0, 'data', '')); processMock.spawn.mockReturnValueOnce(mockSpawn(0, 'data', ''));
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
const result = await sut.handleBackupDatabase(); const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.FAILED); expect(result).toBe(JobStatus.FAILED);
}); });
it('should fail if write stream fails', async () => { it('should fail if write stream fails', async () => {
mocks.storage.createWriteStream.mockImplementation(() => { storageMock.createWriteStream.mockImplementation(() => {
throw new Error('error'); throw new Error('error');
}); });
const result = await sut.handleBackupDatabase(); const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.FAILED); expect(result).toBe(JobStatus.FAILED);
}); });
it('should fail if rename fails', async () => { it('should fail if rename fails', async () => {
mocks.storage.rename.mockRejectedValue(new Error('error')); storageMock.rename.mockRejectedValue(new Error('error'));
const result = await sut.handleBackupDatabase(); const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.FAILED); expect(result).toBe(JobStatus.FAILED);
}); });
it('should ignore unlink failing and still return failed job status', async () => { it('should ignore unlink failing and still return failed job status', async () => {
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
mocks.storage.unlink.mockRejectedValue(new Error('error')); storageMock.unlink.mockRejectedValue(new Error('error'));
const result = await sut.handleBackupDatabase(); const result = await sut.handleBackupDatabase();
expect(mocks.storage.unlink).toHaveBeenCalled(); expect(storageMock.unlink).toHaveBeenCalled();
expect(result).toBe(JobStatus.FAILED); expect(result).toBe(JobStatus.FAILED);
}); });
it.each` it.each`
@@ -195,9 +206,9 @@ describe(BackupService.name, () => {
`( `(
`should use pg_dumpall $expectedVersion with postgres version $postgresVersion`, `should use pg_dumpall $expectedVersion with postgres version $postgresVersion`,
async ({ postgresVersion, expectedVersion }) => { async ({ postgresVersion, expectedVersion }) => {
mocks.database.getPostgresVersion.mockResolvedValue(postgresVersion); databaseMock.getPostgresVersion.mockResolvedValue(postgresVersion);
await sut.handleBackupDatabase(); await sut.handleBackupDatabase();
expect(mocks.process.spawn).toHaveBeenCalledWith( expect(processMock.spawn).toHaveBeenCalledWith(
`/usr/lib/postgresql/${expectedVersion}/bin/pg_dumpall`, `/usr/lib/postgresql/${expectedVersion}/bin/pg_dumpall`,
expect.any(Array), expect.any(Array),
expect.any(Object), expect.any(Object),
@@ -209,9 +220,9 @@ describe(BackupService.name, () => {
${'13.99.99'} ${'13.99.99'}
${'18.0.0'} ${'18.0.0'}
`(`should fail if postgres version $postgresVersion is not supported`, async ({ postgresVersion }) => { `(`should fail if postgres version $postgresVersion is not supported`, async ({ postgresVersion }) => {
mocks.database.getPostgresVersion.mockResolvedValue(postgresVersion); databaseMock.getPostgresVersion.mockResolvedValue(postgresVersion);
const result = await sut.handleBackupDatabase(); const result = await sut.handleBackupDatabase();
expect(mocks.process.spawn).not.toHaveBeenCalled(); expect(processMock.spawn).not.toHaveBeenCalled();
expect(result).toBe(JobStatus.FAILED); expect(result).toBe(JobStatus.FAILED);
}); });
}); });
+7 -8
View File
@@ -17,10 +17,13 @@ import { IMachineLearningRepository } from 'src/interfaces/machine-learning.inte
import { IMoveRepository } from 'src/interfaces/move.interface'; import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { IProcessRepository } from 'src/interfaces/process.interface';
import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISearchRepository } from 'src/interfaces/search.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { IStackRepository } from 'src/interfaces/stack.interface'; import { IStackRepository } from 'src/interfaces/stack.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface'; import { ITagRepository } from 'src/interfaces/tag.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
@@ -30,7 +33,6 @@ import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AuditRepository } from 'src/repositories/audit.repository'; import { AuditRepository } from 'src/repositories/audit.repository';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { CronRepository } from 'src/repositories/cron.repository'; import { CronRepository } from 'src/repositories/cron.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { MapRepository } from 'src/repositories/map.repository'; import { MapRepository } from 'src/repositories/map.repository';
import { MediaRepository } from 'src/repositories/media.repository'; import { MediaRepository } from 'src/repositories/media.repository';
@@ -38,10 +40,7 @@ import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository';
import { NotificationRepository } from 'src/repositories/notification.repository'; import { NotificationRepository } from 'src/repositories/notification.repository';
import { OAuthRepository } from 'src/repositories/oauth.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository';
import { ProcessRepository } from 'src/repositories/process.repository';
import { ServerInfoRepository } from 'src/repositories/server-info.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository';
import { SessionRepository } from 'src/repositories/session.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TelemetryRepository } from 'src/repositories/telemetry.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { TrashRepository } from 'src/repositories/trash.repository'; import { TrashRepository } from 'src/repositories/trash.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
@@ -62,7 +61,7 @@ export class BaseService {
@Inject(IAssetRepository) protected assetRepository: IAssetRepository, @Inject(IAssetRepository) protected assetRepository: IAssetRepository,
protected configRepository: ConfigRepository, protected configRepository: ConfigRepository,
protected cronRepository: CronRepository, protected cronRepository: CronRepository,
@Inject(ICryptoRepository) protected cryptoRepository: CryptoRepository, @Inject(ICryptoRepository) protected cryptoRepository: ICryptoRepository,
@Inject(IDatabaseRepository) protected databaseRepository: IDatabaseRepository, @Inject(IDatabaseRepository) protected databaseRepository: IDatabaseRepository,
@Inject(IEventRepository) protected eventRepository: IEventRepository, @Inject(IEventRepository) protected eventRepository: IEventRepository,
@Inject(IJobRepository) protected jobRepository: IJobRepository, @Inject(IJobRepository) protected jobRepository: IJobRepository,
@@ -78,14 +77,14 @@ export class BaseService {
protected oauthRepository: OAuthRepository, protected oauthRepository: OAuthRepository,
@Inject(IPartnerRepository) protected partnerRepository: IPartnerRepository, @Inject(IPartnerRepository) protected partnerRepository: IPartnerRepository,
@Inject(IPersonRepository) protected personRepository: IPersonRepository, @Inject(IPersonRepository) protected personRepository: IPersonRepository,
protected processRepository: ProcessRepository, @Inject(IProcessRepository) protected processRepository: IProcessRepository,
@Inject(ISearchRepository) protected searchRepository: ISearchRepository, @Inject(ISearchRepository) protected searchRepository: ISearchRepository,
protected serverInfoRepository: ServerInfoRepository, protected serverInfoRepository: ServerInfoRepository,
protected sessionRepository: SessionRepository, @Inject(ISessionRepository) protected sessionRepository: ISessionRepository,
@Inject(ISharedLinkRepository) protected sharedLinkRepository: ISharedLinkRepository, @Inject(ISharedLinkRepository) protected sharedLinkRepository: ISharedLinkRepository,
@Inject(IStackRepository) protected stackRepository: IStackRepository, @Inject(IStackRepository) protected stackRepository: IStackRepository,
@Inject(IStorageRepository) protected storageRepository: IStorageRepository, @Inject(IStorageRepository) protected storageRepository: IStorageRepository,
protected systemMetadataRepository: SystemMetadataRepository, @Inject(ISystemMetadataRepository) protected systemMetadataRepository: ISystemMetadataRepository,
@Inject(ITagRepository) protected tagRepository: ITagRepository, @Inject(ITagRepository) protected tagRepository: ITagRepository,
protected telemetryRepository: TelemetryRepository, protected telemetryRepository: TelemetryRepository,
protected trashRepository: TrashRepository, protected trashRepository: TrashRepository,
+19 -15
View File
@@ -1,27 +1,31 @@
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { CliService } from 'src/services/cli.service'; import { CliService } from 'src/services/cli.service';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService } from 'test/utils';
import { describe, it } from 'vitest'; import { Mocked, describe, it } from 'vitest';
describe(CliService.name, () => { describe(CliService.name, () => {
let sut: CliService; let sut: CliService;
let mocks: ServiceMocks;
let userMock: Mocked<IUserRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(CliService)); ({ sut, userMock, systemMock } = newTestService(CliService));
}); });
describe('listUsers', () => { describe('listUsers', () => {
it('should list users', async () => { it('should list users', async () => {
mocks.user.getList.mockResolvedValue([userStub.admin]); userMock.getList.mockResolvedValue([userStub.admin]);
await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]); await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]);
expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true }); expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: true });
}); });
}); });
describe('resetAdminPassword', () => { describe('resetAdminPassword', () => {
it('should only work when there is an admin account', async () => { it('should only work when there is an admin account', async () => {
mocks.user.getAdmin.mockResolvedValue(void 0); userMock.getAdmin.mockResolvedValue(void 0);
const ask = vitest.fn().mockResolvedValue('new-password'); const ask = vitest.fn().mockResolvedValue('new-password');
await expect(sut.resetAdminPassword(ask)).rejects.toThrowError('Admin account does not exist'); await expect(sut.resetAdminPassword(ask)).rejects.toThrowError('Admin account does not exist');
@@ -30,12 +34,12 @@ describe(CliService.name, () => {
}); });
it('should default to a random password', async () => { it('should default to a random password', async () => {
mocks.user.getAdmin.mockResolvedValue(userStub.admin); userMock.getAdmin.mockResolvedValue(userStub.admin);
const ask = vitest.fn().mockImplementation(() => {}); const ask = vitest.fn().mockImplementation(() => {});
const response = await sut.resetAdminPassword(ask); const response = await sut.resetAdminPassword(ask);
const [id, update] = mocks.user.update.mock.calls[0]; const [id, update] = userMock.update.mock.calls[0];
expect(response.provided).toBe(false); expect(response.provided).toBe(false);
expect(ask).toHaveBeenCalled(); expect(ask).toHaveBeenCalled();
@@ -44,12 +48,12 @@ describe(CliService.name, () => {
}); });
it('should use the supplied password', async () => { it('should use the supplied password', async () => {
mocks.user.getAdmin.mockResolvedValue(userStub.admin); userMock.getAdmin.mockResolvedValue(userStub.admin);
const ask = vitest.fn().mockResolvedValue('new-password'); const ask = vitest.fn().mockResolvedValue('new-password');
const response = await sut.resetAdminPassword(ask); const response = await sut.resetAdminPassword(ask);
const [id, update] = mocks.user.update.mock.calls[0]; const [id, update] = userMock.update.mock.calls[0];
expect(response.provided).toBe(true); expect(response.provided).toBe(true);
expect(ask).toHaveBeenCalled(); expect(ask).toHaveBeenCalled();
@@ -61,28 +65,28 @@ describe(CliService.name, () => {
describe('disablePasswordLogin', () => { describe('disablePasswordLogin', () => {
it('should disable password login', async () => { it('should disable password login', async () => {
await sut.disablePasswordLogin(); await sut.disablePasswordLogin();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith('system-config', { passwordLogin: { enabled: false } }); expect(systemMock.set).toHaveBeenCalledWith('system-config', { passwordLogin: { enabled: false } });
}); });
}); });
describe('enablePasswordLogin', () => { describe('enablePasswordLogin', () => {
it('should enable password login', async () => { it('should enable password login', async () => {
await sut.enablePasswordLogin(); await sut.enablePasswordLogin();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith('system-config', {}); expect(systemMock.set).toHaveBeenCalledWith('system-config', {});
}); });
}); });
describe('disableOAuthLogin', () => { describe('disableOAuthLogin', () => {
it('should disable oauth login', async () => { it('should disable oauth login', async () => {
await sut.disableOAuthLogin(); await sut.disableOAuthLogin();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith('system-config', {}); expect(systemMock.set).toHaveBeenCalledWith('system-config', {});
}); });
}); });
describe('enableOAuthLogin', () => { describe('enableOAuthLogin', () => {
it('should enable oauth login', async () => { it('should enable oauth login', async () => {
await sut.enableOAuthLogin(); await sut.enableOAuthLogin();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith('system-config', { oauth: { enabled: true } }); expect(systemMock.set).toHaveBeenCalledWith('system-config', { oauth: { enabled: true } });
}); });
}); });
}); });
+125 -116
View File
@@ -1,12 +1,21 @@
import { DatabaseExtension, EXTENSION_NAMES, VectorExtension } from 'src/interfaces/database.interface'; import {
DatabaseExtension,
EXTENSION_NAMES,
IDatabaseRepository,
VectorExtension,
} from 'src/interfaces/database.interface';
import { DatabaseService } from 'src/services/database.service'; import { DatabaseService } from 'src/services/database.service';
import { IConfigRepository, ILoggingRepository } from 'src/types';
import { mockEnvData } from 'test/repositories/config.repository.mock'; import { mockEnvData } from 'test/repositories/config.repository.mock';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
describe(DatabaseService.name, () => { describe(DatabaseService.name, () => {
let sut: DatabaseService; let sut: DatabaseService;
let mocks: ServiceMocks;
let configMock: Mocked<IConfigRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let loggerMock: Mocked<ILoggingRepository>;
let extensionRange: string; let extensionRange: string;
let versionBelowRange: string; let versionBelowRange: string;
let minVersionInRange: string; let minVersionInRange: string;
@@ -14,16 +23,16 @@ describe(DatabaseService.name, () => {
let versionAboveRange: string; let versionAboveRange: string;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(DatabaseService)); ({ sut, configMock, databaseMock, loggerMock } = newTestService(DatabaseService));
extensionRange = '0.2.x'; extensionRange = '0.2.x';
mocks.database.getExtensionVersionRange.mockReturnValue(extensionRange); databaseMock.getExtensionVersionRange.mockReturnValue(extensionRange);
versionBelowRange = '0.1.0'; versionBelowRange = '0.1.0';
minVersionInRange = '0.2.0'; minVersionInRange = '0.2.0';
updateInRange = '0.2.1'; updateInRange = '0.2.1';
versionAboveRange = '0.3.0'; versionAboveRange = '0.3.0';
mocks.database.getExtensionVersion.mockResolvedValue({ databaseMock.getExtensionVersion.mockResolvedValue({
installedVersion: minVersionInRange, installedVersion: minVersionInRange,
availableVersion: minVersionInRange, availableVersion: minVersionInRange,
}); });
@@ -35,11 +44,11 @@ describe(DatabaseService.name, () => {
describe('onBootstrap', () => { describe('onBootstrap', () => {
it('should throw an error if PostgreSQL version is below minimum supported version', async () => { it('should throw an error if PostgreSQL version is below minimum supported version', async () => {
mocks.database.getPostgresVersion.mockResolvedValueOnce('13.10.0'); databaseMock.getPostgresVersion.mockResolvedValueOnce('13.10.0');
await expect(sut.onBootstrap()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0'); await expect(sut.onBootstrap()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0');
expect(mocks.database.getPostgresVersion).toHaveBeenCalledTimes(1); expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1);
}); });
describe.each(<Array<{ extension: VectorExtension; extensionName: string }>>[ describe.each(<Array<{ extension: VectorExtension; extensionName: string }>>[
@@ -47,7 +56,7 @@ describe(DatabaseService.name, () => {
{ extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] }, { extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] },
])('should work with $extensionName', ({ extension, extensionName }) => { ])('should work with $extensionName', ({ extension, extensionName }) => {
beforeEach(() => { beforeEach(() => {
mocks.config.getEnv.mockReturnValue( configMock.getEnv.mockReturnValue(
mockEnvData({ mockEnvData({
database: { database: {
config: { config: {
@@ -76,34 +85,34 @@ describe(DatabaseService.name, () => {
}); });
it(`should start up successfully with ${extension}`, async () => { it(`should start up successfully with ${extension}`, async () => {
mocks.database.getPostgresVersion.mockResolvedValue('14.0.0'); databaseMock.getPostgresVersion.mockResolvedValue('14.0.0');
mocks.database.getExtensionVersion.mockResolvedValue({ databaseMock.getExtensionVersion.mockResolvedValue({
installedVersion: null, installedVersion: null,
availableVersion: minVersionInRange, availableVersion: minVersionInRange,
}); });
await expect(sut.onBootstrap()).resolves.toBeUndefined(); await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(mocks.database.getPostgresVersion).toHaveBeenCalled(); expect(databaseMock.getPostgresVersion).toHaveBeenCalled();
expect(mocks.database.createExtension).toHaveBeenCalledWith(extension); expect(databaseMock.createExtension).toHaveBeenCalledWith(extension);
expect(mocks.database.createExtension).toHaveBeenCalledTimes(1); expect(databaseMock.createExtension).toHaveBeenCalledTimes(1);
expect(mocks.database.getExtensionVersion).toHaveBeenCalled(); expect(databaseMock.getExtensionVersion).toHaveBeenCalled();
expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
expect(mocks.logger.fatal).not.toHaveBeenCalled(); expect(loggerMock.fatal).not.toHaveBeenCalled();
}); });
it(`should throw an error if the ${extension} extension is not installed`, async () => { it(`should throw an error if the ${extension} extension is not installed`, async () => {
mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null }); databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null });
const message = `The ${extensionName} extension is not available in this Postgres instance. const message = `The ${extensionName} extension is not available in this Postgres instance.
If using a container image, ensure the image has the extension installed.`; If using a container image, ensure the image has the extension installed.`;
await expect(sut.onBootstrap()).rejects.toThrow(message); await expect(sut.onBootstrap()).rejects.toThrow(message);
expect(mocks.database.createExtension).not.toHaveBeenCalled(); expect(databaseMock.createExtension).not.toHaveBeenCalled();
expect(mocks.database.runMigrations).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).not.toHaveBeenCalled();
}); });
it(`should throw an error if the ${extension} extension version is below minimum supported version`, async () => { it(`should throw an error if the ${extension} extension version is below minimum supported version`, async () => {
mocks.database.getExtensionVersion.mockResolvedValue({ databaseMock.getExtensionVersion.mockResolvedValue({
installedVersion: versionBelowRange, installedVersion: versionBelowRange,
availableVersion: versionBelowRange, availableVersion: versionBelowRange,
}); });
@@ -112,80 +121,80 @@ describe(DatabaseService.name, () => {
`The ${extensionName} extension version is ${versionBelowRange}, but Immich only supports ${extensionRange}`, `The ${extensionName} extension version is ${versionBelowRange}, but Immich only supports ${extensionRange}`,
); );
expect(mocks.database.runMigrations).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).not.toHaveBeenCalled();
}); });
it(`should throw an error if ${extension} extension version is a nightly`, async () => { it(`should throw an error if ${extension} extension version is a nightly`, async () => {
mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' }); databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' });
await expect(sut.onBootstrap()).rejects.toThrow( await expect(sut.onBootstrap()).rejects.toThrow(
`The ${extensionName} extension version is 0.0.0, which means it is a nightly release.`, `The ${extensionName} extension version is 0.0.0, which means it is a nightly release.`,
); );
expect(mocks.database.createExtension).not.toHaveBeenCalled(); expect(databaseMock.createExtension).not.toHaveBeenCalled();
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(mocks.database.runMigrations).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).not.toHaveBeenCalled();
}); });
it(`should do in-range update for ${extension} extension`, async () => { it(`should do in-range update for ${extension} extension`, async () => {
mocks.database.getExtensionVersion.mockResolvedValue({ databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: updateInRange, availableVersion: updateInRange,
installedVersion: minVersionInRange, installedVersion: minVersionInRange,
}); });
mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false }); databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false });
await expect(sut.onBootstrap()).resolves.toBeUndefined(); await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(mocks.database.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
expect(mocks.database.updateVectorExtension).toHaveBeenCalledTimes(1); expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1);
expect(mocks.database.getExtensionVersion).toHaveBeenCalled(); expect(databaseMock.getExtensionVersion).toHaveBeenCalled();
expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
expect(mocks.logger.fatal).not.toHaveBeenCalled(); expect(loggerMock.fatal).not.toHaveBeenCalled();
}); });
it(`should not upgrade ${extension} if same version`, async () => { it(`should not upgrade ${extension} if same version`, async () => {
mocks.database.getExtensionVersion.mockResolvedValue({ databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: minVersionInRange, availableVersion: minVersionInRange,
installedVersion: minVersionInRange, installedVersion: minVersionInRange,
}); });
await expect(sut.onBootstrap()).resolves.toBeUndefined(); await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
expect(mocks.logger.fatal).not.toHaveBeenCalled(); expect(loggerMock.fatal).not.toHaveBeenCalled();
}); });
it(`should throw error if ${extension} available version is below range`, async () => { it(`should throw error if ${extension} available version is below range`, async () => {
mocks.database.getExtensionVersion.mockResolvedValue({ databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: versionBelowRange, availableVersion: versionBelowRange,
installedVersion: null, installedVersion: null,
}); });
await expect(sut.onBootstrap()).rejects.toThrow(); await expect(sut.onBootstrap()).rejects.toThrow();
expect(mocks.database.createExtension).not.toHaveBeenCalled(); expect(databaseMock.createExtension).not.toHaveBeenCalled();
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(mocks.database.runMigrations).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).not.toHaveBeenCalled();
expect(mocks.logger.fatal).not.toHaveBeenCalled(); expect(loggerMock.fatal).not.toHaveBeenCalled();
}); });
it(`should throw error if ${extension} available version is above range`, async () => { it(`should throw error if ${extension} available version is above range`, async () => {
mocks.database.getExtensionVersion.mockResolvedValue({ databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: versionAboveRange, availableVersion: versionAboveRange,
installedVersion: minVersionInRange, installedVersion: minVersionInRange,
}); });
await expect(sut.onBootstrap()).rejects.toThrow(); await expect(sut.onBootstrap()).rejects.toThrow();
expect(mocks.database.createExtension).not.toHaveBeenCalled(); expect(databaseMock.createExtension).not.toHaveBeenCalled();
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(mocks.database.runMigrations).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).not.toHaveBeenCalled();
expect(mocks.logger.fatal).not.toHaveBeenCalled(); expect(loggerMock.fatal).not.toHaveBeenCalled();
}); });
it('should throw error if available version is below installed version', async () => { it('should throw error if available version is below installed version', async () => {
mocks.database.getExtensionVersion.mockResolvedValue({ databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: minVersionInRange, availableVersion: minVersionInRange,
installedVersion: updateInRange, installedVersion: updateInRange,
}); });
@@ -194,13 +203,13 @@ describe(DatabaseService.name, () => {
`The database currently has ${extensionName} ${updateInRange} activated, but the Postgres instance only has ${minVersionInRange} available.`, `The database currently has ${extensionName} ${updateInRange} activated, but the Postgres instance only has ${minVersionInRange} available.`,
); );
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(mocks.database.runMigrations).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).not.toHaveBeenCalled();
expect(mocks.logger.fatal).not.toHaveBeenCalled(); expect(loggerMock.fatal).not.toHaveBeenCalled();
}); });
it('should throw error if installed version is not in version range', async () => { it('should throw error if installed version is not in version range', async () => {
mocks.database.getExtensionVersion.mockResolvedValue({ databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: minVersionInRange, availableVersion: minVersionInRange,
installedVersion: versionAboveRange, installedVersion: versionAboveRange,
}); });
@@ -209,84 +218,84 @@ describe(DatabaseService.name, () => {
`The ${extensionName} extension version is ${versionAboveRange}, but Immich only supports`, `The ${extensionName} extension version is ${versionAboveRange}, but Immich only supports`,
); );
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(mocks.database.runMigrations).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).not.toHaveBeenCalled();
expect(mocks.logger.fatal).not.toHaveBeenCalled(); expect(loggerMock.fatal).not.toHaveBeenCalled();
}); });
it(`should raise error if ${extension} extension upgrade failed`, async () => { it(`should raise error if ${extension} extension upgrade failed`, async () => {
mocks.database.getExtensionVersion.mockResolvedValue({ databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: updateInRange, availableVersion: updateInRange,
installedVersion: minVersionInRange, installedVersion: minVersionInRange,
}); });
mocks.database.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension')); databaseMock.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension'));
await expect(sut.onBootstrap()).rejects.toThrow('Failed to update extension'); await expect(sut.onBootstrap()).rejects.toThrow('Failed to update extension');
expect(mocks.logger.warn.mock.calls[0][0]).toContain( expect(loggerMock.warn.mock.calls[0][0]).toContain(
`The ${extensionName} extension can be updated to ${updateInRange}.`, `The ${extensionName} extension can be updated to ${updateInRange}.`,
); );
expect(mocks.logger.fatal).not.toHaveBeenCalled(); expect(loggerMock.fatal).not.toHaveBeenCalled();
expect(mocks.database.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
expect(mocks.database.runMigrations).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).not.toHaveBeenCalled();
}); });
it(`should warn if ${extension} extension update requires restart`, async () => { it(`should warn if ${extension} extension update requires restart`, async () => {
mocks.database.getExtensionVersion.mockResolvedValue({ databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: updateInRange, availableVersion: updateInRange,
installedVersion: minVersionInRange, installedVersion: minVersionInRange,
}); });
mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: true }); databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: true });
await expect(sut.onBootstrap()).resolves.toBeUndefined(); await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(mocks.logger.warn).toHaveBeenCalledTimes(1); expect(loggerMock.warn).toHaveBeenCalledTimes(1);
expect(mocks.logger.warn.mock.calls[0][0]).toContain(extensionName); expect(loggerMock.warn.mock.calls[0][0]).toContain(extensionName);
expect(mocks.database.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
expect(mocks.logger.fatal).not.toHaveBeenCalled(); expect(loggerMock.fatal).not.toHaveBeenCalled();
}); });
it(`should reindex ${extension} indices if needed`, async () => { it(`should reindex ${extension} indices if needed`, async () => {
mocks.database.shouldReindex.mockResolvedValue(true); databaseMock.shouldReindex.mockResolvedValue(true);
await expect(sut.onBootstrap()).resolves.toBeUndefined(); await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(2); expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2);
expect(mocks.database.reindex).toHaveBeenCalledTimes(2); expect(databaseMock.reindex).toHaveBeenCalledTimes(2);
expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
expect(mocks.logger.fatal).not.toHaveBeenCalled(); expect(loggerMock.fatal).not.toHaveBeenCalled();
}); });
it(`should throw an error if reindexing fails`, async () => { it(`should throw an error if reindexing fails`, async () => {
mocks.database.shouldReindex.mockResolvedValue(true); databaseMock.shouldReindex.mockResolvedValue(true);
mocks.database.reindex.mockRejectedValue(new Error('Error reindexing')); databaseMock.reindex.mockRejectedValue(new Error('Error reindexing'));
await expect(sut.onBootstrap()).rejects.toBeDefined(); await expect(sut.onBootstrap()).rejects.toBeDefined();
expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(1); expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(1);
expect(mocks.database.reindex).toHaveBeenCalledTimes(1); expect(databaseMock.reindex).toHaveBeenCalledTimes(1);
expect(mocks.database.runMigrations).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).not.toHaveBeenCalled();
expect(mocks.logger.fatal).not.toHaveBeenCalled(); expect(loggerMock.fatal).not.toHaveBeenCalled();
expect(mocks.logger.warn).toHaveBeenCalledWith( expect(loggerMock.warn).toHaveBeenCalledWith(
expect.stringContaining('Could not run vector reindexing checks.'), expect.stringContaining('Could not run vector reindexing checks.'),
); );
}); });
it(`should not reindex ${extension} indices if not needed`, async () => { it(`should not reindex ${extension} indices if not needed`, async () => {
mocks.database.shouldReindex.mockResolvedValue(false); databaseMock.shouldReindex.mockResolvedValue(false);
await expect(sut.onBootstrap()).resolves.toBeUndefined(); await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(2); expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2);
expect(mocks.database.reindex).toHaveBeenCalledTimes(0); expect(databaseMock.reindex).toHaveBeenCalledTimes(0);
expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
expect(mocks.logger.fatal).not.toHaveBeenCalled(); expect(loggerMock.fatal).not.toHaveBeenCalled();
}); });
}); });
it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => { it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => {
mocks.config.getEnv.mockReturnValue( configMock.getEnv.mockReturnValue(
mockEnvData({ mockEnvData({
database: { database: {
config: { config: {
@@ -315,11 +324,11 @@ describe(DatabaseService.name, () => {
await expect(sut.onBootstrap()).resolves.toBeUndefined(); await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(mocks.database.runMigrations).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).not.toHaveBeenCalled();
}); });
it(`should throw error if pgvector extension could not be created`, async () => { it(`should throw error if pgvector extension could not be created`, async () => {
mocks.config.getEnv.mockReturnValue( configMock.getEnv.mockReturnValue(
mockEnvData({ mockEnvData({
database: { database: {
config: { config: {
@@ -345,41 +354,41 @@ describe(DatabaseService.name, () => {
}, },
}), }),
); );
mocks.database.getExtensionVersion.mockResolvedValue({ databaseMock.getExtensionVersion.mockResolvedValue({
installedVersion: null, installedVersion: null,
availableVersion: minVersionInRange, availableVersion: minVersionInRange,
}); });
mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false }); databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false });
mocks.database.createExtension.mockRejectedValue(new Error('Failed to create extension')); databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension'));
await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension');
expect(mocks.logger.fatal).toHaveBeenCalledTimes(1); expect(loggerMock.fatal).toHaveBeenCalledTimes(1);
expect(mocks.logger.fatal.mock.calls[0][0]).toContain( expect(loggerMock.fatal.mock.calls[0][0]).toContain(
`Alternatively, if your Postgres instance has pgvecto.rs, you may use this instead`, `Alternatively, if your Postgres instance has pgvecto.rs, you may use this instead`,
); );
expect(mocks.database.createExtension).toHaveBeenCalledTimes(1); expect(databaseMock.createExtension).toHaveBeenCalledTimes(1);
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(mocks.database.runMigrations).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).not.toHaveBeenCalled();
}); });
it(`should throw error if pgvecto.rs extension could not be created`, async () => { it(`should throw error if pgvecto.rs extension could not be created`, async () => {
mocks.database.getExtensionVersion.mockResolvedValue({ databaseMock.getExtensionVersion.mockResolvedValue({
installedVersion: null, installedVersion: null,
availableVersion: minVersionInRange, availableVersion: minVersionInRange,
}); });
mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false }); databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false });
mocks.database.createExtension.mockRejectedValue(new Error('Failed to create extension')); databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension'));
await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension');
expect(mocks.logger.fatal).toHaveBeenCalledTimes(1); expect(loggerMock.fatal).toHaveBeenCalledTimes(1);
expect(mocks.logger.fatal.mock.calls[0][0]).toContain( expect(loggerMock.fatal.mock.calls[0][0]).toContain(
`Alternatively, if your Postgres instance has pgvector, you may use this instead`, `Alternatively, if your Postgres instance has pgvector, you may use this instead`,
); );
expect(mocks.database.createExtension).toHaveBeenCalledTimes(1); expect(databaseMock.createExtension).toHaveBeenCalledTimes(1);
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(mocks.database.runMigrations).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).not.toHaveBeenCalled();
}); });
}); });
@@ -394,38 +403,38 @@ describe(DatabaseService.name, () => {
it('should not override interval', () => { it('should not override interval', () => {
sut.handleConnectionError(new Error('Error')); sut.handleConnectionError(new Error('Error'));
expect(mocks.logger.error).toHaveBeenCalled(); expect(loggerMock.error).toHaveBeenCalled();
sut.handleConnectionError(new Error('foo')); sut.handleConnectionError(new Error('foo'));
expect(mocks.logger.error).toHaveBeenCalledTimes(1); expect(loggerMock.error).toHaveBeenCalledTimes(1);
}); });
it('should reconnect when interval elapses', async () => { it('should reconnect when interval elapses', async () => {
mocks.database.reconnect.mockResolvedValue(true); databaseMock.reconnect.mockResolvedValue(true);
sut.handleConnectionError(new Error('error')); sut.handleConnectionError(new Error('error'));
await vi.advanceTimersByTimeAsync(5000); await vi.advanceTimersByTimeAsync(5000);
expect(mocks.database.reconnect).toHaveBeenCalledTimes(1); expect(databaseMock.reconnect).toHaveBeenCalledTimes(1);
expect(mocks.logger.log).toHaveBeenCalledWith('Database reconnected'); expect(loggerMock.log).toHaveBeenCalledWith('Database reconnected');
await vi.advanceTimersByTimeAsync(5000); await vi.advanceTimersByTimeAsync(5000);
expect(mocks.database.reconnect).toHaveBeenCalledTimes(1); expect(databaseMock.reconnect).toHaveBeenCalledTimes(1);
}); });
it('should try again when reconnection fails', async () => { it('should try again when reconnection fails', async () => {
mocks.database.reconnect.mockResolvedValueOnce(false); databaseMock.reconnect.mockResolvedValueOnce(false);
sut.handleConnectionError(new Error('error')); sut.handleConnectionError(new Error('error'));
await vi.advanceTimersByTimeAsync(5000); await vi.advanceTimersByTimeAsync(5000);
expect(mocks.database.reconnect).toHaveBeenCalledTimes(1); expect(databaseMock.reconnect).toHaveBeenCalledTimes(1);
expect(mocks.logger.warn).toHaveBeenCalledWith(expect.stringContaining('Database connection failed')); expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining('Database connection failed'));
mocks.database.reconnect.mockResolvedValueOnce(true); databaseMock.reconnect.mockResolvedValueOnce(true);
await vi.advanceTimersByTimeAsync(5000); await vi.advanceTimersByTimeAsync(5000);
expect(mocks.database.reconnect).toHaveBeenCalledTimes(2); expect(databaseMock.reconnect).toHaveBeenCalledTimes(2);
expect(mocks.logger.log).toHaveBeenCalledWith('Database reconnected'); expect(loggerMock.log).toHaveBeenCalledWith('Database reconnected');
}); });
}); });
}); });
+46 -39
View File
@@ -1,12 +1,16 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { DownloadResponseDto } from 'src/dtos/download.dto'; import { DownloadResponseDto } from 'src/dtos/download.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { DownloadService } from 'src/services/download.service'; import { DownloadService } from 'src/services/download.service';
import { ILoggingRepository } from 'src/types';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { Readable } from 'typeorm/platform/PlatformTools.js'; import { Readable } from 'typeorm/platform/PlatformTools.js';
import { vitest } from 'vitest'; import { Mocked, vitest } from 'vitest';
const downloadResponse: DownloadResponseDto = { const downloadResponse: DownloadResponseDto = {
totalSize: 105_000, totalSize: 105_000,
@@ -20,14 +24,17 @@ const downloadResponse: DownloadResponseDto = {
describe(DownloadService.name, () => { describe(DownloadService.name, () => {
let sut: DownloadService; let sut: DownloadService;
let mocks: ServiceMocks; let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>;
let loggerMock: Mocked<ILoggingRepository>;
let storageMock: Mocked<IStorageRepository>;
it('should work', () => { it('should work', () => {
expect(sut).toBeDefined(); expect(sut).toBeDefined();
}); });
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(DownloadService)); ({ sut, accessMock, assetMock, loggerMock, storageMock } = newTestService(DownloadService));
}); });
describe('downloadArchive', () => { describe('downloadArchive', () => {
@@ -38,9 +45,9 @@ describe(DownloadService.name, () => {
stream: new Readable(), stream: new Readable(),
}; };
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.noResizePath, id: 'asset-1' }]); assetMock.getByIds.mockResolvedValue([{ ...assetStub.noResizePath, id: 'asset-1' }]);
mocks.storage.createZipStream.mockReturnValue(archiveMock); storageMock.createZipStream.mockReturnValue(archiveMock);
await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({
stream: archiveMock.stream, stream: archiveMock.stream,
@@ -57,19 +64,19 @@ describe(DownloadService.name, () => {
stream: new Readable(), stream: new Readable(),
}; };
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
mocks.storage.realpath.mockRejectedValue(new Error('Could not read file')); storageMock.realpath.mockRejectedValue(new Error('Could not read file'));
mocks.asset.getByIds.mockResolvedValue([ assetMock.getByIds.mockResolvedValue([
{ ...assetStub.noResizePath, id: 'asset-1' }, { ...assetStub.noResizePath, id: 'asset-1' },
{ ...assetStub.noWebpPath, id: 'asset-2' }, { ...assetStub.noWebpPath, id: 'asset-2' },
]); ]);
mocks.storage.createZipStream.mockReturnValue(archiveMock); storageMock.createZipStream.mockReturnValue(archiveMock);
await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({
stream: archiveMock.stream, stream: archiveMock.stream,
}); });
expect(mocks.logger.warn).toHaveBeenCalledTimes(2); expect(loggerMock.warn).toHaveBeenCalledTimes(2);
expect(archiveMock.addFile).toHaveBeenCalledTimes(2); expect(archiveMock.addFile).toHaveBeenCalledTimes(2);
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_456.jpg', 'IMG_456.jpg'); expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_456.jpg', 'IMG_456.jpg');
@@ -82,12 +89,12 @@ describe(DownloadService.name, () => {
stream: new Readable(), stream: new Readable(),
}; };
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
mocks.asset.getByIds.mockResolvedValue([ assetMock.getByIds.mockResolvedValue([
{ ...assetStub.noResizePath, id: 'asset-1' }, { ...assetStub.noResizePath, id: 'asset-1' },
{ ...assetStub.noWebpPath, id: 'asset-2' }, { ...assetStub.noWebpPath, id: 'asset-2' },
]); ]);
mocks.storage.createZipStream.mockReturnValue(archiveMock); storageMock.createZipStream.mockReturnValue(archiveMock);
await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({
stream: archiveMock.stream, stream: archiveMock.stream,
@@ -105,12 +112,12 @@ describe(DownloadService.name, () => {
stream: new Readable(), stream: new Readable(),
}; };
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
mocks.asset.getByIds.mockResolvedValue([ assetMock.getByIds.mockResolvedValue([
{ ...assetStub.noResizePath, id: 'asset-1' }, { ...assetStub.noResizePath, id: 'asset-1' },
{ ...assetStub.noResizePath, id: 'asset-2' }, { ...assetStub.noResizePath, id: 'asset-2' },
]); ]);
mocks.storage.createZipStream.mockReturnValue(archiveMock); storageMock.createZipStream.mockReturnValue(archiveMock);
await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({
stream: archiveMock.stream, stream: archiveMock.stream,
@@ -128,12 +135,12 @@ describe(DownloadService.name, () => {
stream: new Readable(), stream: new Readable(),
}; };
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
mocks.asset.getByIds.mockResolvedValue([ assetMock.getByIds.mockResolvedValue([
{ ...assetStub.noResizePath, id: 'asset-2' }, { ...assetStub.noResizePath, id: 'asset-2' },
{ ...assetStub.noResizePath, id: 'asset-1' }, { ...assetStub.noResizePath, id: 'asset-1' },
]); ]);
mocks.storage.createZipStream.mockReturnValue(archiveMock); storageMock.createZipStream.mockReturnValue(archiveMock);
await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({
stream: archiveMock.stream, stream: archiveMock.stream,
@@ -151,12 +158,12 @@ describe(DownloadService.name, () => {
stream: new Readable(), stream: new Readable(),
}; };
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
mocks.asset.getByIds.mockResolvedValue([ assetMock.getByIds.mockResolvedValue([
{ ...assetStub.noResizePath, id: 'asset-1', originalPath: '/path/to/symlink.jpg' }, { ...assetStub.noResizePath, id: 'asset-1', originalPath: '/path/to/symlink.jpg' },
]); ]);
mocks.storage.realpath.mockResolvedValue('/path/to/realpath.jpg'); storageMock.realpath.mockResolvedValue('/path/to/realpath.jpg');
mocks.storage.createZipStream.mockReturnValue(archiveMock); storageMock.createZipStream.mockReturnValue(archiveMock);
await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1'] })).resolves.toEqual({ await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1'] })).resolves.toEqual({
stream: archiveMock.stream, stream: archiveMock.stream,
@@ -172,30 +179,30 @@ describe(DownloadService.name, () => {
}); });
it('should return a list of archives (assetIds)', async () => { it('should return a list of archives (assetIds)', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
mocks.asset.getByIds.mockResolvedValue([assetStub.image, assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.image, assetStub.video]);
const assetIds = ['asset-1', 'asset-2']; const assetIds = ['asset-1', 'asset-2'];
await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual(downloadResponse); await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual(downloadResponse);
expect(mocks.asset.getByIds).toHaveBeenCalledWith(['asset-1', 'asset-2'], { exifInfo: true }); expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1', 'asset-2'], { exifInfo: true });
}); });
it('should return a list of archives (albumId)', async () => { it('should return a list of archives (albumId)', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1']));
mocks.asset.getByAlbumId.mockResolvedValue({ assetMock.getByAlbumId.mockResolvedValue({
items: [assetStub.image, assetStub.video], items: [assetStub.image, assetStub.video],
hasNextPage: false, hasNextPage: false,
}); });
await expect(sut.getDownloadInfo(authStub.admin, { albumId: 'album-1' })).resolves.toEqual(downloadResponse); await expect(sut.getDownloadInfo(authStub.admin, { albumId: 'album-1' })).resolves.toEqual(downloadResponse);
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-1'])); expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-1']));
expect(mocks.asset.getByAlbumId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, 'album-1'); expect(assetMock.getByAlbumId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, 'album-1');
}); });
it('should return a list of archives (userId)', async () => { it('should return a list of archives (userId)', async () => {
mocks.asset.getByUserId.mockResolvedValue({ assetMock.getByUserId.mockResolvedValue({
items: [assetStub.image, assetStub.video], items: [assetStub.image, assetStub.video],
hasNextPage: false, hasNextPage: false,
}); });
@@ -204,13 +211,13 @@ describe(DownloadService.name, () => {
downloadResponse, downloadResponse,
); );
expect(mocks.asset.getByUserId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, authStub.admin.user.id, { expect(assetMock.getByUserId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, authStub.admin.user.id, {
isVisible: true, isVisible: true,
}); });
}); });
it('should split archives by size', async () => { it('should split archives by size', async () => {
mocks.asset.getByUserId.mockResolvedValue({ assetMock.getByUserId.mockResolvedValue({
items: [ items: [
{ ...assetStub.image, id: 'asset-1' }, { ...assetStub.image, id: 'asset-1' },
{ ...assetStub.video, id: 'asset-2' }, { ...assetStub.video, id: 'asset-2' },
@@ -238,8 +245,8 @@ describe(DownloadService.name, () => {
const assetIds = [assetStub.livePhotoStillAsset.id]; const assetIds = [assetStub.livePhotoStillAsset.id];
const assets = [assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]; const assets = [assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset];
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds));
mocks.asset.getByIds.mockImplementation( assetMock.getByIds.mockImplementation(
(ids) => (ids) =>
Promise.resolve( Promise.resolve(
ids.map((id) => assets.find((asset) => asset.id === id)).filter((asset) => !!asset), ids.map((id) => assets.find((asset) => asset.id === id)).filter((asset) => !!asset),
@@ -264,8 +271,8 @@ describe(DownloadService.name, () => {
{ ...assetStub.livePhotoMotionAsset, originalPath: 'upload/encoded-video/uuid-MP.mp4' }, { ...assetStub.livePhotoMotionAsset, originalPath: 'upload/encoded-video/uuid-MP.mp4' },
]; ];
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds));
mocks.asset.getByIds.mockImplementation( assetMock.getByIds.mockImplementation(
(ids) => (ids) =>
Promise.resolve( Promise.resolve(
ids.map((id) => assets.find((asset) => asset.id === id)).filter((asset) => !!asset), ids.map((id) => assets.find((asset) => asset.id === id)).filter((asset) => !!asset),
+56 -48
View File
@@ -1,20 +1,28 @@
import { WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { JobName, JobStatus } from 'src/interfaces/job.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { DuplicateService } from 'src/services/duplicate.service'; import { DuplicateService } from 'src/services/duplicate.service';
import { SearchService } from 'src/services/search.service'; import { SearchService } from 'src/services/search.service';
import { ILoggingRepository } from 'src/types';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService } from 'test/utils';
import { beforeEach, vitest } from 'vitest'; import { Mocked, beforeEach, vitest } from 'vitest';
vitest.useFakeTimers(); vitest.useFakeTimers();
describe(SearchService.name, () => { describe(SearchService.name, () => {
let sut: DuplicateService; let sut: DuplicateService;
let mocks: ServiceMocks;
let assetMock: Mocked<IAssetRepository>;
let jobMock: Mocked<IJobRepository>;
let loggerMock: Mocked<ILoggingRepository>;
let searchMock: Mocked<ISearchRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(DuplicateService)); ({ sut, assetMock, jobMock, loggerMock, searchMock, systemMock } = newTestService(DuplicateService));
}); });
it('should work', () => { it('should work', () => {
@@ -23,7 +31,7 @@ describe(SearchService.name, () => {
describe('getDuplicates', () => { describe('getDuplicates', () => {
it('should get duplicates', async () => { it('should get duplicates', async () => {
mocks.asset.getDuplicates.mockResolvedValue([ assetMock.getDuplicates.mockResolvedValue([
{ {
duplicateId: assetStub.hasDupe.duplicateId!, duplicateId: assetStub.hasDupe.duplicateId!,
assets: [assetStub.hasDupe, assetStub.hasDupe], assets: [assetStub.hasDupe, assetStub.hasDupe],
@@ -43,7 +51,7 @@ describe(SearchService.name, () => {
describe('handleQueueSearchDuplicates', () => { describe('handleQueueSearchDuplicates', () => {
beforeEach(() => { beforeEach(() => {
mocks.systemMetadata.get.mockResolvedValue({ systemMock.get.mockResolvedValue({
machineLearning: { machineLearning: {
enabled: true, enabled: true,
duplicateDetection: { duplicateDetection: {
@@ -54,7 +62,7 @@ describe(SearchService.name, () => {
}); });
it('should skip if machine learning is disabled', async () => { it('should skip if machine learning is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ systemMock.get.mockResolvedValue({
machineLearning: { machineLearning: {
enabled: false, enabled: false,
duplicateDetection: { duplicateDetection: {
@@ -64,13 +72,13 @@ describe(SearchService.name, () => {
}); });
await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED); await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED);
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled();
expect(mocks.systemMetadata.get).toHaveBeenCalled(); expect(systemMock.get).toHaveBeenCalled();
}); });
it('should skip if duplicate detection is disabled', async () => { it('should skip if duplicate detection is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ systemMock.get.mockResolvedValue({
machineLearning: { machineLearning: {
enabled: true, enabled: true,
duplicateDetection: { duplicateDetection: {
@@ -80,21 +88,21 @@ describe(SearchService.name, () => {
}); });
await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED); await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED);
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled();
expect(mocks.systemMetadata.get).toHaveBeenCalled(); expect(systemMock.get).toHaveBeenCalled();
}); });
it('should queue missing assets', async () => { it('should queue missing assets', async () => {
mocks.asset.getWithout.mockResolvedValue({ assetMock.getWithout.mockResolvedValue({
items: [assetStub.image], items: [assetStub.image],
hasNextPage: false, hasNextPage: false,
}); });
await sut.handleQueueSearchDuplicates({}); await sut.handleQueueSearchDuplicates({});
expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.DUPLICATE); expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.DUPLICATE);
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.DUPLICATE_DETECTION, name: JobName.DUPLICATE_DETECTION,
data: { id: assetStub.image.id }, data: { id: assetStub.image.id },
@@ -103,15 +111,15 @@ describe(SearchService.name, () => {
}); });
it('should queue all assets', async () => { it('should queue all assets', async () => {
mocks.asset.getAll.mockResolvedValue({ assetMock.getAll.mockResolvedValue({
items: [assetStub.image], items: [assetStub.image],
hasNextPage: false, hasNextPage: false,
}); });
await sut.handleQueueSearchDuplicates({ force: true }); await sut.handleQueueSearchDuplicates({ force: true });
expect(mocks.asset.getAll).toHaveBeenCalled(); expect(assetMock.getAll).toHaveBeenCalled();
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.DUPLICATE_DETECTION, name: JobName.DUPLICATE_DETECTION,
data: { id: assetStub.image.id }, data: { id: assetStub.image.id },
@@ -122,7 +130,7 @@ describe(SearchService.name, () => {
describe('handleSearchDuplicates', () => { describe('handleSearchDuplicates', () => {
beforeEach(() => { beforeEach(() => {
mocks.systemMetadata.get.mockResolvedValue({ systemMock.get.mockResolvedValue({
machineLearning: { machineLearning: {
enabled: true, enabled: true,
duplicateDetection: { duplicateDetection: {
@@ -133,7 +141,7 @@ describe(SearchService.name, () => {
}); });
it('should skip if machine learning is disabled', async () => { it('should skip if machine learning is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ systemMock.get.mockResolvedValue({
machineLearning: { machineLearning: {
enabled: false, enabled: false,
duplicateDetection: { duplicateDetection: {
@@ -142,7 +150,7 @@ describe(SearchService.name, () => {
}, },
}); });
const id = assetStub.livePhotoMotionAsset.id; const id = assetStub.livePhotoMotionAsset.id;
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
const result = await sut.handleSearchDuplicates({ id }); const result = await sut.handleSearchDuplicates({ id });
@@ -150,7 +158,7 @@ describe(SearchService.name, () => {
}); });
it('should skip if duplicate detection is disabled', async () => { it('should skip if duplicate detection is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ systemMock.get.mockResolvedValue({
machineLearning: { machineLearning: {
enabled: true, enabled: true,
duplicateDetection: { duplicateDetection: {
@@ -159,7 +167,7 @@ describe(SearchService.name, () => {
}, },
}); });
const id = assetStub.livePhotoMotionAsset.id; const id = assetStub.livePhotoMotionAsset.id;
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
const result = await sut.handleSearchDuplicates({ id }); const result = await sut.handleSearchDuplicates({ id });
@@ -170,40 +178,40 @@ describe(SearchService.name, () => {
const result = await sut.handleSearchDuplicates({ id: assetStub.image.id }); const result = await sut.handleSearchDuplicates({ id: assetStub.image.id });
expect(result).toBe(JobStatus.FAILED); expect(result).toBe(JobStatus.FAILED);
expect(mocks.logger.error).toHaveBeenCalledWith(`Asset ${assetStub.image.id} not found`); expect(loggerMock.error).toHaveBeenCalledWith(`Asset ${assetStub.image.id} not found`);
}); });
it('should skip if asset is not visible', async () => { it('should skip if asset is not visible', async () => {
const id = assetStub.livePhotoMotionAsset.id; const id = assetStub.livePhotoMotionAsset.id;
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
const result = await sut.handleSearchDuplicates({ id }); const result = await sut.handleSearchDuplicates({ id });
expect(result).toBe(JobStatus.SKIPPED); expect(result).toBe(JobStatus.SKIPPED);
expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${id} is not visible, skipping`); expect(loggerMock.debug).toHaveBeenCalledWith(`Asset ${id} is not visible, skipping`);
}); });
it('should fail if asset is missing preview image', async () => { it('should fail if asset is missing preview image', async () => {
mocks.asset.getById.mockResolvedValue(assetStub.noResizePath); assetMock.getById.mockResolvedValue(assetStub.noResizePath);
const result = await sut.handleSearchDuplicates({ id: assetStub.noResizePath.id }); const result = await sut.handleSearchDuplicates({ id: assetStub.noResizePath.id });
expect(result).toBe(JobStatus.FAILED); expect(result).toBe(JobStatus.FAILED);
expect(mocks.logger.warn).toHaveBeenCalledWith(`Asset ${assetStub.noResizePath.id} is missing preview image`); expect(loggerMock.warn).toHaveBeenCalledWith(`Asset ${assetStub.noResizePath.id} is missing preview image`);
}); });
it('should fail if asset is missing embedding', async () => { it('should fail if asset is missing embedding', async () => {
mocks.asset.getById.mockResolvedValue(assetStub.image); assetMock.getById.mockResolvedValue(assetStub.image);
const result = await sut.handleSearchDuplicates({ id: assetStub.image.id }); const result = await sut.handleSearchDuplicates({ id: assetStub.image.id });
expect(result).toBe(JobStatus.FAILED); expect(result).toBe(JobStatus.FAILED);
expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${assetStub.image.id} is missing embedding`); expect(loggerMock.debug).toHaveBeenCalledWith(`Asset ${assetStub.image.id} is missing embedding`);
}); });
it('should search for duplicates and update asset with duplicateId', async () => { it('should search for duplicates and update asset with duplicateId', async () => {
mocks.asset.getById.mockResolvedValue(assetStub.hasEmbedding); assetMock.getById.mockResolvedValue(assetStub.hasEmbedding);
mocks.search.searchDuplicates.mockResolvedValue([ searchMock.searchDuplicates.mockResolvedValue([
{ assetId: assetStub.image.id, distance: 0.01, duplicateId: null }, { assetId: assetStub.image.id, distance: 0.01, duplicateId: null },
]); ]);
const expectedAssetIds = [assetStub.image.id, assetStub.hasEmbedding.id]; const expectedAssetIds = [assetStub.image.id, assetStub.hasEmbedding.id];
@@ -211,58 +219,58 @@ describe(SearchService.name, () => {
const result = await sut.handleSearchDuplicates({ id: assetStub.hasEmbedding.id }); const result = await sut.handleSearchDuplicates({ id: assetStub.hasEmbedding.id });
expect(result).toBe(JobStatus.SUCCESS); expect(result).toBe(JobStatus.SUCCESS);
expect(mocks.search.searchDuplicates).toHaveBeenCalledWith({ expect(searchMock.searchDuplicates).toHaveBeenCalledWith({
assetId: assetStub.hasEmbedding.id, assetId: assetStub.hasEmbedding.id,
embedding: assetStub.hasEmbedding.smartSearch!.embedding, embedding: assetStub.hasEmbedding.smartSearch!.embedding,
maxDistance: 0.01, maxDistance: 0.01,
type: assetStub.hasEmbedding.type, type: assetStub.hasEmbedding.type,
userIds: [assetStub.hasEmbedding.ownerId], userIds: [assetStub.hasEmbedding.ownerId],
}); });
expect(mocks.asset.updateDuplicates).toHaveBeenCalledWith({ expect(assetMock.updateDuplicates).toHaveBeenCalledWith({
assetIds: expectedAssetIds, assetIds: expectedAssetIds,
targetDuplicateId: expect.any(String), targetDuplicateId: expect.any(String),
duplicateIds: [], duplicateIds: [],
}); });
expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith( expect(assetMock.upsertJobStatus).toHaveBeenCalledWith(
...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })), ...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })),
); );
}); });
it('should use existing duplicate ID among matched duplicates', async () => { it('should use existing duplicate ID among matched duplicates', async () => {
const duplicateId = assetStub.hasDupe.duplicateId; const duplicateId = assetStub.hasDupe.duplicateId;
mocks.asset.getById.mockResolvedValue(assetStub.hasEmbedding); assetMock.getById.mockResolvedValue(assetStub.hasEmbedding);
mocks.search.searchDuplicates.mockResolvedValue([{ assetId: assetStub.hasDupe.id, distance: 0.01, duplicateId }]); searchMock.searchDuplicates.mockResolvedValue([{ assetId: assetStub.hasDupe.id, distance: 0.01, duplicateId }]);
const expectedAssetIds = [assetStub.hasEmbedding.id]; const expectedAssetIds = [assetStub.hasEmbedding.id];
const result = await sut.handleSearchDuplicates({ id: assetStub.hasEmbedding.id }); const result = await sut.handleSearchDuplicates({ id: assetStub.hasEmbedding.id });
expect(result).toBe(JobStatus.SUCCESS); expect(result).toBe(JobStatus.SUCCESS);
expect(mocks.search.searchDuplicates).toHaveBeenCalledWith({ expect(searchMock.searchDuplicates).toHaveBeenCalledWith({
assetId: assetStub.hasEmbedding.id, assetId: assetStub.hasEmbedding.id,
embedding: assetStub.hasEmbedding.smartSearch!.embedding, embedding: assetStub.hasEmbedding.smartSearch!.embedding,
maxDistance: 0.01, maxDistance: 0.01,
type: assetStub.hasEmbedding.type, type: assetStub.hasEmbedding.type,
userIds: [assetStub.hasEmbedding.ownerId], userIds: [assetStub.hasEmbedding.ownerId],
}); });
expect(mocks.asset.updateDuplicates).toHaveBeenCalledWith({ expect(assetMock.updateDuplicates).toHaveBeenCalledWith({
assetIds: expectedAssetIds, assetIds: expectedAssetIds,
targetDuplicateId: assetStub.hasDupe.duplicateId, targetDuplicateId: assetStub.hasDupe.duplicateId,
duplicateIds: [], duplicateIds: [],
}); });
expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith( expect(assetMock.upsertJobStatus).toHaveBeenCalledWith(
...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })), ...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })),
); );
}); });
it('should remove duplicateId if no duplicates found and asset has duplicateId', async () => { it('should remove duplicateId if no duplicates found and asset has duplicateId', async () => {
mocks.asset.getById.mockResolvedValue(assetStub.hasDupe); assetMock.getById.mockResolvedValue(assetStub.hasDupe);
mocks.search.searchDuplicates.mockResolvedValue([]); searchMock.searchDuplicates.mockResolvedValue([]);
const result = await sut.handleSearchDuplicates({ id: assetStub.hasDupe.id }); const result = await sut.handleSearchDuplicates({ id: assetStub.hasDupe.id });
expect(result).toBe(JobStatus.SUCCESS); expect(result).toBe(JobStatus.SUCCESS);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.hasDupe.id, duplicateId: null }); expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.hasDupe.id, duplicateId: null });
expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith({ expect(assetMock.upsertJobStatus).toHaveBeenCalledWith({
assetId: assetStub.hasDupe.id, assetId: assetStub.hasDupe.id,
duplicatesDetectedAt: expect.any(Date), duplicatesDetectedAt: expect.any(Date),
}); });
+59 -51
View File
@@ -1,19 +1,27 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { defaults, SystemConfig } from 'src/config'; import { defaults, SystemConfig } from 'src/config';
import { ImmichWorker } from 'src/enum'; import { ImmichWorker } from 'src/enum';
import { JobCommand, JobItem, JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IJobRepository, JobCommand, JobItem, JobName, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { JobService } from 'src/services/job.service'; import { JobService } from 'src/services/job.service';
import { IConfigRepository, ILoggingRepository } from 'src/types';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { ITelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
describe(JobService.name, () => { describe(JobService.name, () => {
let sut: JobService; let sut: JobService;
let mocks: ServiceMocks; let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let jobMock: Mocked<IJobRepository>;
let loggerMock: Mocked<ILoggingRepository>;
let telemetryMock: ITelemetryRepositoryMock;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(JobService, {})); ({ sut, assetMock, configMock, jobMock, loggerMock, telemetryMock } = newTestService(JobService, {}));
mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); configMock.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES);
}); });
it('should work', () => { it('should work', () => {
@@ -24,11 +32,11 @@ describe(JobService.name, () => {
it('should update concurrency', () => { it('should update concurrency', () => {
sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig }); sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig });
expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(15); expect(jobMock.setConcurrency).toHaveBeenCalledTimes(15);
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FACIAL_RECOGNITION, 1); expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FACIAL_RECOGNITION, 1);
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DUPLICATE_DETECTION, 1); expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DUPLICATE_DETECTION, 1);
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BACKGROUND_TASK, 5); expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BACKGROUND_TASK, 5);
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.STORAGE_TEMPLATE_MIGRATION, 1); expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.STORAGE_TEMPLATE_MIGRATION, 1);
}); });
}); });
@@ -36,7 +44,7 @@ describe(JobService.name, () => {
it('should run the scheduled jobs', async () => { it('should run the scheduled jobs', async () => {
await sut.handleNightlyJobs(); await sut.handleNightlyJobs();
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ name: JobName.ASSET_DELETION_CHECK }, { name: JobName.ASSET_DELETION_CHECK },
{ name: JobName.USER_DELETE_CHECK }, { name: JobName.USER_DELETE_CHECK },
{ name: JobName.PERSON_CLEANUP }, { name: JobName.PERSON_CLEANUP },
@@ -51,7 +59,7 @@ describe(JobService.name, () => {
describe('getAllJobStatus', () => { describe('getAllJobStatus', () => {
it('should get all job statuses', async () => { it('should get all job statuses', async () => {
mocks.job.getJobCounts.mockResolvedValue({ jobMock.getJobCounts.mockResolvedValue({
active: 1, active: 1,
completed: 1, completed: 1,
failed: 1, failed: 1,
@@ -59,7 +67,7 @@ describe(JobService.name, () => {
waiting: 1, waiting: 1,
paused: 1, paused: 1,
}); });
mocks.job.getQueueStatus.mockResolvedValue({ jobMock.getQueueStatus.mockResolvedValue({
isActive: true, isActive: true,
isPaused: true, isPaused: true,
}); });
@@ -103,121 +111,121 @@ describe(JobService.name, () => {
it('should handle a pause command', async () => { it('should handle a pause command', async () => {
await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.PAUSE, force: false }); await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.PAUSE, force: false });
expect(mocks.job.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); expect(jobMock.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION);
}); });
it('should handle a resume command', async () => { it('should handle a resume command', async () => {
await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.RESUME, force: false }); await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.RESUME, force: false });
expect(mocks.job.resume).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); expect(jobMock.resume).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION);
}); });
it('should handle an empty command', async () => { it('should handle an empty command', async () => {
await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.EMPTY, force: false }); await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.EMPTY, force: false });
expect(mocks.job.empty).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); expect(jobMock.empty).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION);
}); });
it('should not start a job that is already running', async () => { it('should not start a job that is already running', async () => {
mocks.job.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: false }); jobMock.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: false });
await expect( await expect(
sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false }), sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled();
}); });
it('should handle a start video conversion command', async () => { it('should handle a start video conversion command', async () => {
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false }); await sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false });
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force: false } }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force: false } });
}); });
it('should handle a start storage template migration command', async () => { it('should handle a start storage template migration command', async () => {
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.handleCommand(QueueName.STORAGE_TEMPLATE_MIGRATION, { command: JobCommand.START, force: false }); await sut.handleCommand(QueueName.STORAGE_TEMPLATE_MIGRATION, { command: JobCommand.START, force: false });
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.STORAGE_TEMPLATE_MIGRATION }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.STORAGE_TEMPLATE_MIGRATION });
}); });
it('should handle a start smart search command', async () => { it('should handle a start smart search command', async () => {
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.handleCommand(QueueName.SMART_SEARCH, { command: JobCommand.START, force: false }); await sut.handleCommand(QueueName.SMART_SEARCH, { command: JobCommand.START, force: false });
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_SMART_SEARCH, data: { force: false } }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_SMART_SEARCH, data: { force: false } });
}); });
it('should handle a start metadata extraction command', async () => { it('should handle a start metadata extraction command', async () => {
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.START, force: false }); await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.START, force: false });
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force: false } }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force: false } });
}); });
it('should handle a start sidecar command', async () => { it('should handle a start sidecar command', async () => {
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.handleCommand(QueueName.SIDECAR, { command: JobCommand.START, force: false }); await sut.handleCommand(QueueName.SIDECAR, { command: JobCommand.START, force: false });
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_SIDECAR, data: { force: false } }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_SIDECAR, data: { force: false } });
}); });
it('should handle a start thumbnail generation command', async () => { it('should handle a start thumbnail generation command', async () => {
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.handleCommand(QueueName.THUMBNAIL_GENERATION, { command: JobCommand.START, force: false }); await sut.handleCommand(QueueName.THUMBNAIL_GENERATION, { command: JobCommand.START, force: false });
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } });
}); });
it('should handle a start face detection command', async () => { it('should handle a start face detection command', async () => {
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.handleCommand(QueueName.FACE_DETECTION, { command: JobCommand.START, force: false }); await sut.handleCommand(QueueName.FACE_DETECTION, { command: JobCommand.START, force: false });
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_FACE_DETECTION, data: { force: false } }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_FACE_DETECTION, data: { force: false } });
}); });
it('should handle a start facial recognition command', async () => { it('should handle a start facial recognition command', async () => {
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.handleCommand(QueueName.FACIAL_RECOGNITION, { command: JobCommand.START, force: false }); await sut.handleCommand(QueueName.FACIAL_RECOGNITION, { command: JobCommand.START, force: false });
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } });
}); });
it('should throw a bad request when an invalid queue is used', async () => { it('should throw a bad request when an invalid queue is used', async () => {
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await expect( await expect(
sut.handleCommand(QueueName.BACKGROUND_TASK, { command: JobCommand.START, force: false }), sut.handleCommand(QueueName.BACKGROUND_TASK, { command: JobCommand.START, force: false }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled();
}); });
}); });
describe('onJobStart', () => { describe('onJobStart', () => {
it('should process a successful job', async () => { it('should process a successful job', async () => {
mocks.job.run.mockResolvedValue(JobStatus.SUCCESS); jobMock.run.mockResolvedValue(JobStatus.SUCCESS);
await sut.onJobStart(QueueName.BACKGROUND_TASK, { await sut.onJobStart(QueueName.BACKGROUND_TASK, {
name: JobName.DELETE_FILES, name: JobName.DELETE_FILES,
data: { files: ['path/to/file'] }, data: { files: ['path/to/file'] },
}); });
expect(mocks.telemetry.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', 1); expect(telemetryMock.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', 1);
expect(mocks.telemetry.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', -1); expect(telemetryMock.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', -1);
expect(mocks.telemetry.jobs.addToCounter).toHaveBeenCalledWith('immich.jobs.delete_files.success', 1); expect(telemetryMock.jobs.addToCounter).toHaveBeenCalledWith('immich.jobs.delete_files.success', 1);
expect(mocks.logger.error).not.toHaveBeenCalled(); expect(loggerMock.error).not.toHaveBeenCalled();
}); });
const tests: Array<{ item: JobItem; jobs: JobName[] }> = [ const tests: Array<{ item: JobItem; jobs: JobName[] }> = [
@@ -279,34 +287,34 @@ describe(JobService.name, () => {
it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => { it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => {
if (item.name === JobName.GENERATE_THUMBNAILS && item.data.source === 'upload') { if (item.name === JobName.GENERATE_THUMBNAILS && item.data.source === 'upload') {
if (item.data.id === 'asset-live-image') { if (item.data.id === 'asset-live-image') {
mocks.asset.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoStillAsset]); assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoStillAsset]);
} else { } else {
mocks.asset.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoMotionAsset]); assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoMotionAsset]);
} }
} }
mocks.job.run.mockResolvedValue(JobStatus.SUCCESS); jobMock.run.mockResolvedValue(JobStatus.SUCCESS);
await sut.onJobStart(QueueName.BACKGROUND_TASK, item); await sut.onJobStart(QueueName.BACKGROUND_TASK, item);
if (jobs.length > 1) { if (jobs.length > 1) {
expect(mocks.job.queueAll).toHaveBeenCalledWith( expect(jobMock.queueAll).toHaveBeenCalledWith(
jobs.map((jobName) => ({ name: jobName, data: expect.anything() })), jobs.map((jobName) => ({ name: jobName, data: expect.anything() })),
); );
} else { } else {
expect(mocks.job.queue).toHaveBeenCalledTimes(jobs.length); expect(jobMock.queue).toHaveBeenCalledTimes(jobs.length);
for (const jobName of jobs) { for (const jobName of jobs) {
expect(mocks.job.queue).toHaveBeenCalledWith({ name: jobName, data: expect.anything() }); expect(jobMock.queue).toHaveBeenCalledWith({ name: jobName, data: expect.anything() });
} }
} }
}); });
it(`should not queue any jobs when ${item.name} fails`, async () => { it(`should not queue any jobs when ${item.name} fails`, async () => {
mocks.job.run.mockResolvedValue(JobStatus.FAILED); jobMock.run.mockResolvedValue(JobStatus.FAILED);
await sut.onJobStart(QueueName.BACKGROUND_TASK, item); await sut.onJobStart(QueueName.BACKGROUND_TASK, item);
expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled();
}); });
} }
}); });
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -228,14 +228,13 @@ export class LibraryService extends BaseService {
return mapLibrary(library); return mapLibrary(library);
} }
private async syncFiles({ id, ownerId }: LibraryEntity, assetPaths: string[]) { private async syncFiles({ id }: LibraryEntity, assetPaths: string[]) {
await this.jobRepository.queueAll( await this.jobRepository.queueAll(
assetPaths.map((assetPath) => ({ assetPaths.map((assetPath) => ({
name: JobName.LIBRARY_SYNC_FILE, name: JobName.LIBRARY_SYNC_FILE,
data: { data: {
id, id,
assetPath, assetPath,
ownerId,
}, },
})), })),
); );
@@ -401,7 +400,7 @@ export class LibraryService extends BaseService {
const mtime = stat.mtime; const mtime = stat.mtime;
asset = await this.assetRepository.create({ asset = await this.assetRepository.create({
ownerId: job.ownerId, ownerId: library.ownerId,
libraryId: job.id, libraryId: job.id,
checksum: pathHash, checksum: pathHash,
originalPath: assetPath, originalPath: assetPath,
@@ -433,12 +432,18 @@ export class LibraryService extends BaseService {
async queueScan(id: string) { async queueScan(id: string) {
await this.findOrFail(id); await this.findOrFail(id);
// We purge any existing scan jobs for this library. This is because the scan settings
// might have changed and the user wants to start a new scan with these settings.
await this.jobRepository.removeJob(id, JobName.LIBRARY_QUEUE_SYNC_FILES);
await this.jobRepository.removeJob(id, JobName.LIBRARY_QUEUE_SYNC_ASSETS);
await this.jobRepository.queue({ await this.jobRepository.queue({
name: JobName.LIBRARY_QUEUE_SYNC_FILES, name: JobName.LIBRARY_QUEUE_SYNC_FILES,
data: { data: {
id, id,
}, },
}); });
await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id } }); await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id } });
} }
+21 -14
View File
@@ -1,16 +1,23 @@
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { MapService } from 'src/services/map.service'; import { MapService } from 'src/services/map.service';
import { IMapRepository } from 'src/types';
import { albumStub } from 'test/fixtures/album.stub'; import { albumStub } from 'test/fixtures/album.stub';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { partnerStub } from 'test/fixtures/partner.stub'; import { partnerStub } from 'test/fixtures/partner.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
describe(MapService.name, () => { describe(MapService.name, () => {
let sut: MapService; let sut: MapService;
let mocks: ServiceMocks;
let albumMock: Mocked<IAlbumRepository>;
let mapMock: Mocked<IMapRepository>;
let partnerMock: Mocked<IPartnerRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(MapService)); ({ sut, albumMock, mapMock, partnerMock } = newTestService(MapService));
}); });
describe('getMapMarkers', () => { describe('getMapMarkers', () => {
@@ -24,8 +31,8 @@ describe(MapService.name, () => {
state: asset.exifInfo!.state, state: asset.exifInfo!.state,
country: asset.exifInfo!.country, country: asset.exifInfo!.country,
}; };
mocks.partner.getAll.mockResolvedValue([]); partnerMock.getAll.mockResolvedValue([]);
mocks.map.getMapMarkers.mockResolvedValue([marker]); mapMock.getMapMarkers.mockResolvedValue([marker]);
const markers = await sut.getMapMarkers(authStub.user1, {}); const markers = await sut.getMapMarkers(authStub.user1, {});
@@ -43,12 +50,12 @@ describe(MapService.name, () => {
state: asset.exifInfo!.state, state: asset.exifInfo!.state,
country: asset.exifInfo!.country, country: asset.exifInfo!.country,
}; };
mocks.partner.getAll.mockResolvedValue([partnerStub.adminToUser1]); partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1]);
mocks.map.getMapMarkers.mockResolvedValue([marker]); mapMock.getMapMarkers.mockResolvedValue([marker]);
const markers = await sut.getMapMarkers(authStub.user1, { withPartners: true }); const markers = await sut.getMapMarkers(authStub.user1, { withPartners: true });
expect(mocks.map.getMapMarkers).toHaveBeenCalledWith( expect(mapMock.getMapMarkers).toHaveBeenCalledWith(
[authStub.user1.user.id, partnerStub.adminToUser1.sharedById], [authStub.user1.user.id, partnerStub.adminToUser1.sharedById],
expect.arrayContaining([]), expect.arrayContaining([]),
{ withPartners: true }, { withPartners: true },
@@ -67,10 +74,10 @@ describe(MapService.name, () => {
state: asset.exifInfo!.state, state: asset.exifInfo!.state,
country: asset.exifInfo!.country, country: asset.exifInfo!.country,
}; };
mocks.partner.getAll.mockResolvedValue([]); partnerMock.getAll.mockResolvedValue([]);
mocks.map.getMapMarkers.mockResolvedValue([marker]); mapMock.getMapMarkers.mockResolvedValue([marker]);
mocks.album.getOwned.mockResolvedValue([albumStub.empty]); albumMock.getOwned.mockResolvedValue([albumStub.empty]);
mocks.album.getShared.mockResolvedValue([albumStub.sharedWithUser]); albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]);
const markers = await sut.getMapMarkers(authStub.user1, { withSharedAlbums: true }); const markers = await sut.getMapMarkers(authStub.user1, { withSharedAlbums: true });
@@ -81,13 +88,13 @@ describe(MapService.name, () => {
describe('reverseGeocode', () => { describe('reverseGeocode', () => {
it('should reverse geocode a location', async () => { it('should reverse geocode a location', async () => {
mocks.map.reverseGeocode.mockResolvedValue({ city: 'foo', state: 'bar', country: 'baz' }); mapMock.reverseGeocode.mockResolvedValue({ city: 'foo', state: 'bar', country: 'baz' });
await expect(sut.reverseGeocode({ lat: 42, lon: 69 })).resolves.toEqual([ await expect(sut.reverseGeocode({ lat: 42, lon: 69 })).resolves.toEqual([
{ city: 'foo', state: 'bar', country: 'baz' }, { city: 'foo', state: 'bar', country: 'baz' },
]); ]);
expect(mocks.map.reverseGeocode).toHaveBeenCalledWith({ latitude: 42, longitude: 69 }); expect(mapMock.reverseGeocode).toHaveBeenCalledWith({ latitude: 42, longitude: 69 });
}); });
}); });
}); });
File diff suppressed because it is too large Load Diff
+46 -41
View File
@@ -1,17 +1,22 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { MemoryType } from 'src/enum'; import { MemoryType } from 'src/enum';
import { MemoryService } from 'src/services/memory.service'; import { MemoryService } from 'src/services/memory.service';
import { IMemoryRepository } from 'src/types';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { memoryStub } from 'test/fixtures/memory.stub'; import { memoryStub } from 'test/fixtures/memory.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
describe(MemoryService.name, () => { describe(MemoryService.name, () => {
let sut: MemoryService; let sut: MemoryService;
let mocks: ServiceMocks;
let accessMock: IAccessRepositoryMock;
let memoryMock: Mocked<IMemoryRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(MemoryService)); ({ sut, accessMock, memoryMock } = newTestService(MemoryService));
}); });
it('should be defined', () => { it('should be defined', () => {
@@ -20,7 +25,7 @@ describe(MemoryService.name, () => {
describe('search', () => { describe('search', () => {
it('should search memories', async () => { it('should search memories', async () => {
mocks.memory.search.mockResolvedValue([memoryStub.memory1, memoryStub.empty]); memoryMock.search.mockResolvedValue([memoryStub.memory1, memoryStub.empty]);
await expect(sut.search(authStub.admin)).resolves.toEqual( await expect(sut.search(authStub.admin)).resolves.toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ id: 'memory1', assets: expect.any(Array) }), expect.objectContaining({ id: 'memory1', assets: expect.any(Array) }),
@@ -40,22 +45,22 @@ describe(MemoryService.name, () => {
}); });
it('should throw an error when the memory is not found', async () => { it('should throw an error when the memory is not found', async () => {
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['race-condition'])); accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['race-condition']));
await expect(sut.get(authStub.admin, 'race-condition')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.get(authStub.admin, 'race-condition')).rejects.toBeInstanceOf(BadRequestException);
}); });
it('should get a memory by id', async () => { it('should get a memory by id', async () => {
mocks.memory.get.mockResolvedValue(memoryStub.memory1); memoryMock.get.mockResolvedValue(memoryStub.memory1);
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
await expect(sut.get(authStub.admin, 'memory1')).resolves.toMatchObject({ id: 'memory1' }); await expect(sut.get(authStub.admin, 'memory1')).resolves.toMatchObject({ id: 'memory1' });
expect(mocks.memory.get).toHaveBeenCalledWith('memory1'); expect(memoryMock.get).toHaveBeenCalledWith('memory1');
expect(mocks.access.memory.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['memory1'])); expect(accessMock.memory.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['memory1']));
}); });
}); });
describe('create', () => { describe('create', () => {
it('should skip assets the user does not have access to', async () => { it('should skip assets the user does not have access to', async () => {
mocks.memory.create.mockResolvedValue(memoryStub.empty); memoryMock.create.mockResolvedValue(memoryStub.empty);
await expect( await expect(
sut.create(authStub.admin, { sut.create(authStub.admin, {
type: MemoryType.ON_THIS_DAY, type: MemoryType.ON_THIS_DAY,
@@ -64,7 +69,7 @@ describe(MemoryService.name, () => {
memoryAt: new Date(2024), memoryAt: new Date(2024),
}), }),
).resolves.toMatchObject({ assets: [] }); ).resolves.toMatchObject({ assets: [] });
expect(mocks.memory.create).toHaveBeenCalledWith( expect(memoryMock.create).toHaveBeenCalledWith(
{ {
ownerId: 'admin_id', ownerId: 'admin_id',
memoryAt: expect.any(Date), memoryAt: expect.any(Date),
@@ -78,8 +83,8 @@ describe(MemoryService.name, () => {
}); });
it('should create a memory', async () => { it('should create a memory', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1']));
mocks.memory.create.mockResolvedValue(memoryStub.memory1); memoryMock.create.mockResolvedValue(memoryStub.memory1);
await expect( await expect(
sut.create(authStub.admin, { sut.create(authStub.admin, {
type: MemoryType.ON_THIS_DAY, type: MemoryType.ON_THIS_DAY,
@@ -88,7 +93,7 @@ describe(MemoryService.name, () => {
memoryAt: new Date(2024, 0, 1), memoryAt: new Date(2024, 0, 1),
}), }),
).resolves.toBeDefined(); ).resolves.toBeDefined();
expect(mocks.memory.create).toHaveBeenCalledWith( expect(memoryMock.create).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
ownerId: userStub.admin.id, ownerId: userStub.admin.id,
}), }),
@@ -97,7 +102,7 @@ describe(MemoryService.name, () => {
}); });
it('should create a memory without assets', async () => { it('should create a memory without assets', async () => {
mocks.memory.create.mockResolvedValue(memoryStub.memory1); memoryMock.create.mockResolvedValue(memoryStub.memory1);
await expect( await expect(
sut.create(authStub.admin, { sut.create(authStub.admin, {
type: MemoryType.ON_THIS_DAY, type: MemoryType.ON_THIS_DAY,
@@ -113,27 +118,27 @@ describe(MemoryService.name, () => {
await expect(sut.update(authStub.admin, 'not-found', { isSaved: true })).rejects.toBeInstanceOf( await expect(sut.update(authStub.admin, 'not-found', { isSaved: true })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
expect(mocks.memory.update).not.toHaveBeenCalled(); expect(memoryMock.update).not.toHaveBeenCalled();
}); });
it('should update a memory', async () => { it('should update a memory', async () => {
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
mocks.memory.update.mockResolvedValue(memoryStub.memory1); memoryMock.update.mockResolvedValue(memoryStub.memory1);
await expect(sut.update(authStub.admin, 'memory1', { isSaved: true })).resolves.toBeDefined(); await expect(sut.update(authStub.admin, 'memory1', { isSaved: true })).resolves.toBeDefined();
expect(mocks.memory.update).toHaveBeenCalledWith('memory1', expect.objectContaining({ isSaved: true })); expect(memoryMock.update).toHaveBeenCalledWith('memory1', expect.objectContaining({ isSaved: true }));
}); });
}); });
describe('remove', () => { describe('remove', () => {
it('should require access', async () => { it('should require access', async () => {
await expect(sut.remove(authStub.admin, 'not-found')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.remove(authStub.admin, 'not-found')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.memory.delete).not.toHaveBeenCalled(); expect(memoryMock.delete).not.toHaveBeenCalled();
}); });
it('should delete a memory', async () => { it('should delete a memory', async () => {
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
await expect(sut.remove(authStub.admin, 'memory1')).resolves.toBeUndefined(); await expect(sut.remove(authStub.admin, 'memory1')).resolves.toBeUndefined();
expect(mocks.memory.delete).toHaveBeenCalledWith('memory1'); expect(memoryMock.delete).toHaveBeenCalledWith('memory1');
}); });
}); });
@@ -142,36 +147,36 @@ describe(MemoryService.name, () => {
await expect(sut.addAssets(authStub.admin, 'not-found', { ids: ['asset1'] })).rejects.toBeInstanceOf( await expect(sut.addAssets(authStub.admin, 'not-found', { ids: ['asset1'] })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
expect(mocks.memory.addAssetIds).not.toHaveBeenCalled(); expect(memoryMock.addAssetIds).not.toHaveBeenCalled();
}); });
it('should require asset access', async () => { it('should require asset access', async () => {
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
mocks.memory.get.mockResolvedValue(memoryStub.memory1); memoryMock.get.mockResolvedValue(memoryStub.memory1);
await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['not-found'] })).resolves.toEqual([ await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['not-found'] })).resolves.toEqual([
{ error: 'no_permission', id: 'not-found', success: false }, { error: 'no_permission', id: 'not-found', success: false },
]); ]);
expect(mocks.memory.addAssetIds).not.toHaveBeenCalled(); expect(memoryMock.addAssetIds).not.toHaveBeenCalled();
}); });
it('should skip assets already in the memory', async () => { it('should skip assets already in the memory', async () => {
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
mocks.memory.get.mockResolvedValue(memoryStub.memory1); memoryMock.get.mockResolvedValue(memoryStub.memory1);
mocks.memory.getAssetIds.mockResolvedValue(new Set(['asset1'])); memoryMock.getAssetIds.mockResolvedValue(new Set(['asset1']));
await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([ await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([
{ error: 'duplicate', id: 'asset1', success: false }, { error: 'duplicate', id: 'asset1', success: false },
]); ]);
expect(mocks.memory.addAssetIds).not.toHaveBeenCalled(); expect(memoryMock.addAssetIds).not.toHaveBeenCalled();
}); });
it('should add assets', async () => { it('should add assets', async () => {
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1']));
mocks.memory.get.mockResolvedValue(memoryStub.memory1); memoryMock.get.mockResolvedValue(memoryStub.memory1);
await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([ await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([
{ id: 'asset1', success: true }, { id: 'asset1', success: true },
]); ]);
expect(mocks.memory.addAssetIds).toHaveBeenCalledWith('memory1', ['asset1']); expect(memoryMock.addAssetIds).toHaveBeenCalledWith('memory1', ['asset1']);
}); });
}); });
@@ -180,25 +185,25 @@ describe(MemoryService.name, () => {
await expect(sut.removeAssets(authStub.admin, 'not-found', { ids: ['asset1'] })).rejects.toBeInstanceOf( await expect(sut.removeAssets(authStub.admin, 'not-found', { ids: ['asset1'] })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
expect(mocks.memory.removeAssetIds).not.toHaveBeenCalled(); expect(memoryMock.removeAssetIds).not.toHaveBeenCalled();
}); });
it('should skip assets not in the memory', async () => { it('should skip assets not in the memory', async () => {
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
await expect(sut.removeAssets(authStub.admin, 'memory1', { ids: ['not-found'] })).resolves.toEqual([ await expect(sut.removeAssets(authStub.admin, 'memory1', { ids: ['not-found'] })).resolves.toEqual([
{ error: 'not_found', id: 'not-found', success: false }, { error: 'not_found', id: 'not-found', success: false },
]); ]);
expect(mocks.memory.removeAssetIds).not.toHaveBeenCalled(); expect(memoryMock.removeAssetIds).not.toHaveBeenCalled();
}); });
it('should remove assets', async () => { it('should remove assets', async () => {
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1']));
mocks.memory.getAssetIds.mockResolvedValue(new Set(['asset1'])); memoryMock.getAssetIds.mockResolvedValue(new Set(['asset1']));
await expect(sut.removeAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([ await expect(sut.removeAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([
{ id: 'asset1', success: true }, { id: 'asset1', success: true },
]); ]);
expect(mocks.memory.removeAssetIds).toHaveBeenCalledWith('memory1', ['asset1']); expect(memoryMock.removeAssetIds).toHaveBeenCalledWith('memory1', ['asset1']);
}); });
}); });
}); });
File diff suppressed because it is too large Load Diff
+132 -123
View File
@@ -4,13 +4,20 @@ import { SystemConfigDto } from 'src/dtos/system-config.dto';
import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AlbumUserEntity } from 'src/entities/album-user.entity';
import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetFileType, UserMetadataKey } from 'src/enum'; import { AssetFileType, UserMetadataKey } from 'src/enum';
import { INotifyAlbumUpdateJob, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, INotifyAlbumUpdateJob, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { EmailTemplate } from 'src/repositories/notification.repository'; import { EmailTemplate } from 'src/repositories/notification.repository';
import { NotificationService } from 'src/services/notification.service'; import { NotificationService } from 'src/services/notification.service';
import { INotificationRepository } from 'src/types';
import { albumStub } from 'test/fixtures/album.stub'; import { albumStub } from 'test/fixtures/album.stub';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
const configs = { const configs = {
smtpDisabled: Object.freeze<SystemConfig>({ smtpDisabled: Object.freeze<SystemConfig>({
@@ -51,10 +58,18 @@ const configs = {
describe(NotificationService.name, () => { describe(NotificationService.name, () => {
let sut: NotificationService; let sut: NotificationService;
let mocks: ServiceMocks;
let albumMock: Mocked<IAlbumRepository>;
let assetMock: Mocked<IAssetRepository>;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>;
let notificationMock: Mocked<INotificationRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let userMock: Mocked<IUserRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(NotificationService)); ({ sut, albumMock, assetMock, eventMock, jobMock, notificationMock, systemMock, userMock } =
newTestService(NotificationService));
}); });
it('should work', () => { it('should work', () => {
@@ -65,8 +80,8 @@ describe(NotificationService.name, () => {
it('should emit client and server events', () => { it('should emit client and server events', () => {
const update = { oldConfig: defaults, newConfig: defaults }; const update = { oldConfig: defaults, newConfig: defaults };
expect(sut.onConfigUpdate(update)).toBeUndefined(); expect(sut.onConfigUpdate(update)).toBeUndefined();
expect(mocks.event.clientBroadcast).toHaveBeenCalledWith('on_config_update'); expect(eventMock.clientBroadcast).toHaveBeenCalledWith('on_config_update');
expect(mocks.event.serverSend).toHaveBeenCalledWith('config.update', update); expect(eventMock.serverSend).toHaveBeenCalledWith('config.update', update);
}); });
}); });
@@ -75,18 +90,18 @@ describe(NotificationService.name, () => {
const oldConfig = configs.smtpDisabled; const oldConfig = configs.smtpDisabled;
const newConfig = configs.smtpEnabled; const newConfig = configs.smtpEnabled;
mocks.notification.verifySmtp.mockResolvedValue(true); notificationMock.verifySmtp.mockResolvedValue(true);
await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow();
expect(mocks.notification.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); expect(notificationMock.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport);
}); });
it('validates smtp config when transport changes', async () => { it('validates smtp config when transport changes', async () => {
const oldConfig = configs.smtpEnabled; const oldConfig = configs.smtpEnabled;
const newConfig = configs.smtpTransport; const newConfig = configs.smtpTransport;
mocks.notification.verifySmtp.mockResolvedValue(true); notificationMock.verifySmtp.mockResolvedValue(true);
await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow();
expect(mocks.notification.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); expect(notificationMock.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport);
}); });
it('skips smtp validation when there are no changes', async () => { it('skips smtp validation when there are no changes', async () => {
@@ -94,7 +109,7 @@ describe(NotificationService.name, () => {
const newConfig = { ...configs.smtpEnabled }; const newConfig = { ...configs.smtpEnabled };
await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow();
expect(mocks.notification.verifySmtp).not.toHaveBeenCalled(); expect(notificationMock.verifySmtp).not.toHaveBeenCalled();
}); });
it('skips smtp validation with DTO when there are no changes', async () => { it('skips smtp validation with DTO when there are no changes', async () => {
@@ -102,7 +117,7 @@ describe(NotificationService.name, () => {
const newConfig = plainToInstance(SystemConfigDto, configs.smtpEnabled); const newConfig = plainToInstance(SystemConfigDto, configs.smtpEnabled);
await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow();
expect(mocks.notification.verifySmtp).not.toHaveBeenCalled(); expect(notificationMock.verifySmtp).not.toHaveBeenCalled();
}); });
it('skips smtp validation when smtp is disabled', async () => { it('skips smtp validation when smtp is disabled', async () => {
@@ -110,14 +125,14 @@ describe(NotificationService.name, () => {
const newConfig = { ...configs.smtpDisabled }; const newConfig = { ...configs.smtpDisabled };
await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow();
expect(mocks.notification.verifySmtp).not.toHaveBeenCalled(); expect(notificationMock.verifySmtp).not.toHaveBeenCalled();
}); });
it('should fail if smtp configuration is invalid', async () => { it('should fail if smtp configuration is invalid', async () => {
const oldConfig = configs.smtpDisabled; const oldConfig = configs.smtpDisabled;
const newConfig = configs.smtpEnabled; const newConfig = configs.smtpEnabled;
mocks.notification.verifySmtp.mockRejectedValue(new Error('Failed validating smtp')); notificationMock.verifySmtp.mockRejectedValue(new Error('Failed validating smtp'));
await expect(sut.onConfigValidate({ oldConfig, newConfig })).rejects.toBeInstanceOf(Error); await expect(sut.onConfigValidate({ oldConfig, newConfig })).rejects.toBeInstanceOf(Error);
}); });
}); });
@@ -125,14 +140,14 @@ describe(NotificationService.name, () => {
describe('onAssetHide', () => { describe('onAssetHide', () => {
it('should send connected clients an event', () => { it('should send connected clients an event', () => {
sut.onAssetHide({ assetId: 'asset-id', userId: 'user-id' }); sut.onAssetHide({ assetId: 'asset-id', userId: 'user-id' });
expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_hidden', 'user-id', 'asset-id'); expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_hidden', 'user-id', 'asset-id');
}); });
}); });
describe('onAssetShow', () => { describe('onAssetShow', () => {
it('should queue the generate thumbnail job', async () => { it('should queue the generate thumbnail job', async () => {
await sut.onAssetShow({ assetId: 'asset-id', userId: 'user-id' }); await sut.onAssetShow({ assetId: 'asset-id', userId: 'user-id' });
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.GENERATE_THUMBNAILS, name: JobName.GENERATE_THUMBNAILS,
data: { id: 'asset-id', notify: true }, data: { id: 'asset-id', notify: true },
}); });
@@ -142,12 +157,12 @@ describe(NotificationService.name, () => {
describe('onUserSignupEvent', () => { describe('onUserSignupEvent', () => {
it('skips when notify is false', async () => { it('skips when notify is false', async () => {
await sut.onUserSignup({ id: '', notify: false }); await sut.onUserSignup({ id: '', notify: false });
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled();
}); });
it('should queue notify signup event if notify is true', async () => { it('should queue notify signup event if notify is true', async () => {
await sut.onUserSignup({ id: '', notify: true }); await sut.onUserSignup({ id: '', notify: true });
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.NOTIFY_SIGNUP, name: JobName.NOTIFY_SIGNUP,
data: { id: '', tempPassword: undefined }, data: { id: '', tempPassword: undefined },
}); });
@@ -157,7 +172,7 @@ describe(NotificationService.name, () => {
describe('onAlbumUpdateEvent', () => { describe('onAlbumUpdateEvent', () => {
it('should queue notify album update event', async () => { it('should queue notify album update event', async () => {
await sut.onAlbumUpdate({ id: 'album', recipientIds: ['42'] }); await sut.onAlbumUpdate({ id: 'album', recipientIds: ['42'] });
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.NOTIFY_ALBUM_UPDATE, name: JobName.NOTIFY_ALBUM_UPDATE,
data: { id: 'album', recipientIds: ['42'], delay: 300_000 }, data: { id: 'album', recipientIds: ['42'], delay: 300_000 },
}); });
@@ -167,7 +182,7 @@ describe(NotificationService.name, () => {
describe('onAlbumInviteEvent', () => { describe('onAlbumInviteEvent', () => {
it('should queue notify album invite event', async () => { it('should queue notify album invite event', async () => {
await sut.onAlbumInvite({ id: '', userId: '42' }); await sut.onAlbumInvite({ id: '', userId: '42' });
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.NOTIFY_ALBUM_INVITE, name: JobName.NOTIFY_ALBUM_INVITE,
data: { id: '', recipientId: '42' }, data: { id: '', recipientId: '42' },
}); });
@@ -178,67 +193,67 @@ describe(NotificationService.name, () => {
it('should send a on_session_delete client event', () => { it('should send a on_session_delete client event', () => {
vi.useFakeTimers(); vi.useFakeTimers();
sut.onSessionDelete({ sessionId: 'id' }); sut.onSessionDelete({ sessionId: 'id' });
expect(mocks.event.clientSend).not.toHaveBeenCalled(); expect(eventMock.clientSend).not.toHaveBeenCalled();
vi.advanceTimersByTime(500); vi.advanceTimersByTime(500);
expect(mocks.event.clientSend).toHaveBeenCalledWith('on_session_delete', 'id', 'id'); expect(eventMock.clientSend).toHaveBeenCalledWith('on_session_delete', 'id', 'id');
}); });
}); });
describe('onAssetTrash', () => { describe('onAssetTrash', () => {
it('should send connected clients an event', () => { it('should send connected clients an event', () => {
sut.onAssetTrash({ assetId: 'asset-id', userId: 'user-id' }); sut.onAssetTrash({ assetId: 'asset-id', userId: 'user-id' });
expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']); expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']);
}); });
}); });
describe('onAssetDelete', () => { describe('onAssetDelete', () => {
it('should send connected clients an event', () => { it('should send connected clients an event', () => {
sut.onAssetDelete({ assetId: 'asset-id', userId: 'user-id' }); sut.onAssetDelete({ assetId: 'asset-id', userId: 'user-id' });
expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_delete', 'user-id', 'asset-id'); expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_delete', 'user-id', 'asset-id');
}); });
}); });
describe('onAssetsTrash', () => { describe('onAssetsTrash', () => {
it('should send connected clients an event', () => { it('should send connected clients an event', () => {
sut.onAssetsTrash({ assetIds: ['asset-id'], userId: 'user-id' }); sut.onAssetsTrash({ assetIds: ['asset-id'], userId: 'user-id' });
expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']); expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']);
}); });
}); });
describe('onAssetsRestore', () => { describe('onAssetsRestore', () => {
it('should send connected clients an event', () => { it('should send connected clients an event', () => {
sut.onAssetsRestore({ assetIds: ['asset-id'], userId: 'user-id' }); sut.onAssetsRestore({ assetIds: ['asset-id'], userId: 'user-id' });
expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_restore', 'user-id', ['asset-id']); expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_restore', 'user-id', ['asset-id']);
}); });
}); });
describe('onStackCreate', () => { describe('onStackCreate', () => {
it('should send connected clients an event', () => { it('should send connected clients an event', () => {
sut.onStackCreate({ stackId: 'stack-id', userId: 'user-id' }); sut.onStackCreate({ stackId: 'stack-id', userId: 'user-id' });
expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id');
}); });
}); });
describe('onStackUpdate', () => { describe('onStackUpdate', () => {
it('should send connected clients an event', () => { it('should send connected clients an event', () => {
sut.onStackUpdate({ stackId: 'stack-id', userId: 'user-id' }); sut.onStackUpdate({ stackId: 'stack-id', userId: 'user-id' });
expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id');
}); });
}); });
describe('onStackDelete', () => { describe('onStackDelete', () => {
it('should send connected clients an event', () => { it('should send connected clients an event', () => {
sut.onStackDelete({ stackId: 'stack-id', userId: 'user-id' }); sut.onStackDelete({ stackId: 'stack-id', userId: 'user-id' });
expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id');
}); });
}); });
describe('onStacksDelete', () => { describe('onStacksDelete', () => {
it('should send connected clients an event', () => { it('should send connected clients an event', () => {
sut.onStacksDelete({ stackIds: ['stack-id'], userId: 'user-id' }); sut.onStacksDelete({ stackIds: ['stack-id'], userId: 'user-id' });
expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id');
}); });
}); });
@@ -248,8 +263,8 @@ describe(NotificationService.name, () => {
}); });
it('should throw error if smtp validation fails', async () => { it('should throw error if smtp validation fails', async () => {
mocks.user.get.mockResolvedValue(userStub.admin); userMock.get.mockResolvedValue(userStub.admin);
mocks.notification.verifySmtp.mockRejectedValue(''); notificationMock.verifySmtp.mockRejectedValue('');
await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow( await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow(
'Failed to verify SMTP configuration', 'Failed to verify SMTP configuration',
@@ -257,16 +272,16 @@ describe(NotificationService.name, () => {
}); });
it('should send email to default domain', async () => { it('should send email to default domain', async () => {
mocks.user.get.mockResolvedValue(userStub.admin); userMock.get.mockResolvedValue(userStub.admin);
mocks.notification.verifySmtp.mockResolvedValue(true); notificationMock.verifySmtp.mockResolvedValue(true);
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow(); await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow();
expect(mocks.notification.renderEmail).toHaveBeenCalledWith({ expect(notificationMock.renderEmail).toHaveBeenCalledWith({
template: EmailTemplate.TEST_EMAIL, template: EmailTemplate.TEST_EMAIL,
data: { baseUrl: 'http://localhost:2283', displayName: userStub.admin.name }, data: { baseUrl: 'http://localhost:2283', displayName: userStub.admin.name },
}); });
expect(mocks.notification.sendEmail).toHaveBeenCalledWith( expect(notificationMock.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
subject: 'Test email from Immich', subject: 'Test email from Immich',
smtp: configs.smtpTransport.notifications.smtp.transport, smtp: configs.smtpTransport.notifications.smtp.transport,
@@ -275,17 +290,17 @@ describe(NotificationService.name, () => {
}); });
it('should send email to external domain', async () => { it('should send email to external domain', async () => {
mocks.user.get.mockResolvedValue(userStub.admin); userMock.get.mockResolvedValue(userStub.admin);
mocks.notification.verifySmtp.mockResolvedValue(true); notificationMock.verifySmtp.mockResolvedValue(true);
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.systemMetadata.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } }); systemMock.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } });
await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow(); await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow();
expect(mocks.notification.renderEmail).toHaveBeenCalledWith({ expect(notificationMock.renderEmail).toHaveBeenCalledWith({
template: EmailTemplate.TEST_EMAIL, template: EmailTemplate.TEST_EMAIL,
data: { baseUrl: 'https://demo.immich.app', displayName: userStub.admin.name }, data: { baseUrl: 'https://demo.immich.app', displayName: userStub.admin.name },
}); });
expect(mocks.notification.sendEmail).toHaveBeenCalledWith( expect(notificationMock.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
subject: 'Test email from Immich', subject: 'Test email from Immich',
smtp: configs.smtpTransport.notifications.smtp.transport, smtp: configs.smtpTransport.notifications.smtp.transport,
@@ -294,18 +309,18 @@ describe(NotificationService.name, () => {
}); });
it('should send email with replyTo', async () => { it('should send email with replyTo', async () => {
mocks.user.get.mockResolvedValue(userStub.admin); userMock.get.mockResolvedValue(userStub.admin);
mocks.notification.verifySmtp.mockResolvedValue(true); notificationMock.verifySmtp.mockResolvedValue(true);
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
await expect( await expect(
sut.sendTestEmail('', { ...configs.smtpTransport.notifications.smtp, replyTo: 'demo@immich.app' }), sut.sendTestEmail('', { ...configs.smtpTransport.notifications.smtp, replyTo: 'demo@immich.app' }),
).resolves.not.toThrow(); ).resolves.not.toThrow();
expect(mocks.notification.renderEmail).toHaveBeenCalledWith({ expect(notificationMock.renderEmail).toHaveBeenCalledWith({
template: EmailTemplate.TEST_EMAIL, template: EmailTemplate.TEST_EMAIL,
data: { baseUrl: 'http://localhost:2283', displayName: userStub.admin.name }, data: { baseUrl: 'http://localhost:2283', displayName: userStub.admin.name },
}); });
expect(mocks.notification.sendEmail).toHaveBeenCalledWith( expect(notificationMock.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
subject: 'Test email from Immich', subject: 'Test email from Immich',
smtp: configs.smtpTransport.notifications.smtp.transport, smtp: configs.smtpTransport.notifications.smtp.transport,
@@ -321,12 +336,12 @@ describe(NotificationService.name, () => {
}); });
it('should be successful', async () => { it('should be successful', async () => {
mocks.user.get.mockResolvedValue(userStub.admin); userMock.get.mockResolvedValue(userStub.admin);
mocks.systemMetadata.get.mockResolvedValue({ server: {} }); systemMock.get.mockResolvedValue({ server: {} });
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
await expect(sut.handleUserSignup({ id: '' })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleUserSignup({ id: '' })).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SEND_EMAIL, name: JobName.SEND_EMAIL,
data: expect.objectContaining({ subject: 'Welcome to Immich' }), data: expect.objectContaining({ subject: 'Welcome to Immich' }),
}); });
@@ -336,19 +351,19 @@ describe(NotificationService.name, () => {
describe('handleAlbumInvite', () => { describe('handleAlbumInvite', () => {
it('should skip if album could not be found', async () => { it('should skip if album could not be found', async () => {
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SKIPPED); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SKIPPED);
expect(mocks.user.get).not.toHaveBeenCalled(); expect(userMock.get).not.toHaveBeenCalled();
}); });
it('should skip if recipient could not be found', async () => { it('should skip if recipient could not be found', async () => {
mocks.album.getById.mockResolvedValue(albumStub.empty); albumMock.getById.mockResolvedValue(albumStub.empty);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SKIPPED); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SKIPPED);
expect(mocks.asset.getById).not.toHaveBeenCalled(); expect(assetMock.getById).not.toHaveBeenCalled();
}); });
it('should skip if the recipient has email notifications disabled', async () => { it('should skip if the recipient has email notifications disabled', async () => {
mocks.album.getById.mockResolvedValue(albumStub.empty); albumMock.getById.mockResolvedValue(albumStub.empty);
mocks.user.get.mockResolvedValue({ userMock.get.mockResolvedValue({
...userStub.user1, ...userStub.user1,
metadata: [ metadata: [
{ {
@@ -364,8 +379,8 @@ describe(NotificationService.name, () => {
}); });
it('should skip if the recipient has email notifications for album invite disabled', async () => { it('should skip if the recipient has email notifications for album invite disabled', async () => {
mocks.album.getById.mockResolvedValue(albumStub.empty); albumMock.getById.mockResolvedValue(albumStub.empty);
mocks.user.get.mockResolvedValue({ userMock.get.mockResolvedValue({
...userStub.user1, ...userStub.user1,
metadata: [ metadata: [
{ {
@@ -381,8 +396,8 @@ describe(NotificationService.name, () => {
}); });
it('should send invite email', async () => { it('should send invite email', async () => {
mocks.album.getById.mockResolvedValue(albumStub.empty); albumMock.getById.mockResolvedValue(albumStub.empty);
mocks.user.get.mockResolvedValue({ userMock.get.mockResolvedValue({
...userStub.user1, ...userStub.user1,
metadata: [ metadata: [
{ {
@@ -393,19 +408,19 @@ describe(NotificationService.name, () => {
}, },
], ],
}); });
mocks.systemMetadata.get.mockResolvedValue({ server: {} }); systemMock.get.mockResolvedValue({ server: {} });
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SEND_EMAIL, name: JobName.SEND_EMAIL,
data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album') }), data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album') }),
}); });
}); });
it('should send invite email without album thumbnail if thumbnail asset does not exist', async () => { it('should send invite email without album thumbnail if thumbnail asset does not exist', async () => {
mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail);
mocks.user.get.mockResolvedValue({ userMock.get.mockResolvedValue({
...userStub.user1, ...userStub.user1,
metadata: [ metadata: [
{ {
@@ -416,14 +431,14 @@ describe(NotificationService.name, () => {
}, },
], ],
}); });
mocks.systemMetadata.get.mockResolvedValue({ server: {} }); systemMock.get.mockResolvedValue({ server: {} });
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.asset.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, {
files: true, files: true,
}); });
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SEND_EMAIL, name: JobName.SEND_EMAIL,
data: expect.objectContaining({ data: expect.objectContaining({
subject: expect.stringContaining('You have been added to a shared album'), subject: expect.stringContaining('You have been added to a shared album'),
@@ -433,8 +448,8 @@ describe(NotificationService.name, () => {
}); });
it('should send invite email with album thumbnail as jpeg', async () => { it('should send invite email with album thumbnail as jpeg', async () => {
mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail);
mocks.user.get.mockResolvedValue({ userMock.get.mockResolvedValue({
...userStub.user1, ...userStub.user1,
metadata: [ metadata: [
{ {
@@ -445,18 +460,18 @@ describe(NotificationService.name, () => {
}, },
], ],
}); });
mocks.systemMetadata.get.mockResolvedValue({ server: {} }); systemMock.get.mockResolvedValue({ server: {} });
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.asset.getById.mockResolvedValue({ assetMock.getById.mockResolvedValue({
...assetStub.image, ...assetStub.image,
files: [{ assetId: 'asset-id', type: AssetFileType.THUMBNAIL, path: 'path-to-thumb.jpg' } as AssetFileEntity], files: [{ assetId: 'asset-id', type: AssetFileType.THUMBNAIL, path: 'path-to-thumb.jpg' } as AssetFileEntity],
}); });
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.asset.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, {
files: true, files: true,
}); });
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SEND_EMAIL, name: JobName.SEND_EMAIL,
data: expect.objectContaining({ data: expect.objectContaining({
subject: expect.stringContaining('You have been added to a shared album'), subject: expect.stringContaining('You have been added to a shared album'),
@@ -466,8 +481,8 @@ describe(NotificationService.name, () => {
}); });
it('should send invite email with album thumbnail and arbitrary extension', async () => { it('should send invite email with album thumbnail and arbitrary extension', async () => {
mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail);
mocks.user.get.mockResolvedValue({ userMock.get.mockResolvedValue({
...userStub.user1, ...userStub.user1,
metadata: [ metadata: [
{ {
@@ -478,15 +493,15 @@ describe(NotificationService.name, () => {
}, },
], ],
}); });
mocks.systemMetadata.get.mockResolvedValue({ server: {} }); systemMock.get.mockResolvedValue({ server: {} });
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.asset.getById.mockResolvedValue(assetStub.image); assetMock.getById.mockResolvedValue(assetStub.image);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.asset.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, {
files: true, files: true,
}); });
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SEND_EMAIL, name: JobName.SEND_EMAIL,
data: expect.objectContaining({ data: expect.objectContaining({
subject: expect.stringContaining('You have been added to a shared album'), subject: expect.stringContaining('You have been added to a shared album'),
@@ -499,35 +514,35 @@ describe(NotificationService.name, () => {
describe('handleAlbumUpdate', () => { describe('handleAlbumUpdate', () => {
it('should skip if album could not be found', async () => { it('should skip if album could not be found', async () => {
await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED); await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED);
expect(mocks.user.get).not.toHaveBeenCalled(); expect(userMock.get).not.toHaveBeenCalled();
}); });
it('should skip if owner could not be found', async () => { it('should skip if owner could not be found', async () => {
mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail);
await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED); await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED);
expect(mocks.systemMetadata.get).not.toHaveBeenCalled(); expect(systemMock.get).not.toHaveBeenCalled();
}); });
it('should skip recipient that could not be looked up', async () => { it('should skip recipient that could not be looked up', async () => {
mocks.album.getById.mockResolvedValue({ albumMock.getById.mockResolvedValue({
...albumStub.emptyWithValidThumbnail, ...albumStub.emptyWithValidThumbnail,
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity],
}); });
mocks.user.get.mockResolvedValueOnce(userStub.user1); userMock.get.mockResolvedValueOnce(userStub.user1);
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
expect(mocks.notification.renderEmail).not.toHaveBeenCalled(); expect(notificationMock.renderEmail).not.toHaveBeenCalled();
}); });
it('should skip recipient with disabled email notifications', async () => { it('should skip recipient with disabled email notifications', async () => {
mocks.album.getById.mockResolvedValue({ albumMock.getById.mockResolvedValue({
...albumStub.emptyWithValidThumbnail, ...albumStub.emptyWithValidThumbnail,
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity],
}); });
mocks.user.get.mockResolvedValue({ userMock.get.mockResolvedValue({
...userStub.user1, ...userStub.user1,
metadata: [ metadata: [
{ {
@@ -538,19 +553,19 @@ describe(NotificationService.name, () => {
}, },
], ],
}); });
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
expect(mocks.notification.renderEmail).not.toHaveBeenCalled(); expect(notificationMock.renderEmail).not.toHaveBeenCalled();
}); });
it('should skip recipient with disabled email notifications for the album update event', async () => { it('should skip recipient with disabled email notifications for the album update event', async () => {
mocks.album.getById.mockResolvedValue({ albumMock.getById.mockResolvedValue({
...albumStub.emptyWithValidThumbnail, ...albumStub.emptyWithValidThumbnail,
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity],
}); });
mocks.user.get.mockResolvedValue({ userMock.get.mockResolvedValue({
...userStub.user1, ...userStub.user1,
metadata: [ metadata: [
{ {
@@ -561,31 +576,31 @@ describe(NotificationService.name, () => {
}, },
], ],
}); });
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
expect(mocks.notification.renderEmail).not.toHaveBeenCalled(); expect(notificationMock.renderEmail).not.toHaveBeenCalled();
}); });
it('should send email', async () => { it('should send email', async () => {
mocks.album.getById.mockResolvedValue({ albumMock.getById.mockResolvedValue({
...albumStub.emptyWithValidThumbnail, ...albumStub.emptyWithValidThumbnail,
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity],
}); });
mocks.user.get.mockResolvedValue(userStub.user1); userMock.get.mockResolvedValue(userStub.user1);
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
expect(mocks.notification.renderEmail).toHaveBeenCalled(); expect(notificationMock.renderEmail).toHaveBeenCalled();
expect(mocks.job.queue).toHaveBeenCalled(); expect(jobMock.queue).toHaveBeenCalled();
}); });
it('should add new recipients for new images if job is already queued', async () => { it('should add new recipients for new images if job is already queued', async () => {
mocks.job.removeJob.mockResolvedValue({ id: '1', recipientIds: ['2', '3', '4'] } as INotifyAlbumUpdateJob); jobMock.removeJob.mockResolvedValue({ id: '1', recipientIds: ['2', '3', '4'] } as INotifyAlbumUpdateJob);
await sut.onAlbumUpdate({ id: '1', recipientIds: ['1', '2', '3'] } as INotifyAlbumUpdateJob); await sut.onAlbumUpdate({ id: '1', recipientIds: ['1', '2', '3'] } as INotifyAlbumUpdateJob);
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.NOTIFY_ALBUM_UPDATE, name: JobName.NOTIFY_ALBUM_UPDATE,
data: { data: {
id: '1', id: '1',
@@ -598,32 +613,26 @@ describe(NotificationService.name, () => {
describe('handleSendEmail', () => { describe('handleSendEmail', () => {
it('should skip if smtp notifications are disabled', async () => { it('should skip if smtp notifications are disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ notifications: { smtp: { enabled: false } } }); systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: false } } });
await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SKIPPED); await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SKIPPED);
}); });
it('should send mail successfully', async () => { it('should send mail successfully', async () => {
mocks.systemMetadata.get.mockResolvedValue({ systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: true, from: 'test@immich.app' } } });
notifications: { smtp: { enabled: true, from: 'test@immich.app' } }, notificationMock.sendEmail.mockResolvedValue({ messageId: '', response: '' });
});
mocks.notification.sendEmail.mockResolvedValue({ messageId: '', response: '' });
await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.notification.sendEmail).toHaveBeenCalledWith( expect(notificationMock.sendEmail).toHaveBeenCalledWith(expect.objectContaining({ replyTo: 'test@immich.app' }));
expect.objectContaining({ replyTo: 'test@immich.app' }),
);
}); });
it('should send mail with replyTo successfully', async () => { it('should send mail with replyTo successfully', async () => {
mocks.systemMetadata.get.mockResolvedValue({ systemMock.get.mockResolvedValue({
notifications: { smtp: { enabled: true, from: 'test@immich.app', replyTo: 'demo@immich.app' } }, notifications: { smtp: { enabled: true, from: 'test@immich.app', replyTo: 'demo@immich.app' } },
}); });
mocks.notification.sendEmail.mockResolvedValue({ messageId: '', response: '' }); notificationMock.sendEmail.mockResolvedValue({ messageId: '', response: '' });
await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.notification.sendEmail).toHaveBeenCalledWith( expect(notificationMock.sendEmail).toHaveBeenCalledWith(expect.objectContaining({ replyTo: 'demo@immich.app' }));
expect.objectContaining({ replyTo: 'demo@immich.app' }),
);
}); });
}); });
}); });
+24 -20
View File
@@ -1,16 +1,20 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { PartnerDirection } from 'src/interfaces/partner.interface'; import { IPartnerRepository, PartnerDirection } from 'src/interfaces/partner.interface';
import { PartnerService } from 'src/services/partner.service'; import { PartnerService } from 'src/services/partner.service';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { partnerStub } from 'test/fixtures/partner.stub'; import { partnerStub } from 'test/fixtures/partner.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
describe(PartnerService.name, () => { describe(PartnerService.name, () => {
let sut: PartnerService; let sut: PartnerService;
let mocks: ServiceMocks;
let accessMock: IAccessRepositoryMock;
let partnerMock: Mocked<IPartnerRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(PartnerService)); ({ sut, accessMock, partnerMock } = newTestService(PartnerService));
}); });
it('should work', () => { it('should work', () => {
@@ -19,55 +23,55 @@ describe(PartnerService.name, () => {
describe('search', () => { describe('search', () => {
it("should return a list of partners with whom I've shared my library", async () => { it("should return a list of partners with whom I've shared my library", async () => {
mocks.partner.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]); partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]);
await expect(sut.search(authStub.user1, { direction: PartnerDirection.SharedBy })).resolves.toBeDefined(); await expect(sut.search(authStub.user1, { direction: PartnerDirection.SharedBy })).resolves.toBeDefined();
expect(mocks.partner.getAll).toHaveBeenCalledWith(authStub.user1.user.id); expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id);
}); });
it('should return a list of partners who have shared their libraries with me', async () => { it('should return a list of partners who have shared their libraries with me', async () => {
mocks.partner.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]); partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]);
await expect(sut.search(authStub.user1, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined(); await expect(sut.search(authStub.user1, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined();
expect(mocks.partner.getAll).toHaveBeenCalledWith(authStub.user1.user.id); expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id);
}); });
}); });
describe('create', () => { describe('create', () => {
it('should create a new partner', async () => { it('should create a new partner', async () => {
mocks.partner.get.mockResolvedValue(void 0); partnerMock.get.mockResolvedValue(void 0);
mocks.partner.create.mockResolvedValue(partnerStub.adminToUser1); partnerMock.create.mockResolvedValue(partnerStub.adminToUser1);
await expect(sut.create(authStub.admin, authStub.user1.user.id)).resolves.toBeDefined(); await expect(sut.create(authStub.admin, authStub.user1.user.id)).resolves.toBeDefined();
expect(mocks.partner.create).toHaveBeenCalledWith({ expect(partnerMock.create).toHaveBeenCalledWith({
sharedById: authStub.admin.user.id, sharedById: authStub.admin.user.id,
sharedWithId: authStub.user1.user.id, sharedWithId: authStub.user1.user.id,
}); });
}); });
it('should throw an error when the partner already exists', async () => { it('should throw an error when the partner already exists', async () => {
mocks.partner.get.mockResolvedValue(partnerStub.adminToUser1); partnerMock.get.mockResolvedValue(partnerStub.adminToUser1);
await expect(sut.create(authStub.admin, authStub.user1.user.id)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.create(authStub.admin, authStub.user1.user.id)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.partner.create).not.toHaveBeenCalled(); expect(partnerMock.create).not.toHaveBeenCalled();
}); });
}); });
describe('remove', () => { describe('remove', () => {
it('should remove a partner', async () => { it('should remove a partner', async () => {
mocks.partner.get.mockResolvedValue(partnerStub.adminToUser1); partnerMock.get.mockResolvedValue(partnerStub.adminToUser1);
await sut.remove(authStub.admin, authStub.user1.user.id); await sut.remove(authStub.admin, authStub.user1.user.id);
expect(mocks.partner.remove).toHaveBeenCalledWith(partnerStub.adminToUser1); expect(partnerMock.remove).toHaveBeenCalledWith(partnerStub.adminToUser1);
}); });
it('should throw an error when the partner does not exist', async () => { it('should throw an error when the partner does not exist', async () => {
mocks.partner.get.mockResolvedValue(void 0); partnerMock.get.mockResolvedValue(void 0);
await expect(sut.remove(authStub.admin, authStub.user1.user.id)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.remove(authStub.admin, authStub.user1.user.id)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.partner.remove).not.toHaveBeenCalled(); expect(partnerMock.remove).not.toHaveBeenCalled();
}); });
}); });
@@ -79,11 +83,11 @@ describe(PartnerService.name, () => {
}); });
it('should update partner', async () => { it('should update partner', async () => {
mocks.access.partner.checkUpdateAccess.mockResolvedValue(new Set(['shared-by-id'])); accessMock.partner.checkUpdateAccess.mockResolvedValue(new Set(['shared-by-id']));
mocks.partner.update.mockResolvedValue(partnerStub.adminToUser1); partnerMock.update.mockResolvedValue(partnerStub.adminToUser1);
await expect(sut.update(authStub.admin, 'shared-by-id', { inTimeline: true })).resolves.toBeDefined(); await expect(sut.update(authStub.admin, 'shared-by-id', { inTimeline: true })).resolves.toBeDefined();
expect(mocks.partner.update).toHaveBeenCalledWith( expect(partnerMock.update).toHaveBeenCalledWith(
{ sharedById: 'shared-by-id', sharedWithId: authStub.admin.user.id }, { sharedById: 'shared-by-id', sharedWithId: authStub.admin.user.id },
{ inTimeline: true }, { inTimeline: true },
); );
File diff suppressed because it is too large Load Diff
+35 -29
View File
@@ -1,20 +1,26 @@
import { mapAsset } from 'src/dtos/asset-response.dto'; import { mapAsset } from 'src/dtos/asset-response.dto';
import { SearchSuggestionType } from 'src/dtos/search.dto'; import { SearchSuggestionType } from 'src/dtos/search.dto';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
import { SearchService } from 'src/services/search.service'; import { SearchService } from 'src/services/search.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { personStub } from 'test/fixtures/person.stub'; import { personStub } from 'test/fixtures/person.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService } from 'test/utils';
import { beforeEach, vitest } from 'vitest'; import { Mocked, beforeEach, vitest } from 'vitest';
vitest.useFakeTimers(); vitest.useFakeTimers();
describe(SearchService.name, () => { describe(SearchService.name, () => {
let sut: SearchService; let sut: SearchService;
let mocks: ServiceMocks;
let assetMock: Mocked<IAssetRepository>;
let personMock: Mocked<IPersonRepository>;
let searchMock: Mocked<ISearchRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(SearchService)); ({ sut, assetMock, personMock, searchMock } = newTestService(SearchService));
}); });
it('should work', () => { it('should work', () => {
@@ -25,25 +31,25 @@ describe(SearchService.name, () => {
it('should pass options to search', async () => { it('should pass options to search', async () => {
const { name } = personStub.withName; const { name } = personStub.withName;
mocks.person.getByName.mockResolvedValue([]); personMock.getByName.mockResolvedValue([]);
await sut.searchPerson(authStub.user1, { name, withHidden: false }); await sut.searchPerson(authStub.user1, { name, withHidden: false });
expect(mocks.person.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: false }); expect(personMock.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: false });
await sut.searchPerson(authStub.user1, { name, withHidden: true }); await sut.searchPerson(authStub.user1, { name, withHidden: true });
expect(mocks.person.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: true }); expect(personMock.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: true });
}); });
}); });
describe('getExploreData', () => { describe('getExploreData', () => {
it('should get assets by city and tag', async () => { it('should get assets by city and tag', async () => {
mocks.asset.getAssetIdByCity.mockResolvedValue({ assetMock.getAssetIdByCity.mockResolvedValue({
fieldName: 'exifInfo.city', fieldName: 'exifInfo.city',
items: [{ value: 'test-city', data: assetStub.withLocation.id }], items: [{ value: 'test-city', data: assetStub.withLocation.id }],
}); });
mocks.asset.getByIdsWithAllRelations.mockResolvedValue([assetStub.withLocation]); assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.withLocation]);
const expectedResponse = [ const expectedResponse = [
{ fieldName: 'exifInfo.city', items: [{ value: 'test-city', data: mapAsset(assetStub.withLocation) }] }, { fieldName: 'exifInfo.city', items: [{ value: 'test-city', data: mapAsset(assetStub.withLocation) }] },
]; ];
@@ -56,83 +62,83 @@ describe(SearchService.name, () => {
describe('getSearchSuggestions', () => { describe('getSearchSuggestions', () => {
it('should return search suggestions for country', async () => { it('should return search suggestions for country', async () => {
mocks.search.getCountries.mockResolvedValue(['USA']); searchMock.getCountries.mockResolvedValue(['USA']);
await expect( await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }), sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }),
).resolves.toEqual(['USA']); ).resolves.toEqual(['USA']);
expect(mocks.search.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
}); });
it('should return search suggestions for country (including null)', async () => { it('should return search suggestions for country (including null)', async () => {
mocks.search.getCountries.mockResolvedValue(['USA']); searchMock.getCountries.mockResolvedValue(['USA']);
await expect( await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.COUNTRY }), sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.COUNTRY }),
).resolves.toEqual(['USA', null]); ).resolves.toEqual(['USA', null]);
expect(mocks.search.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
}); });
it('should return search suggestions for state', async () => { it('should return search suggestions for state', async () => {
mocks.search.getStates.mockResolvedValue(['California']); searchMock.getStates.mockResolvedValue(['California']);
await expect( await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.STATE }), sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.STATE }),
).resolves.toEqual(['California']); ).resolves.toEqual(['California']);
expect(mocks.search.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); expect(searchMock.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
}); });
it('should return search suggestions for state (including null)', async () => { it('should return search suggestions for state (including null)', async () => {
mocks.search.getStates.mockResolvedValue(['California']); searchMock.getStates.mockResolvedValue(['California']);
await expect( await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.STATE }), sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.STATE }),
).resolves.toEqual(['California', null]); ).resolves.toEqual(['California', null]);
expect(mocks.search.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); expect(searchMock.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
}); });
it('should return search suggestions for city', async () => { it('should return search suggestions for city', async () => {
mocks.search.getCities.mockResolvedValue(['Denver']); searchMock.getCities.mockResolvedValue(['Denver']);
await expect( await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CITY }), sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CITY }),
).resolves.toEqual(['Denver']); ).resolves.toEqual(['Denver']);
expect(mocks.search.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); expect(searchMock.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
}); });
it('should return search suggestions for city (including null)', async () => { it('should return search suggestions for city (including null)', async () => {
mocks.search.getCities.mockResolvedValue(['Denver']); searchMock.getCities.mockResolvedValue(['Denver']);
await expect( await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CITY }), sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CITY }),
).resolves.toEqual(['Denver', null]); ).resolves.toEqual(['Denver', null]);
expect(mocks.search.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); expect(searchMock.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
}); });
it('should return search suggestions for camera make', async () => { it('should return search suggestions for camera make', async () => {
mocks.search.getCameraMakes.mockResolvedValue(['Nikon']); searchMock.getCameraMakes.mockResolvedValue(['Nikon']);
await expect( await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CAMERA_MAKE }), sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CAMERA_MAKE }),
).resolves.toEqual(['Nikon']); ).resolves.toEqual(['Nikon']);
expect(mocks.search.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); expect(searchMock.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
}); });
it('should return search suggestions for camera make (including null)', async () => { it('should return search suggestions for camera make (including null)', async () => {
mocks.search.getCameraMakes.mockResolvedValue(['Nikon']); searchMock.getCameraMakes.mockResolvedValue(['Nikon']);
await expect( await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CAMERA_MAKE }), sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CAMERA_MAKE }),
).resolves.toEqual(['Nikon', null]); ).resolves.toEqual(['Nikon', null]);
expect(mocks.search.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); expect(searchMock.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
}); });
it('should return search suggestions for camera model', async () => { it('should return search suggestions for camera model', async () => {
mocks.search.getCameraModels.mockResolvedValue(['Fujifilm X100VI']); searchMock.getCameraModels.mockResolvedValue(['Fujifilm X100VI']);
await expect( await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CAMERA_MODEL }), sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CAMERA_MODEL }),
).resolves.toEqual(['Fujifilm X100VI']); ).resolves.toEqual(['Fujifilm X100VI']);
expect(mocks.search.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); expect(searchMock.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
}); });
it('should return search suggestions for camera model (including null)', async () => { it('should return search suggestions for camera model (including null)', async () => {
mocks.search.getCameraModels.mockResolvedValue(['Fujifilm X100VI']); searchMock.getCameraModels.mockResolvedValue(['Fujifilm X100VI']);
await expect( await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CAMERA_MODEL }), sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CAMERA_MODEL }),
).resolves.toEqual(['Fujifilm X100VI', null]); ).resolves.toEqual(['Fujifilm X100VI', null]);
expect(mocks.search.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); expect(searchMock.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
}); });
}); });
}); });
+32 -25
View File
@@ -1,13 +1,20 @@
import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataKey } from 'src/enum';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { ServerService } from 'src/services/server.service'; import { ServerService } from 'src/services/server.service';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
describe(ServerService.name, () => { describe(ServerService.name, () => {
let sut: ServerService; let sut: ServerService;
let mocks: ServiceMocks;
let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let userMock: Mocked<IUserRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(ServerService)); ({ sut, storageMock, systemMock, userMock } = newTestService(ServerService));
}); });
it('should work', () => { it('should work', () => {
@@ -16,7 +23,7 @@ describe(ServerService.name, () => {
describe('getStorage', () => { describe('getStorage', () => {
it('should return the disk space as B', async () => { it('should return the disk space as B', async () => {
mocks.storage.checkDiskUsage.mockResolvedValue({ free: 200, available: 300, total: 500 }); storageMock.checkDiskUsage.mockResolvedValue({ free: 200, available: 300, total: 500 });
await expect(sut.getStorage()).resolves.toEqual({ await expect(sut.getStorage()).resolves.toEqual({
diskAvailable: '300 B', diskAvailable: '300 B',
@@ -28,11 +35,11 @@ describe(ServerService.name, () => {
diskUseRaw: 300, diskUseRaw: 300,
}); });
expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library');
}); });
it('should return the disk space as KiB', async () => { it('should return the disk space as KiB', async () => {
mocks.storage.checkDiskUsage.mockResolvedValue({ free: 200_000, available: 300_000, total: 500_000 }); storageMock.checkDiskUsage.mockResolvedValue({ free: 200_000, available: 300_000, total: 500_000 });
await expect(sut.getStorage()).resolves.toEqual({ await expect(sut.getStorage()).resolves.toEqual({
diskAvailable: '293.0 KiB', diskAvailable: '293.0 KiB',
@@ -44,11 +51,11 @@ describe(ServerService.name, () => {
diskUseRaw: 300_000, diskUseRaw: 300_000,
}); });
expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library');
}); });
it('should return the disk space as MiB', async () => { it('should return the disk space as MiB', async () => {
mocks.storage.checkDiskUsage.mockResolvedValue({ free: 200_000_000, available: 300_000_000, total: 500_000_000 }); storageMock.checkDiskUsage.mockResolvedValue({ free: 200_000_000, available: 300_000_000, total: 500_000_000 });
await expect(sut.getStorage()).resolves.toEqual({ await expect(sut.getStorage()).resolves.toEqual({
diskAvailable: '286.1 MiB', diskAvailable: '286.1 MiB',
@@ -60,11 +67,11 @@ describe(ServerService.name, () => {
diskUseRaw: 300_000_000, diskUseRaw: 300_000_000,
}); });
expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library');
}); });
it('should return the disk space as GiB', async () => { it('should return the disk space as GiB', async () => {
mocks.storage.checkDiskUsage.mockResolvedValue({ storageMock.checkDiskUsage.mockResolvedValue({
free: 200_000_000_000, free: 200_000_000_000,
available: 300_000_000_000, available: 300_000_000_000,
total: 500_000_000_000, total: 500_000_000_000,
@@ -80,11 +87,11 @@ describe(ServerService.name, () => {
diskUseRaw: 300_000_000_000, diskUseRaw: 300_000_000_000,
}); });
expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library');
}); });
it('should return the disk space as TiB', async () => { it('should return the disk space as TiB', async () => {
mocks.storage.checkDiskUsage.mockResolvedValue({ storageMock.checkDiskUsage.mockResolvedValue({
free: 200_000_000_000_000, free: 200_000_000_000_000,
available: 300_000_000_000_000, available: 300_000_000_000_000,
total: 500_000_000_000_000, total: 500_000_000_000_000,
@@ -100,11 +107,11 @@ describe(ServerService.name, () => {
diskUseRaw: 300_000_000_000_000, diskUseRaw: 300_000_000_000_000,
}); });
expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library');
}); });
it('should return the disk space as PiB', async () => { it('should return the disk space as PiB', async () => {
mocks.storage.checkDiskUsage.mockResolvedValue({ storageMock.checkDiskUsage.mockResolvedValue({
free: 200_000_000_000_000_000, free: 200_000_000_000_000_000,
available: 300_000_000_000_000_000, available: 300_000_000_000_000_000,
total: 500_000_000_000_000_000, total: 500_000_000_000_000_000,
@@ -120,7 +127,7 @@ describe(ServerService.name, () => {
diskUseRaw: 300_000_000_000_000_000, diskUseRaw: 300_000_000_000_000_000,
}); });
expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library');
}); });
}); });
@@ -148,7 +155,7 @@ describe(ServerService.name, () => {
trash: true, trash: true,
email: false, email: false,
}); });
expect(mocks.systemMetadata.get).toHaveBeenCalled(); expect(systemMock.get).toHaveBeenCalled();
}); });
}); });
@@ -166,13 +173,13 @@ describe(ServerService.name, () => {
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
}); });
expect(mocks.systemMetadata.get).toHaveBeenCalled(); expect(systemMock.get).toHaveBeenCalled();
}); });
}); });
describe('getStats', () => { describe('getStats', () => {
it('should total up usage by user', async () => { it('should total up usage by user', async () => {
mocks.user.getUserStats.mockResolvedValue([ userMock.getUserStats.mockResolvedValue([
{ {
userId: 'user1', userId: 'user1',
userName: '1 User', userName: '1 User',
@@ -245,36 +252,36 @@ describe(ServerService.name, () => {
], ],
}); });
expect(mocks.user.getUserStats).toHaveBeenCalled(); expect(userMock.getUserStats).toHaveBeenCalled();
}); });
}); });
describe('setLicense', () => { describe('setLicense', () => {
it('should save license if valid', async () => { it('should save license if valid', async () => {
mocks.systemMetadata.set.mockResolvedValue(); systemMock.set.mockResolvedValue();
const license = { licenseKey: 'IMSV-license-key', activationKey: 'activation-key' }; const license = { licenseKey: 'IMSV-license-key', activationKey: 'activation-key' };
await sut.setLicense(license); await sut.setLicense(license);
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.LICENSE, expect.any(Object)); expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.LICENSE, expect.any(Object));
}); });
it('should not save license if invalid', async () => { it('should not save license if invalid', async () => {
mocks.user.upsertMetadata.mockResolvedValue(); userMock.upsertMetadata.mockResolvedValue();
const license = { licenseKey: 'license-key', activationKey: 'activation-key' }; const license = { licenseKey: 'license-key', activationKey: 'activation-key' };
const call = sut.setLicense(license); const call = sut.setLicense(license);
await expect(call).rejects.toThrowError('Invalid license key'); await expect(call).rejects.toThrowError('Invalid license key');
expect(mocks.user.upsertMetadata).not.toHaveBeenCalled(); expect(userMock.upsertMetadata).not.toHaveBeenCalled();
}); });
}); });
describe('deleteLicense', () => { describe('deleteLicense', () => {
it('should delete license', async () => { it('should delete license', async () => {
mocks.user.upsertMetadata.mockResolvedValue(); userMock.upsertMetadata.mockResolvedValue();
await sut.deleteLicense(); await sut.deleteLicense();
expect(mocks.user.upsertMetadata).not.toHaveBeenCalled(); expect(userMock.upsertMetadata).not.toHaveBeenCalled();
}); });
}); });
}); });
+23 -19
View File
@@ -1,15 +1,21 @@
import { UserEntity } from 'src/entities/user.entity';
import { JobStatus } from 'src/interfaces/job.interface'; import { JobStatus } from 'src/interfaces/job.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { SessionService } from 'src/services/session.service'; import { SessionService } from 'src/services/session.service';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { sessionStub } from 'test/fixtures/session.stub'; import { sessionStub } from 'test/fixtures/session.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
describe('SessionService', () => { describe('SessionService', () => {
let sut: SessionService; let sut: SessionService;
let mocks: ServiceMocks;
let accessMock: Mocked<IAccessRepositoryMock>;
let sessionMock: Mocked<ISessionRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(SessionService)); ({ sut, accessMock, sessionMock } = newTestService(SessionService));
}); });
it('should be defined', () => { it('should be defined', () => {
@@ -18,13 +24,13 @@ describe('SessionService', () => {
describe('handleCleanup', () => { describe('handleCleanup', () => {
it('should return skipped if nothing is to be deleted', async () => { it('should return skipped if nothing is to be deleted', async () => {
mocks.session.search.mockResolvedValue([]); sessionMock.search.mockResolvedValue([]);
await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SKIPPED); await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SKIPPED);
expect(mocks.session.search).toHaveBeenCalled(); expect(sessionMock.search).toHaveBeenCalled();
}); });
it('should delete sessions', async () => { it('should delete sessions', async () => {
mocks.session.search.mockResolvedValue([ sessionMock.search.mockResolvedValue([
{ {
createdAt: new Date('1970-01-01T00:00:00.00Z'), createdAt: new Date('1970-01-01T00:00:00.00Z'),
updatedAt: new Date('1970-01-02T00:00:00.00Z'), updatedAt: new Date('1970-01-02T00:00:00.00Z'),
@@ -32,18 +38,19 @@ describe('SessionService', () => {
deviceType: '', deviceType: '',
id: '123', id: '123',
token: '420', token: '420',
user: {} as UserEntity,
userId: '42', userId: '42',
}, },
]); ]);
await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SUCCESS); await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SUCCESS);
expect(mocks.session.delete).toHaveBeenCalledWith('123'); expect(sessionMock.delete).toHaveBeenCalledWith('123');
}); });
}); });
describe('getAll', () => { describe('getAll', () => {
it('should get the devices', async () => { it('should get the devices', async () => {
mocks.session.getByUserId.mockResolvedValue([sessionStub.valid as any, sessionStub.inactive]); sessionMock.getByUserId.mockResolvedValue([sessionStub.valid, sessionStub.inactive]);
await expect(sut.getAll(authStub.user1)).resolves.toEqual([ await expect(sut.getAll(authStub.user1)).resolves.toEqual([
{ {
createdAt: '2021-01-01T00:00:00.000Z', createdAt: '2021-01-01T00:00:00.000Z',
@@ -63,33 +70,30 @@ describe('SessionService', () => {
}, },
]); ]);
expect(mocks.session.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); expect(sessionMock.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id);
}); });
}); });
describe('logoutDevices', () => { describe('logoutDevices', () => {
it('should logout all devices', async () => { it('should logout all devices', async () => {
mocks.session.getByUserId.mockResolvedValue([sessionStub.inactive, sessionStub.valid] as any[]); sessionMock.getByUserId.mockResolvedValue([sessionStub.inactive, sessionStub.valid]);
await sut.deleteAll(authStub.user1); await sut.deleteAll(authStub.user1);
expect(mocks.session.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); expect(sessionMock.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id);
expect(mocks.session.delete).toHaveBeenCalledWith('not_active'); expect(sessionMock.delete).toHaveBeenCalledWith('not_active');
expect(mocks.session.delete).not.toHaveBeenCalledWith('token-id'); expect(sessionMock.delete).not.toHaveBeenCalledWith('token-id');
}); });
}); });
describe('logoutDevice', () => { describe('logoutDevice', () => {
it('should logout the device', async () => { it('should logout the device', async () => {
mocks.access.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1'])); accessMock.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1']));
await sut.delete(authStub.user1, 'token-1'); await sut.delete(authStub.user1, 'token-1');
expect(mocks.access.authDevice.checkOwnerAccess).toHaveBeenCalledWith( expect(accessMock.authDevice.checkOwnerAccess).toHaveBeenCalledWith(authStub.user1.user.id, new Set(['token-1']));
authStub.user1.user.id, expect(sessionMock.delete).toHaveBeenCalledWith('token-1');
new Set(['token-1']),
);
expect(mocks.session.delete).toHaveBeenCalledWith('token-1');
}); });
}); });
}); });
+64 -59
View File
@@ -2,19 +2,24 @@ import { BadRequestException, ForbiddenException, UnauthorizedException } from '
import _ from 'lodash'; import _ from 'lodash';
import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { SharedLinkType } from 'src/enum'; import { SharedLinkType } from 'src/enum';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { SharedLinkService } from 'src/services/shared-link.service'; import { SharedLinkService } from 'src/services/shared-link.service';
import { albumStub } from 'test/fixtures/album.stub'; import { albumStub } from 'test/fixtures/album.stub';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
describe(SharedLinkService.name, () => { describe(SharedLinkService.name, () => {
let sut: SharedLinkService; let sut: SharedLinkService;
let mocks: ServiceMocks;
let accessMock: IAccessRepositoryMock;
let sharedLinkMock: Mocked<ISharedLinkRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(SharedLinkService)); ({ sut, accessMock, sharedLinkMock } = newTestService(SharedLinkService));
}); });
it('should work', () => { it('should work', () => {
@@ -23,46 +28,46 @@ describe(SharedLinkService.name, () => {
describe('getAll', () => { describe('getAll', () => {
it('should return all shared links for a user', async () => { it('should return all shared links for a user', async () => {
mocks.sharedLink.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]); sharedLinkMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]);
await expect(sut.getAll(authStub.user1, {})).resolves.toEqual([ await expect(sut.getAll(authStub.user1)).resolves.toEqual([
sharedLinkResponseStub.expired, sharedLinkResponseStub.expired,
sharedLinkResponseStub.valid, sharedLinkResponseStub.valid,
]); ]);
expect(mocks.sharedLink.getAll).toHaveBeenCalledWith({ userId: authStub.user1.user.id }); expect(sharedLinkMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id);
}); });
}); });
describe('getMine', () => { describe('getMine', () => {
it('should only work for a public user', async () => { it('should only work for a public user', async () => {
await expect(sut.getMine(authStub.admin, {})).rejects.toBeInstanceOf(ForbiddenException); await expect(sut.getMine(authStub.admin, {})).rejects.toBeInstanceOf(ForbiddenException);
expect(mocks.sharedLink.get).not.toHaveBeenCalled(); expect(sharedLinkMock.get).not.toHaveBeenCalled();
}); });
it('should return the shared link for the public user', async () => { it('should return the shared link for the public user', async () => {
const authDto = authStub.adminSharedLink; const authDto = authStub.adminSharedLink;
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.valid); await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.valid);
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
}); });
it('should not return metadata', async () => { it('should not return metadata', async () => {
const authDto = authStub.adminSharedLinkNoExif; const authDto = authStub.adminSharedLinkNoExif;
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); sharedLinkMock.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata); await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata);
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
}); });
it('should throw an error for an invalid password protected shared link', async () => { it('should throw an error for an invalid password protected shared link', async () => {
const authDto = authStub.adminSharedLink; const authDto = authStub.adminSharedLink;
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.passwordRequired); sharedLinkMock.get.mockResolvedValue(sharedLinkStub.passwordRequired);
await expect(sut.getMine(authDto, {})).rejects.toBeInstanceOf(UnauthorizedException); await expect(sut.getMine(authDto, {})).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
}); });
it('should allow a correct password on a password protected shared link', async () => { it('should allow a correct password on a password protected shared link', async () => {
mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' }); sharedLinkMock.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' });
await expect(sut.getMine(authStub.adminSharedLink, { password: '123' })).resolves.toBeDefined(); await expect(sut.getMine(authStub.adminSharedLink, { password: '123' })).resolves.toBeDefined();
expect(mocks.sharedLink.get).toHaveBeenCalledWith( expect(sharedLinkMock.get).toHaveBeenCalledWith(
authStub.adminSharedLink.user.id, authStub.adminSharedLink.user.id,
authStub.adminSharedLink.sharedLink?.id, authStub.adminSharedLink.sharedLink?.id,
); );
@@ -72,14 +77,14 @@ describe(SharedLinkService.name, () => {
describe('get', () => { describe('get', () => {
it('should throw an error for an invalid shared link', async () => { it('should throw an error for an invalid shared link', async () => {
await expect(sut.get(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.get(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id');
expect(mocks.sharedLink.update).not.toHaveBeenCalled(); expect(sharedLinkMock.update).not.toHaveBeenCalled();
}); });
it('should get a shared link by id', async () => { it('should get a shared link by id', async () => {
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.get(authStub.user1, sharedLinkStub.valid.id)).resolves.toEqual(sharedLinkResponseStub.valid); await expect(sut.get(authStub.user1, sharedLinkStub.valid.id)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
}); });
}); });
@@ -109,16 +114,16 @@ describe(SharedLinkService.name, () => {
}); });
it('should create an album shared link', async () => { it('should create an album shared link', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id]));
mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.valid); sharedLinkMock.create.mockResolvedValue(sharedLinkStub.valid);
await sut.create(authStub.admin, { type: SharedLinkType.ALBUM, albumId: albumStub.oneAsset.id }); await sut.create(authStub.admin, { type: SharedLinkType.ALBUM, albumId: albumStub.oneAsset.id });
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith( expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id, authStub.admin.user.id,
new Set([albumStub.oneAsset.id]), new Set([albumStub.oneAsset.id]),
); );
expect(mocks.sharedLink.create).toHaveBeenCalledWith({ expect(sharedLinkMock.create).toHaveBeenCalledWith({
type: SharedLinkType.ALBUM, type: SharedLinkType.ALBUM,
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
albumId: albumStub.oneAsset.id, albumId: albumStub.oneAsset.id,
@@ -132,8 +137,8 @@ describe(SharedLinkService.name, () => {
}); });
it('should create an individual shared link', async () => { it('should create an individual shared link', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual);
await sut.create(authStub.admin, { await sut.create(authStub.admin, {
type: SharedLinkType.INDIVIDUAL, type: SharedLinkType.INDIVIDUAL,
@@ -143,11 +148,11 @@ describe(SharedLinkService.name, () => {
allowUpload: true, allowUpload: true,
}); });
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id, authStub.admin.user.id,
new Set([assetStub.image.id]), new Set([assetStub.image.id]),
); );
expect(mocks.sharedLink.create).toHaveBeenCalledWith({ expect(sharedLinkMock.create).toHaveBeenCalledWith({
type: SharedLinkType.INDIVIDUAL, type: SharedLinkType.INDIVIDUAL,
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
albumId: null, albumId: null,
@@ -162,8 +167,8 @@ describe(SharedLinkService.name, () => {
}); });
it('should create a shared link with allowDownload set to false when showMetadata is false', async () => { it('should create a shared link with allowDownload set to false when showMetadata is false', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual);
await sut.create(authStub.admin, { await sut.create(authStub.admin, {
type: SharedLinkType.INDIVIDUAL, type: SharedLinkType.INDIVIDUAL,
@@ -173,11 +178,11 @@ describe(SharedLinkService.name, () => {
allowUpload: true, allowUpload: true,
}); });
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id, authStub.admin.user.id,
new Set([assetStub.image.id]), new Set([assetStub.image.id]),
); );
expect(mocks.sharedLink.create).toHaveBeenCalledWith({ expect(sharedLinkMock.create).toHaveBeenCalledWith({
type: SharedLinkType.INDIVIDUAL, type: SharedLinkType.INDIVIDUAL,
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
albumId: null, albumId: null,
@@ -195,16 +200,16 @@ describe(SharedLinkService.name, () => {
describe('update', () => { describe('update', () => {
it('should throw an error for an invalid shared link', async () => { it('should throw an error for an invalid shared link', async () => {
await expect(sut.update(authStub.user1, 'missing-id', {})).rejects.toBeInstanceOf(BadRequestException); await expect(sut.update(authStub.user1, 'missing-id', {})).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id');
expect(mocks.sharedLink.update).not.toHaveBeenCalled(); expect(sharedLinkMock.update).not.toHaveBeenCalled();
}); });
it('should update a shared link', async () => { it('should update a shared link', async () => {
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid);
mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid); sharedLinkMock.update.mockResolvedValue(sharedLinkStub.valid);
await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false }); await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false });
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
expect(mocks.sharedLink.update).toHaveBeenCalledWith({ expect(sharedLinkMock.update).toHaveBeenCalledWith({
id: sharedLinkStub.valid.id, id: sharedLinkStub.valid.id,
userId: authStub.user1.user.id, userId: authStub.user1.user.id,
allowDownload: false, allowDownload: false,
@@ -215,30 +220,30 @@ describe(SharedLinkService.name, () => {
describe('remove', () => { describe('remove', () => {
it('should throw an error for an invalid shared link', async () => { it('should throw an error for an invalid shared link', async () => {
await expect(sut.remove(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.remove(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id');
expect(mocks.sharedLink.update).not.toHaveBeenCalled(); expect(sharedLinkMock.update).not.toHaveBeenCalled();
}); });
it('should remove a key', async () => { it('should remove a key', async () => {
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid);
await sut.remove(authStub.user1, sharedLinkStub.valid.id); await sut.remove(authStub.user1, sharedLinkStub.valid.id);
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
expect(mocks.sharedLink.remove).toHaveBeenCalledWith(sharedLinkStub.valid); expect(sharedLinkMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid);
}); });
}); });
describe('addAssets', () => { describe('addAssets', () => {
it('should not work on album shared links', async () => { it('should not work on album shared links', async () => {
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
}); });
it('should add assets to a shared link', async () => { it('should add assets to a shared link', async () => {
mocks.sharedLink.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); sharedLinkMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual));
mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-3'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-3']));
await expect( await expect(
sut.addAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2', 'asset-3'] }), sut.addAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2', 'asset-3'] }),
@@ -248,9 +253,9 @@ describe(SharedLinkService.name, () => {
{ assetId: 'asset-3', success: true }, { assetId: 'asset-3', success: true },
]); ]);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledTimes(1); expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledTimes(1);
expect(mocks.sharedLink.update).toHaveBeenCalled(); expect(sharedLinkMock.update).toHaveBeenCalled();
expect(mocks.sharedLink.update).toHaveBeenCalledWith({ expect(sharedLinkMock.update).toHaveBeenCalledWith({
...sharedLinkStub.individual, ...sharedLinkStub.individual,
assetIds: ['asset-3'], assetIds: ['asset-3'],
}); });
@@ -259,15 +264,15 @@ describe(SharedLinkService.name, () => {
describe('removeAssets', () => { describe('removeAssets', () => {
it('should not work on album shared links', async () => { it('should not work on album shared links', async () => {
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
}); });
it('should remove assets from a shared link', async () => { it('should remove assets from a shared link', async () => {
mocks.sharedLink.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); sharedLinkMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual));
mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual);
await expect( await expect(
sut.removeAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2'] }), sut.removeAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2'] }),
@@ -276,39 +281,39 @@ describe(SharedLinkService.name, () => {
{ assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND }, { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND },
]); ]);
expect(mocks.sharedLink.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] }); expect(sharedLinkMock.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] });
}); });
}); });
describe('getMetadataTags', () => { describe('getMetadataTags', () => {
it('should return null when auth is not a shared link', async () => { it('should return null when auth is not a shared link', async () => {
await expect(sut.getMetadataTags(authStub.admin)).resolves.toBe(null); await expect(sut.getMetadataTags(authStub.admin)).resolves.toBe(null);
expect(mocks.sharedLink.get).not.toHaveBeenCalled(); expect(sharedLinkMock.get).not.toHaveBeenCalled();
}); });
it('should return null when shared link has a password', async () => { it('should return null when shared link has a password', async () => {
await expect(sut.getMetadataTags(authStub.passwordSharedLink)).resolves.toBe(null); await expect(sut.getMetadataTags(authStub.passwordSharedLink)).resolves.toBe(null);
expect(mocks.sharedLink.get).not.toHaveBeenCalled(); expect(sharedLinkMock.get).not.toHaveBeenCalled();
}); });
it('should return metadata tags', async () => { it('should return metadata tags', async () => {
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.individual); sharedLinkMock.get.mockResolvedValue(sharedLinkStub.individual);
await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
description: '1 shared photos & videos', description: '1 shared photos & videos',
imageUrl: `http://localhost:2283/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`, imageUrl: `http://localhost:2283/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`,
title: 'Public Share', title: 'Public Share',
}); });
expect(mocks.sharedLink.get).toHaveBeenCalled(); expect(sharedLinkMock.get).toHaveBeenCalled();
}); });
it('should return metadata tags with a default image path if the asset id is not set', async () => { it('should return metadata tags with a default image path if the asset id is not set', async () => {
mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, album: undefined, assets: [] }); sharedLinkMock.get.mockResolvedValue({ ...sharedLinkStub.individual, album: undefined, assets: [] });
await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
description: '0 shared photos & videos', description: '0 shared photos & videos',
imageUrl: `http://localhost:2283/feature-panel.png`, imageUrl: `http://localhost:2283/feature-panel.png`,
title: 'Public Share', title: 'Public Share',
}); });
expect(mocks.sharedLink.get).toHaveBeenCalled(); expect(sharedLinkMock.get).toHaveBeenCalled();
}); });
}); });
}); });
+2 -5
View File
@@ -9,7 +9,6 @@ import {
SharedLinkEditDto, SharedLinkEditDto,
SharedLinkPasswordDto, SharedLinkPasswordDto,
SharedLinkResponseDto, SharedLinkResponseDto,
SharedLinkSearchDto,
} from 'src/dtos/shared-link.dto'; } from 'src/dtos/shared-link.dto';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { Permission, SharedLinkType } from 'src/enum'; import { Permission, SharedLinkType } from 'src/enum';
@@ -18,10 +17,8 @@ import { getExternalDomain, OpenGraphTags } from 'src/utils/misc';
@Injectable() @Injectable()
export class SharedLinkService extends BaseService { export class SharedLinkService extends BaseService {
async getAll(auth: AuthDto, { albumId }: SharedLinkSearchDto): Promise<SharedLinkResponseDto[]> { async getAll(auth: AuthDto): Promise<SharedLinkResponseDto[]> {
return this.sharedLinkRepository return this.sharedLinkRepository.getAll(auth.user.id).then((links) => links.map((link) => mapSharedLink(link)));
.getAll({ userId: auth.user.id, albumId })
.then((links) => links.map((link) => mapSharedLink(link)));
} }
async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise<SharedLinkResponseDto> { async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise<SharedLinkResponseDto> {
+125 -115
View File
@@ -1,22 +1,36 @@
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { ImmichWorker } from 'src/enum'; import { ImmichWorker } from 'src/enum';
import { WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { JobName, JobStatus } from 'src/interfaces/job.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { SmartInfoService } from 'src/services/smart-info.service'; import { SmartInfoService } from 'src/services/smart-info.service';
import { IConfigRepository } from 'src/types';
import { getCLIPModelInfo } from 'src/utils/misc'; import { getCLIPModelInfo } from 'src/utils/misc';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
describe(SmartInfoService.name, () => { describe(SmartInfoService.name, () => {
let sut: SmartInfoService; let sut: SmartInfoService;
let mocks: ServiceMocks;
let assetMock: Mocked<IAssetRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let jobMock: Mocked<IJobRepository>;
let machineLearningMock: Mocked<IMachineLearningRepository>;
let searchMock: Mocked<ISearchRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let configMock: Mocked<IConfigRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(SmartInfoService)); ({ sut, assetMock, databaseMock, jobMock, machineLearningMock, searchMock, systemMock, configMock } =
newTestService(SmartInfoService));
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); configMock.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES);
}); });
it('should work', () => { it('should work', () => {
@@ -56,79 +70,79 @@ describe(SmartInfoService.name, () => {
it('should return if machine learning is disabled', async () => { it('should return if machine learning is disabled', async () => {
await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningDisabled as SystemConfig }); await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningDisabled as SystemConfig });
expect(mocks.search.getDimensionSize).not.toHaveBeenCalled(); expect(searchMock.getDimensionSize).not.toHaveBeenCalled();
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); expect(searchMock.setDimensionSize).not.toHaveBeenCalled();
expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
expect(mocks.job.getQueueStatus).not.toHaveBeenCalled(); expect(jobMock.getQueueStatus).not.toHaveBeenCalled();
expect(mocks.job.pause).not.toHaveBeenCalled(); expect(jobMock.pause).not.toHaveBeenCalled();
expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled(); expect(jobMock.waitForQueueCompletion).not.toHaveBeenCalled();
expect(mocks.job.resume).not.toHaveBeenCalled(); expect(jobMock.resume).not.toHaveBeenCalled();
}); });
it('should return if model and DB dimension size are equal', async () => { it('should return if model and DB dimension size are equal', async () => {
mocks.search.getDimensionSize.mockResolvedValue(512); searchMock.getDimensionSize.mockResolvedValue(512);
await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig }); await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig });
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1);
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); expect(searchMock.setDimensionSize).not.toHaveBeenCalled();
expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
expect(mocks.job.getQueueStatus).not.toHaveBeenCalled(); expect(jobMock.getQueueStatus).not.toHaveBeenCalled();
expect(mocks.job.pause).not.toHaveBeenCalled(); expect(jobMock.pause).not.toHaveBeenCalled();
expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled(); expect(jobMock.waitForQueueCompletion).not.toHaveBeenCalled();
expect(mocks.job.resume).not.toHaveBeenCalled(); expect(jobMock.resume).not.toHaveBeenCalled();
}); });
it('should update DB dimension size if model and DB have different values', async () => { it('should update DB dimension size if model and DB have different values', async () => {
mocks.search.getDimensionSize.mockResolvedValue(768); searchMock.getDimensionSize.mockResolvedValue(768);
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig }); await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig });
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1);
expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(512); expect(searchMock.setDimensionSize).toHaveBeenCalledWith(512);
expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); expect(jobMock.getQueueStatus).toHaveBeenCalledTimes(1);
expect(mocks.job.pause).toHaveBeenCalledTimes(1); expect(jobMock.pause).toHaveBeenCalledTimes(1);
expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); expect(jobMock.waitForQueueCompletion).toHaveBeenCalledTimes(1);
expect(mocks.job.resume).toHaveBeenCalledTimes(1); expect(jobMock.resume).toHaveBeenCalledTimes(1);
}); });
it('should skip pausing and resuming queue if already paused', async () => { it('should skip pausing and resuming queue if already paused', async () => {
mocks.search.getDimensionSize.mockResolvedValue(768); searchMock.getDimensionSize.mockResolvedValue(768);
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true });
await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig }); await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig });
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1);
expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(512); expect(searchMock.setDimensionSize).toHaveBeenCalledWith(512);
expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); expect(jobMock.getQueueStatus).toHaveBeenCalledTimes(1);
expect(mocks.job.pause).not.toHaveBeenCalled(); expect(jobMock.pause).not.toHaveBeenCalled();
expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); expect(jobMock.waitForQueueCompletion).toHaveBeenCalledTimes(1);
expect(mocks.job.resume).not.toHaveBeenCalled(); expect(jobMock.resume).not.toHaveBeenCalled();
}); });
}); });
describe('onConfigUpdateEvent', () => { describe('onConfigUpdateEvent', () => {
it('should return if machine learning is disabled', async () => { it('should return if machine learning is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled);
await sut.onConfigUpdate({ await sut.onConfigUpdate({
newConfig: systemConfigStub.machineLearningDisabled as SystemConfig, newConfig: systemConfigStub.machineLearningDisabled as SystemConfig,
oldConfig: systemConfigStub.machineLearningDisabled as SystemConfig, oldConfig: systemConfigStub.machineLearningDisabled as SystemConfig,
}); });
expect(mocks.systemMetadata.get).not.toHaveBeenCalled(); expect(systemMock.get).not.toHaveBeenCalled();
expect(mocks.search.getDimensionSize).not.toHaveBeenCalled(); expect(searchMock.getDimensionSize).not.toHaveBeenCalled();
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); expect(searchMock.setDimensionSize).not.toHaveBeenCalled();
expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
expect(mocks.job.getQueueStatus).not.toHaveBeenCalled(); expect(jobMock.getQueueStatus).not.toHaveBeenCalled();
expect(mocks.job.pause).not.toHaveBeenCalled(); expect(jobMock.pause).not.toHaveBeenCalled();
expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled(); expect(jobMock.waitForQueueCompletion).not.toHaveBeenCalled();
expect(mocks.job.resume).not.toHaveBeenCalled(); expect(jobMock.resume).not.toHaveBeenCalled();
}); });
it('should return if model and DB dimension size are equal', async () => { it('should return if model and DB dimension size are equal', async () => {
mocks.search.getDimensionSize.mockResolvedValue(512); searchMock.getDimensionSize.mockResolvedValue(512);
await sut.onConfigUpdate({ await sut.onConfigUpdate({
newConfig: { newConfig: {
@@ -139,18 +153,18 @@ describe(SmartInfoService.name, () => {
} as SystemConfig, } as SystemConfig,
}); });
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1);
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); expect(searchMock.setDimensionSize).not.toHaveBeenCalled();
expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
expect(mocks.job.getQueueStatus).not.toHaveBeenCalled(); expect(jobMock.getQueueStatus).not.toHaveBeenCalled();
expect(mocks.job.pause).not.toHaveBeenCalled(); expect(jobMock.pause).not.toHaveBeenCalled();
expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled(); expect(jobMock.waitForQueueCompletion).not.toHaveBeenCalled();
expect(mocks.job.resume).not.toHaveBeenCalled(); expect(jobMock.resume).not.toHaveBeenCalled();
}); });
it('should update DB dimension size if model and DB have different values', async () => { it('should update DB dimension size if model and DB have different values', async () => {
mocks.search.getDimensionSize.mockResolvedValue(512); searchMock.getDimensionSize.mockResolvedValue(512);
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.onConfigUpdate({ await sut.onConfigUpdate({
newConfig: { newConfig: {
@@ -161,17 +175,17 @@ describe(SmartInfoService.name, () => {
} as SystemConfig, } as SystemConfig,
}); });
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1);
expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(768); expect(searchMock.setDimensionSize).toHaveBeenCalledWith(768);
expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); expect(jobMock.getQueueStatus).toHaveBeenCalledTimes(1);
expect(mocks.job.pause).toHaveBeenCalledTimes(1); expect(jobMock.pause).toHaveBeenCalledTimes(1);
expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); expect(jobMock.waitForQueueCompletion).toHaveBeenCalledTimes(1);
expect(mocks.job.resume).toHaveBeenCalledTimes(1); expect(jobMock.resume).toHaveBeenCalledTimes(1);
}); });
it('should clear embeddings if old and new models are different', async () => { it('should clear embeddings if old and new models are different', async () => {
mocks.search.getDimensionSize.mockResolvedValue(512); searchMock.getDimensionSize.mockResolvedValue(512);
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.onConfigUpdate({ await sut.onConfigUpdate({
newConfig: { newConfig: {
@@ -182,18 +196,18 @@ describe(SmartInfoService.name, () => {
} as SystemConfig, } as SystemConfig,
}); });
expect(mocks.search.deleteAllSearchEmbeddings).toHaveBeenCalled(); expect(searchMock.deleteAllSearchEmbeddings).toHaveBeenCalled();
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1);
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); expect(searchMock.setDimensionSize).not.toHaveBeenCalled();
expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); expect(jobMock.getQueueStatus).toHaveBeenCalledTimes(1);
expect(mocks.job.pause).toHaveBeenCalledTimes(1); expect(jobMock.pause).toHaveBeenCalledTimes(1);
expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); expect(jobMock.waitForQueueCompletion).toHaveBeenCalledTimes(1);
expect(mocks.job.resume).toHaveBeenCalledTimes(1); expect(jobMock.resume).toHaveBeenCalledTimes(1);
}); });
it('should skip pausing and resuming queue if already paused', async () => { it('should skip pausing and resuming queue if already paused', async () => {
mocks.search.getDimensionSize.mockResolvedValue(512); searchMock.getDimensionSize.mockResolvedValue(512);
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true });
await sut.onConfigUpdate({ await sut.onConfigUpdate({
newConfig: { newConfig: {
@@ -204,119 +218,115 @@ describe(SmartInfoService.name, () => {
} as SystemConfig, } as SystemConfig,
}); });
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1);
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); expect(searchMock.setDimensionSize).not.toHaveBeenCalled();
expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); expect(jobMock.getQueueStatus).toHaveBeenCalledTimes(1);
expect(mocks.job.pause).not.toHaveBeenCalled(); expect(jobMock.pause).not.toHaveBeenCalled();
expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); expect(jobMock.waitForQueueCompletion).toHaveBeenCalledTimes(1);
expect(mocks.job.resume).not.toHaveBeenCalled(); expect(jobMock.resume).not.toHaveBeenCalled();
}); });
}); });
describe('handleQueueEncodeClip', () => { describe('handleQueueEncodeClip', () => {
it('should do nothing if machine learning is disabled', async () => { it('should do nothing if machine learning is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled);
await sut.handleQueueEncodeClip({}); await sut.handleQueueEncodeClip({});
expect(mocks.asset.getAll).not.toHaveBeenCalled(); expect(assetMock.getAll).not.toHaveBeenCalled();
expect(mocks.asset.getWithout).not.toHaveBeenCalled(); expect(assetMock.getWithout).not.toHaveBeenCalled();
}); });
it('should queue the assets without clip embeddings', async () => { it('should queue the assets without clip embeddings', async () => {
mocks.asset.getWithout.mockResolvedValue({ assetMock.getWithout.mockResolvedValue({
items: [assetStub.image], items: [assetStub.image],
hasNextPage: false, hasNextPage: false,
}); });
await sut.handleQueueEncodeClip({ force: false }); await sut.handleQueueEncodeClip({ force: false });
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }]);
{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }, expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.SMART_SEARCH);
]); expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.SMART_SEARCH);
expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
}); });
it('should queue all the assets', async () => { it('should queue all the assets', async () => {
mocks.asset.getAll.mockResolvedValue({ assetMock.getAll.mockResolvedValue({
items: [assetStub.image], items: [assetStub.image],
hasNextPage: false, hasNextPage: false,
}); });
await sut.handleQueueEncodeClip({ force: true }); await sut.handleQueueEncodeClip({ force: true });
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }]);
{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }, expect(assetMock.getAll).toHaveBeenCalled();
]); expect(searchMock.deleteAllSearchEmbeddings).toHaveBeenCalled();
expect(mocks.asset.getAll).toHaveBeenCalled();
expect(mocks.search.deleteAllSearchEmbeddings).toHaveBeenCalled();
}); });
}); });
describe('handleEncodeClip', () => { describe('handleEncodeClip', () => {
it('should do nothing if machine learning is disabled', async () => { it('should do nothing if machine learning is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled);
expect(await sut.handleEncodeClip({ id: '123' })).toEqual(JobStatus.SKIPPED); expect(await sut.handleEncodeClip({ id: '123' })).toEqual(JobStatus.SKIPPED);
expect(mocks.asset.getByIds).not.toHaveBeenCalled(); expect(assetMock.getByIds).not.toHaveBeenCalled();
expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); expect(machineLearningMock.encodeImage).not.toHaveBeenCalled();
}); });
it('should skip assets without a resize path', async () => { it('should skip assets without a resize path', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.noResizePath]); assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]);
expect(await sut.handleEncodeClip({ id: assetStub.noResizePath.id })).toEqual(JobStatus.FAILED); expect(await sut.handleEncodeClip({ id: assetStub.noResizePath.id })).toEqual(JobStatus.FAILED);
expect(mocks.search.upsert).not.toHaveBeenCalled(); expect(searchMock.upsert).not.toHaveBeenCalled();
expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); expect(machineLearningMock.encodeImage).not.toHaveBeenCalled();
}); });
it('should save the returned objects', async () => { it('should save the returned objects', async () => {
mocks.machineLearning.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]'); machineLearningMock.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]');
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS);
expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith( expect(machineLearningMock.encodeImage).toHaveBeenCalledWith(
['http://immich-machine-learning:3003'], ['http://immich-machine-learning:3003'],
'/uploads/user-id/thumbs/path.jpg', '/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ modelName: 'ViT-B-32__openai' }), expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
); );
expect(mocks.search.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]'); expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]');
}); });
it('should skip invisible assets', async () => { it('should skip invisible assets', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]);
expect(await sut.handleEncodeClip({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); expect(await sut.handleEncodeClip({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED);
expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); expect(machineLearningMock.encodeImage).not.toHaveBeenCalled();
expect(mocks.search.upsert).not.toHaveBeenCalled(); expect(searchMock.upsert).not.toHaveBeenCalled();
}); });
it('should fail if asset could not be found', async () => { it('should fail if asset could not be found', async () => {
mocks.asset.getByIds.mockResolvedValue([]); assetMock.getByIds.mockResolvedValue([]);
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.FAILED); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.FAILED);
expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); expect(machineLearningMock.encodeImage).not.toHaveBeenCalled();
expect(mocks.search.upsert).not.toHaveBeenCalled(); expect(searchMock.upsert).not.toHaveBeenCalled();
}); });
it('should wait for database', async () => { it('should wait for database', async () => {
mocks.machineLearning.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]'); machineLearningMock.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]');
mocks.database.isBusy.mockReturnValue(true); databaseMock.isBusy.mockReturnValue(true);
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS);
expect(mocks.database.wait).toHaveBeenCalledWith(512); expect(databaseMock.wait).toHaveBeenCalledWith(512);
expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith( expect(machineLearningMock.encodeImage).toHaveBeenCalledWith(
['http://immich-machine-learning:3003'], ['http://immich-machine-learning:3003'],
'/uploads/user-id/thumbs/path.jpg', '/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ modelName: 'ViT-B-32__openai' }), expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
); );
expect(mocks.search.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]'); expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]');
}); });
}); });
+55 -48
View File
@@ -1,15 +1,22 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IStackRepository } from 'src/interfaces/stack.interface';
import { StackService } from 'src/services/stack.service'; import { StackService } from 'src/services/stack.service';
import { assetStub, stackStub } from 'test/fixtures/asset.stub'; import { assetStub, stackStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
describe(StackService.name, () => { describe(StackService.name, () => {
let sut: StackService; let sut: StackService;
let mocks: ServiceMocks;
let accessMock: IAccessRepositoryMock;
let eventMock: Mocked<IEventRepository>;
let stackMock: Mocked<IStackRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(StackService)); ({ sut, accessMock, eventMock, stackMock } = newTestService(StackService));
}); });
it('should be defined', () => { it('should be defined', () => {
@@ -18,10 +25,10 @@ describe(StackService.name, () => {
describe('search', () => { describe('search', () => {
it('should search stacks', async () => { it('should search stacks', async () => {
mocks.stack.search.mockResolvedValue([stackStub('stack-id', [assetStub.image])]); stackMock.search.mockResolvedValue([stackStub('stack-id', [assetStub.image])]);
await sut.search(authStub.admin, { primaryAssetId: assetStub.image.id }); await sut.search(authStub.admin, { primaryAssetId: assetStub.image.id });
expect(mocks.stack.search).toHaveBeenCalledWith({ expect(stackMock.search).toHaveBeenCalledWith({
ownerId: authStub.admin.user.id, ownerId: authStub.admin.user.id,
primaryAssetId: assetStub.image.id, primaryAssetId: assetStub.image.id,
}); });
@@ -34,13 +41,13 @@ describe(StackService.name, () => {
sut.create(authStub.admin, { assetIds: [assetStub.image.id, assetStub.image1.id] }), sut.create(authStub.admin, { assetIds: [assetStub.image.id, assetStub.image1.id] }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalled(); expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalled();
expect(mocks.stack.create).not.toHaveBeenCalled(); expect(stackMock.create).not.toHaveBeenCalled();
}); });
it('should create a stack', async () => { it('should create a stack', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id, assetStub.image1.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id, assetStub.image1.id]));
mocks.stack.create.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); stackMock.create.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1]));
await expect( await expect(
sut.create(authStub.admin, { assetIds: [assetStub.image.id, assetStub.image1.id] }), sut.create(authStub.admin, { assetIds: [assetStub.image.id, assetStub.image1.id] }),
).resolves.toEqual({ ).resolves.toEqual({
@@ -52,11 +59,11 @@ describe(StackService.name, () => {
], ],
}); });
expect(mocks.event.emit).toHaveBeenCalledWith('stack.create', { expect(eventMock.emit).toHaveBeenCalledWith('stack.create', {
stackId: 'stack-id', stackId: 'stack-id',
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
}); });
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalled(); expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalled();
}); });
}); });
@@ -64,22 +71,22 @@ describe(StackService.name, () => {
it('should require stack.read permissions', async () => { it('should require stack.read permissions', async () => {
await expect(sut.get(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.get(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled(); expect(accessMock.stack.checkOwnerAccess).toHaveBeenCalled();
expect(mocks.stack.getById).not.toHaveBeenCalled(); expect(stackMock.getById).not.toHaveBeenCalled();
}); });
it('should fail if stack could not be found', async () => { it('should fail if stack could not be found', async () => {
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
await expect(sut.get(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(Error); await expect(sut.get(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(Error);
expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled(); expect(accessMock.stack.checkOwnerAccess).toHaveBeenCalled();
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); expect(stackMock.getById).toHaveBeenCalledWith('stack-id');
}); });
it('should get stack', async () => { it('should get stack', async () => {
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); stackMock.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1]));
await expect(sut.get(authStub.admin, 'stack-id')).resolves.toEqual({ await expect(sut.get(authStub.admin, 'stack-id')).resolves.toEqual({
id: 'stack-id', id: 'stack-id',
@@ -89,8 +96,8 @@ describe(StackService.name, () => {
expect.objectContaining({ id: assetStub.image1.id }), expect.objectContaining({ id: assetStub.image1.id }),
], ],
}); });
expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled(); expect(accessMock.stack.checkOwnerAccess).toHaveBeenCalled();
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); expect(stackMock.getById).toHaveBeenCalledWith('stack-id');
}); });
}); });
@@ -98,47 +105,47 @@ describe(StackService.name, () => {
it('should require stack.update permissions', async () => { it('should require stack.update permissions', async () => {
await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(BadRequestException); await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.stack.getById).not.toHaveBeenCalled(); expect(stackMock.getById).not.toHaveBeenCalled();
expect(mocks.stack.update).not.toHaveBeenCalled(); expect(stackMock.update).not.toHaveBeenCalled();
expect(mocks.event.emit).not.toHaveBeenCalled(); expect(eventMock.emit).not.toHaveBeenCalled();
}); });
it('should fail if stack could not be found', async () => { it('should fail if stack could not be found', async () => {
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(Error); await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(Error);
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); expect(stackMock.getById).toHaveBeenCalledWith('stack-id');
expect(mocks.stack.update).not.toHaveBeenCalled(); expect(stackMock.update).not.toHaveBeenCalled();
expect(mocks.event.emit).not.toHaveBeenCalled(); expect(eventMock.emit).not.toHaveBeenCalled();
}); });
it('should fail if the provided primary asset id is not in the stack', async () => { it('should fail if the provided primary asset id is not in the stack', async () => {
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); stackMock.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1]));
await expect(sut.update(authStub.admin, 'stack-id', { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf( await expect(sut.update(authStub.admin, 'stack-id', { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); expect(stackMock.getById).toHaveBeenCalledWith('stack-id');
expect(mocks.stack.update).not.toHaveBeenCalled(); expect(stackMock.update).not.toHaveBeenCalled();
expect(mocks.event.emit).not.toHaveBeenCalled(); expect(eventMock.emit).not.toHaveBeenCalled();
}); });
it('should update stack', async () => { it('should update stack', async () => {
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); stackMock.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1]));
mocks.stack.update.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); stackMock.update.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1]));
await sut.update(authStub.admin, 'stack-id', { primaryAssetId: assetStub.image1.id }); await sut.update(authStub.admin, 'stack-id', { primaryAssetId: assetStub.image1.id });
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); expect(stackMock.getById).toHaveBeenCalledWith('stack-id');
expect(mocks.stack.update).toHaveBeenCalledWith('stack-id', { expect(stackMock.update).toHaveBeenCalledWith('stack-id', {
id: 'stack-id', id: 'stack-id',
primaryAssetId: assetStub.image1.id, primaryAssetId: assetStub.image1.id,
}); });
expect(mocks.event.emit).toHaveBeenCalledWith('stack.update', { expect(eventMock.emit).toHaveBeenCalledWith('stack.update', {
stackId: 'stack-id', stackId: 'stack-id',
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
}); });
@@ -149,17 +156,17 @@ describe(StackService.name, () => {
it('should require stack.delete permissions', async () => { it('should require stack.delete permissions', async () => {
await expect(sut.delete(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.delete(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.stack.delete).not.toHaveBeenCalled(); expect(stackMock.delete).not.toHaveBeenCalled();
expect(mocks.event.emit).not.toHaveBeenCalled(); expect(eventMock.emit).not.toHaveBeenCalled();
}); });
it('should delete stack', async () => { it('should delete stack', async () => {
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
await sut.delete(authStub.admin, 'stack-id'); await sut.delete(authStub.admin, 'stack-id');
expect(mocks.stack.delete).toHaveBeenCalledWith('stack-id'); expect(stackMock.delete).toHaveBeenCalledWith('stack-id');
expect(mocks.event.emit).toHaveBeenCalledWith('stack.delete', { expect(eventMock.emit).toHaveBeenCalledWith('stack.delete', {
stackId: 'stack-id', stackId: 'stack-id',
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
}); });
@@ -170,17 +177,17 @@ describe(StackService.name, () => {
it('should require stack.delete permissions', async () => { it('should require stack.delete permissions', async () => {
await expect(sut.deleteAll(authStub.admin, { ids: ['stack-id'] })).rejects.toBeInstanceOf(BadRequestException); await expect(sut.deleteAll(authStub.admin, { ids: ['stack-id'] })).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.stack.deleteAll).not.toHaveBeenCalled(); expect(stackMock.deleteAll).not.toHaveBeenCalled();
expect(mocks.event.emit).not.toHaveBeenCalled(); expect(eventMock.emit).not.toHaveBeenCalled();
}); });
it('should delete all stacks', async () => { it('should delete all stacks', async () => {
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
await sut.deleteAll(authStub.admin, { ids: ['stack-id'] }); await sut.deleteAll(authStub.admin, { ids: ['stack-id'] });
expect(mocks.stack.deleteAll).toHaveBeenCalledWith(['stack-id']); expect(stackMock.deleteAll).toHaveBeenCalledWith(['stack-id']);
expect(mocks.event.emit).toHaveBeenCalledWith('stacks.delete', { expect(eventMock.emit).toHaveBeenCalledWith('stacks.delete', {
stackIds: ['stack-id'], stackIds: ['stack-id'],
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
}); });
@@ -1,26 +1,42 @@
import { Stats } from 'node:fs'; import { Stats } from 'node:fs';
import { defaults, SystemConfig } from 'src/config'; import { SystemConfig, defaults } from 'src/config';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { AssetPathType } from 'src/enum'; import { AssetPathType } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { JobStatus } from 'src/interfaces/job.interface'; import { JobStatus } from 'src/interfaces/job.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageTemplateService } from 'src/services/storage-template.service';
import { albumStub } from 'test/fixtures/album.stub'; import { albumStub } from 'test/fixtures/album.stub';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
describe(StorageTemplateService.name, () => { describe(StorageTemplateService.name, () => {
let sut: StorageTemplateService; let sut: StorageTemplateService;
let mocks: ServiceMocks;
let albumMock: Mocked<IAlbumRepository>;
let assetMock: Mocked<IAssetRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let moveMock: Mocked<IMoveRepository>;
let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let userMock: Mocked<IUserRepository>;
it('should work', () => { it('should work', () => {
expect(sut).toBeDefined(); expect(sut).toBeDefined();
}); });
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(StorageTemplateService)); ({ sut, albumMock, assetMock, cryptoMock, moveMock, storageMock, systemMock, userMock } =
newTestService(StorageTemplateService));
mocks.systemMetadata.get.mockResolvedValue({ storageTemplate: { enabled: true } }); systemMock.get.mockResolvedValue({ storageTemplate: { enabled: true } });
sut.onConfigInit({ newConfig: defaults }); sut.onConfigInit({ newConfig: defaults });
}); });
@@ -91,31 +107,31 @@ describe(StorageTemplateService.name, () => {
describe('handleMigrationSingle', () => { describe('handleMigrationSingle', () => {
it('should skip when storage template is disabled', async () => { it('should skip when storage template is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ storageTemplate: { enabled: false } }); systemMock.get.mockResolvedValue({ storageTemplate: { enabled: false } });
await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED); await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED);
expect(mocks.asset.getByIds).not.toHaveBeenCalled(); expect(assetMock.getByIds).not.toHaveBeenCalled();
expect(mocks.storage.checkFileExists).not.toHaveBeenCalled(); expect(storageMock.checkFileExists).not.toHaveBeenCalled();
expect(mocks.storage.rename).not.toHaveBeenCalled(); expect(storageMock.rename).not.toHaveBeenCalled();
expect(mocks.storage.copyFile).not.toHaveBeenCalled(); expect(storageMock.copyFile).not.toHaveBeenCalled();
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalled();
expect(mocks.move.create).not.toHaveBeenCalled(); expect(moveMock.create).not.toHaveBeenCalled();
expect(mocks.move.update).not.toHaveBeenCalled(); expect(moveMock.update).not.toHaveBeenCalled();
expect(mocks.storage.stat).not.toHaveBeenCalled(); expect(storageMock.stat).not.toHaveBeenCalled();
}); });
it('should migrate single moving picture', async () => { it('should migrate single moving picture', async () => {
mocks.user.get.mockResolvedValue(userStub.user1); userMock.get.mockResolvedValue(userStub.user1);
const newMotionPicturePath = `upload/library/${userStub.user1.id}/2022/2022-06-19/${assetStub.livePhotoStillAsset.id}.mp4`; const newMotionPicturePath = `upload/library/${userStub.user1.id}/2022/2022-06-19/${assetStub.livePhotoStillAsset.id}.mp4`;
const newStillPicturePath = `upload/library/${userStub.user1.id}/2022/2022-06-19/${assetStub.livePhotoStillAsset.id}.jpeg`; const newStillPicturePath = `upload/library/${userStub.user1.id}/2022/2022-06-19/${assetStub.livePhotoStillAsset.id}.jpeg`;
mocks.asset.getByIds.mockImplementation((ids) => { assetMock.getByIds.mockImplementation((ids) => {
const assets = [assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]; const assets = [assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset];
return Promise.resolve( return Promise.resolve(
ids.map((id) => assets.find((asset) => asset.id === id)).filter((asset) => !!asset), ids.map((id) => assets.find((asset) => asset.id === id)).filter((asset) => !!asset),
) as Promise<AssetEntity[]>; ) as Promise<AssetEntity[]>;
}); });
mocks.move.create.mockResolvedValueOnce({ moveMock.create.mockResolvedValueOnce({
id: '123', id: '123',
entityId: assetStub.livePhotoStillAsset.id, entityId: assetStub.livePhotoStillAsset.id,
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
@@ -123,7 +139,7 @@ describe(StorageTemplateService.name, () => {
newPath: newStillPicturePath, newPath: newStillPicturePath,
}); });
mocks.move.create.mockResolvedValueOnce({ moveMock.create.mockResolvedValueOnce({
id: '124', id: '124',
entityId: assetStub.livePhotoMotionAsset.id, entityId: assetStub.livePhotoMotionAsset.id,
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
@@ -135,14 +151,14 @@ describe(StorageTemplateService.name, () => {
JobStatus.SUCCESS, JobStatus.SUCCESS,
); );
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true });
expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2); expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2);
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(assetMock.update).toHaveBeenCalledWith({
id: assetStub.livePhotoStillAsset.id, id: assetStub.livePhotoStillAsset.id,
originalPath: newStillPicturePath, originalPath: newStillPicturePath,
}); });
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(assetMock.update).toHaveBeenCalledWith({
id: assetStub.livePhotoMotionAsset.id, id: assetStub.livePhotoMotionAsset.id,
originalPath: newMotionPicturePath, originalPath: newMotionPicturePath,
}); });
@@ -157,13 +173,13 @@ describe(StorageTemplateService.name, () => {
sut.onConfigInit({ newConfig: config }); sut.onConfigInit({ newConfig: config });
mocks.user.get.mockResolvedValue(user); userMock.get.mockResolvedValue(user);
mocks.asset.getByIds.mockResolvedValueOnce([asset]); assetMock.getByIds.mockResolvedValueOnce([asset]);
mocks.album.getByAssetId.mockResolvedValueOnce([album]); albumMock.getByAssetId.mockResolvedValueOnce([album]);
expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS); expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS);
expect(mocks.move.create).toHaveBeenCalledWith({ expect(moveMock.create).toHaveBeenCalledWith({
entityId: asset.id, entityId: asset.id,
newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${album.albumName}/${asset.originalFileName}`, newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${album.albumName}/${asset.originalFileName}`,
oldPath: asset.originalPath, oldPath: asset.originalPath,
@@ -178,13 +194,13 @@ describe(StorageTemplateService.name, () => {
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}'; config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}';
sut.onConfigInit({ newConfig: config }); sut.onConfigInit({ newConfig: config });
mocks.user.get.mockResolvedValue(user); userMock.get.mockResolvedValue(user);
mocks.asset.getByIds.mockResolvedValueOnce([asset]); assetMock.getByIds.mockResolvedValueOnce([asset]);
expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS); expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS);
const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0'); const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0');
expect(mocks.move.create).toHaveBeenCalledWith({ expect(moveMock.create).toHaveBeenCalledWith({
entityId: asset.id, entityId: asset.id,
newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/other/${month}/${asset.originalFileName}`, newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/other/${month}/${asset.originalFileName}`,
oldPath: asset.originalPath, oldPath: asset.originalPath,
@@ -193,22 +209,20 @@ describe(StorageTemplateService.name, () => {
}); });
it('should migrate previously failed move from original path when it still exists', async () => { it('should migrate previously failed move from original path when it still exists', async () => {
mocks.user.get.mockResolvedValue(userStub.user1); userMock.get.mockResolvedValue(userStub.user1);
const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`;
const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`; const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`;
mocks.storage.checkFileExists.mockImplementation((path) => storageMock.checkFileExists.mockImplementation((path) => Promise.resolve(path === assetStub.image.originalPath));
Promise.resolve(path === assetStub.image.originalPath), moveMock.getByEntity.mockResolvedValue({
);
mocks.move.getByEntity.mockResolvedValue({
id: '123', id: '123',
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
oldPath: assetStub.image.originalPath, oldPath: assetStub.image.originalPath,
newPath: previousFailedNewPath, newPath: previousFailedNewPath,
}); });
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
mocks.move.update.mockResolvedValue({ moveMock.update.mockResolvedValue({
id: '123', id: '123',
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
@@ -218,37 +232,37 @@ describe(StorageTemplateService.name, () => {
await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true });
expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(3); expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3);
expect(mocks.storage.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); expect(storageMock.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath);
expect(mocks.move.update).toHaveBeenCalledWith('123', { expect(moveMock.update).toHaveBeenCalledWith('123', {
id: '123', id: '123',
oldPath: assetStub.image.originalPath, oldPath: assetStub.image.originalPath,
newPath, newPath,
}); });
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(assetMock.update).toHaveBeenCalledWith({
id: assetStub.image.id, id: assetStub.image.id,
originalPath: newPath, originalPath: newPath,
}); });
}); });
it('should migrate previously failed move from previous new path when old path no longer exists, should validate file size still matches before moving', async () => { it('should migrate previously failed move from previous new path when old path no longer exists, should validate file size still matches before moving', async () => {
mocks.user.get.mockResolvedValue(userStub.user1); userMock.get.mockResolvedValue(userStub.user1);
const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`;
const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`; const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`;
mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(path === previousFailedNewPath)); storageMock.checkFileExists.mockImplementation((path) => Promise.resolve(path === previousFailedNewPath));
mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats); storageMock.stat.mockResolvedValue({ size: 5000 } as Stats);
mocks.crypto.hashFile.mockResolvedValue(assetStub.image.checksum); cryptoMock.hashFile.mockResolvedValue(assetStub.image.checksum);
mocks.move.getByEntity.mockResolvedValue({ moveMock.getByEntity.mockResolvedValue({
id: '123', id: '123',
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
oldPath: assetStub.image.originalPath, oldPath: assetStub.image.originalPath,
newPath: previousFailedNewPath, newPath: previousFailedNewPath,
}); });
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
mocks.move.update.mockResolvedValue({ moveMock.update.mockResolvedValue({
id: '123', id: '123',
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
@@ -258,31 +272,31 @@ describe(StorageTemplateService.name, () => {
await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true });
expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(3); expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3);
expect(mocks.storage.stat).toHaveBeenCalledWith(previousFailedNewPath); expect(storageMock.stat).toHaveBeenCalledWith(previousFailedNewPath);
expect(mocks.storage.rename).toHaveBeenCalledWith(previousFailedNewPath, newPath); expect(storageMock.rename).toHaveBeenCalledWith(previousFailedNewPath, newPath);
expect(mocks.storage.copyFile).not.toHaveBeenCalled(); expect(storageMock.copyFile).not.toHaveBeenCalled();
expect(mocks.move.update).toHaveBeenCalledWith('123', { expect(moveMock.update).toHaveBeenCalledWith('123', {
id: '123', id: '123',
oldPath: previousFailedNewPath, oldPath: previousFailedNewPath,
newPath, newPath,
}); });
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(assetMock.update).toHaveBeenCalledWith({
id: assetStub.image.id, id: assetStub.image.id,
originalPath: newPath, originalPath: newPath,
}); });
}); });
it('should fail move if copying and hash of asset and the new file do not match', async () => { it('should fail move if copying and hash of asset and the new file do not match', async () => {
mocks.user.get.mockResolvedValue(userStub.user1); userMock.get.mockResolvedValue(userStub.user1);
const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`; const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`;
mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); storageMock.rename.mockRejectedValue({ code: 'EXDEV' });
mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats); storageMock.stat.mockResolvedValue({ size: 5000 } as Stats);
mocks.crypto.hashFile.mockResolvedValue(Buffer.from('different-hash', 'utf8')); cryptoMock.hashFile.mockResolvedValue(Buffer.from('different-hash', 'utf8'));
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
mocks.move.create.mockResolvedValue({ moveMock.create.mockResolvedValue({
id: '123', id: '123',
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
@@ -292,20 +306,20 @@ describe(StorageTemplateService.name, () => {
await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true });
expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(1); expect(storageMock.checkFileExists).toHaveBeenCalledTimes(1);
expect(mocks.storage.stat).toHaveBeenCalledWith(newPath); expect(storageMock.stat).toHaveBeenCalledWith(newPath);
expect(mocks.move.create).toHaveBeenCalledWith({ expect(moveMock.create).toHaveBeenCalledWith({
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
oldPath: assetStub.image.originalPath, oldPath: assetStub.image.originalPath,
newPath, newPath,
}); });
expect(mocks.storage.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); expect(storageMock.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath);
expect(mocks.storage.copyFile).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); expect(storageMock.copyFile).toHaveBeenCalledWith(assetStub.image.originalPath, newPath);
expect(mocks.storage.unlink).toHaveBeenCalledWith(newPath); expect(storageMock.unlink).toHaveBeenCalledWith(newPath);
expect(mocks.storage.unlink).toHaveBeenCalledTimes(1); expect(storageMock.unlink).toHaveBeenCalledTimes(1);
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalled();
}); });
it.each` it.each`
@@ -315,22 +329,22 @@ describe(StorageTemplateService.name, () => {
`( `(
'should fail to migrate previously failed move from previous new path when old path no longer exists if $reason validation fails', 'should fail to migrate previously failed move from previous new path when old path no longer exists if $reason validation fails',
async ({ failedPathChecksum, failedPathSize }) => { async ({ failedPathChecksum, failedPathSize }) => {
mocks.user.get.mockResolvedValue(userStub.user1); userMock.get.mockResolvedValue(userStub.user1);
const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`;
const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`; const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`;
mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(previousFailedNewPath === path)); storageMock.checkFileExists.mockImplementation((path) => Promise.resolve(previousFailedNewPath === path));
mocks.storage.stat.mockResolvedValue({ size: failedPathSize } as Stats); storageMock.stat.mockResolvedValue({ size: failedPathSize } as Stats);
mocks.crypto.hashFile.mockResolvedValue(failedPathChecksum); cryptoMock.hashFile.mockResolvedValue(failedPathChecksum);
mocks.move.getByEntity.mockResolvedValue({ moveMock.getByEntity.mockResolvedValue({
id: '123', id: '123',
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
oldPath: assetStub.image.originalPath, oldPath: assetStub.image.originalPath,
newPath: previousFailedNewPath, newPath: previousFailedNewPath,
}); });
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
mocks.move.update.mockResolvedValue({ moveMock.update.mockResolvedValue({
id: '123', id: '123',
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
@@ -340,37 +354,37 @@ describe(StorageTemplateService.name, () => {
await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true });
expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(3); expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3);
expect(mocks.storage.stat).toHaveBeenCalledWith(previousFailedNewPath); expect(storageMock.stat).toHaveBeenCalledWith(previousFailedNewPath);
expect(mocks.storage.rename).not.toHaveBeenCalled(); expect(storageMock.rename).not.toHaveBeenCalled();
expect(mocks.storage.copyFile).not.toHaveBeenCalled(); expect(storageMock.copyFile).not.toHaveBeenCalled();
expect(mocks.move.update).not.toHaveBeenCalled(); expect(moveMock.update).not.toHaveBeenCalled();
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalled();
}, },
); );
}); });
describe('handle template migration', () => { describe('handle template migration', () => {
it('should handle no assets', async () => { it('should handle no assets', async () => {
mocks.asset.getAll.mockResolvedValue({ assetMock.getAll.mockResolvedValue({
items: [], items: [],
hasNextPage: false, hasNextPage: false,
}); });
mocks.user.getList.mockResolvedValue([]); userMock.getList.mockResolvedValue([]);
await sut.handleMigration(); await sut.handleMigration();
expect(mocks.asset.getAll).toHaveBeenCalled(); expect(assetMock.getAll).toHaveBeenCalled();
}); });
it('should handle an asset with a duplicate destination', async () => { it('should handle an asset with a duplicate destination', async () => {
mocks.asset.getAll.mockResolvedValue({ assetMock.getAll.mockResolvedValue({
items: [assetStub.image], items: [assetStub.image],
hasNextPage: false, hasNextPage: false,
}); });
mocks.user.getList.mockResolvedValue([userStub.user1]); userMock.getList.mockResolvedValue([userStub.user1]);
mocks.move.create.mockResolvedValue({ moveMock.create.mockResolvedValue({
id: '123', id: '123',
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
@@ -378,22 +392,22 @@ describe(StorageTemplateService.name, () => {
newPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg', newPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg',
}); });
mocks.storage.checkFileExists.mockResolvedValueOnce(true); storageMock.checkFileExists.mockResolvedValueOnce(true);
mocks.storage.checkFileExists.mockResolvedValueOnce(false); storageMock.checkFileExists.mockResolvedValueOnce(false);
await sut.handleMigration(); await sut.handleMigration();
expect(mocks.asset.getAll).toHaveBeenCalled(); expect(assetMock.getAll).toHaveBeenCalled();
expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2); expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2);
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(assetMock.update).toHaveBeenCalledWith({
id: assetStub.image.id, id: assetStub.image.id,
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg', originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg',
}); });
expect(mocks.user.getList).toHaveBeenCalled(); expect(userMock.getList).toHaveBeenCalled();
}); });
it('should skip when an asset already matches the template', async () => { it('should skip when an asset already matches the template', async () => {
mocks.asset.getAll.mockResolvedValue({ assetMock.getAll.mockResolvedValue({
items: [ items: [
{ {
...assetStub.image, ...assetStub.image,
@@ -402,19 +416,19 @@ describe(StorageTemplateService.name, () => {
], ],
hasNextPage: false, hasNextPage: false,
}); });
mocks.user.getList.mockResolvedValue([userStub.user1]); userMock.getList.mockResolvedValue([userStub.user1]);
await sut.handleMigration(); await sut.handleMigration();
expect(mocks.asset.getAll).toHaveBeenCalled(); expect(assetMock.getAll).toHaveBeenCalled();
expect(mocks.storage.rename).not.toHaveBeenCalled(); expect(storageMock.rename).not.toHaveBeenCalled();
expect(mocks.storage.copyFile).not.toHaveBeenCalled(); expect(storageMock.copyFile).not.toHaveBeenCalled();
expect(mocks.storage.checkFileExists).not.toHaveBeenCalledTimes(2); expect(storageMock.checkFileExists).not.toHaveBeenCalledTimes(2);
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalled();
}); });
it('should skip when an asset is probably a duplicate', async () => { it('should skip when an asset is probably a duplicate', async () => {
mocks.asset.getAll.mockResolvedValue({ assetMock.getAll.mockResolvedValue({
items: [ items: [
{ {
...assetStub.image, ...assetStub.image,
@@ -423,24 +437,24 @@ describe(StorageTemplateService.name, () => {
], ],
hasNextPage: false, hasNextPage: false,
}); });
mocks.user.getList.mockResolvedValue([userStub.user1]); userMock.getList.mockResolvedValue([userStub.user1]);
await sut.handleMigration(); await sut.handleMigration();
expect(mocks.asset.getAll).toHaveBeenCalled(); expect(assetMock.getAll).toHaveBeenCalled();
expect(mocks.storage.rename).not.toHaveBeenCalled(); expect(storageMock.rename).not.toHaveBeenCalled();
expect(mocks.storage.copyFile).not.toHaveBeenCalled(); expect(storageMock.copyFile).not.toHaveBeenCalled();
expect(mocks.storage.checkFileExists).not.toHaveBeenCalledTimes(2); expect(storageMock.checkFileExists).not.toHaveBeenCalledTimes(2);
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalled();
}); });
it('should move an asset', async () => { it('should move an asset', async () => {
mocks.asset.getAll.mockResolvedValue({ assetMock.getAll.mockResolvedValue({
items: [assetStub.image], items: [assetStub.image],
hasNextPage: false, hasNextPage: false,
}); });
mocks.user.getList.mockResolvedValue([userStub.user1]); userMock.getList.mockResolvedValue([userStub.user1]);
mocks.move.create.mockResolvedValue({ moveMock.create.mockResolvedValue({
id: '123', id: '123',
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
@@ -450,24 +464,24 @@ describe(StorageTemplateService.name, () => {
await sut.handleMigration(); await sut.handleMigration();
expect(mocks.asset.getAll).toHaveBeenCalled(); expect(assetMock.getAll).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith( expect(storageMock.rename).toHaveBeenCalledWith(
'/original/path.jpg', '/original/path.jpg',
'upload/library/user-id/2023/2023-02-23/asset-id.jpg', 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
); );
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(assetMock.update).toHaveBeenCalledWith({
id: assetStub.image.id, id: assetStub.image.id,
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
}); });
}); });
it('should use the user storage label', async () => { it('should use the user storage label', async () => {
mocks.asset.getAll.mockResolvedValue({ assetMock.getAll.mockResolvedValue({
items: [assetStub.image], items: [assetStub.image],
hasNextPage: false, hasNextPage: false,
}); });
mocks.user.getList.mockResolvedValue([userStub.storageLabel]); userMock.getList.mockResolvedValue([userStub.storageLabel]);
mocks.move.create.mockResolvedValue({ moveMock.create.mockResolvedValue({
id: '123', id: '123',
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
@@ -477,12 +491,12 @@ describe(StorageTemplateService.name, () => {
await sut.handleMigration(); await sut.handleMigration();
expect(mocks.asset.getAll).toHaveBeenCalled(); expect(assetMock.getAll).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith( expect(storageMock.rename).toHaveBeenCalledWith(
'/original/path.jpg', '/original/path.jpg',
'upload/library/label-1/2023/2023-02-23/asset-id.jpg', 'upload/library/label-1/2023/2023-02-23/asset-id.jpg',
); );
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(assetMock.update).toHaveBeenCalledWith({
id: assetStub.image.id, id: assetStub.image.id,
originalPath: 'upload/library/label-1/2023/2023-02-23/asset-id.jpg', originalPath: 'upload/library/label-1/2023/2023-02-23/asset-id.jpg',
}); });
@@ -490,105 +504,105 @@ describe(StorageTemplateService.name, () => {
it('should copy the file if rename fails due to EXDEV (rename across filesystems)', async () => { it('should copy the file if rename fails due to EXDEV (rename across filesystems)', async () => {
const newPath = 'upload/library/user-id/2023/2023-02-23/asset-id.jpg'; const newPath = 'upload/library/user-id/2023/2023-02-23/asset-id.jpg';
mocks.asset.getAll.mockResolvedValue({ assetMock.getAll.mockResolvedValue({
items: [assetStub.image], items: [assetStub.image],
hasNextPage: false, hasNextPage: false,
}); });
mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); storageMock.rename.mockRejectedValue({ code: 'EXDEV' });
mocks.user.getList.mockResolvedValue([userStub.user1]); userMock.getList.mockResolvedValue([userStub.user1]);
mocks.move.create.mockResolvedValue({ moveMock.create.mockResolvedValue({
id: '123', id: '123',
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
oldPath: assetStub.image.originalPath, oldPath: assetStub.image.originalPath,
newPath, newPath,
}); });
mocks.storage.stat.mockResolvedValueOnce({ storageMock.stat.mockResolvedValueOnce({
atime: new Date(), atime: new Date(),
mtime: new Date(), mtime: new Date(),
} as Stats); } as Stats);
mocks.storage.stat.mockResolvedValueOnce({ storageMock.stat.mockResolvedValueOnce({
size: 5000, size: 5000,
} as Stats); } as Stats);
mocks.storage.stat.mockResolvedValueOnce({ storageMock.stat.mockResolvedValueOnce({
atime: new Date(), atime: new Date(),
mtime: new Date(), mtime: new Date(),
} as Stats); } as Stats);
mocks.crypto.hashFile.mockResolvedValue(assetStub.image.checksum); cryptoMock.hashFile.mockResolvedValue(assetStub.image.checksum);
await sut.handleMigration(); await sut.handleMigration();
expect(mocks.asset.getAll).toHaveBeenCalled(); expect(assetMock.getAll).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith('/original/path.jpg', newPath); expect(storageMock.rename).toHaveBeenCalledWith('/original/path.jpg', newPath);
expect(mocks.storage.copyFile).toHaveBeenCalledWith('/original/path.jpg', newPath); expect(storageMock.copyFile).toHaveBeenCalledWith('/original/path.jpg', newPath);
expect(mocks.storage.stat).toHaveBeenCalledWith(newPath); expect(storageMock.stat).toHaveBeenCalledWith(newPath);
expect(mocks.storage.stat).toHaveBeenCalledWith(assetStub.image.originalPath); expect(storageMock.stat).toHaveBeenCalledWith(assetStub.image.originalPath);
expect(mocks.storage.utimes).toHaveBeenCalledWith(newPath, expect.any(Date), expect.any(Date)); expect(storageMock.utimes).toHaveBeenCalledWith(newPath, expect.any(Date), expect.any(Date));
expect(mocks.storage.unlink).toHaveBeenCalledWith(assetStub.image.originalPath); expect(storageMock.unlink).toHaveBeenCalledWith(assetStub.image.originalPath);
expect(mocks.storage.unlink).toHaveBeenCalledTimes(1); expect(storageMock.unlink).toHaveBeenCalledTimes(1);
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(assetMock.update).toHaveBeenCalledWith({
id: assetStub.image.id, id: assetStub.image.id,
originalPath: newPath, originalPath: newPath,
}); });
}); });
it('should not update the database if the move fails due to incorrect newPath filesize', async () => { it('should not update the database if the move fails due to incorrect newPath filesize', async () => {
mocks.asset.getAll.mockResolvedValue({ assetMock.getAll.mockResolvedValue({
items: [assetStub.image], items: [assetStub.image],
hasNextPage: false, hasNextPage: false,
}); });
mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); storageMock.rename.mockRejectedValue({ code: 'EXDEV' });
mocks.user.getList.mockResolvedValue([userStub.user1]); userMock.getList.mockResolvedValue([userStub.user1]);
mocks.move.create.mockResolvedValue({ moveMock.create.mockResolvedValue({
id: '123', id: '123',
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
oldPath: assetStub.image.originalPath, oldPath: assetStub.image.originalPath,
newPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', newPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
}); });
mocks.storage.stat.mockResolvedValue({ storageMock.stat.mockResolvedValue({
size: 100, size: 100,
} as Stats); } as Stats);
await sut.handleMigration(); await sut.handleMigration();
expect(mocks.asset.getAll).toHaveBeenCalled(); expect(assetMock.getAll).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith( expect(storageMock.rename).toHaveBeenCalledWith(
'/original/path.jpg', '/original/path.jpg',
'upload/library/user-id/2023/2023-02-23/asset-id.jpg', 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
); );
expect(mocks.storage.copyFile).toHaveBeenCalledWith( expect(storageMock.copyFile).toHaveBeenCalledWith(
'/original/path.jpg', '/original/path.jpg',
'upload/library/user-id/2023/2023-02-23/asset-id.jpg', 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
); );
expect(mocks.storage.stat).toHaveBeenCalledWith('upload/library/user-id/2023/2023-02-23/asset-id.jpg'); expect(storageMock.stat).toHaveBeenCalledWith('upload/library/user-id/2023/2023-02-23/asset-id.jpg');
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalled();
}); });
it('should not update the database if the move fails', async () => { it('should not update the database if the move fails', async () => {
mocks.asset.getAll.mockResolvedValue({ assetMock.getAll.mockResolvedValue({
items: [assetStub.image], items: [assetStub.image],
hasNextPage: false, hasNextPage: false,
}); });
mocks.storage.rename.mockRejectedValue(new Error('Read only system')); storageMock.rename.mockRejectedValue(new Error('Read only system'));
mocks.storage.copyFile.mockRejectedValue(new Error('Read only system')); storageMock.copyFile.mockRejectedValue(new Error('Read only system'));
mocks.move.create.mockResolvedValue({ moveMock.create.mockResolvedValue({
id: 'move-123', id: 'move-123',
entityId: '123', entityId: '123',
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
oldPath: assetStub.image.originalPath, oldPath: assetStub.image.originalPath,
newPath: '', newPath: '',
}); });
mocks.user.getList.mockResolvedValue([userStub.user1]); userMock.getList.mockResolvedValue([userStub.user1]);
await sut.handleMigration(); await sut.handleMigration();
expect(mocks.asset.getAll).toHaveBeenCalled(); expect(assetMock.getAll).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith( expect(storageMock.rename).toHaveBeenCalledWith(
'/original/path.jpg', '/original/path.jpg',
'upload/library/user-id/2023/2023-02-23/asset-id.jpg', 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
); );
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalled();
}); });
}); });
}); });
+54 -50
View File
@@ -1,15 +1,23 @@
import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataKey } from 'src/enum';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { StorageService } from 'src/services/storage.service'; import { StorageService } from 'src/services/storage.service';
import { IConfigRepository, ILoggingRepository } from 'src/types';
import { ImmichStartupError } from 'src/utils/misc'; import { ImmichStartupError } from 'src/utils/misc';
import { mockEnvData } from 'test/repositories/config.repository.mock'; import { mockEnvData } from 'test/repositories/config.repository.mock';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
describe(StorageService.name, () => { describe(StorageService.name, () => {
let sut: StorageService; let sut: StorageService;
let mocks: ServiceMocks;
let configMock: Mocked<IConfigRepository>;
let loggerMock: Mocked<ILoggingRepository>;
let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(StorageService)); ({ sut, configMock, loggerMock, storageMock, systemMock } = newTestService(StorageService));
}); });
it('should work', () => { it('should work', () => {
@@ -18,11 +26,11 @@ describe(StorageService.name, () => {
describe('onBootstrap', () => { describe('onBootstrap', () => {
it('should enable mount folder checking', async () => { it('should enable mount folder checking', async () => {
mocks.systemMetadata.get.mockResolvedValue(null); systemMock.get.mockResolvedValue(null);
await expect(sut.onBootstrap()).resolves.toBeUndefined(); await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, {
mountChecks: { mountChecks: {
backups: true, backups: true,
'encoded-video': true, 'encoded-video': true,
@@ -32,22 +40,22 @@ describe(StorageService.name, () => {
upload: true, upload: true,
}, },
}); });
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/encoded-video'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/encoded-video');
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/library'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library');
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/profile'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile');
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs');
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/upload'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload');
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/backups'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/backups');
expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/encoded-video/.immich', expect.any(Buffer)); expect(storageMock.createFile).toHaveBeenCalledWith('upload/encoded-video/.immich', expect.any(Buffer));
expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); expect(storageMock.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer));
expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/profile/.immich', expect.any(Buffer)); expect(storageMock.createFile).toHaveBeenCalledWith('upload/profile/.immich', expect.any(Buffer));
expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/thumbs/.immich', expect.any(Buffer)); expect(storageMock.createFile).toHaveBeenCalledWith('upload/thumbs/.immich', expect.any(Buffer));
expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/upload/.immich', expect.any(Buffer)); expect(storageMock.createFile).toHaveBeenCalledWith('upload/upload/.immich', expect.any(Buffer));
expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer)); expect(storageMock.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer));
}); });
it('should enable mount folder checking for a new folder type', async () => { it('should enable mount folder checking for a new folder type', async () => {
mocks.systemMetadata.get.mockResolvedValue({ systemMock.get.mockResolvedValue({
mountChecks: { mountChecks: {
backups: false, backups: false,
'encoded-video': true, 'encoded-video': true,
@@ -60,7 +68,7 @@ describe(StorageService.name, () => {
await expect(sut.onBootstrap()).resolves.toBeUndefined(); await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, {
mountChecks: { mountChecks: {
backups: true, backups: true,
'encoded-video': true, 'encoded-video': true,
@@ -70,68 +78,64 @@ describe(StorageService.name, () => {
upload: true, upload: true,
}, },
}); });
expect(mocks.storage.mkdirSync).toHaveBeenCalledTimes(2); expect(storageMock.mkdirSync).toHaveBeenCalledTimes(2);
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/library'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library');
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/backups'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/backups');
expect(mocks.storage.createFile).toHaveBeenCalledTimes(2); expect(storageMock.createFile).toHaveBeenCalledTimes(2);
expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); expect(storageMock.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer));
expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer)); expect(storageMock.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer));
}); });
it('should throw an error if .immich is missing', async () => { it('should throw an error if .immich is missing', async () => {
mocks.systemMetadata.get.mockResolvedValue({ mountChecks: { upload: true } }); systemMock.get.mockResolvedValue({ mountChecks: { upload: true } });
mocks.storage.readFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); storageMock.readFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
await expect(sut.onBootstrap()).rejects.toThrow('Failed to read'); await expect(sut.onBootstrap()).rejects.toThrow('Failed to read');
expect(mocks.storage.createOrOverwriteFile).not.toHaveBeenCalled(); expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled();
expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); expect(systemMock.set).not.toHaveBeenCalled();
}); });
it('should throw an error if .immich is present but read-only', async () => { it('should throw an error if .immich is present but read-only', async () => {
mocks.systemMetadata.get.mockResolvedValue({ mountChecks: { upload: true } }); systemMock.get.mockResolvedValue({ mountChecks: { upload: true } });
mocks.storage.overwriteFile.mockRejectedValue( storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
new Error("ENOENT: no such file or directory, open '/app/.immich'"),
);
await expect(sut.onBootstrap()).rejects.toThrow('Failed to write'); await expect(sut.onBootstrap()).rejects.toThrow('Failed to write');
expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); expect(systemMock.set).not.toHaveBeenCalled();
}); });
it('should skip mount file creation if file already exists', async () => { it('should skip mount file creation if file already exists', async () => {
const error = new Error('Error creating file') as any; const error = new Error('Error creating file') as any;
error.code = 'EEXIST'; error.code = 'EEXIST';
mocks.systemMetadata.get.mockResolvedValue({ mountChecks: {} }); systemMock.get.mockResolvedValue({ mountChecks: {} });
mocks.storage.createFile.mockRejectedValue(error); storageMock.createFile.mockRejectedValue(error);
await expect(sut.onBootstrap()).resolves.toBeUndefined(); await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(mocks.logger.warn).toHaveBeenCalledWith('Found existing mount file, skipping creation'); expect(loggerMock.warn).toHaveBeenCalledWith('Found existing mount file, skipping creation');
}); });
it('should throw an error if mount file could not be created', async () => { it('should throw an error if mount file could not be created', async () => {
mocks.systemMetadata.get.mockResolvedValue({ mountChecks: {} }); systemMock.get.mockResolvedValue({ mountChecks: {} });
mocks.storage.createFile.mockRejectedValue(new Error('Error creating file')); storageMock.createFile.mockRejectedValue(new Error('Error creating file'));
await expect(sut.onBootstrap()).rejects.toBeInstanceOf(ImmichStartupError); await expect(sut.onBootstrap()).rejects.toBeInstanceOf(ImmichStartupError);
expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); expect(systemMock.set).not.toHaveBeenCalled();
}); });
it('should startup if checks are disabled', async () => { it('should startup if checks are disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ mountChecks: { upload: true } }); systemMock.get.mockResolvedValue({ mountChecks: { upload: true } });
mocks.config.getEnv.mockReturnValue( configMock.getEnv.mockReturnValue(
mockEnvData({ mockEnvData({
storage: { ignoreMountCheckErrors: true }, storage: { ignoreMountCheckErrors: true },
}), }),
); );
mocks.storage.overwriteFile.mockRejectedValue( storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
new Error("ENOENT: no such file or directory, open '/app/.immich'"),
);
await expect(sut.onBootstrap()).resolves.toBeUndefined(); await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); expect(systemMock.set).not.toHaveBeenCalled();
}); });
}); });
@@ -139,21 +143,21 @@ describe(StorageService.name, () => {
it('should handle null values', async () => { it('should handle null values', async () => {
await sut.handleDeleteFiles({ files: [undefined, null] }); await sut.handleDeleteFiles({ files: [undefined, null] });
expect(mocks.storage.unlink).not.toHaveBeenCalled(); expect(storageMock.unlink).not.toHaveBeenCalled();
}); });
it('should handle an error removing a file', async () => { it('should handle an error removing a file', async () => {
mocks.storage.unlink.mockRejectedValue(new Error('something-went-wrong')); storageMock.unlink.mockRejectedValue(new Error('something-went-wrong'));
await sut.handleDeleteFiles({ files: ['path/to/something'] }); await sut.handleDeleteFiles({ files: ['path/to/something'] });
expect(mocks.storage.unlink).toHaveBeenCalledWith('path/to/something'); expect(storageMock.unlink).toHaveBeenCalledWith('path/to/something');
}); });
it('should remove the file', async () => { it('should remove the file', async () => {
await sut.handleDeleteFiles({ files: ['path/to/something'] }); await sut.handleDeleteFiles({ files: ['path/to/something'] });
expect(mocks.storage.unlink).toHaveBeenCalledWith('path/to/something'); expect(storageMock.unlink).toHaveBeenCalledWith('path/to/something');
}); });
}); });
}); });
+27 -20
View File
@@ -1,20 +1,27 @@
import { mapAsset } from 'src/dtos/asset-response.dto'; import { mapAsset } from 'src/dtos/asset-response.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { SyncService } from 'src/services/sync.service'; import { SyncService } from 'src/services/sync.service';
import { IAuditRepository } from 'src/types';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { partnerStub } from 'test/fixtures/partner.stub'; import { partnerStub } from 'test/fixtures/partner.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
const untilDate = new Date(2024); const untilDate = new Date(2024);
const mapAssetOpts = { auth: authStub.user1, stripMetadata: false, withStack: true }; const mapAssetOpts = { auth: authStub.user1, stripMetadata: false, withStack: true };
describe(SyncService.name, () => { describe(SyncService.name, () => {
let sut: SyncService; let sut: SyncService;
let mocks: ServiceMocks;
let assetMock: Mocked<IAssetRepository>;
let auditMock: Mocked<IAuditRepository>;
let partnerMock: Mocked<IPartnerRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(SyncService)); ({ sut, assetMock, auditMock, partnerMock } = newTestService(SyncService));
}); });
it('should exist', () => { it('should exist', () => {
@@ -23,12 +30,12 @@ describe(SyncService.name, () => {
describe('getAllAssetsForUserFullSync', () => { describe('getAllAssetsForUserFullSync', () => {
it('should return a list of all assets owned by the user', async () => { it('should return a list of all assets owned by the user', async () => {
mocks.asset.getAllForUserFullSync.mockResolvedValue([assetStub.external, assetStub.hasEncodedVideo]); assetMock.getAllForUserFullSync.mockResolvedValue([assetStub.external, assetStub.hasEncodedVideo]);
await expect(sut.getFullSync(authStub.user1, { limit: 2, updatedUntil: untilDate })).resolves.toEqual([ await expect(sut.getFullSync(authStub.user1, { limit: 2, updatedUntil: untilDate })).resolves.toEqual([
mapAsset(assetStub.external, mapAssetOpts), mapAsset(assetStub.external, mapAssetOpts),
mapAsset(assetStub.hasEncodedVideo, mapAssetOpts), mapAsset(assetStub.hasEncodedVideo, mapAssetOpts),
]); ]);
expect(mocks.asset.getAllForUserFullSync).toHaveBeenCalledWith({ expect(assetMock.getAllForUserFullSync).toHaveBeenCalledWith({
ownerId: authStub.user1.user.id, ownerId: authStub.user1.user.id,
updatedUntil: untilDate, updatedUntil: untilDate,
limit: 2, limit: 2,
@@ -38,39 +45,39 @@ describe(SyncService.name, () => {
describe('getChangesForDeltaSync', () => { describe('getChangesForDeltaSync', () => {
it('should return a response requiring a full sync when partners are out of sync', async () => { it('should return a response requiring a full sync when partners are out of sync', async () => {
mocks.partner.getAll.mockResolvedValue([partnerStub.adminToUser1]); partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1]);
await expect( await expect(
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }),
).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] }); ).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] });
expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(0); expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(0);
expect(mocks.audit.getAfter).toHaveBeenCalledTimes(0); expect(auditMock.getAfter).toHaveBeenCalledTimes(0);
}); });
it('should return a response requiring a full sync when last sync was too long ago', async () => { it('should return a response requiring a full sync when last sync was too long ago', async () => {
mocks.partner.getAll.mockResolvedValue([]); partnerMock.getAll.mockResolvedValue([]);
await expect( await expect(
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(2000), userIds: [authStub.user1.user.id] }), sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(2000), userIds: [authStub.user1.user.id] }),
).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] }); ).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] });
expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(0); expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(0);
expect(mocks.audit.getAfter).toHaveBeenCalledTimes(0); expect(auditMock.getAfter).toHaveBeenCalledTimes(0);
}); });
it('should return a response requiring a full sync when there are too many changes', async () => { it('should return a response requiring a full sync when there are too many changes', async () => {
mocks.partner.getAll.mockResolvedValue([]); partnerMock.getAll.mockResolvedValue([]);
mocks.asset.getChangedDeltaSync.mockResolvedValue( assetMock.getChangedDeltaSync.mockResolvedValue(
Array.from<AssetEntity>({ length: 10_000 }).fill(assetStub.image), Array.from<AssetEntity>({ length: 10_000 }).fill(assetStub.image),
); );
await expect( await expect(
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }),
).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] }); ).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] });
expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1); expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(1);
expect(mocks.audit.getAfter).toHaveBeenCalledTimes(0); expect(auditMock.getAfter).toHaveBeenCalledTimes(0);
}); });
it('should return a response with changes and deletions', async () => { it('should return a response with changes and deletions', async () => {
mocks.partner.getAll.mockResolvedValue([]); partnerMock.getAll.mockResolvedValue([]);
mocks.asset.getChangedDeltaSync.mockResolvedValue([assetStub.image1]); assetMock.getChangedDeltaSync.mockResolvedValue([assetStub.image1]);
mocks.audit.getAfter.mockResolvedValue([assetStub.external.id]); auditMock.getAfter.mockResolvedValue([assetStub.external.id]);
await expect( await expect(
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }),
).resolves.toEqual({ ).resolves.toEqual({
@@ -78,8 +85,8 @@ describe(SyncService.name, () => {
upserted: [mapAsset(assetStub.image1, mapAssetOpts)], upserted: [mapAsset(assetStub.image1, mapAssetOpts)],
deleted: [assetStub.external.id], deleted: [assetStub.external.id],
}); });
expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1); expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(1);
expect(mocks.audit.getAfter).toHaveBeenCalledTimes(1); expect(auditMock.getAfter).toHaveBeenCalledTimes(1);
}); });
}); });
}); });
@@ -12,11 +12,14 @@ import {
VideoCodec, VideoCodec,
VideoContainer, VideoContainer,
} from 'src/enum'; } from 'src/enum';
import { IEventRepository } from 'src/interfaces/event.interface';
import { QueueName } from 'src/interfaces/job.interface'; import { QueueName } from 'src/interfaces/job.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { SystemConfigService } from 'src/services/system-config.service'; import { SystemConfigService } from 'src/services/system-config.service';
import { DeepPartial } from 'src/types'; import { DeepPartial, IConfigRepository, ILoggingRepository } from 'src/types';
import { mockEnvData } from 'test/repositories/config.repository.mock'; import { mockEnvData } from 'test/repositories/config.repository.mock';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
const partialConfig = { const partialConfig = {
ffmpeg: { crf: 30 }, ffmpeg: { crf: 30 },
@@ -196,10 +199,14 @@ const updatedConfig = Object.freeze<SystemConfig>({
describe(SystemConfigService.name, () => { describe(SystemConfigService.name, () => {
let sut: SystemConfigService; let sut: SystemConfigService;
let mocks: ServiceMocks;
let configMock: Mocked<IConfigRepository>;
let eventMock: Mocked<IEventRepository>;
let loggerMock: Mocked<ILoggingRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(SystemConfigService)); ({ sut, configMock, eventMock, loggerMock, systemMock } = newTestService(SystemConfigService));
}); });
it('should work', () => { it('should work', () => {
@@ -208,22 +215,22 @@ describe(SystemConfigService.name, () => {
describe('getDefaults', () => { describe('getDefaults', () => {
it('should return the default config', () => { it('should return the default config', () => {
mocks.systemMetadata.get.mockResolvedValue(partialConfig); systemMock.get.mockResolvedValue(partialConfig);
expect(sut.getDefaults()).toEqual(defaults); expect(sut.getDefaults()).toEqual(defaults);
expect(mocks.systemMetadata.get).not.toHaveBeenCalled(); expect(systemMock.get).not.toHaveBeenCalled();
}); });
}); });
describe('getConfig', () => { describe('getConfig', () => {
it('should return the default config', async () => { it('should return the default config', async () => {
mocks.systemMetadata.get.mockResolvedValue({}); systemMock.get.mockResolvedValue({});
await expect(sut.getSystemConfig()).resolves.toEqual(defaults); await expect(sut.getSystemConfig()).resolves.toEqual(defaults);
}); });
it('should merge the overrides', async () => { it('should merge the overrides', async () => {
mocks.systemMetadata.get.mockResolvedValue({ systemMock.get.mockResolvedValue({
ffmpeg: { crf: 30 }, ffmpeg: { crf: 30 },
oauth: { autoLaunch: true }, oauth: { autoLaunch: true },
trash: { days: 10 }, trash: { days: 10 },
@@ -234,17 +241,17 @@ describe(SystemConfigService.name, () => {
}); });
it('should load the config from a json file', async () => { it('should load the config from a json file', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig));
await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig);
expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json'); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json');
}); });
it('should transform booleans', async () => { it('should transform booleans', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { twoPass: 'false' } })); systemMock.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { twoPass: 'false' } }));
await expect(sut.getSystemConfig()).resolves.toMatchObject({ await expect(sut.getSystemConfig()).resolves.toMatchObject({
ffmpeg: expect.objectContaining({ twoPass: false }), ffmpeg: expect.objectContaining({ twoPass: false }),
@@ -252,8 +259,8 @@ describe(SystemConfigService.name, () => {
}); });
it('should transform numbers', async () => { it('should transform numbers', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { threads: '42' } })); systemMock.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { threads: '42' } }));
await expect(sut.getSystemConfig()).resolves.toMatchObject({ await expect(sut.getSystemConfig()).resolves.toMatchObject({
ffmpeg: expect.objectContaining({ threads: 42 }), ffmpeg: expect.objectContaining({ threads: 42 }),
@@ -261,10 +268,8 @@ describe(SystemConfigService.name, () => {
}); });
it('should accept valid cron expressions', async () => { it('should accept valid cron expressions', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue( systemMock.readFile.mockResolvedValue(JSON.stringify({ library: { scan: { cronExpression: '0 0 * * *' } } }));
JSON.stringify({ library: { scan: { cronExpression: '0 0 * * *' } } }),
);
await expect(sut.getSystemConfig()).resolves.toMatchObject({ await expect(sut.getSystemConfig()).resolves.toMatchObject({
library: { library: {
@@ -277,8 +282,8 @@ describe(SystemConfigService.name, () => {
}); });
it('should reject invalid cron expressions', async () => { it('should reject invalid cron expressions', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ library: { scan: { cronExpression: 'foo' } } })); systemMock.readFile.mockResolvedValue(JSON.stringify({ library: { scan: { cronExpression: 'foo' } } }));
await expect(sut.getSystemConfig()).rejects.toThrow( await expect(sut.getSystemConfig()).rejects.toThrow(
'library.scan.cronExpression has failed the following constraints: cronValidator', 'library.scan.cronExpression has failed the following constraints: cronValidator',
@@ -286,22 +291,22 @@ describe(SystemConfigService.name, () => {
}); });
it('should log errors with the config file', async () => { it('should log errors with the config file', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(`{ "ffmpeg2": true, "ffmpeg2": true }`); systemMock.readFile.mockResolvedValue(`{ "ffmpeg2": true, "ffmpeg2": true }`);
await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error); await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error);
expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json'); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json');
expect(mocks.logger.error).toHaveBeenCalledTimes(2); expect(loggerMock.error).toHaveBeenCalledTimes(2);
expect(mocks.logger.error.mock.calls[0][0]).toEqual('Unable to load configuration file: immich-config.json'); expect(loggerMock.error.mock.calls[0][0]).toEqual('Unable to load configuration file: immich-config.json');
expect(mocks.logger.error.mock.calls[1][0].toString()).toEqual( expect(loggerMock.error.mock.calls[1][0].toString()).toEqual(
expect.stringContaining('YAMLException: duplicated mapping key (1:20)'), expect.stringContaining('YAMLException: duplicated mapping key (1:20)'),
); );
}); });
it('should load the config from a yaml file', async () => { it('should load the config from a yaml file', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' })); configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' }));
const partialConfig = ` const partialConfig = `
ffmpeg: ffmpeg:
crf: 30 crf: 30
@@ -312,26 +317,26 @@ describe(SystemConfigService.name, () => {
user: user:
deleteDelay: 15 deleteDelay: 15
`; `;
mocks.systemMetadata.readFile.mockResolvedValue(partialConfig); systemMock.readFile.mockResolvedValue(partialConfig);
await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig);
expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.yaml'); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.yaml');
}); });
it('should accept an empty configuration file', async () => { it('should accept an empty configuration file', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({})); systemMock.readFile.mockResolvedValue(JSON.stringify({}));
await expect(sut.getSystemConfig()).resolves.toEqual(defaults); await expect(sut.getSystemConfig()).resolves.toEqual(defaults);
expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json'); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json');
}); });
it('should allow underscores in the machine learning url', async () => { it('should allow underscores in the machine learning url', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
const partialConfig = { machineLearning: { urls: ['immich_machine_learning'] } }; const partialConfig = { machineLearning: { urls: ['immich_machine_learning'] } };
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig));
const config = await sut.getSystemConfig(); const config = await sut.getSystemConfig();
expect(config.machineLearning.urls).toEqual(['immich_machine_learning']); expect(config.machineLearning.urls).toEqual(['immich_machine_learning']);
@@ -345,9 +350,9 @@ describe(SystemConfigService.name, () => {
for (const { should, externalDomain, result } of externalDomainTests) { for (const { should, externalDomain, result } of externalDomainTests) {
it(`should normalize an external domain ${should}`, async () => { it(`should normalize an external domain ${should}`, async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
const partialConfig = { server: { externalDomain } }; const partialConfig = { server: { externalDomain } };
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig));
const config = await sut.getSystemConfig(); const config = await sut.getSystemConfig();
expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app'); expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app');
@@ -355,14 +360,14 @@ describe(SystemConfigService.name, () => {
} }
it('should warn for unknown options in yaml', async () => { it('should warn for unknown options in yaml', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' })); configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' }));
const partialConfig = ` const partialConfig = `
unknownOption: true unknownOption: true
`; `;
mocks.systemMetadata.readFile.mockResolvedValue(partialConfig); systemMock.readFile.mockResolvedValue(partialConfig);
await sut.getSystemConfig(); await sut.getSystemConfig();
expect(mocks.logger.warn).toHaveBeenCalled(); expect(loggerMock.warn).toHaveBeenCalled();
}); });
const tests = [ const tests = [
@@ -376,12 +381,12 @@ describe(SystemConfigService.name, () => {
for (const test of tests) { for (const test of tests) {
it(`should ${test.should}`, async () => { it(`should ${test.should}`, async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(test.config)); systemMock.readFile.mockResolvedValue(JSON.stringify(test.config));
if (test.warn) { if (test.warn) {
await sut.getSystemConfig(); await sut.getSystemConfig();
expect(mocks.logger.warn).toHaveBeenCalled(); expect(loggerMock.warn).toHaveBeenCalled();
} else { } else {
await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error); await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error);
} }
@@ -391,19 +396,19 @@ describe(SystemConfigService.name, () => {
describe('updateConfig', () => { describe('updateConfig', () => {
it('should update the config and emit an event', async () => { it('should update the config and emit an event', async () => {
mocks.systemMetadata.get.mockResolvedValue(partialConfig); systemMock.get.mockResolvedValue(partialConfig);
await expect(sut.updateSystemConfig(updatedConfig)).resolves.toEqual(updatedConfig); await expect(sut.updateSystemConfig(updatedConfig)).resolves.toEqual(updatedConfig);
expect(mocks.event.emit).toHaveBeenCalledWith( expect(eventMock.emit).toHaveBeenCalledWith(
'config.update', 'config.update',
expect.objectContaining({ oldConfig: expect.any(Object), newConfig: updatedConfig }), expect.objectContaining({ oldConfig: expect.any(Object), newConfig: updatedConfig }),
); );
}); });
it('should throw an error if a config file is in use', async () => { it('should throw an error if a config file is in use', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({})); systemMock.readFile.mockResolvedValue(JSON.stringify({}));
await expect(sut.updateSystemConfig(defaults)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.updateSystemConfig(defaults)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); expect(systemMock.set).not.toHaveBeenCalled();
}); });
}); });
@@ -1,13 +1,15 @@
import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataKey } from 'src/enum';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { SystemMetadataService } from 'src/services/system-metadata.service'; import { SystemMetadataService } from 'src/services/system-metadata.service';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
describe(SystemMetadataService.name, () => { describe(SystemMetadataService.name, () => {
let sut: SystemMetadataService; let sut: SystemMetadataService;
let mocks: ServiceMocks; let systemMock: Mocked<ISystemMetadataRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(SystemMetadataService)); ({ sut, systemMock } = newTestService(SystemMetadataService));
}); });
it('should work', () => { it('should work', () => {
@@ -16,32 +18,32 @@ describe(SystemMetadataService.name, () => {
describe('getAdminOnboarding', () => { describe('getAdminOnboarding', () => {
it('should get isOnboarded state', async () => { it('should get isOnboarded state', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isOnboarded: true }); systemMock.get.mockResolvedValue({ isOnboarded: true });
await expect(sut.getAdminOnboarding()).resolves.toEqual({ isOnboarded: true }); await expect(sut.getAdminOnboarding()).resolves.toEqual({ isOnboarded: true });
expect(mocks.systemMetadata.get).toHaveBeenCalledWith('admin-onboarding'); expect(systemMock.get).toHaveBeenCalledWith('admin-onboarding');
}); });
it('should default isOnboarded to false', async () => { it('should default isOnboarded to false', async () => {
await expect(sut.getAdminOnboarding()).resolves.toEqual({ isOnboarded: false }); await expect(sut.getAdminOnboarding()).resolves.toEqual({ isOnboarded: false });
expect(mocks.systemMetadata.get).toHaveBeenCalledWith('admin-onboarding'); expect(systemMock.get).toHaveBeenCalledWith('admin-onboarding');
}); });
}); });
describe('updateAdminOnboarding', () => { describe('updateAdminOnboarding', () => {
it('should update isOnboarded to true', async () => { it('should update isOnboarded to true', async () => {
await expect(sut.updateAdminOnboarding({ isOnboarded: true })).resolves.toBeUndefined(); await expect(sut.updateAdminOnboarding({ isOnboarded: true })).resolves.toBeUndefined();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true }); expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true });
}); });
it('should update isOnboarded to false', async () => { it('should update isOnboarded to false', async () => {
await expect(sut.updateAdminOnboarding({ isOnboarded: false })).resolves.toBeUndefined(); await expect(sut.updateAdminOnboarding({ isOnboarded: false })).resolves.toBeUndefined();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: false }); expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: false });
}); });
}); });
describe('getReverseGeocodingState', () => { describe('getReverseGeocodingState', () => {
it('should get reverse geocoding state', async () => { it('should get reverse geocoding state', async () => {
mocks.systemMetadata.get.mockResolvedValue({ lastUpdate: '2024-01-01', lastImportFileName: 'foo.bar' }); systemMock.get.mockResolvedValue({ lastUpdate: '2024-01-01', lastImportFileName: 'foo.bar' });
await expect(sut.getReverseGeocodingState()).resolves.toEqual({ await expect(sut.getReverseGeocodingState()).resolves.toEqual({
lastUpdate: '2024-01-01', lastUpdate: '2024-01-01',
lastImportFileName: 'foo.bar', lastImportFileName: 'foo.bar',
+75 -70
View File
@@ -1,19 +1,24 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { JobStatus } from 'src/interfaces/job.interface'; import { JobStatus } from 'src/interfaces/job.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { TagService } from 'src/services/tag.service'; import { TagService } from 'src/services/tag.service';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub'; import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
describe(TagService.name, () => { describe(TagService.name, () => {
let sut: TagService; let sut: TagService;
let mocks: ServiceMocks;
let accessMock: IAccessRepositoryMock;
let tagMock: Mocked<ITagRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(TagService)); ({ sut, accessMock, tagMock } = newTestService(TagService));
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1']));
}); });
it('should work', () => { it('should work', () => {
@@ -22,76 +27,76 @@ describe(TagService.name, () => {
describe('getAll', () => { describe('getAll', () => {
it('should return all tags for a user', async () => { it('should return all tags for a user', async () => {
mocks.tag.getAll.mockResolvedValue([tagStub.tag1]); tagMock.getAll.mockResolvedValue([tagStub.tag1]);
await expect(sut.getAll(authStub.admin)).resolves.toEqual([tagResponseStub.tag1]); await expect(sut.getAll(authStub.admin)).resolves.toEqual([tagResponseStub.tag1]);
expect(mocks.tag.getAll).toHaveBeenCalledWith(authStub.admin.user.id); expect(tagMock.getAll).toHaveBeenCalledWith(authStub.admin.user.id);
}); });
}); });
describe('get', () => { describe('get', () => {
it('should throw an error for an invalid id', async () => { it('should throw an error for an invalid id', async () => {
mocks.tag.get.mockResolvedValue(null); tagMock.get.mockResolvedValue(null);
await expect(sut.get(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.get(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.tag.get).toHaveBeenCalledWith('tag-1'); expect(tagMock.get).toHaveBeenCalledWith('tag-1');
}); });
it('should return a tag for a user', async () => { it('should return a tag for a user', async () => {
mocks.tag.get.mockResolvedValue(tagStub.tag1); tagMock.get.mockResolvedValue(tagStub.tag1);
await expect(sut.get(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1); await expect(sut.get(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1);
expect(mocks.tag.get).toHaveBeenCalledWith('tag-1'); expect(tagMock.get).toHaveBeenCalledWith('tag-1');
}); });
}); });
describe('create', () => { describe('create', () => {
it('should throw an error for no parent tag access', async () => { it('should throw an error for no parent tag access', async () => {
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set()); accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.create(authStub.admin, { name: 'tag', parentId: 'tag-parent' })).rejects.toBeInstanceOf( await expect(sut.create(authStub.admin, { name: 'tag', parentId: 'tag-parent' })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
expect(mocks.tag.create).not.toHaveBeenCalled(); expect(tagMock.create).not.toHaveBeenCalled();
}); });
it('should create a tag with a parent', async () => { it('should create a tag with a parent', async () => {
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent'])); accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent']));
mocks.tag.create.mockResolvedValue(tagStub.tag1); tagMock.create.mockResolvedValue(tagStub.tag1);
mocks.tag.get.mockResolvedValueOnce(tagStub.parent); tagMock.get.mockResolvedValueOnce(tagStub.parent);
mocks.tag.get.mockResolvedValueOnce(tagStub.child); tagMock.get.mockResolvedValueOnce(tagStub.child);
await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).resolves.toBeDefined(); await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).resolves.toBeDefined();
expect(mocks.tag.create).toHaveBeenCalledWith(expect.objectContaining({ value: 'Parent/tagA' })); expect(tagMock.create).toHaveBeenCalledWith(expect.objectContaining({ value: 'Parent/tagA' }));
}); });
it('should handle invalid parent ids', async () => { it('should handle invalid parent ids', async () => {
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent'])); accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent']));
await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).rejects.toBeInstanceOf( await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
expect(mocks.tag.create).not.toHaveBeenCalled(); expect(tagMock.create).not.toHaveBeenCalled();
}); });
}); });
describe('create', () => { describe('create', () => {
it('should throw an error for a duplicate tag', async () => { it('should throw an error for a duplicate tag', async () => {
mocks.tag.getByValue.mockResolvedValue(tagStub.tag1); tagMock.getByValue.mockResolvedValue(tagStub.tag1);
await expect(sut.create(authStub.admin, { name: 'tag-1' })).rejects.toBeInstanceOf(BadRequestException); await expect(sut.create(authStub.admin, { name: 'tag-1' })).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.tag.getByValue).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); expect(tagMock.getByValue).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(mocks.tag.create).not.toHaveBeenCalled(); expect(tagMock.create).not.toHaveBeenCalled();
}); });
it('should create a new tag', async () => { it('should create a new tag', async () => {
mocks.tag.create.mockResolvedValue(tagStub.tag1); tagMock.create.mockResolvedValue(tagStub.tag1);
await expect(sut.create(authStub.admin, { name: 'tag-1' })).resolves.toEqual(tagResponseStub.tag1); await expect(sut.create(authStub.admin, { name: 'tag-1' })).resolves.toEqual(tagResponseStub.tag1);
expect(mocks.tag.create).toHaveBeenCalledWith({ expect(tagMock.create).toHaveBeenCalledWith({
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
value: 'tag-1', value: 'tag-1',
}); });
}); });
it('should create a new tag with optional color', async () => { it('should create a new tag with optional color', async () => {
mocks.tag.create.mockResolvedValue(tagStub.color1); tagMock.create.mockResolvedValue(tagStub.color1);
await expect(sut.create(authStub.admin, { name: 'tag-1', color: '#000000' })).resolves.toEqual( await expect(sut.create(authStub.admin, { name: 'tag-1', color: '#000000' })).resolves.toEqual(
tagResponseStub.color1, tagResponseStub.color1,
); );
expect(mocks.tag.create).toHaveBeenCalledWith({ expect(tagMock.create).toHaveBeenCalledWith({
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
value: 'tag-1', value: 'tag-1',
color: '#000000', color: '#000000',
@@ -101,26 +106,26 @@ describe(TagService.name, () => {
describe('update', () => { describe('update', () => {
it('should throw an error for no update permission', async () => { it('should throw an error for no update permission', async () => {
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set()); accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).rejects.toBeInstanceOf( await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
expect(mocks.tag.update).not.toHaveBeenCalled(); expect(tagMock.update).not.toHaveBeenCalled();
}); });
it('should update a tag', async () => { it('should update a tag', async () => {
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1']));
mocks.tag.update.mockResolvedValue(tagStub.color1); tagMock.update.mockResolvedValue(tagStub.color1);
await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).resolves.toEqual(tagResponseStub.color1); await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).resolves.toEqual(tagResponseStub.color1);
expect(mocks.tag.update).toHaveBeenCalledWith({ id: 'tag-1', color: '#000000' }); expect(tagMock.update).toHaveBeenCalledWith({ id: 'tag-1', color: '#000000' });
}); });
}); });
describe('upsert', () => { describe('upsert', () => {
it('should upsert a new tag', async () => { it('should upsert a new tag', async () => {
mocks.tag.upsertValue.mockResolvedValue(tagStub.parent); tagMock.upsertValue.mockResolvedValue(tagStub.parent);
await expect(sut.upsert(authStub.admin, { tags: ['Parent'] })).resolves.toBeDefined(); await expect(sut.upsert(authStub.admin, { tags: ['Parent'] })).resolves.toBeDefined();
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ expect(tagMock.upsertValue).toHaveBeenCalledWith({
value: 'Parent', value: 'Parent',
userId: 'admin_id', userId: 'admin_id',
parentId: undefined, parentId: undefined,
@@ -128,16 +133,16 @@ describe(TagService.name, () => {
}); });
it('should upsert a nested tag', async () => { it('should upsert a nested tag', async () => {
mocks.tag.getByValue.mockResolvedValueOnce(null); tagMock.getByValue.mockResolvedValueOnce(null);
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parent); tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent);
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.child); tagMock.upsertValue.mockResolvedValueOnce(tagStub.child);
await expect(sut.upsert(authStub.admin, { tags: ['Parent/Child'] })).resolves.toBeDefined(); await expect(sut.upsert(authStub.admin, { tags: ['Parent/Child'] })).resolves.toBeDefined();
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, {
value: 'Parent', value: 'Parent',
userId: 'admin_id', userId: 'admin_id',
parent: undefined, parent: undefined,
}); });
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, {
value: 'Parent/Child', value: 'Parent/Child',
userId: 'admin_id', userId: 'admin_id',
parent: expect.objectContaining({ id: 'tag-parent' }), parent: expect.objectContaining({ id: 'tag-parent' }),
@@ -145,16 +150,16 @@ describe(TagService.name, () => {
}); });
it('should upsert a tag and ignore leading and trailing slashes', async () => { it('should upsert a tag and ignore leading and trailing slashes', async () => {
mocks.tag.getByValue.mockResolvedValueOnce(null); tagMock.getByValue.mockResolvedValueOnce(null);
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parent); tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent);
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.child); tagMock.upsertValue.mockResolvedValueOnce(tagStub.child);
await expect(sut.upsert(authStub.admin, { tags: ['/Parent/Child/'] })).resolves.toBeDefined(); await expect(sut.upsert(authStub.admin, { tags: ['/Parent/Child/'] })).resolves.toBeDefined();
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, {
value: 'Parent', value: 'Parent',
userId: 'admin_id', userId: 'admin_id',
parent: undefined, parent: undefined,
}); });
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, {
value: 'Parent/Child', value: 'Parent/Child',
userId: 'admin_id', userId: 'admin_id',
parent: expect.objectContaining({ id: 'tag-parent' }), parent: expect.objectContaining({ id: 'tag-parent' }),
@@ -164,32 +169,32 @@ describe(TagService.name, () => {
describe('remove', () => { describe('remove', () => {
it('should throw an error for an invalid id', async () => { it('should throw an error for an invalid id', async () => {
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set()); accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.tag.delete).not.toHaveBeenCalled(); expect(tagMock.delete).not.toHaveBeenCalled();
}); });
it('should remove a tag', async () => { it('should remove a tag', async () => {
mocks.tag.get.mockResolvedValue(tagStub.tag1); tagMock.get.mockResolvedValue(tagStub.tag1);
await sut.remove(authStub.admin, 'tag-1'); await sut.remove(authStub.admin, 'tag-1');
expect(mocks.tag.delete).toHaveBeenCalledWith('tag-1'); expect(tagMock.delete).toHaveBeenCalledWith('tag-1');
}); });
}); });
describe('bulkTagAssets', () => { describe('bulkTagAssets', () => {
it('should handle invalid requests', async () => { it('should handle invalid requests', async () => {
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set()); accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
mocks.tag.upsertAssetIds.mockResolvedValue([]); tagMock.upsertAssetIds.mockResolvedValue([]);
await expect(sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1'], assetIds: ['asset-1'] })).resolves.toEqual({ await expect(sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1'], assetIds: ['asset-1'] })).resolves.toEqual({
count: 0, count: 0,
}); });
expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([]); expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([]);
}); });
it('should upsert records', async () => { it('should upsert records', async () => {
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2'])); accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
mocks.tag.upsertAssetIds.mockResolvedValue([ tagMock.upsertAssetIds.mockResolvedValue([
{ tagId: 'tag-1', assetId: 'asset-1' }, { tagId: 'tag-1', assetId: 'asset-1' },
{ tagId: 'tag-1', assetId: 'asset-2' }, { tagId: 'tag-1', assetId: 'asset-2' },
{ tagId: 'tag-1', assetId: 'asset-3' }, { tagId: 'tag-1', assetId: 'asset-3' },
@@ -202,7 +207,7 @@ describe(TagService.name, () => {
).resolves.toEqual({ ).resolves.toEqual({
count: 6, count: 6,
}); });
expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([ expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([
{ tagId: 'tag-1', assetId: 'asset-1' }, { tagId: 'tag-1', assetId: 'asset-1' },
{ tagId: 'tag-1', assetId: 'asset-2' }, { tagId: 'tag-1', assetId: 'asset-2' },
{ tagId: 'tag-1', assetId: 'asset-3' }, { tagId: 'tag-1', assetId: 'asset-3' },
@@ -215,19 +220,19 @@ describe(TagService.name, () => {
describe('addAssets', () => { describe('addAssets', () => {
it('should handle invalid ids', async () => { it('should handle invalid ids', async () => {
mocks.tag.get.mockResolvedValue(null); tagMock.get.mockResolvedValue(null);
mocks.tag.getAssetIds.mockResolvedValue(new Set([])); tagMock.getAssetIds.mockResolvedValue(new Set([]));
await expect(sut.addAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([ await expect(sut.addAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([
{ id: 'asset-1', success: false, error: 'no_permission' }, { id: 'asset-1', success: false, error: 'no_permission' },
]); ]);
expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']); expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']);
expect(mocks.tag.addAssetIds).not.toHaveBeenCalled(); expect(tagMock.addAssetIds).not.toHaveBeenCalled();
}); });
it('should accept accept ids that are new and reject the rest', async () => { it('should accept accept ids that are new and reject the rest', async () => {
mocks.tag.get.mockResolvedValue(tagStub.tag1); tagMock.get.mockResolvedValue(tagStub.tag1);
mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1'])); tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2']));
await expect( await expect(
sut.addAssets(authStub.admin, 'tag-1', { sut.addAssets(authStub.admin, 'tag-1', {
@@ -238,23 +243,23 @@ describe(TagService.name, () => {
{ id: 'asset-2', success: true }, { id: 'asset-2', success: true },
]); ]);
expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
expect(mocks.tag.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']); expect(tagMock.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']);
}); });
}); });
describe('removeAssets', () => { describe('removeAssets', () => {
it('should throw an error for an invalid id', async () => { it('should throw an error for an invalid id', async () => {
mocks.tag.get.mockResolvedValue(null); tagMock.get.mockResolvedValue(null);
mocks.tag.getAssetIds.mockResolvedValue(new Set()); tagMock.getAssetIds.mockResolvedValue(new Set());
await expect(sut.removeAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([ await expect(sut.removeAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([
{ id: 'asset-1', success: false, error: 'not_found' }, { id: 'asset-1', success: false, error: 'not_found' },
]); ]);
}); });
it('should accept accept ids that are tagged and reject the rest', async () => { it('should accept accept ids that are tagged and reject the rest', async () => {
mocks.tag.get.mockResolvedValue(tagStub.tag1); tagMock.get.mockResolvedValue(tagStub.tag1);
mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1'])); tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1']));
await expect( await expect(
sut.removeAssets(authStub.admin, 'tag-1', { sut.removeAssets(authStub.admin, 'tag-1', {
@@ -265,15 +270,15 @@ describe(TagService.name, () => {
{ id: 'asset-2', success: false, error: BulkIdErrorReason.NOT_FOUND }, { id: 'asset-2', success: false, error: BulkIdErrorReason.NOT_FOUND },
]); ]);
expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
expect(mocks.tag.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']); expect(tagMock.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']);
}); });
}); });
describe('handleTagCleanup', () => { describe('handleTagCleanup', () => {
it('should delete empty tags', async () => { it('should delete empty tags', async () => {
await expect(sut.handleTagCleanup()).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleTagCleanup()).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.tag.deleteEmptyTags).toHaveBeenCalled(); expect(tagMock.deleteEmptyTags).toHaveBeenCalled();
}); });
}); });
}); });
+26 -22
View File
@@ -1,28 +1,32 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { TimeBucketSize } from 'src/interfaces/asset.interface'; import { IAssetRepository, TimeBucketSize } from 'src/interfaces/asset.interface';
import { TimelineService } from 'src/services/timeline.service'; import { TimelineService } from 'src/services/timeline.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
describe(TimelineService.name, () => { describe(TimelineService.name, () => {
let sut: TimelineService; let sut: TimelineService;
let mocks: ServiceMocks;
let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(TimelineService)); ({ sut, accessMock, assetMock } = newTestService(TimelineService));
}); });
describe('getTimeBuckets', () => { describe('getTimeBuckets', () => {
it("should return buckets if userId and albumId aren't set", async () => { it("should return buckets if userId and albumId aren't set", async () => {
mocks.asset.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]); assetMock.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]);
await expect( await expect(
sut.getTimeBuckets(authStub.admin, { sut.getTimeBuckets(authStub.admin, {
size: TimeBucketSize.DAY, size: TimeBucketSize.DAY,
}), }),
).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }])); ).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }]));
expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({ expect(assetMock.getTimeBuckets).toHaveBeenCalledWith({
size: TimeBucketSize.DAY, size: TimeBucketSize.DAY,
userIds: [authStub.admin.user.id], userIds: [authStub.admin.user.id],
}); });
@@ -31,15 +35,15 @@ describe(TimelineService.name, () => {
describe('getTimeBucket', () => { describe('getTimeBucket', () => {
it('should return the assets for a album time bucket if user has album.read', async () => { it('should return the assets for a album time bucket if user has album.read', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); assetMock.getTimeBucket.mockResolvedValue([assetStub.image]);
await expect( await expect(
sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }), sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }),
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id'])); expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id']));
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', {
size: TimeBucketSize.DAY, size: TimeBucketSize.DAY,
timeBucket: 'bucket', timeBucket: 'bucket',
albumId: 'album-id', albumId: 'album-id',
@@ -47,7 +51,7 @@ describe(TimelineService.name, () => {
}); });
it('should return the assets for a archive time bucket if user has archive.read', async () => { it('should return the assets for a archive time bucket if user has archive.read', async () => {
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); assetMock.getTimeBucket.mockResolvedValue([assetStub.image]);
await expect( await expect(
sut.getTimeBucket(authStub.admin, { sut.getTimeBucket(authStub.admin, {
@@ -57,7 +61,7 @@ describe(TimelineService.name, () => {
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
}), }),
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( expect(assetMock.getTimeBucket).toHaveBeenCalledWith(
'bucket', 'bucket',
expect.objectContaining({ expect.objectContaining({
size: TimeBucketSize.DAY, size: TimeBucketSize.DAY,
@@ -69,7 +73,7 @@ describe(TimelineService.name, () => {
}); });
it('should include partner shared assets', async () => { it('should include partner shared assets', async () => {
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); assetMock.getTimeBucket.mockResolvedValue([assetStub.image]);
await expect( await expect(
sut.getTimeBucket(authStub.admin, { sut.getTimeBucket(authStub.admin, {
@@ -80,7 +84,7 @@ describe(TimelineService.name, () => {
withPartners: true, withPartners: true,
}), }),
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', {
size: TimeBucketSize.DAY, size: TimeBucketSize.DAY,
timeBucket: 'bucket', timeBucket: 'bucket',
isArchived: false, isArchived: false,
@@ -90,8 +94,8 @@ describe(TimelineService.name, () => {
}); });
it('should check permissions to read tag', async () => { it('should check permissions to read tag', async () => {
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); assetMock.getTimeBucket.mockResolvedValue([assetStub.image]);
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-123'])); accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-123']));
await expect( await expect(
sut.getTimeBucket(authStub.admin, { sut.getTimeBucket(authStub.admin, {
@@ -101,7 +105,7 @@ describe(TimelineService.name, () => {
tagId: 'tag-123', tagId: 'tag-123',
}), }),
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', {
size: TimeBucketSize.DAY, size: TimeBucketSize.DAY,
tagId: 'tag-123', tagId: 'tag-123',
timeBucket: 'bucket', timeBucket: 'bucket',
@@ -110,8 +114,8 @@ describe(TimelineService.name, () => {
}); });
it('should strip metadata if showExif is disabled', async () => { it('should strip metadata if showExif is disabled', async () => {
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id'])); accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id']));
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); assetMock.getTimeBucket.mockResolvedValue([assetStub.image]);
const buckets = await sut.getTimeBucket( const buckets = await sut.getTimeBucket(
{ ...authStub.admin, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } }, { ...authStub.admin, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } },
@@ -124,7 +128,7 @@ describe(TimelineService.name, () => {
); );
expect(buckets).toEqual([expect.objectContaining({ id: 'asset-id' })]); expect(buckets).toEqual([expect.objectContaining({ id: 'asset-id' })]);
expect(buckets[0]).not.toHaveProperty('exif'); expect(buckets[0]).not.toHaveProperty('exif');
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', {
size: TimeBucketSize.DAY, size: TimeBucketSize.DAY,
timeBucket: 'bucket', timeBucket: 'bucket',
isArchived: true, isArchived: true,
@@ -133,7 +137,7 @@ describe(TimelineService.name, () => {
}); });
it('should return the assets for a library time bucket if user has library.read', async () => { it('should return the assets for a library time bucket if user has library.read', async () => {
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); assetMock.getTimeBucket.mockResolvedValue([assetStub.image]);
await expect( await expect(
sut.getTimeBucket(authStub.admin, { sut.getTimeBucket(authStub.admin, {
@@ -142,7 +146,7 @@ describe(TimelineService.name, () => {
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
}), }),
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( expect(assetMock.getTimeBucket).toHaveBeenCalledWith(
'bucket', 'bucket',
expect.objectContaining({ expect.objectContaining({
size: TimeBucketSize.DAY, size: TimeBucketSize.DAY,
+30 -24
View File
@@ -1,8 +1,11 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { JobName, JobStatus } from 'src/interfaces/job.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { TrashService } from 'src/services/trash.service'; import { TrashService } from 'src/services/trash.service';
import { ITrashRepository } from 'src/types';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
async function* makeAssetIdStream(count: number): AsyncIterableIterator<{ id: string }> { async function* makeAssetIdStream(count: number): AsyncIterableIterator<{ id: string }> {
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
@@ -13,14 +16,17 @@ async function* makeAssetIdStream(count: number): AsyncIterableIterator<{ id: st
describe(TrashService.name, () => { describe(TrashService.name, () => {
let sut: TrashService; let sut: TrashService;
let mocks: ServiceMocks;
let accessMock: IAccessRepositoryMock;
let jobMock: Mocked<IJobRepository>;
let trashMock: Mocked<ITrashRepository>;
it('should work', () => { it('should work', () => {
expect(sut).toBeDefined(); expect(sut).toBeDefined();
}); });
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(TrashService)); ({ sut, accessMock, jobMock, trashMock } = newTestService(TrashService));
}); });
describe('restoreAssets', () => { describe('restoreAssets', () => {
@@ -34,64 +40,64 @@ describe(TrashService.name, () => {
it('should handle an empty list', async () => { it('should handle an empty list', async () => {
await expect(sut.restoreAssets(authStub.user1, { ids: [] })).resolves.toEqual({ count: 0 }); await expect(sut.restoreAssets(authStub.user1, { ids: [] })).resolves.toEqual({ count: 0 });
expect(mocks.access.asset.checkOwnerAccess).not.toHaveBeenCalled(); expect(accessMock.asset.checkOwnerAccess).not.toHaveBeenCalled();
}); });
it('should restore a batch of assets', async () => { it('should restore a batch of assets', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2']));
await sut.restoreAssets(authStub.user1, { ids: ['asset1', 'asset2'] }); await sut.restoreAssets(authStub.user1, { ids: ['asset1', 'asset2'] });
expect(mocks.trash.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']); expect(trashMock.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']);
expect(mocks.job.queue.mock.calls).toEqual([]); expect(jobMock.queue.mock.calls).toEqual([]);
}); });
}); });
describe('restore', () => { describe('restore', () => {
it('should handle an empty trash', async () => { it('should handle an empty trash', async () => {
mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(0)); trashMock.getDeletedIds.mockResolvedValue(makeAssetIdStream(0));
mocks.trash.restore.mockResolvedValue(0); trashMock.restore.mockResolvedValue(0);
await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 0 }); await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 0 });
expect(mocks.trash.restore).toHaveBeenCalledWith('user-id'); expect(trashMock.restore).toHaveBeenCalledWith('user-id');
}); });
it('should restore', async () => { it('should restore', async () => {
mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(1)); trashMock.getDeletedIds.mockResolvedValue(makeAssetIdStream(1));
mocks.trash.restore.mockResolvedValue(1); trashMock.restore.mockResolvedValue(1);
await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 1 }); await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 1 });
expect(mocks.trash.restore).toHaveBeenCalledWith('user-id'); expect(trashMock.restore).toHaveBeenCalledWith('user-id');
}); });
}); });
describe('empty', () => { describe('empty', () => {
it('should handle an empty trash', async () => { it('should handle an empty trash', async () => {
mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(0)); trashMock.getDeletedIds.mockResolvedValue(makeAssetIdStream(0));
mocks.trash.empty.mockResolvedValue(0); trashMock.empty.mockResolvedValue(0);
await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 0 }); await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 0 });
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled();
}); });
it('should empty the trash', async () => { it('should empty the trash', async () => {
mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(1)); trashMock.getDeletedIds.mockResolvedValue(makeAssetIdStream(1));
mocks.trash.empty.mockResolvedValue(1); trashMock.empty.mockResolvedValue(1);
await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 1 }); await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 1 });
expect(mocks.trash.empty).toHaveBeenCalledWith('user-id'); expect(trashMock.empty).toHaveBeenCalledWith('user-id');
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} });
}); });
}); });
describe('onAssetsDelete', () => { describe('onAssetsDelete', () => {
it('should queue the empty trash job', async () => { it('should queue the empty trash job', async () => {
await expect(sut.onAssetsDelete()).resolves.toBeUndefined(); await expect(sut.onAssetsDelete()).resolves.toBeUndefined();
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} });
}); });
}); });
describe('handleQueueEmptyTrash', () => { describe('handleQueueEmptyTrash', () => {
it('should queue asset delete jobs', async () => { it('should queue asset delete jobs', async () => {
mocks.trash.getDeletedIds.mockReturnValue(makeAssetIdStream(1)); trashMock.getDeletedIds.mockReturnValue(makeAssetIdStream(1));
await expect(sut.handleQueueEmptyTrash()).resolves.toEqual(JobStatus.SUCCESS); await expect(sut.handleQueueEmptyTrash()).resolves.toEqual(JobStatus.SUCCESS);
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.ASSET_DELETION, name: JobName.ASSET_DELETION,
data: { id: 'asset-1', deleteOnDisk: true }, data: { id: 'asset-1', deleteOnDisk: true },
+43 -40
View File
@@ -1,28 +1,31 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { mapUserAdmin } from 'src/dtos/user.dto'; import { mapUserAdmin } from 'src/dtos/user.dto';
import { UserStatus } from 'src/enum'; import { UserStatus } from 'src/enum';
import { JobName } from 'src/interfaces/job.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { UserAdminService } from 'src/services/user-admin.service'; import { UserAdminService } from 'src/services/user-admin.service';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService } from 'test/utils';
import { describe } from 'vitest'; import { Mocked, describe } from 'vitest';
describe(UserAdminService.name, () => { describe(UserAdminService.name, () => {
let sut: UserAdminService; let sut: UserAdminService;
let mocks: ServiceMocks;
let jobMock: Mocked<IJobRepository>;
let userMock: Mocked<IUserRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(UserAdminService)); ({ sut, jobMock, userMock } = newTestService(UserAdminService));
mocks.user.get.mockImplementation((userId) => userMock.get.mockImplementation((userId) =>
Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? undefined), Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? undefined),
); );
}); });
describe('create', () => { describe('create', () => {
it('should not create a user if there is no local admin account', async () => { it('should not create a user if there is no local admin account', async () => {
mocks.user.getAdmin.mockResolvedValueOnce(void 0); userMock.getAdmin.mockResolvedValueOnce(void 0);
await expect( await expect(
sut.create({ sut.create({
@@ -34,8 +37,8 @@ describe(UserAdminService.name, () => {
}); });
it('should create user', async () => { it('should create user', async () => {
mocks.user.getAdmin.mockResolvedValue(userStub.admin); userMock.getAdmin.mockResolvedValue(userStub.admin);
mocks.user.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
await expect( await expect(
sut.create({ sut.create({
@@ -46,8 +49,8 @@ describe(UserAdminService.name, () => {
}), }),
).resolves.toEqual(mapUserAdmin(userStub.user1)); ).resolves.toEqual(mapUserAdmin(userStub.user1));
expect(mocks.user.getAdmin).toBeCalled(); expect(userMock.getAdmin).toBeCalled();
expect(mocks.user.create).toBeCalledWith({ expect(userMock.create).toBeCalledWith({
email: userStub.user1.email, email: userStub.user1.email,
name: userStub.user1.name, name: userStub.user1.name,
storageLabel: 'label', storageLabel: 'label',
@@ -63,20 +66,20 @@ describe(UserAdminService.name, () => {
email: 'immich@test.com', email: 'immich@test.com',
storageLabel: 'storage_label', storageLabel: 'storage_label',
}; };
mocks.user.getByEmail.mockResolvedValue(void 0); userMock.getByEmail.mockResolvedValue(void 0);
mocks.user.getByStorageLabel.mockResolvedValue(void 0); userMock.getByStorageLabel.mockResolvedValue(void 0);
mocks.user.update.mockResolvedValue(userStub.user1); userMock.update.mockResolvedValue(userStub.user1);
await sut.update(authStub.user1, userStub.user1.id, update); await sut.update(authStub.user1, userStub.user1.id, update);
expect(mocks.user.getByEmail).toHaveBeenCalledWith(update.email); expect(userMock.getByEmail).toHaveBeenCalledWith(update.email);
expect(mocks.user.getByStorageLabel).toHaveBeenCalledWith(update.storageLabel); expect(userMock.getByStorageLabel).toHaveBeenCalledWith(update.storageLabel);
}); });
it('should not set an empty string for storage label', async () => { it('should not set an empty string for storage label', async () => {
mocks.user.update.mockResolvedValue(userStub.user1); userMock.update.mockResolvedValue(userStub.user1);
await sut.update(authStub.admin, userStub.user1.id, { storageLabel: '' }); await sut.update(authStub.admin, userStub.user1.id, { storageLabel: '' });
expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
storageLabel: null, storageLabel: null,
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
}); });
@@ -85,27 +88,27 @@ describe(UserAdminService.name, () => {
it('should not change an email to one already in use', async () => { it('should not change an email to one already in use', async () => {
const dto = { id: userStub.user1.id, email: 'updated@test.com' }; const dto = { id: userStub.user1.id, email: 'updated@test.com' };
mocks.user.get.mockResolvedValue(userStub.user1); userMock.get.mockResolvedValue(userStub.user1);
mocks.user.getByEmail.mockResolvedValue(userStub.admin); userMock.getByEmail.mockResolvedValue(userStub.admin);
await expect(sut.update(authStub.admin, userStub.user1.id, dto)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.update(authStub.admin, userStub.user1.id, dto)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.user.update).not.toHaveBeenCalled(); expect(userMock.update).not.toHaveBeenCalled();
}); });
it('should not let the admin change the storage label to one already in use', async () => { it('should not let the admin change the storage label to one already in use', async () => {
const dto = { id: userStub.user1.id, storageLabel: 'admin' }; const dto = { id: userStub.user1.id, storageLabel: 'admin' };
mocks.user.get.mockResolvedValue(userStub.user1); userMock.get.mockResolvedValue(userStub.user1);
mocks.user.getByStorageLabel.mockResolvedValue(userStub.admin); userMock.getByStorageLabel.mockResolvedValue(userStub.admin);
await expect(sut.update(authStub.admin, userStub.user1.id, dto)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.update(authStub.admin, userStub.user1.id, dto)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.user.update).not.toHaveBeenCalled(); expect(userMock.update).not.toHaveBeenCalled();
}); });
it('update user information should throw error if user not found', async () => { it('update user information should throw error if user not found', async () => {
mocks.user.get.mockResolvedValueOnce(void 0); userMock.get.mockResolvedValueOnce(void 0);
await expect( await expect(
sut.update(authStub.admin, userStub.user1.id, { shouldChangePassword: true }), sut.update(authStub.admin, userStub.user1.id, { shouldChangePassword: true }),
@@ -115,10 +118,10 @@ describe(UserAdminService.name, () => {
describe('delete', () => { describe('delete', () => {
it('should throw error if user could not be found', async () => { it('should throw error if user could not be found', async () => {
mocks.user.get.mockResolvedValue(void 0); userMock.get.mockResolvedValue(void 0);
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toThrowError(BadRequestException); await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toThrowError(BadRequestException);
expect(mocks.user.delete).not.toHaveBeenCalled(); expect(userMock.delete).not.toHaveBeenCalled();
}); });
it('cannot delete admin user', async () => { it('cannot delete admin user', async () => {
@@ -128,33 +131,33 @@ describe(UserAdminService.name, () => {
it('should require the auth user be an admin', async () => { it('should require the auth user be an admin', async () => {
await expect(sut.delete(authStub.user1, authStub.admin.user.id, {})).rejects.toBeInstanceOf(ForbiddenException); await expect(sut.delete(authStub.user1, authStub.admin.user.id, {})).rejects.toBeInstanceOf(ForbiddenException);
expect(mocks.user.delete).not.toHaveBeenCalled(); expect(userMock.delete).not.toHaveBeenCalled();
}); });
it('should delete user', async () => { it('should delete user', async () => {
mocks.user.get.mockResolvedValue(userStub.user1); userMock.get.mockResolvedValue(userStub.user1);
mocks.user.update.mockResolvedValue(userStub.user1); userMock.update.mockResolvedValue(userStub.user1);
await expect(sut.delete(authStub.admin, userStub.user1.id, {})).resolves.toEqual(mapUserAdmin(userStub.user1)); await expect(sut.delete(authStub.admin, userStub.user1.id, {})).resolves.toEqual(mapUserAdmin(userStub.user1));
expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
status: UserStatus.DELETED, status: UserStatus.DELETED,
deletedAt: expect.any(Date), deletedAt: expect.any(Date),
}); });
}); });
it('should force delete user', async () => { it('should force delete user', async () => {
mocks.user.get.mockResolvedValue(userStub.user1); userMock.get.mockResolvedValue(userStub.user1);
mocks.user.update.mockResolvedValue(userStub.user1); userMock.update.mockResolvedValue(userStub.user1);
await expect(sut.delete(authStub.admin, userStub.user1.id, { force: true })).resolves.toEqual( await expect(sut.delete(authStub.admin, userStub.user1.id, { force: true })).resolves.toEqual(
mapUserAdmin(userStub.user1), mapUserAdmin(userStub.user1),
); );
expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
status: UserStatus.REMOVING, status: UserStatus.REMOVING,
deletedAt: expect.any(Date), deletedAt: expect.any(Date),
}); });
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.USER_DELETION, name: JobName.USER_DELETION,
data: { id: userStub.user1.id, force: true }, data: { id: userStub.user1.id, force: true },
}); });
@@ -163,16 +166,16 @@ describe(UserAdminService.name, () => {
describe('restore', () => { describe('restore', () => {
it('should throw error if user could not be found', async () => { it('should throw error if user could not be found', async () => {
mocks.user.get.mockResolvedValue(void 0); userMock.get.mockResolvedValue(void 0);
await expect(sut.restore(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException); await expect(sut.restore(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException);
expect(mocks.user.update).not.toHaveBeenCalled(); expect(userMock.update).not.toHaveBeenCalled();
}); });
it('should restore an user', async () => { it('should restore an user', async () => {
mocks.user.get.mockResolvedValue(userStub.user1); userMock.get.mockResolvedValue(userStub.user1);
mocks.user.restore.mockResolvedValue(userStub.user1); userMock.restore.mockResolvedValue(userStub.user1);
await expect(sut.restore(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUserAdmin(userStub.user1)); await expect(sut.restore(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUserAdmin(userStub.user1));
expect(mocks.user.restore).toHaveBeenCalledWith(userStub.user1.id); expect(userMock.restore).toHaveBeenCalledWith(userStub.user1.id);
}); });
}); });
}); });
+85 -75
View File
@@ -1,13 +1,18 @@
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { CacheControl, UserMetadataKey } from 'src/enum'; import { CacheControl, UserMetadataKey } from 'src/enum';
import { JobName } from 'src/interfaces/job.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { UserService } from 'src/services/user.service'; import { UserService } from 'src/services/user.service';
import { ImmichFileResponse } from 'src/utils/file'; import { ImmichFileResponse } from 'src/utils/file';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
const makeDeletedAt = (daysAgo: number) => { const makeDeletedAt = (daysAgo: number) => {
const deletedAt = new Date(); const deletedAt = new Date();
@@ -17,63 +22,68 @@ const makeDeletedAt = (daysAgo: number) => {
describe(UserService.name, () => { describe(UserService.name, () => {
let sut: UserService; let sut: UserService;
let mocks: ServiceMocks;
let albumMock: Mocked<IAlbumRepository>;
let jobMock: Mocked<IJobRepository>;
let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let userMock: Mocked<IUserRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(UserService)); ({ sut, albumMock, jobMock, storageMock, systemMock, userMock } = newTestService(UserService));
mocks.user.get.mockImplementation((userId) => userMock.get.mockImplementation((userId) =>
Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? undefined), Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? undefined),
); );
}); });
describe('getAll', () => { describe('getAll', () => {
it('admin should get all users', async () => { it('admin should get all users', async () => {
mocks.user.getList.mockResolvedValue([userStub.admin]); userMock.getList.mockResolvedValue([userStub.admin]);
await expect(sut.search(authStub.admin)).resolves.toEqual([ await expect(sut.search(authStub.admin)).resolves.toEqual([
expect.objectContaining({ expect.objectContaining({
id: authStub.admin.user.id, id: authStub.admin.user.id,
email: authStub.admin.user.email, email: authStub.admin.user.email,
}), }),
]); ]);
expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: false }); expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false });
}); });
it('non-admin should get all users when publicUsers enabled', async () => { it('non-admin should get all users when publicUsers enabled', async () => {
mocks.user.getList.mockResolvedValue([userStub.user1]); userMock.getList.mockResolvedValue([userStub.user1]);
await expect(sut.search(authStub.user1)).resolves.toEqual([ await expect(sut.search(authStub.user1)).resolves.toEqual([
expect.objectContaining({ expect.objectContaining({
id: authStub.user1.user.id, id: authStub.user1.user.id,
email: authStub.user1.user.email, email: authStub.user1.user.email,
}), }),
]); ]);
expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: false }); expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false });
}); });
it('non-admin user should only receive itself when publicUsers is disabled', async () => { it('non-admin user should only receive itself when publicUsers is disabled', async () => {
mocks.user.getList.mockResolvedValue([userStub.user1]); userMock.getList.mockResolvedValue([userStub.user1]);
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.publicUsersDisabled); systemMock.get.mockResolvedValue(systemConfigStub.publicUsersDisabled);
await expect(sut.search(authStub.user1)).resolves.toEqual([ await expect(sut.search(authStub.user1)).resolves.toEqual([
expect.objectContaining({ expect.objectContaining({
id: authStub.user1.user.id, id: authStub.user1.user.id,
email: authStub.user1.user.email, email: authStub.user1.user.email,
}), }),
]); ]);
expect(mocks.user.getList).not.toHaveBeenCalledWith({ withDeleted: false }); expect(userMock.getList).not.toHaveBeenCalledWith({ withDeleted: false });
}); });
}); });
describe('get', () => { describe('get', () => {
it('should get a user by id', async () => { it('should get a user by id', async () => {
mocks.user.get.mockResolvedValue(userStub.admin); userMock.get.mockResolvedValue(userStub.admin);
await sut.get(authStub.admin.user.id); await sut.get(authStub.admin.user.id);
expect(mocks.user.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false }); expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false });
}); });
it('should throw an error if a user is not found', async () => { it('should throw an error if a user is not found', async () => {
mocks.user.get.mockResolvedValue(void 0); userMock.get.mockResolvedValue(void 0);
await expect(sut.get(authStub.admin.user.id)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.get(authStub.admin.user.id)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.user.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false }); expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false });
}); });
}); });
@@ -90,78 +100,78 @@ describe(UserService.name, () => {
describe('createProfileImage', () => { describe('createProfileImage', () => {
it('should throw an error if the user does not exist', async () => { it('should throw an error if the user does not exist', async () => {
const file = { path: '/profile/path' } as Express.Multer.File; const file = { path: '/profile/path' } as Express.Multer.File;
mocks.user.get.mockResolvedValue(void 0); userMock.get.mockResolvedValue(void 0);
mocks.user.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); userMock.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path });
await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(BadRequestException); await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(BadRequestException);
}); });
it('should throw an error if the user profile could not be updated with the new image', async () => { it('should throw an error if the user profile could not be updated with the new image', async () => {
const file = { path: '/profile/path' } as Express.Multer.File; const file = { path: '/profile/path' } as Express.Multer.File;
mocks.user.get.mockResolvedValue(userStub.profilePath); userMock.get.mockResolvedValue(userStub.profilePath);
mocks.user.update.mockRejectedValue(new InternalServerErrorException('mocked error')); userMock.update.mockRejectedValue(new InternalServerErrorException('mocked error'));
await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(InternalServerErrorException); await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(InternalServerErrorException);
}); });
it('should delete the previous profile image', async () => { it('should delete the previous profile image', async () => {
const file = { path: '/profile/path' } as Express.Multer.File; const file = { path: '/profile/path' } as Express.Multer.File;
mocks.user.get.mockResolvedValue(userStub.profilePath); userMock.get.mockResolvedValue(userStub.profilePath);
const files = [userStub.profilePath.profileImagePath]; const files = [userStub.profilePath.profileImagePath];
mocks.user.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); userMock.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path });
await sut.createProfileImage(authStub.admin, file); await sut.createProfileImage(authStub.admin, file);
expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]); expect(jobMock.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]);
}); });
it('should not delete the profile image if it has not been set', async () => { it('should not delete the profile image if it has not been set', async () => {
const file = { path: '/profile/path' } as Express.Multer.File; const file = { path: '/profile/path' } as Express.Multer.File;
mocks.user.get.mockResolvedValue(userStub.admin); userMock.get.mockResolvedValue(userStub.admin);
mocks.user.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); userMock.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path });
await sut.createProfileImage(authStub.admin, file); await sut.createProfileImage(authStub.admin, file);
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled();
}); });
}); });
describe('deleteProfileImage', () => { describe('deleteProfileImage', () => {
it('should send an http error has no profile image', async () => { it('should send an http error has no profile image', async () => {
mocks.user.get.mockResolvedValue(userStub.admin); userMock.get.mockResolvedValue(userStub.admin);
await expect(sut.deleteProfileImage(authStub.admin)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.deleteProfileImage(authStub.admin)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled();
}); });
it('should delete the profile image if user has one', async () => { it('should delete the profile image if user has one', async () => {
mocks.user.get.mockResolvedValue(userStub.profilePath); userMock.get.mockResolvedValue(userStub.profilePath);
const files = [userStub.profilePath.profileImagePath]; const files = [userStub.profilePath.profileImagePath];
await sut.deleteProfileImage(authStub.admin); await sut.deleteProfileImage(authStub.admin);
expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]); expect(jobMock.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]);
}); });
}); });
describe('getUserProfileImage', () => { describe('getUserProfileImage', () => {
it('should throw an error if the user does not exist', async () => { it('should throw an error if the user does not exist', async () => {
mocks.user.get.mockResolvedValue(void 0); userMock.get.mockResolvedValue(void 0);
await expect(sut.getProfileImage(userStub.admin.id)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.getProfileImage(userStub.admin.id)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.user.get).toHaveBeenCalledWith(userStub.admin.id, {}); expect(userMock.get).toHaveBeenCalledWith(userStub.admin.id, {});
}); });
it('should throw an error if the user does not have a picture', async () => { it('should throw an error if the user does not have a picture', async () => {
mocks.user.get.mockResolvedValue(userStub.admin); userMock.get.mockResolvedValue(userStub.admin);
await expect(sut.getProfileImage(userStub.admin.id)).rejects.toBeInstanceOf(NotFoundException); await expect(sut.getProfileImage(userStub.admin.id)).rejects.toBeInstanceOf(NotFoundException);
expect(mocks.user.get).toHaveBeenCalledWith(userStub.admin.id, {}); expect(userMock.get).toHaveBeenCalledWith(userStub.admin.id, {});
}); });
it('should return the profile picture', async () => { it('should return the profile picture', async () => {
mocks.user.get.mockResolvedValue(userStub.profilePath); userMock.get.mockResolvedValue(userStub.profilePath);
await expect(sut.getProfileImage(userStub.profilePath.id)).resolves.toEqual( await expect(sut.getProfileImage(userStub.profilePath.id)).resolves.toEqual(
new ImmichFileResponse({ new ImmichFileResponse({
@@ -171,13 +181,13 @@ describe(UserService.name, () => {
}), }),
); );
expect(mocks.user.get).toHaveBeenCalledWith(userStub.profilePath.id, {}); expect(userMock.get).toHaveBeenCalledWith(userStub.profilePath.id, {});
}); });
}); });
describe('handleQueueUserDelete', () => { describe('handleQueueUserDelete', () => {
it('should skip users not ready for deletion', async () => { it('should skip users not ready for deletion', async () => {
mocks.user.getDeletedUsers.mockResolvedValue([ userMock.getDeletedUsers.mockResolvedValue([
{}, {},
{ deletedAt: undefined }, { deletedAt: undefined },
{ deletedAt: null }, { deletedAt: null },
@@ -186,14 +196,14 @@ describe(UserService.name, () => {
await sut.handleUserDeleteCheck(); await sut.handleUserDeleteCheck();
expect(mocks.user.getDeletedUsers).toHaveBeenCalled(); expect(userMock.getDeletedUsers).toHaveBeenCalled();
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled();
expect(mocks.job.queueAll).toHaveBeenCalledWith([]); expect(jobMock.queueAll).toHaveBeenCalledWith([]);
}); });
it('should skip users not ready for deletion - deleteDelay30', async () => { it('should skip users not ready for deletion - deleteDelay30', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.deleteDelay30); systemMock.get.mockResolvedValue(systemConfigStub.deleteDelay30);
mocks.user.getDeletedUsers.mockResolvedValue([ userMock.getDeletedUsers.mockResolvedValue([
{}, {},
{ deletedAt: undefined }, { deletedAt: undefined },
{ deletedAt: null }, { deletedAt: null },
@@ -202,120 +212,120 @@ describe(UserService.name, () => {
await sut.handleUserDeleteCheck(); await sut.handleUserDeleteCheck();
expect(mocks.user.getDeletedUsers).toHaveBeenCalled(); expect(userMock.getDeletedUsers).toHaveBeenCalled();
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled();
expect(mocks.job.queueAll).toHaveBeenCalledWith([]); expect(jobMock.queueAll).toHaveBeenCalledWith([]);
}); });
it('should queue user ready for deletion', async () => { it('should queue user ready for deletion', async () => {
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) }; const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) };
mocks.user.getDeletedUsers.mockResolvedValue([user] as UserEntity[]); userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]);
await sut.handleUserDeleteCheck(); await sut.handleUserDeleteCheck();
expect(mocks.user.getDeletedUsers).toHaveBeenCalled(); expect(userMock.getDeletedUsers).toHaveBeenCalled();
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]); expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]);
}); });
it('should queue user ready for deletion - deleteDelay30', async () => { it('should queue user ready for deletion - deleteDelay30', async () => {
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(31) }; const user = { id: 'deleted-user', deletedAt: makeDeletedAt(31) };
mocks.user.getDeletedUsers.mockResolvedValue([user] as UserEntity[]); userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]);
await sut.handleUserDeleteCheck(); await sut.handleUserDeleteCheck();
expect(mocks.user.getDeletedUsers).toHaveBeenCalled(); expect(userMock.getDeletedUsers).toHaveBeenCalled();
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]); expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]);
}); });
}); });
describe('handleUserDelete', () => { describe('handleUserDelete', () => {
it('should skip users not ready for deletion', async () => { it('should skip users not ready for deletion', async () => {
const user = { id: 'user-1', deletedAt: makeDeletedAt(5) } as UserEntity; const user = { id: 'user-1', deletedAt: makeDeletedAt(5) } as UserEntity;
mocks.user.get.mockResolvedValue(user); userMock.get.mockResolvedValue(user);
await sut.handleUserDelete({ id: user.id }); await sut.handleUserDelete({ id: user.id });
expect(mocks.storage.unlinkDir).not.toHaveBeenCalled(); expect(storageMock.unlinkDir).not.toHaveBeenCalled();
expect(mocks.user.delete).not.toHaveBeenCalled(); expect(userMock.delete).not.toHaveBeenCalled();
}); });
it('should delete the user and associated assets', async () => { it('should delete the user and associated assets', async () => {
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity; const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity;
mocks.user.get.mockResolvedValue(user); userMock.get.mockResolvedValue(user);
await sut.handleUserDelete({ id: user.id }); await sut.handleUserDelete({ id: user.id });
const options = { force: true, recursive: true }; const options = { force: true, recursive: true };
expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/library/deleted-user', options); expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/library/deleted-user', options);
expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/upload/deleted-user', options); expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/upload/deleted-user', options);
expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/profile/deleted-user', options); expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/profile/deleted-user', options);
expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/thumbs/deleted-user', options); expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/thumbs/deleted-user', options);
expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/encoded-video/deleted-user', options); expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/encoded-video/deleted-user', options);
expect(mocks.album.deleteAll).toHaveBeenCalledWith(user.id); expect(albumMock.deleteAll).toHaveBeenCalledWith(user.id);
expect(mocks.user.delete).toHaveBeenCalledWith(user, true); expect(userMock.delete).toHaveBeenCalledWith(user, true);
}); });
it('should delete the library path for a storage label', async () => { it('should delete the library path for a storage label', async () => {
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10), storageLabel: 'admin' } as UserEntity; const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10), storageLabel: 'admin' } as UserEntity;
mocks.user.get.mockResolvedValue(user); userMock.get.mockResolvedValue(user);
await sut.handleUserDelete({ id: user.id }); await sut.handleUserDelete({ id: user.id });
const options = { force: true, recursive: true }; const options = { force: true, recursive: true };
expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/library/admin', options); expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/library/admin', options);
}); });
}); });
describe('setLicense', () => { describe('setLicense', () => {
it('should save client license if valid', async () => { it('should save client license if valid', async () => {
mocks.user.upsertMetadata.mockResolvedValue(); userMock.upsertMetadata.mockResolvedValue();
const license = { licenseKey: 'IMCL-license-key', activationKey: 'activation-key' }; const license = { licenseKey: 'IMCL-license-key', activationKey: 'activation-key' };
await sut.setLicense(authStub.user1, license); await sut.setLicense(authStub.user1, license);
expect(mocks.user.upsertMetadata).toHaveBeenCalledWith(authStub.user1.user.id, { expect(userMock.upsertMetadata).toHaveBeenCalledWith(authStub.user1.user.id, {
key: UserMetadataKey.LICENSE, key: UserMetadataKey.LICENSE,
value: expect.any(Object), value: expect.any(Object),
}); });
}); });
it('should save server license as client if valid', async () => { it('should save server license as client if valid', async () => {
mocks.user.upsertMetadata.mockResolvedValue(); userMock.upsertMetadata.mockResolvedValue();
const license = { licenseKey: 'IMSV-license-key', activationKey: 'activation-key' }; const license = { licenseKey: 'IMSV-license-key', activationKey: 'activation-key' };
await sut.setLicense(authStub.user1, license); await sut.setLicense(authStub.user1, license);
expect(mocks.user.upsertMetadata).toHaveBeenCalledWith(authStub.user1.user.id, { expect(userMock.upsertMetadata).toHaveBeenCalledWith(authStub.user1.user.id, {
key: UserMetadataKey.LICENSE, key: UserMetadataKey.LICENSE,
value: expect.any(Object), value: expect.any(Object),
}); });
}); });
it('should not save license if invalid', async () => { it('should not save license if invalid', async () => {
mocks.user.upsertMetadata.mockResolvedValue(); userMock.upsertMetadata.mockResolvedValue();
const license = { licenseKey: 'license-key', activationKey: 'activation-key' }; const license = { licenseKey: 'license-key', activationKey: 'activation-key' };
const call = sut.setLicense(authStub.admin, license); const call = sut.setLicense(authStub.admin, license);
await expect(call).rejects.toThrowError('Invalid license key'); await expect(call).rejects.toThrowError('Invalid license key');
expect(mocks.user.upsertMetadata).not.toHaveBeenCalled(); expect(userMock.upsertMetadata).not.toHaveBeenCalled();
}); });
}); });
describe('deleteLicense', () => { describe('deleteLicense', () => {
it('should delete license', async () => { it('should delete license', async () => {
mocks.user.upsertMetadata.mockResolvedValue(); userMock.upsertMetadata.mockResolvedValue();
await sut.deleteLicense(authStub.admin); await sut.deleteLicense(authStub.admin);
expect(mocks.user.upsertMetadata).not.toHaveBeenCalled(); expect(userMock.upsertMetadata).not.toHaveBeenCalled();
}); });
}); });
describe('handleUserSyncUsage', () => { describe('handleUserSyncUsage', () => {
it('should sync usage', async () => { it('should sync usage', async () => {
await sut.handleUserSyncUsage(); await sut.handleUserSyncUsage();
expect(mocks.user.syncUsage).toHaveBeenCalledTimes(1); expect(userMock.syncUsage).toHaveBeenCalledTimes(1);
}); });
}); });
}); });

Some files were not shown because too many files have changed in this diff Show More