From d2759ec41e6e4f59247641f5444fd8702536820e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 19 Aug 2026 10:36:35 -0400 Subject: [PATCH] refactor: library e2e (#30865) --- e2e/src/specs/server/api/library.e2e-spec.ts | 418 ------------------ .../specs/services/library.service.spec.ts | 371 +++++++++++++--- 2 files changed, 319 insertions(+), 470 deletions(-) diff --git a/e2e/src/specs/server/api/library.e2e-spec.ts b/e2e/src/specs/server/api/library.e2e-spec.ts index b508a52f3..5c98332a1 100644 --- a/e2e/src/specs/server/api/library.e2e-spec.ts +++ b/e2e/src/specs/server/api/library.e2e-spec.ts @@ -138,29 +138,6 @@ describe('/libraries', () => { }); describe('POST /libraries/:id/scan', () => { - it('should import new asset when scanning external library', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/directoryA`], - }); - - const { status } = await request(app) - .post(`/libraries/${library.id}/scan`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - - const { assets } = await utils.searchAssets(admin.accessToken, { - originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`, - libraryId: library.id, - }); - expect(assets.count).toBe(1); - }); - it('should process metadata and thumbnails for external asset', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, @@ -180,337 +157,6 @@ describe('/libraries', () => { expect(asset.thumbhash).not.toBe(null); }); - it('should scan external library with exclusion pattern', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - exclusionPatterns: ['**/directoryA/**'], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(1); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining('directoryB/assetB.png') }), - ]), - ); - }); - - it('should scan external library with multiple exclusion patterns', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(0); - - expect(assets.items).toEqual([]); - }); - - it('should remove assets covered by a new 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 }); - - expect(assets.count).toBe(2); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining('directoryA/assetA.png') }), - expect.objectContaining({ originalPath: expect.stringContaining('directoryB/assetB.png') }), - ]), - ); - } - - await utils.updateLibrary(admin.accessToken, library.id, { - exclusionPatterns: ['**/directoryA/**'], - }); - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(1); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining('directoryB/assetB.png') }), - ]), - ); - } - - await utils.updateLibrary(admin.accessToken, library.id, { - exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'], - }); - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(0); - - expect(assets.items).toEqual([]); - } - }); - - it('should scan multiple import paths', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(2); - expect(assets.items.find((asset) => asset.originalPath.includes('directoryA'))).toBeDefined(); - expect(assets.items.find((asset) => asset.originalPath.includes('directoryB'))).toBeDefined(); - }); - - it('should scan multiple import paths with commas', async () => { - // https://github.com/immich-app/immich/issues/10699 - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/folder, a`, `${testAssetDirInternal}/temp/folder, b`], - }); - - utils.createImageFile(`${testAssetDir}/temp/folder, a/assetA.png`); - utils.createImageFile(`${testAssetDir}/temp/folder, b/assetB.png`); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(2); - expect(assets.items.find((asset) => asset.originalPath.includes('folder, a'))).toBeDefined(); - expect(assets.items.find((asset) => asset.originalPath.includes('folder, b'))).toBeDefined(); - - utils.removeImageFile(`${testAssetDir}/temp/folder, a/assetA.png`); - utils.removeImageFile(`${testAssetDir}/temp/folder, b/assetB.png`); - }); - - it('should scan multiple import paths with braces', async () => { - // https://github.com/immich-app/immich/issues/10699 - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/folder{ a`, `${testAssetDirInternal}/temp/folder} b`], - }); - - utils.createImageFile(`${testAssetDir}/temp/folder{ a/assetA.png`); - utils.createImageFile(`${testAssetDir}/temp/folder} b/assetB.png`); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(2); - expect(assets.items.find((asset) => asset.originalPath.includes('folder{ a'))).toBeDefined(); - expect(assets.items.find((asset) => asset.originalPath.includes('folder} b'))).toBeDefined(); - - utils.removeImageFile(`${testAssetDir}/temp/folder{ a/assetA.png`); - utils.removeImageFile(`${testAssetDir}/temp/folder} b/assetB.png`); - }); - - const annoyingChars = [ - "'", - '"', - '`', - '*', - '{', - '}', - ',', - '(', - ')', - '[', - ']', - '?', - '!', - '@', - '#', - '$', - '%', - '^', - '&', - '=', - '+', - '~', - '|', - '<', - '>', - ';', - ':', - '/', // We never got backslashes to work - ]; - - it.each(annoyingChars)('should scan multiple import paths with %s', async (char) => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/folder${char}1`, `${testAssetDirInternal}/temp/folder${char}2`], - }); - - utils.createImageFile(`${testAssetDir}/temp/folder${char}1/asset1.png`); - utils.createImageFile(`${testAssetDir}/temp/folder${char}2/asset2.png`); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining(`folder${char}1/asset1.png`) }), - expect.objectContaining({ originalPath: expect.stringContaining(`folder${char}2/asset2.png`) }), - ]), - ); - - utils.removeImageFile(`${testAssetDir}/temp/folder${char}1/asset1.png`); - utils.removeImageFile(`${testAssetDir}/temp/folder${char}2/asset2.png`); - }); - - it('should respect exclusion patterns when using multiple import paths', async () => { - // https://github.com/immich-app/immich/issues/17121 - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/exclusion/`, `${testAssetDirInternal}/temp/exclusion2/`], - }); - - const excludedFolder = `Raw`; - - utils.createImageFile(`${testAssetDir}/temp/exclusion/asset1.png`); - utils.createImageFile(`${testAssetDir}/temp/exclusion/${excludedFolder}/asset2.png`); - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - expect.objectContaining({ originalPath: expect.stringContaining(`${excludedFolder}/asset2.png`) }), - ]), - ); - } - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - expect.objectContaining({ originalPath: expect.stringContaining(`${excludedFolder}/asset2.png`) }), - ]), - ); - } - - await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: [`**/${excludedFolder}/**`] }); - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - ]); - } - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - ]); - } - - utils.removeImageFile(`${testAssetDir}/temp/exclusion/asset1.png`); - utils.removeImageFile(`${testAssetDir}/temp/exclusion/${excludedFolder}/asset2.png`); - }); - - const annoyingExclusionPatterns = ['@', '#', '$', '%', '^', '&', '=']; - - it.each(annoyingExclusionPatterns)('should support exclusion patterns with %s', async (char) => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/exclusion/`], - }); - - const excludedFolder = `${char}folder`; - - utils.createImageFile(`${testAssetDir}/temp/exclusion/asset1.png`); - utils.createImageFile(`${testAssetDir}/temp/exclusion/${excludedFolder}/asset2.png`); - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - expect.objectContaining({ originalPath: expect.stringContaining(`${excludedFolder}/asset2.png`) }), - ]), - ); - } - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - expect.objectContaining({ originalPath: expect.stringContaining(`${excludedFolder}/asset2.png`) }), - ]), - ); - } - - await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: [`**/${excludedFolder}/**`] }); - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - ]); - } - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - ]); - } - - utils.removeImageFile(`${testAssetDir}/temp/exclusion/asset1.png`); - utils.removeImageFile(`${testAssetDir}/temp/exclusion/${excludedFolder}/asset2.png`); - }); - it('should reimport a modified file', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, @@ -625,70 +271,6 @@ describe('/libraries', () => { }); }); - describe('POST /libraries/:id/validate', () => { - it('should pass with no import paths', async () => { - const response = await utils.validateLibrary(admin.accessToken, library.id, { importPaths: [] }); - expect(response.importPaths).toEqual([]); - }); - - it('should fail if path does not exist', async () => { - const pathToTest = `${testAssetDirInternal}/does/not/exist`; - - const response = await utils.validateLibrary(admin.accessToken, library.id, { - importPaths: [pathToTest], - }); - - expect(response.importPaths?.length).toEqual(1); - const pathResponse = response?.importPaths?.at(0); - - expect(pathResponse).toEqual({ - importPath: pathToTest, - isValid: false, - message: `Path does not exist (ENOENT)`, - }); - }); - - it("should fail if path isn't absolute", async () => { - const pathToTest = `relative/path`; - - const cwd = process.cwd(); - // Create directory in cwd - utils.createDirectory(`${cwd}/${pathToTest}`); - - const response = await utils.validateLibrary(admin.accessToken, library.id, { - importPaths: [pathToTest], - }); - - utils.removeDirectory(`${cwd}/${pathToTest}`); - - expect(response.importPaths?.length).toEqual(1); - const pathResponse = response?.importPaths?.at(0); - - expect(pathResponse).toEqual({ - importPath: pathToTest, - isValid: false, - message: expect.stringMatching('Import path must be absolute, try /usr/src/app/relative/path'), - }); - }); - - it('should fail if path is a file', async () => { - const pathToTest = `${testAssetDirInternal}/albums/nature/el_torcal_rocks.jpg`; - - const response = await utils.validateLibrary(admin.accessToken, library.id, { - importPaths: [pathToTest], - }); - - expect(response.importPaths?.length).toEqual(1); - const pathResponse = response?.importPaths?.at(0); - - expect(pathResponse).toEqual({ - importPath: pathToTest, - isValid: false, - message: `Not a directory`, - }); - }); - }); - describe('DELETE /libraries/:id', () => { it('should delete an external library', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); diff --git a/server/test/medium/specs/services/library.service.spec.ts b/server/test/medium/specs/services/library.service.spec.ts index e55186906..6b1d2fff3 100644 --- a/server/test/medium/specs/services/library.service.spec.ts +++ b/server/test/medium/specs/services/library.service.spec.ts @@ -1,7 +1,8 @@ import { Kysely } from 'kysely'; import { copyFile, mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; +import { StorageCore } from 'src/cores/storage.core'; import { AssetStatus, JobName, JobStatus } from 'src/enum'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; @@ -13,9 +14,13 @@ 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 { MediumTestContext, testAssetsDir } from 'test/medium.factory'; +import { newUuid } from 'test/small.factory'; import { getKyselyDB } from 'test/utils'; +// `validateImportPath` checks candidate paths against the media location +StorageCore.setMediaLocation('/photos'); + let defaultDatabase: Kysely; const fileModifiedAt = new Date(1_700_000_000_000); @@ -34,20 +39,68 @@ const copyTestAsset = async (source: string, filePath: string, modifiedAt: Date return filePath; }; +class LibraryTestContext extends MediumTestContext { + constructor(database: Kysely) { + super(LibraryService, { + database, + real: [AssetRepository, AssetJobRepository, CryptoRepository, LibraryRepository, StorageRepository], + mock: [EventRepository, JobRepository, LoggingRepository], + }); + + const jobs = this.getMock(JobRepository); + jobs.queue.mockResolvedValue(); + jobs.queueAll.mockResolvedValue(); + + this.getMock(EventRepository).emit.mockResolvedValue(); + } + + async createLibrary(options: { importPaths?: string[]; exclusionPatterns?: string[] } = {}) { + const { user } = await this.newUser(); + return this.get(LibraryRepository).create({ + ownerId: user.id, + name: 'Medium test library', + importPaths: options.importPaths ?? [], + exclusionPatterns: options.exclusionPatterns ?? [], + }); + } + + /** Runs a full library scan, manually routing jobs to their handlers */ + async scan(libraryId: string) { + const jobs = this.getMock(JobRepository); + + jobs.queue.mockClear(); + await this.sut.handleQueueSyncFiles({ id: libraryId }); + for (const [job] of jobs.queue.mock.calls) { + if (job.name === JobName.LibrarySyncFiles) { + await this.sut.handleSyncFiles(job.data); + } + } + + jobs.queue.mockClear(); + await this.sut.handleQueueSyncAssets({ id: libraryId }); + for (const [job] of jobs.queue.mock.calls) { + if (job.name === JobName.LibrarySyncAssets) { + await this.sut.handleSyncAssets(job.data); + } + } + } + + /** The paths a library scan left visible, i.e. neither offline nor trashed */ + async getAssetPaths(libraryId: string) { + const assets = await this.database + .selectFrom('asset') + .select('originalPath') + .where('libraryId', '=', libraryId) + .where('deletedAt', 'is', null) + .execute(); + + return assets.map(({ originalPath }) => originalPath).sort(); + } +} + const setup = (db?: Kysely) => { - const context = newMediumService(LibraryService, { - database: db || defaultDatabase, - real: [AssetRepository, AssetJobRepository, CryptoRepository, LibraryRepository, StorageRepository], - mock: [EventRepository, JobRepository, LoggingRepository], - }); - - const jobs = context.ctx.getMock(JobRepository); - jobs.queue.mockResolvedValue(); - jobs.queueAll.mockResolvedValue(); - - context.ctx.getMock(EventRepository).emit.mockResolvedValue(); - - return context; + const ctx = new LibraryTestContext(db || defaultDatabase); + return { sut: ctx.sut, ctx }; }; beforeAll(async () => { @@ -73,25 +126,11 @@ describe(LibraryService.name, () => { await rm(tempDir, { recursive: true, force: true }); }); - 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 assetRepo = ctx.get(AssetRepository); - - const library = await createLibrary(ctx); + const library = await ctx.createLibrary({ importPaths: [importPath] }); const { asset } = await ctx.newAsset({ ownerId: library.ownerId, libraryId: library.id, @@ -121,8 +160,7 @@ describe(LibraryService.name, () => { 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 library = await ctx.createLibrary({ importPaths: [importPath] }); const { asset } = await ctx.newAsset({ ownerId: library.ownerId, libraryId: library.id, @@ -142,8 +180,7 @@ describe(LibraryService.name, () => { 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, { + const library = await ctx.createLibrary({ importPaths: [importRoot], exclusionPatterns: ['**/excluded/**'], }); @@ -167,8 +204,7 @@ describe(LibraryService.name, () => { it('should not set an asset offline if file exists in import path and is not excluded', async () => { const { sut, ctx } = setup(); const assetRepo = ctx.get(AssetRepository); - - const library = await createLibrary(ctx, { + const library = await ctx.createLibrary({ importPaths: [importRoot], exclusionPatterns: ['**/excluded/**'], }); @@ -202,8 +238,7 @@ describe(LibraryService.name, () => { 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 assetRepo = ctx.get(AssetRepository); - - const library = await createLibrary(ctx, { importPaths: [importPath] }); + const library = await ctx.createLibrary({ importPaths: [importPath] }); const { asset } = await ctx.newAsset({ ownerId: library.ownerId, libraryId: library.id, @@ -233,8 +268,7 @@ describe(LibraryService.name, () => { 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 assetRepo = ctx.get(AssetRepository); - - const library = await createLibrary(ctx, { + const library = await ctx.createLibrary({ importPaths: [importRoot], exclusionPatterns: ['**/excluded/**'], }); @@ -268,8 +302,7 @@ describe(LibraryService.name, () => { it('should keep an offline asset offline if it is outside import paths', async () => { const { sut, ctx } = setup(); const assetRepo = ctx.get(AssetRepository); - - const library = await createLibrary(ctx, { importPaths: [importPath] }); + const library = await ctx.createLibrary({ importPaths: [importPath] }); const { asset } = await ctx.newAsset({ ownerId: library.ownerId, libraryId: library.id, @@ -299,8 +332,7 @@ describe(LibraryService.name, () => { it('should set a trashed asset offline if its file is missing', async () => { const { sut, ctx } = setup(); const assetRepo = ctx.get(AssetRepository); - - const library = await createLibrary(ctx, { importPaths: [importPath] }); + const library = await ctx.createLibrary({ importPaths: [importPath] }); const { asset } = await ctx.newAsset({ ownerId: library.ownerId, libraryId: library.id, @@ -331,8 +363,7 @@ describe(LibraryService.name, () => { it('should set a trashed offline asset to online but keep it in trash', async () => { const { sut, ctx } = setup(); const assetRepo = ctx.get(AssetRepository); - - const library = await createLibrary(ctx, { importPaths: [importPath] }); + const library = await ctx.createLibrary({ importPaths: [importPath] }); const { asset } = await ctx.newAsset({ ownerId: library.ownerId, libraryId: library.id, @@ -364,8 +395,7 @@ describe(LibraryService.name, () => { it('should queue sidecar checks for newly imported assets', async () => { const { sut, ctx } = setup(); const jobs = ctx.getMock(JobRepository); - - const library = await createLibrary(ctx, { importPaths: [importPath] }); + const library = await ctx.createLibrary({ importPaths: [importPath] }); const rawPath = await copyTestAsset('formats/raw/Nikon/D80/glarus.nef', join(importPath, 'glarus.nef')); await expect( @@ -387,8 +417,7 @@ describe(LibraryService.name, () => { it('should queue sidecar checks for assets whose file changed', async () => { const { sut, ctx } = setup(); const jobs = ctx.getMock(JobRepository); - - const library = await createLibrary(ctx, { importPaths: [importPath] }); + const library = await ctx.createLibrary({ importPaths: [importPath] }); const rawPath = await copyTestAsset('formats/raw/Nikon/D80/glarus.nef', join(importPath, 'glarus.nef')); const { asset } = await ctx.newAsset({ @@ -424,8 +453,7 @@ describe(LibraryService.name, () => { it('should not queue sidecar checks for unchanged assets', async () => { const { sut, ctx } = setup(); const jobs = ctx.getMock(JobRepository); - - const library = await createLibrary(ctx, { importPaths: [importPath] }); + const library = await ctx.createLibrary({ importPaths: [importPath] }); const rawPath = await copyTestAsset('formats/raw/Nikon/D80/glarus.nef', join(importPath, 'glarus.nef')); const { asset } = await ctx.newAsset({ @@ -452,4 +480,243 @@ describe(LibraryService.name, () => { expect(jobs.queueAll).not.toHaveBeenCalled(); }); }); + + describe('scanning', () => { + it('should import a new asset', async () => { + const { ctx } = setup(); + + const assetPath = await createFile(join(importPath, 'assetA.png')); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetPath]); + }); + + it('should scan multiple import paths', async () => { + const { ctx } = setup(); + + const directoryA = join(importRoot, 'directoryA'); + const directoryB = join(importRoot, 'directoryB'); + const assetA = await createFile(join(directoryA, 'assetA.png')); + const assetB = await createFile(join(directoryB, 'assetB.png')); + const library = await ctx.createLibrary({ importPaths: [directoryA, directoryB] }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); + }); + + // https://github.com/immich-app/immich/issues/10699 + it('should scan multiple import paths with commas', async () => { + const { ctx } = setup(); + + const folderA = join(importRoot, 'folder, a'); + const folderB = join(importRoot, 'folder, b'); + const assetA = await createFile(join(folderA, 'assetA.png')); + const assetB = await createFile(join(folderB, 'assetB.png')); + const library = await ctx.createLibrary({ importPaths: [folderA, folderB] }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); + }); + + // https://github.com/immich-app/immich/issues/10699 + it('should scan multiple import paths with braces', async () => { + const { ctx } = setup(); + + const folderA = join(importRoot, 'folder{ a'); + const folderB = join(importRoot, 'folder} b'); + const assetA = await createFile(join(folderA, 'assetA.png')); + const assetB = await createFile(join(folderB, 'assetB.png')); + const library = await ctx.createLibrary({ importPaths: [folderA, folderB] }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); + }); + + // We never got backslashes to work + const annoyingChars = [ + "'", + '"', + '`', + '*', + '{', + '}', + ',', + '(', + ')', + '[', + ']', + '?', + '!', + '@', + '#', + '$', + '%', + '^', + '&', + '=', + '+', + '~', + '|', + '<', + '>', + ';', + ':', + '/', + ]; + + it.each(annoyingChars)('should scan multiple import paths with %s', async (char) => { + const { ctx } = setup(); + + const folderA = join(importRoot, `folder${char}1`); + const folderB = join(importRoot, `folder${char}2`); + const asset1 = await createFile(join(folderA, 'asset1.png')); + const asset2 = await createFile(join(folderB, 'asset2.png')); + const library = await ctx.createLibrary({ importPaths: [folderA, folderB] }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort()); + }); + }); + + describe('exclusion patterns', () => { + it('should not import assets covered by an exclusion pattern', async () => { + const { ctx } = setup(); + + await createFile(join(importRoot, 'directoryA/assetA.png')); + const assetB = await createFile(join(importRoot, 'directoryB/assetB.png')); + const library = await ctx.createLibrary({ + importPaths: [importRoot], + exclusionPatterns: ['**/directoryA/**'], + }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetB]); + }); + + it('should not import assets covered by multiple exclusion patterns', async () => { + const { ctx } = setup(); + + await createFile(join(importRoot, 'directoryA/assetA.png')); + await createFile(join(importRoot, 'directoryB/assetB.png')); + const library = await ctx.createLibrary({ + importPaths: [importRoot], + exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'], + }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([]); + }); + + it('should offline assets covered by a new exclusion pattern', async () => { + const { sut, ctx } = setup(); + + const assetA = await createFile(join(importRoot, 'directoryA/assetA.png')); + const assetB = await createFile(join(importRoot, 'directoryB/assetB.png')); + const library = await ctx.createLibrary({ importPaths: [importRoot] }); + + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); + + await sut.update(library.id, { exclusionPatterns: ['**/directoryA/**'] }); + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetB]); + + await sut.update(library.id, { exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'] }); + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([]); + }); + + // https://github.com/immich-app/immich/issues/17121 + it('should respect exclusion patterns when using multiple import paths', async () => { + const { sut, ctx } = setup(); + + const inPath = join(importRoot, 'exclusion'); + // a second import path that never exists on disk, as in the original report + const missingPath = join(importRoot, 'exclusion2'); + const asset1 = await createFile(join(inPath, 'asset1.png')); + const asset2 = await createFile(join(inPath, 'Raw/asset2.png')); + const library = await ctx.createLibrary({ importPaths: [`${inPath}/`, `${missingPath}/`] }); + + // scanning twice must be idempotent + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort()); + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort()); + + await sut.update(library.id, { exclusionPatterns: ['**/Raw/**'] }); + + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]); + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]); + }); + + const annoyingExclusionPatterns = ['@', '#', '$', '%', '^', '&', '=']; + + it.each(annoyingExclusionPatterns)('should support exclusion patterns with %s', async (char) => { + const { sut, ctx } = setup(); + + const inPath = join(importRoot, 'exclusion'); + const excludedFolder = `${char}folder`; + const asset1 = await createFile(join(inPath, 'asset1.png')); + const asset2 = await createFile(join(inPath, excludedFolder, 'asset2.png')); + const library = await ctx.createLibrary({ importPaths: [inPath] }); + + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort()); + + await sut.update(library.id, { exclusionPatterns: [`**/${excludedFolder}/**`] }); + + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]); + }); + }); + + describe('validate', () => { + it('should pass with no import paths', async () => { + const { sut } = setup(); + + await expect(sut.validate(newUuid(), { importPaths: [] })).resolves.toEqual({ importPaths: [] }); + }); + + it('should fail if the path does not exist', async () => { + const { sut } = setup(); + const missingPath = join(tempDir, 'does/not/exist'); + + await expect(sut.validate(newUuid(), { importPaths: [missingPath] })).resolves.toEqual({ + importPaths: [{ importPath: missingPath, isValid: false, message: 'Path does not exist (ENOENT)' }], + }); + }); + + it('should fail if the path is not absolute', async () => { + const { sut } = setup(); + + await expect(sut.validate(newUuid(), { importPaths: ['relative/path'] })).resolves.toEqual({ + importPaths: [ + { + importPath: 'relative/path', + isValid: false, + message: `Import path must be absolute, try ${resolve('relative/path')}`, + }, + ], + }); + }); + + it('should fail if the path is a file', async () => { + const { sut } = setup(); + const filePath = await createFile(join(importPath, 'assetA.png')); + + await expect(sut.validate(newUuid(), { importPaths: [filePath] })).resolves.toEqual({ + importPaths: [{ importPath: filePath, isValid: false, message: 'Not a directory' }], + }); + }); + }); });