From 21c54c740a6f4609e419ea383ea10ac373c0bae8 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Wed, 25 Mar 2026 22:01:52 +0100 Subject: [PATCH] migrate library e2e tests --- e2e/src/specs/server/api/library.e2e-spec.ts | 549 +----------------- server/test/medium.factory.ts | 3 + .../specs/services/library.service.spec.ts | 456 +++++++++++++++ 3 files changed, 460 insertions(+), 548 deletions(-) create mode 100644 server/test/medium/specs/services/library.service.spec.ts diff --git a/e2e/src/specs/server/api/library.e2e-spec.ts b/e2e/src/specs/server/api/library.e2e-spec.ts index 4d67a84647..7b7a28250b 100644 --- a/e2e/src/specs/server/api/library.e2e-spec.ts +++ b/e2e/src/specs/server/api/library.e2e-spec.ts @@ -1,5 +1,5 @@ import { LibraryResponseDto, LoginResponseDto, getAllLibraries } from '@immich/sdk'; -import { cpSync, existsSync, rmSync, unlinkSync } from 'node:fs'; +import { cpSync, existsSync } from 'node:fs'; import { Socket } from 'socket.io-client'; import { userDto, uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; @@ -768,553 +768,6 @@ describe('/libraries', () => { utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); }); - - it('should set an asset offline if its file is missing', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - expect(assets.count).toBe(1); - - utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(trashedAsset.isOffline).toEqual(true); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - expect(newAssets.items).toEqual([]); - }); - - it('should set an asset offline if its file is not in any import path', async () => { - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - expect(assets.count).toBe(1); - - utils.createDirectory(`${testAssetDir}/temp/another-path/`); - - await utils.updateLibrary(admin.accessToken, library.id, { - importPaths: [`${testAssetDirInternal}/temp/another-path/`], - }); - - await utils.scan(admin.accessToken, library.id); - - const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(trashedAsset.isOffline).toBe(true); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([]); - - utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); - utils.removeDirectory(`${testAssetDir}/temp/another-path/`); - }); - - it('should set an asset offline if its file is covered by an exclusion pattern', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { - libraryId: library.id, - originalFileName: 'assetB.png', - }); - expect(assets.count).toBe(1); - - await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/directoryB/**'] }); - - await utils.scan(admin.accessToken, library.id); - - const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - expect(trashedAsset.isTrashed).toBe(true); - expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/directoryB/assetB.png`); - expect(trashedAsset.isOffline).toBe(true); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'assetA.png', - }), - ]); - }); - - it('should not set an asset offline if its file exists, is in an import path, and not covered by an exclusion pattern', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets: assetsBefore } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - expect(assetsBefore.count).toBeGreaterThan(1); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets).toEqual(assetsBefore); - }); - - describe('xmp metadata', async () => { - it('should import metadata from file.xmp', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2000-09-27T12:35:33.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should import metadata from file.ext.xmp', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2000-09-27T12:35:33.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should import metadata in file.ext.xmp before file.xmp if both exist', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); - cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2000-09-27T12:35:33.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should switch from using file.xmp to file.ext.xmp when asset refreshes', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); - unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2010-09-27T12:35:33.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2000-09-27T12:35:33.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should switch from using file metadata to file.ext.xmp metadata when asset refreshes', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2000-09-27T12:35:33.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should switch from using file.ext.xmp to file.xmp when asset refreshes', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); - unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2010-09-27T12:35:33.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should switch from using file.ext.xmp to file metadata', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2010-07-20T17:27:12.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should switch from using file.xmp to file metadata', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2010-07-20T17:27:12.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - }); - - it('should set an offline asset to online if its file exists, is in an import path, and not covered by an exclusion pattern', async () => { - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(1); - - utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - expect(offlineAsset.isTrashed).toBe(true); - expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(offlineAsset.isOffline).toBe(true); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - } - - utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - const backOnlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(backOnlineAsset.isTrashed).toBe(false); - expect(backOnlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(backOnlineAsset.isOffline).toBe(false); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - expect(assets.count).toBe(1); - } - }); - - it('should set a trashed offline asset to online but keep it in trash', async () => { - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(1); - - await utils.deleteAssets(admin.accessToken, [assets.items[0].id]); - - { - const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(trashedAsset.isTrashed).toBe(true); - } - - utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - expect(offlineAsset.isTrashed).toBe(true); - expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(offlineAsset.isOffline).toBe(true); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - } - - utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - const backOnlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(backOnlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(backOnlineAsset.isOffline).toBe(false); - expect(backOnlineAsset.isTrashed).toBe(true); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - } - }); - - it('should not set an offline asset to online if its file exists, is not covered by an exclusion pattern, but is outside of all import paths', async () => { - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - } - - const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(offlineAsset.isTrashed).toBe(true); - expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(offlineAsset.isOffline).toBe(true); - - utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`); - - utils.createDirectory(`${testAssetDir}/temp/another-path/`); - - await utils.updateLibrary(admin.accessToken, library.id, { - importPaths: [`${testAssetDirInternal}/temp/another-path`], - }); - - await utils.scan(admin.accessToken, library.id); - - const stillOfflineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(stillOfflineAsset.isTrashed).toBe(true); - expect(stillOfflineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(stillOfflineAsset.isOffline).toBe(true); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - } - - utils.removeDirectory(`${testAssetDir}/temp/another-path/`); - }); - - it('should not set an offline asset to online if its file exists, is in an import path, but is covered by an exclusion pattern', async () => { - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - await utils.scan(admin.accessToken, library.id); - - { - const { assets: assetsBefore } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - expect(assetsBefore.count).toBe(1); - } - - utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - - const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(offlineAsset.isTrashed).toBe(true); - expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(offlineAsset.isOffline).toBe(true); - - utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`); - - await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); - - await utils.scan(admin.accessToken, library.id); - - const stillOfflineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(stillOfflineAsset.isTrashed).toBe(true); - expect(stillOfflineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(stillOfflineAsset.isOffline).toBe(true); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - } - }); }); describe('POST /libraries/:id/validate', () => { diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index a8aa00c2a3..6faca554a7 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -30,6 +30,7 @@ import { DatabaseRepository } from 'src/repositories/database.repository'; import { EmailRepository } from 'src/repositories/email.repository'; import { EventRepository } from 'src/repositories/event.repository'; import { JobRepository } from 'src/repositories/job.repository'; +import { LibraryRepository } from 'src/repositories/library.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MachineLearningRepository } from 'src/repositories/machine-learning.repository'; import { MapRepository } from 'src/repositories/map.repository'; @@ -406,6 +407,7 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { case AssetEditRepository: case AssetJobRepository: case MemoryRepository: + case LibraryRepository: case NotificationRepository: case OcrRepository: case PartnerRepository: @@ -468,6 +470,7 @@ const newMockRepository = (key: ClassConstructor) => { case AssetJobRepository: case ConfigRepository: case CryptoRepository: + case LibraryRepository: case MemoryRepository: case NotificationRepository: case OcrRepository: diff --git a/server/test/medium/specs/services/library.service.spec.ts b/server/test/medium/specs/services/library.service.spec.ts new file mode 100644 index 0000000000..6e02786c2f --- /dev/null +++ b/server/test/medium/specs/services/library.service.spec.ts @@ -0,0 +1,456 @@ +import { Kysely } from 'kysely'; +import { Stats } from 'node:fs'; +import { join } from 'node:path'; +import { AssetStatus, JobName, JobStatus } from 'src/enum'; +import { AssetJobRepository } from 'src/repositories/asset-job.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { JobRepository } from 'src/repositories/job.repository'; +import { LibraryRepository } from 'src/repositories/library.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; +import { DB } from 'src/schema'; +import { LibraryService } from 'src/services/library.service'; +import { newMediumService, testAssetsDir } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const createFileStats = (mtimeMs: number): Stats => { + return { mtime: new Date(mtimeMs) } as Stats; +}; + +const setup = (db?: Kysely) => { + const context = newMediumService(LibraryService, { + database: db || defaultDatabase, + real: [AssetRepository, AssetJobRepository, CryptoRepository, LibraryRepository], + mock: [StorageRepository, JobRepository, LoggingRepository], + }); + + const jobs = context.ctx.getMock(JobRepository); + jobs.queue.mockResolvedValue(); + jobs.queueAll.mockResolvedValue(); + + return context; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(LibraryService.name, () => { + const importRoot = '/libraries/offline'; + const importPath = `${importRoot}/in-path`; + const excludedPath = `${importRoot}/excluded`; + const outsidePath = '/libraries/outside'; + + const createLibrary = async ( + ctx: ReturnType['ctx'], + options: { importPaths?: string[]; exclusionPatterns?: string[] } = {}, + ) => { + const { user } = await ctx.newUser(); + return ctx.get(LibraryRepository).create({ + ownerId: user.id, + name: 'Medium test library', + importPaths: options.importPaths ?? [importPath], + exclusionPatterns: options.exclusionPatterns ?? [], + }); + }; + + describe('offline asset handling', () => { + it('should set an asset offline if its file is missing', async () => { + const { sut, ctx } = setup(); + const storage = ctx.getMock(StorageRepository); + const assetRepo = ctx.get(AssetRepository); + + const library = await createLibrary(ctx); + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: `${importPath}/offline.png`, + isExternal: true, + isOffline: false, + status: AssetStatus.Active, + }); + + storage.stat.mockRejectedValue(new Error('ENOENT')); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: true })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); + + it('should set an asset offline if its file is not in any import path', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + + const library = await createLibrary(ctx, { importPaths: [importPath] }); + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: `${outsidePath}/offline.png`, + isExternal: true, + isOffline: false, + status: AssetStatus.Active, + }); + + await expect(sut.handleQueueSyncAssets({ id: library.id })).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: true })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); + + it('should set an asset offline if its file is covered by an exclusion pattern', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + + const library = await createLibrary(ctx, { + importPaths: [importRoot], + exclusionPatterns: ['**/excluded/**'], + }); + + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: `${excludedPath}/offline.png`, + isExternal: true, + isOffline: false, + status: AssetStatus.Active, + }); + + await expect(sut.handleQueueSyncAssets({ id: library.id })).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: true })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); + + it('should not set an asset offline if file exists in import path and is not excluded', async () => { + const { sut, ctx } = setup(); + const storage = ctx.getMock(StorageRepository); + const assetRepo = ctx.get(AssetRepository); + + const library = await createLibrary(ctx, { + importPaths: [importRoot], + exclusionPatterns: ['**/excluded/**'], + }); + + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: `${importPath}/online.png`, + isExternal: true, + isOffline: false, + status: AssetStatus.Active, + }); + + storage.stat.mockResolvedValue(createFileStats(1_700_000_000_000)); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: false })); + expect(updated?.deletedAt).toBeNull(); + }); + + it('should set an offline asset to online if its file exists in an import path and is not excluded', async () => { + const { sut, ctx } = setup(); + const storage = ctx.getMock(StorageRepository); + const assetRepo = ctx.get(AssetRepository); + + const library = await createLibrary(ctx, { importPaths: [importPath] }); + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: `${importPath}/offline.png`, + isExternal: true, + isOffline: true, + deletedAt: new Date(), + status: AssetStatus.Active, + }); + + storage.stat.mockResolvedValue(createFileStats(1_700_000_000_000)); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: false })); + expect(updated?.deletedAt).toBeNull(); + }); + + it('should not set an offline asset to online if its file exists in an import path but is excluded', async () => { + const { sut, ctx } = setup(); + const storage = ctx.getMock(StorageRepository); + const assetRepo = ctx.get(AssetRepository); + + const library = await createLibrary(ctx, { + importPaths: [importRoot], + exclusionPatterns: ['**/offline/**'], + }); + + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: `${importRoot}/offline/offline.png`, + isExternal: true, + isOffline: true, + deletedAt: new Date(), + status: AssetStatus.Active, + }); + + storage.stat.mockResolvedValue(createFileStats(1_700_000_000_000)); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: true })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); + + it('should keep an offline asset offline if it is outside import paths', async () => { + const { sut, ctx } = setup(); + const storage = ctx.getMock(StorageRepository); + const assetRepo = ctx.get(AssetRepository); + + const library = await createLibrary(ctx, { importPaths: [importPath] }); + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: `${outsidePath}/offline.png`, + isExternal: true, + isOffline: true, + deletedAt: new Date(), + status: AssetStatus.Active, + }); + + storage.stat.mockResolvedValue(createFileStats(1_700_000_000_000)); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: true })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); + + it('should set a trashed asset offline if its file is missing', async () => { + const { sut, ctx } = setup(); + const storage = ctx.getMock(StorageRepository); + const assetRepo = ctx.get(AssetRepository); + + const library = await createLibrary(ctx, { importPaths: [importPath] }); + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: `${importPath}/offline.png`, + isExternal: true, + isOffline: false, + deletedAt: new Date(), + status: AssetStatus.Trashed, + }); + + storage.stat.mockRejectedValue(new Error('ENOENT')); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: true })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); + + it('should set a trashed offline asset to online but keep it in trash', async () => { + const { sut, ctx } = setup(); + const storage = ctx.getMock(StorageRepository); + const assetRepo = ctx.get(AssetRepository); + + const library = await createLibrary(ctx, { importPaths: [importPath] }); + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: `${importPath}/offline.png`, + isExternal: true, + isOffline: true, + deletedAt: new Date(), + status: AssetStatus.Trashed, + }); + + storage.stat.mockResolvedValue(createFileStats(1_700_000_000_000)); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: false })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); + }); + + describe('xmp scan behavior', () => { + it('should queue sidecar checks for newly imported assets', async () => { + const { sut, ctx } = setup(); + const storage = ctx.getMock(StorageRepository); + const jobs = ctx.getMock(JobRepository); + jobs.queueAll.mockResolvedValue(); + + const library = await createLibrary(ctx, { importPaths: ['/libraries/xmp'] }); + const rawPath = join(testAssetsDir, 'formats/raw/Nikon/D80/glarus.nef'); + + storage.stat.mockResolvedValue(createFileStats(1_700_000_000_000)); + + await expect( + sut.handleSyncFiles({ + libraryId: library.id, + paths: [rawPath], + progressCounter: 1, + }), + ).resolves.toBe(JobStatus.Success); + + expect(jobs.queueAll).toHaveBeenCalledWith([ + expect.objectContaining({ + name: JobName.SidecarCheck, + data: expect.objectContaining({ id: expect.any(String) }), + }), + ]); + }); + + it('should queue sidecar checks for assets whose file changed', async () => { + const { sut, ctx } = setup(); + const storage = ctx.getMock(StorageRepository); + const jobs = ctx.getMock(JobRepository); + jobs.queueAll.mockResolvedValue(); + + const library = await createLibrary(ctx, { importPaths: ['/libraries/xmp'] }); + const rawPath = join(testAssetsDir, 'formats/raw/Nikon/D80/glarus.nef'); + + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: rawPath, + fileModifiedAt: new Date(1_700_000_000_000), + isExternal: true, + isOffline: false, + status: AssetStatus.Active, + }); + + storage.stat.mockResolvedValue(createFileStats(1_700_000_000_001)); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + expect(jobs.queueAll).toHaveBeenCalledWith([ + { + name: JobName.SidecarCheck, + data: { id: asset.id, source: 'upload' }, + }, + ]); + }); + + it('should not queue sidecar checks for unchanged assets', async () => { + const { sut, ctx } = setup(); + const storage = ctx.getMock(StorageRepository); + const jobs = ctx.getMock(JobRepository); + jobs.queueAll.mockResolvedValue(); + + const library = await createLibrary(ctx, { importPaths: ['/libraries/xmp'] }); + const rawPath = join(testAssetsDir, 'formats/raw/Nikon/D80/glarus.nef'); + + const mtimeMs = 1_700_000_000_000; + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: rawPath, + fileModifiedAt: new Date(mtimeMs), + isExternal: true, + isOffline: false, + status: AssetStatus.Active, + }); + + storage.stat.mockResolvedValue(createFileStats(mtimeMs)); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + expect(jobs.queueAll).not.toHaveBeenCalled(); + }); + }); +});