From c63824bcea735d5015e985aaf6da03576ad37ec6 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 19 Aug 2026 11:49:41 -0400 Subject: [PATCH] refactor: library e2e (#30867) --- e2e/src/specs/server/api/library.e2e-spec.ts | 204 +---- .../specs/services/library.service.spec.ts | 721 +++++++++++------- 2 files changed, 441 insertions(+), 484 deletions(-) diff --git a/e2e/src/specs/server/api/library.e2e-spec.ts b/e2e/src/specs/server/api/library.e2e-spec.ts index 5c98332a1e..c3b83cf4e4 100644 --- a/e2e/src/specs/server/api/library.e2e-spec.ts +++ b/e2e/src/specs/server/api/library.e2e-spec.ts @@ -1,24 +1,20 @@ -import { LibraryResponseDto, LoginResponseDto, getAllLibraries } from '@immich/sdk'; -import { cpSync, existsSync } from 'node:fs'; +import { LoginResponseDto } from '@immich/sdk'; +import { cpSync } from 'node:fs'; import { Socket } from 'socket.io-client'; -import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'src/utils'; -import request from 'supertest'; +import { testAssetDir, testAssetDirInternal, utils } from 'src/utils'; import { utimes } from 'utimes'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; describe('/libraries', () => { let admin: LoginResponseDto; - let library: LibraryResponseDto; let websocket: Socket; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup(); await utils.resetAdminConfig(admin.accessToken); - library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); websocket = await utils.connectWebsocket(admin.accessToken); utils.createImageFile(`${testAssetDir}/temp/directoryA/assetA.png`); - utils.createImageFile(`${testAssetDir}/temp/directoryB/assetB.png`); }); afterAll(() => { @@ -30,113 +26,6 @@ describe('/libraries', () => { utils.resetEvents(); }); - describe('POST /libraries', () => { - it('should create an external library with defaults', async () => { - const { status, body } = await request(app) - .post('/libraries') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ ownerId: admin.userId }); - - expect(status).toBe(201); - expect(body).toEqual( - expect.objectContaining({ - ownerId: admin.userId, - name: 'New External Library', - refreshedAt: null, - assetCount: 0, - importPaths: [], - exclusionPatterns: expect.any(Array), - }), - ); - }); - - it('should create an external library with options', async () => { - const { status, body } = await request(app) - .post('/libraries') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ - ownerId: admin.userId, - name: 'My Awesome Library', - importPaths: ['/path/to/import'], - exclusionPatterns: ['**/Raw/**'], - }); - - expect(status).toBe(201); - expect(body).toEqual( - expect.objectContaining({ - name: 'My Awesome Library', - importPaths: ['/path/to/import'], - }), - ); - }); - }); - - describe('PUT /libraries/:id', () => { - it('should change the library name', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ name: 'New Library Name' }); - - expect(status).toBe(200); - expect(body).toEqual( - expect.objectContaining({ - name: 'New Library Name', - }), - ); - }); - - it('should change the import paths', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ importPaths: [testAssetDirInternal] }); - - expect(status).toBe(200); - expect(body).toEqual( - expect.objectContaining({ - importPaths: [testAssetDirInternal], - }), - ); - }); - - it('should change the exclusion pattern', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ exclusionPatterns: ['**/Raw/**'] }); - - expect(status).toBe(200); - expect(body).toEqual( - expect.objectContaining({ - exclusionPatterns: ['**/Raw/**'], - }), - ); - }); - }); - - describe('GET /libraries/:id', () => { - it('should get library by id', async () => { - const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); - - const { status, body } = await request(app) - .get(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual( - expect.objectContaining({ - ownerId: admin.userId, - name: 'New External Library', - refreshedAt: null, - assetCount: 0, - importPaths: [], - exclusionPatterns: expect.any(Array), - }), - ); - }); - }); - describe('POST /libraries/:id/scan', () => { it('should process metadata and thumbnails for external asset', async () => { const library = await utils.createLibrary(admin.accessToken, { @@ -193,42 +82,6 @@ describe('/libraries', () => { utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); }); - it('should not reimport a file with unchanged timestamp', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/reimport`], - }); - - utils.createImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); - await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`); - await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { - libraryId: library.id, - }); - - expect(assets.count).toEqual(1); - - const asset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(asset).toEqual( - expect.objectContaining({ - originalFileName: 'asset.jpg', - exifInfo: expect.not.objectContaining({ - model: 'NIKON D750', - }), - }), - ); - - utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); - }); - it('should not reimport a modified file more than once', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, @@ -270,55 +123,4 @@ describe('/libraries', () => { utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); }); }); - - describe('DELETE /libraries/:id', () => { - it('should delete an external library', async () => { - const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); - - const { status, body } = await request(app) - .delete(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(204); - expect(body).toEqual({}); - - const libraries = await getAllLibraries({ headers: asBearerAuth(admin.accessToken) }); - expect(libraries).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: library.id, - }), - ]), - ); - }); - - it('should delete an external library with assets', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { status, body } = await request(app) - .delete(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(204); - expect(body).toEqual({}); - - const libraries = await getAllLibraries({ headers: asBearerAuth(admin.accessToken) }); - expect(libraries).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: library.id, - }), - ]), - ); - - // ensure no files get deleted - expect(existsSync(`${testAssetDir}/temp/directoryA/assetA.png`)).toBe(true); - expect(existsSync(`${testAssetDir}/temp/directoryB/assetB.png`)).toBe(true); - }); - }); }); diff --git a/server/test/medium/specs/services/library.service.spec.ts b/server/test/medium/specs/services/library.service.spec.ts index 6b1d2fff35..4284e47fee 100644 --- a/server/test/medium/specs/services/library.service.spec.ts +++ b/server/test/medium/specs/services/library.service.spec.ts @@ -1,4 +1,5 @@ import { Kysely } from 'kysely'; +import { existsSync } from 'node:fs'; import { copyFile, mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; @@ -126,37 +127,398 @@ describe(LibraryService.name, () => { await rm(tempDir, { recursive: true, force: true }); }); - describe('offline asset handling', () => { - it('should set an asset offline if its file is missing', async () => { + describe('create', () => { + it('should create an external library with defaults', async () => { const { sut, ctx } = setup(); - const assetRepo = ctx.get(AssetRepository); - const library = await ctx.createLibrary({ importPaths: [importPath] }); - const { asset } = await ctx.newAsset({ - ownerId: library.ownerId, - libraryId: library.id, - // the file is intentionally never created on disk - originalPath: join(importPath, 'offline.png'), - isExternal: true, - isOffline: false, - status: AssetStatus.Active, - }); + const { user } = await ctx.newUser(); - await expect( - sut.handleSyncAssets({ - libraryId: library.id, - importPaths: library.importPaths, - exclusionPatterns: library.exclusionPatterns, - assetIds: [asset.id], - progressCounter: 1, - totalAssets: 1, + await expect(sut.create({ ownerId: user.id })).resolves.toEqual( + expect.objectContaining({ + ownerId: user.id, + name: 'New External Library', + refreshedAt: null, + assetCount: 0, + importPaths: [], + exclusionPatterns: expect.any(Array), }), - ).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 create an external library with options', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + + await expect( + sut.create({ + ownerId: user.id, + name: 'My Awesome Library', + importPaths: [importPath], + exclusionPatterns: ['**/Raw/**'], + }), + ).resolves.toEqual( + expect.objectContaining({ + name: 'My Awesome Library', + importPaths: [importPath], + exclusionPatterns: ['**/Raw/**'], + }), + ); + }); + }); + + describe('get', () => { + it('should get a library by id', async () => { + const { sut, ctx } = setup(); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + + await expect(sut.get(library.id)).resolves.toEqual( + expect.objectContaining({ + id: library.id, + ownerId: library.ownerId, + refreshedAt: null, + assetCount: 0, + importPaths: [importPath], + exclusionPatterns: [], + }), + ); + }); + + it('should throw an error when the library does not exist', async () => { + const { sut } = setup(); + + await expect(sut.get(newUuid())).rejects.toThrow('Library not found'); + }); + }); + + describe('update', () => { + it('should change the library name', async () => { + const { sut, ctx } = setup(); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + + await expect(sut.update(library.id, { name: 'New Library Name' })).resolves.toEqual( + expect.objectContaining({ name: 'New Library Name' }), + ); + }); + + it('should change the import paths', async () => { + const { sut, ctx } = setup(); + await mkdir(importPath, { recursive: true }); + const library = await ctx.createLibrary(); + + await expect(sut.update(library.id, { importPaths: [importPath] })).resolves.toEqual( + expect.objectContaining({ importPaths: [importPath] }), + ); + }); + + it('should reject an import path that does not exist', async () => { + const { sut, ctx } = setup(); + const library = await ctx.createLibrary(); + + await expect(sut.update(library.id, { importPaths: [join(tempDir, 'missing')] })).rejects.toThrow( + 'Invalid import path: Path does not exist (ENOENT)', + ); + }); + + it('should change the exclusion patterns', async () => { + const { sut, ctx } = setup(); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + + await expect(sut.update(library.id, { exclusionPatterns: ['**/Raw/**'] })).resolves.toEqual( + expect.objectContaining({ exclusionPatterns: ['**/Raw/**'] }), + ); + }); + }); + + 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' }], + }); + }); + }); + + describe('handleDeleteLibrary', () => { + it('should delete an empty library', async () => { + const { sut, ctx } = setup(); + const libraryRepo = ctx.get(LibraryRepository); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + + await sut.delete(library.id); + + // the library is hidden right away, but the row survives until the job runs + await expect(libraryRepo.get(library.id)).resolves.toBeUndefined(); + await expect(libraryRepo.get(library.id, true)).resolves.toEqual(expect.objectContaining({ id: library.id })); + expect(ctx.getMock(JobRepository).queue).toHaveBeenCalledWith({ + name: JobName.LibraryDelete, + data: { id: library.id }, + }); + + await expect(sut.handleDeleteLibrary({ id: library.id })).resolves.toBe(JobStatus.Success); + + await expect(libraryRepo.get(library.id, true)).resolves.toBeUndefined(); + }); + + it('should delete a library with assets without deleting the files', async () => { + const { sut, ctx } = setup(); + const libraryRepo = ctx.get(LibraryRepository); + const jobs = ctx.getMock(JobRepository); + const assetA = await createFile(join(importPath, 'assetA.png')); + const assetB = await createFile(join(importPath, 'assetB.png')); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); + + await sut.delete(library.id); + await expect(libraryRepo.get(library.id)).resolves.toBeUndefined(); + + jobs.queueAll.mockClear(); + await expect(sut.handleDeleteLibrary({ id: library.id })).resolves.toBe(JobStatus.Success); + + // the assets are trashed and queued for removal, so the library row stays until they are gone + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([]); + await expect(libraryRepo.get(library.id, true)).resolves.toEqual(expect.objectContaining({ id: library.id })); + // deleteOnDisk is what keeps the files of an external library, so assert it explicitly + expect(jobs.queueAll).toHaveBeenCalledWith([ + { name: JobName.AssetDelete, data: { id: expect.any(String), deleteOnDisk: false } }, + { name: JobName.AssetDelete, data: { id: expect.any(String), deleteOnDisk: false } }, + ]); + + // the asset delete jobs are only queued here, so this just proves the handler itself unlinks nothing + expect(existsSync(assetA)).toBe(true); + expect(existsSync(assetB)).toBe(true); + }); + }); + + describe('queueScan', () => { + 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()); + }); + + 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('handleQueueSyncAssets', () => { 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); @@ -200,6 +562,38 @@ describe(LibraryService.name, () => { expect(updated).toEqual(expect.objectContaining({ isOffline: true })); expect(updated?.deletedAt).toBeInstanceOf(Date); }); + }); + + describe('handleSyncAssets', () => { + it('should set an asset offline if its file is missing', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + // the file is intentionally never created on disk + originalPath: join(importPath, 'offline.png'), + isExternal: true, + isOffline: false, + status: AssetStatus.Active, + }); + + 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 not set an asset offline if file exists in import path and is not excluded', async () => { const { sut, ctx } = setup(); @@ -389,30 +783,6 @@ describe(LibraryService.name, () => { 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 jobs = ctx.getMock(JobRepository); - const library = await ctx.createLibrary({ importPaths: [importPath] }); - const rawPath = await copyTestAsset('formats/raw/Nikon/D80/glarus.nef', join(importPath, 'glarus.nef')); - - 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(); @@ -481,242 +851,27 @@ describe(LibraryService.name, () => { }); }); - describe('scanning', () => { - it('should import a new asset', async () => { - const { ctx } = setup(); - - const assetPath = await createFile(join(importPath, 'assetA.png')); + describe('handleSyncFiles', () => { + it('should queue sidecar checks for newly imported assets', async () => { + const { sut, ctx } = setup(); + const jobs = ctx.getMock(JobRepository); const library = await ctx.createLibrary({ importPaths: [importPath] }); + const rawPath = await copyTestAsset('formats/raw/Nikon/D80/glarus.nef', join(importPath, 'glarus.nef')); - await ctx.scan(library.id); + await expect( + sut.handleSyncFiles({ + libraryId: library.id, + paths: [rawPath], + progressCounter: 1, + }), + ).resolves.toBe(JobStatus.Success); - 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' }], - }); + expect(jobs.queueAll).toHaveBeenCalledWith([ + expect.objectContaining({ + name: JobName.SidecarCheck, + data: expect.objectContaining({ id: expect.any(String) }), + }), + ]); }); }); });