diff --git a/server/src/database.ts b/server/src/database.ts index 371448efe4..4ec76fa9d2 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -10,6 +10,20 @@ export type AuthUser = { quotaSizeInBytes: number | null; }; +export type Library = { + id: string; + ownerId: string; + createdAt: Date; + updatedAt: Date; + updateId: string; + name: string; + importPaths: string[]; + exclusionPatterns: string[]; + deletedAt: Date | null; + refreshedAt: Date | null; + assets?: Asset[]; +}; + export type AuthApiKey = { id: string; permissions: Permission[]; diff --git a/server/src/dtos/library.dto.ts b/server/src/dtos/library.dto.ts index 7fb363dd9a..a0aaace13d 100644 --- a/server/src/dtos/library.dto.ts +++ b/server/src/dtos/library.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { ArrayMaxSize, ArrayUnique, IsNotEmpty, IsString } from 'class-validator'; -import { LibraryEntity } from 'src/entities/library.entity'; +import { Library } from 'src/database'; import { Optional, ValidateUUID } from 'src/validation'; export class CreateLibraryDto { @@ -120,7 +120,7 @@ export class LibraryStatsResponseDto { usage = 0; } -export function mapLibrary(entity: LibraryEntity): LibraryResponseDto { +export function mapLibrary(entity: Library): LibraryResponseDto { let assetCount = 0; if (entity.assets) { assetCount = entity.assets.length; diff --git a/server/src/entities/library.entity.ts b/server/src/entities/library.entity.ts index a594fd83ad..0471661fca 100644 --- a/server/src/entities/library.entity.ts +++ b/server/src/entities/library.entity.ts @@ -26,7 +26,7 @@ export class LibraryEntity { assets!: AssetEntity[]; @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) - owner!: UserEntity; + owner?: UserEntity; @Column() ownerId!: string; diff --git a/server/src/queries/library.repository.sql b/server/src/queries/library.repository.sql index b0b20fd8a2..43500a8748 100644 --- a/server/src/queries/library.repository.sql +++ b/server/src/queries/library.repository.sql @@ -2,34 +2,7 @@ -- LibraryRepository.get select - "libraries".*, - ( - select - to_json(obj) - from - ( - select - "users"."id", - "users"."email", - "users"."createdAt", - "users"."profileImagePath", - "users"."isAdmin", - "users"."shouldChangePassword", - "users"."deletedAt", - "users"."oauthId", - "users"."updatedAt", - "users"."storageLabel", - "users"."name", - "users"."quotaSizeInBytes", - "users"."quotaUsageInBytes", - "users"."status", - "users"."profileChangedAt" - from - "users" - where - "users"."id" = "libraries"."ownerId" - ) as obj - ) as "owner" + "libraries".* from "libraries" where @@ -38,34 +11,7 @@ where -- LibraryRepository.getAll select - "libraries".*, - ( - select - to_json(obj) - from - ( - select - "users"."id", - "users"."email", - "users"."createdAt", - "users"."profileImagePath", - "users"."isAdmin", - "users"."shouldChangePassword", - "users"."deletedAt", - "users"."oauthId", - "users"."updatedAt", - "users"."storageLabel", - "users"."name", - "users"."quotaSizeInBytes", - "users"."quotaUsageInBytes", - "users"."status", - "users"."profileChangedAt" - from - "users" - where - "users"."id" = "libraries"."ownerId" - ) as obj - ) as "owner" + "libraries".* from "libraries" where @@ -75,34 +21,7 @@ order by -- LibraryRepository.getAllDeleted select - "libraries".*, - ( - select - to_json(obj) - from - ( - select - "users"."id", - "users"."email", - "users"."createdAt", - "users"."profileImagePath", - "users"."isAdmin", - "users"."shouldChangePassword", - "users"."deletedAt", - "users"."oauthId", - "users"."updatedAt", - "users"."storageLabel", - "users"."name", - "users"."quotaSizeInBytes", - "users"."quotaUsageInBytes", - "users"."status", - "users"."profileChangedAt" - from - "users" - where - "users"."id" = "libraries"."ownerId" - ) as obj - ) as "owner" + "libraries".* from "libraries" where diff --git a/server/src/repositories/library.repository.ts b/server/src/repositories/library.repository.ts index bd2cb0c1df..efa6e880d1 100644 --- a/server/src/repositories/library.repository.ts +++ b/server/src/repositories/library.repository.ts @@ -1,31 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, sql, Updateable } from 'kysely'; -import { jsonObjectFrom } from 'kysely/helpers/postgres'; +import { Insertable, Kysely, sql, Updateable } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { DB, Libraries } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { LibraryStatsResponseDto } from 'src/dtos/library.dto'; -import { LibraryEntity } from 'src/entities/library.entity'; import { AssetType } from 'src/enum'; -const userColumns = [ - 'users.id', - 'users.email', - 'users.createdAt', - 'users.profileImagePath', - 'users.isAdmin', - 'users.shouldChangePassword', - 'users.deletedAt', - 'users.oauthId', - 'users.updatedAt', - 'users.storageLabel', - 'users.name', - 'users.quotaSizeInBytes', - 'users.quotaUsageInBytes', - 'users.status', - 'users.profileChangedAt', -] as const; - export enum AssetSyncResult { DO_NOTHING, UPDATE, @@ -33,72 +13,59 @@ export enum AssetSyncResult { CHECK_OFFLINE, } -const withOwner = (eb: ExpressionBuilder) => { - return jsonObjectFrom(eb.selectFrom('users').whereRef('users.id', '=', 'libraries.ownerId').select(userColumns)).as( - 'owner', - ); -}; - @Injectable() export class LibraryRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID] }) - get(id: string, withDeleted = false): Promise { + get(id: string, withDeleted = false) { return this.db .selectFrom('libraries') .selectAll('libraries') - .select(withOwner) .where('libraries.id', '=', id) .$if(!withDeleted, (qb) => qb.where('libraries.deletedAt', 'is', null)) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } @GenerateSql({ params: [] }) - getAll(withDeleted = false): Promise { + getAll(withDeleted = false) { return this.db .selectFrom('libraries') .selectAll('libraries') - .select(withOwner) .orderBy('createdAt', 'asc') .$if(!withDeleted, (qb) => qb.where('libraries.deletedAt', 'is', null)) - .execute() as unknown as Promise; + .execute(); } @GenerateSql() - getAllDeleted(): Promise { + getAllDeleted() { return this.db .selectFrom('libraries') .selectAll('libraries') - .select(withOwner) .where('libraries.deletedAt', 'is not', null) .orderBy('createdAt', 'asc') - .execute() as unknown as Promise; + .execute(); } - create(library: Insertable): Promise { - return this.db - .insertInto('libraries') - .values(library) - .returningAll() - .executeTakeFirstOrThrow() as Promise; + create(library: Insertable) { + return this.db.insertInto('libraries').values(library).returningAll().executeTakeFirstOrThrow(); } - async delete(id: string): Promise { + async delete(id: string) { await this.db.deleteFrom('libraries').where('libraries.id', '=', id).execute(); } - async softDelete(id: string): Promise { + async softDelete(id: string) { await this.db.updateTable('libraries').set({ deletedAt: new Date() }).where('libraries.id', '=', id).execute(); } - update(id: string, library: Updateable): Promise { + update(id: string, library: Updateable) { return this.db .updateTable('libraries') .set(library) .where('libraries.id', '=', id) .returningAll() - .executeTakeFirstOrThrow() as Promise; + .executeTakeFirstOrThrow(); } @GenerateSql({ params: [DummyValue.UUID] }) diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 5c89b09561..5d6dfb21f5 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -3,16 +3,14 @@ import { Stats } from 'node:fs'; import { defaults, SystemConfig } from 'src/config'; import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants'; import { mapLibrary } from 'src/dtos/library.dto'; -import { UserEntity } from 'src/entities/user.entity'; import { AssetType, ImmichWorker, JobName, JobStatus } from 'src/enum'; import { LibraryService } from 'src/services/library.service'; import { ILibraryBulkIdsJob, ILibraryFileJob } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { libraryStub } from 'test/fixtures/library.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { userStub } from 'test/fixtures/user.stub'; import { makeMockWatcher } from 'test/repositories/storage.repository.mock'; +import { factory, newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; import { vitest } from 'vitest'; @@ -59,26 +57,19 @@ describe(LibraryService.name, () => { }); it('should initialize watcher for all external libraries', async () => { - mocks.library.getAll.mockResolvedValue([ - libraryStub.externalLibraryWithImportPaths1, - libraryStub.externalLibraryWithImportPaths2, - ]); + const library1 = factory.library({ importPaths: ['/foo', '/bar'] }); + const library2 = factory.library({ importPaths: ['/xyz', '/asdf'] }); + + mocks.library.getAll.mockResolvedValue([library1, library2]); mocks.library.get.mockImplementation((id) => - Promise.resolve( - [libraryStub.externalLibraryWithImportPaths1, libraryStub.externalLibraryWithImportPaths2].find( - (library) => library.id === id, - ), - ), + Promise.resolve([library1, library2].find((library) => library.id === id)), ); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); expect(mocks.storage.watch.mock.calls).toEqual( - expect.arrayContaining([ - (libraryStub.externalLibrary1.importPaths, expect.anything()), - (libraryStub.externalLibrary2.importPaths, expect.anything()), - ]), + expect.arrayContaining([(library1.importPaths, expect.anything()), (library2.importPaths, expect.anything())]), ); }); @@ -153,33 +144,36 @@ describe(LibraryService.name, () => { describe('handleQueueSyncFiles', () => { it('should queue refresh of a new asset', async () => { - mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.get.mockResolvedValue(library); mocks.storage.walk.mockImplementation(mockWalk); mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats); mocks.storage.checkFileExists.mockResolvedValue(true); mocks.asset.filterNewExternalAssetPaths.mockResolvedValue(['/data/user1/photo.jpg']); - await sut.handleQueueSyncFiles({ id: libraryStub.externalLibraryWithImportPaths1.id }); + await sut.handleQueueSyncFiles({ id: library.id }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.LIBRARY_SYNC_FILES, data: { - libraryId: libraryStub.externalLibraryWithImportPaths1.id, + libraryId: library.id, paths: ['/data/user1/photo.jpg'], progressCounter: 1, }, }); }); - it("should fail when library can't be found", async () => { - await expect(sut.handleQueueSyncFiles({ id: libraryStub.externalLibraryWithImportPaths1.id })).resolves.toBe( - JobStatus.SKIPPED, - ); + it('should fail when library is not found', async () => { + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + await expect(sut.handleQueueSyncFiles({ id: library.id })).resolves.toBe(JobStatus.SKIPPED); }); it('should ignore import paths that do not exist', async () => { + const library = factory.library({ importPaths: ['/foo', '/bar'] }); mocks.storage.stat.mockImplementation((path): Promise => { - if (path === libraryStub.externalLibraryWithImportPaths1.importPaths[0]) { + if (path === library.importPaths[0]) { const error = { code: 'ENOENT' } as any; throw error; } @@ -190,12 +184,12 @@ describe(LibraryService.name, () => { mocks.storage.checkFileExists.mockResolvedValue(true); - mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.get.mockResolvedValue(library); - await sut.handleQueueSyncFiles({ id: libraryStub.externalLibraryWithImportPaths1.id }); + await sut.handleQueueSyncFiles({ id: library.id }); expect(mocks.storage.walk).toHaveBeenCalledWith({ - pathsToCrawl: [libraryStub.externalLibraryWithImportPaths1.importPaths[1]], + pathsToCrawl: [library.importPaths[1]], exclusionPatterns: [], includeHidden: false, take: JOBS_LIBRARY_PAGINATION_SIZE, @@ -205,18 +199,20 @@ describe(LibraryService.name, () => { describe('handleQueueSyncFiles', () => { it('should queue refresh of a new asset', async () => { - mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.get.mockResolvedValue(library); mocks.storage.walk.mockImplementation(mockWalk); mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats); mocks.storage.checkFileExists.mockResolvedValue(true); mocks.asset.filterNewExternalAssetPaths.mockResolvedValue(['/data/user1/photo.jpg']); - await sut.handleQueueSyncFiles({ id: libraryStub.externalLibraryWithImportPaths1.id }); + await sut.handleQueueSyncFiles({ id: library.id }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.LIBRARY_SYNC_FILES, data: { - libraryId: libraryStub.externalLibraryWithImportPaths1.id, + libraryId: library.id, paths: ['/data/user1/photo.jpg'], progressCounter: 1, }, @@ -224,12 +220,16 @@ describe(LibraryService.name, () => { }); it("should fail when library can't be found", async () => { - await expect(sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SKIPPED); + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + await expect(sut.handleQueueSyncFiles({ id: library.id })).resolves.toBe(JobStatus.SKIPPED); }); it('should ignore import paths that do not exist', async () => { + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + mocks.storage.stat.mockImplementation((path): Promise => { - if (path === libraryStub.externalLibraryWithImportPaths1.importPaths[0]) { + if (path === library.importPaths[0]) { const error = { code: 'ENOENT' } as any; throw error; } @@ -240,12 +240,12 @@ describe(LibraryService.name, () => { mocks.storage.checkFileExists.mockResolvedValue(true); - mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.get.mockResolvedValue(library); - await sut.handleQueueSyncFiles({ id: libraryStub.externalLibraryWithImportPaths1.id }); + await sut.handleQueueSyncFiles({ id: library.id }); expect(mocks.storage.walk).toHaveBeenCalledWith({ - pathsToCrawl: [libraryStub.externalLibraryWithImportPaths1.importPaths[1]], + pathsToCrawl: [library.importPaths[1]], exclusionPatterns: [], includeHidden: false, take: JOBS_LIBRARY_PAGINATION_SIZE, @@ -255,51 +255,57 @@ describe(LibraryService.name, () => { describe('handleQueueSyncAssets', () => { it('should call the offline check', async () => { - mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + const library = factory.library(); + + mocks.library.get.mockResolvedValue(library); mocks.storage.walk.mockImplementation(async function* generator() {}); mocks.asset.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false }); mocks.asset.getLibraryAssetCount.mockResolvedValue(1); mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(1) }); - const response = await sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id }); + const response = await sut.handleQueueSyncAssets({ id: library.id }); expect(response).toBe(JobStatus.SUCCESS); expect(mocks.asset.detectOfflineExternalAssets).toHaveBeenCalledWith( - libraryStub.externalLibrary1.id, - libraryStub.externalLibrary1.importPaths, - libraryStub.externalLibrary1.exclusionPatterns, + library.id, + library.importPaths, + library.exclusionPatterns, ); }); it('should skip an empty library', async () => { - mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + const library = factory.library(); + + mocks.library.get.mockResolvedValue(library); mocks.storage.walk.mockImplementation(async function* generator() {}); mocks.asset.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false }); mocks.asset.getLibraryAssetCount.mockResolvedValue(0); mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(1) }); - const response = await sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id }); + const response = await sut.handleQueueSyncAssets({ id: library.id }); expect(response).toBe(JobStatus.SUCCESS); expect(mocks.asset.detectOfflineExternalAssets).not.toHaveBeenCalled(); }); it('should queue asset sync', async () => { - mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.get.mockResolvedValue(library); mocks.storage.walk.mockImplementation(async function* generator() {}); mocks.library.streamAssetIds.mockReturnValue(makeStream([assetStub.external])); mocks.asset.getLibraryAssetCount.mockResolvedValue(1); mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(0) }); mocks.library.streamAssetIds.mockReturnValue(makeStream([assetStub.external])); - const response = await sut.handleQueueSyncAssets({ id: libraryStub.externalLibraryWithImportPaths1.id }); + const response = await sut.handleQueueSyncAssets({ id: library.id }); expect(mocks.job.queue).toBeCalledWith({ name: JobName.LIBRARY_SYNC_ASSETS, data: { - libraryId: libraryStub.externalLibraryWithImportPaths1.id, - importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths, - exclusionPatterns: libraryStub.externalLibraryWithImportPaths1.exclusionPatterns, + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, assetIds: [assetStub.external.id], progressCounter: 1, totalAssets: 1, @@ -308,14 +314,14 @@ describe(LibraryService.name, () => { expect(response).toBe(JobStatus.SUCCESS); expect(mocks.asset.detectOfflineExternalAssets).toHaveBeenCalledWith( - libraryStub.externalLibraryWithImportPaths1.id, - libraryStub.externalLibraryWithImportPaths1.importPaths, - libraryStub.externalLibraryWithImportPaths1.exclusionPatterns, + library.id, + library.importPaths, + library.exclusionPatterns, ); }); it("should fail if library can't be found", async () => { - await expect(sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleQueueSyncAssets({ id: newUuid() })).resolves.toBe(JobStatus.SKIPPED); }); }); @@ -323,7 +329,7 @@ describe(LibraryService.name, () => { it('should offline assets no longer on disk', async () => { const mockAssetJob: ILibraryBulkIdsJob = { assetIds: [assetStub.external.id], - libraryId: libraryStub.externalLibrary1.id, + libraryId: newUuid(), importPaths: ['/'], exclusionPatterns: [], totalAssets: 1, @@ -344,7 +350,7 @@ describe(LibraryService.name, () => { it('should set assets deleted from disk as offline', async () => { const mockAssetJob: ILibraryBulkIdsJob = { assetIds: [assetStub.external.id], - libraryId: libraryStub.externalLibrary1.id, + libraryId: newUuid(), importPaths: ['/data/user2'], exclusionPatterns: [], totalAssets: 1, @@ -365,7 +371,7 @@ describe(LibraryService.name, () => { it('should do nothing with offline assets deleted from disk', async () => { const mockAssetJob: ILibraryBulkIdsJob = { assetIds: [assetStub.trashedOffline.id], - libraryId: libraryStub.externalLibrary1.id, + libraryId: newUuid(), importPaths: ['/data/user2'], exclusionPatterns: [], totalAssets: 1, @@ -383,7 +389,7 @@ describe(LibraryService.name, () => { it('should un-trash an asset previously marked as offline', async () => { const mockAssetJob: ILibraryBulkIdsJob = { assetIds: [assetStub.trashedOffline.id], - libraryId: libraryStub.externalLibrary1.id, + libraryId: newUuid(), importPaths: ['/original/'], exclusionPatterns: [], totalAssets: 1, @@ -404,7 +410,7 @@ describe(LibraryService.name, () => { it('should do nothing with offline asset if covered by exclusion pattern', async () => { const mockAssetJob: ILibraryBulkIdsJob = { assetIds: [assetStub.trashedOffline.id], - libraryId: libraryStub.externalLibrary1.id, + libraryId: newUuid(), importPaths: ['/original/'], exclusionPatterns: ['**/path.jpg'], totalAssets: 1, @@ -424,7 +430,7 @@ describe(LibraryService.name, () => { it('should do nothing with offline asset if not in import path', async () => { const mockAssetJob: ILibraryBulkIdsJob = { assetIds: [assetStub.trashedOffline.id], - libraryId: libraryStub.externalLibrary1.id, + libraryId: newUuid(), importPaths: ['/import/'], exclusionPatterns: [], totalAssets: 1, @@ -444,7 +450,7 @@ describe(LibraryService.name, () => { it('should do nothing with unchanged online assets', async () => { const mockAssetJob: ILibraryBulkIdsJob = { assetIds: [assetStub.external.id], - libraryId: libraryStub.externalLibrary1.id, + libraryId: newUuid(), importPaths: ['/'], exclusionPatterns: [], totalAssets: 1, @@ -462,7 +468,7 @@ describe(LibraryService.name, () => { it('should not touch fileCreatedAt when un-trashing an asset previously marked as offline', async () => { const mockAssetJob: ILibraryBulkIdsJob = { assetIds: [assetStub.trashedOffline.id], - libraryId: libraryStub.externalLibrary1.id, + libraryId: newUuid(), importPaths: ['/'], exclusionPatterns: [], totalAssets: 1, @@ -485,7 +491,7 @@ describe(LibraryService.name, () => { it('should update with online assets that have changed', async () => { const mockAssetJob: ILibraryBulkIdsJob = { assetIds: [assetStub.external.id], - libraryId: libraryStub.externalLibrary1.id, + libraryId: newUuid(), importPaths: ['/'], exclusionPatterns: [], totalAssets: 1, @@ -516,11 +522,7 @@ describe(LibraryService.name, () => { }); describe('handleSyncFiles', () => { - let mockUser: UserEntity; - beforeEach(() => { - mockUser = userStub.admin; - mocks.storage.stat.mockResolvedValue({ size: 100, mtime: new Date('2023-01-01'), @@ -529,54 +531,50 @@ describe(LibraryService.name, () => { }); it('should import a new asset', async () => { + const library = factory.library(); + const mockLibraryJob: ILibraryFileJob = { - libraryId: libraryStub.externalLibrary1.id, + libraryId: library.id, paths: ['/data/user1/photo.jpg'], }; mocks.asset.createAll.mockResolvedValue([assetStub.image]); - mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.get.mockResolvedValue(library); await expect(sut.handleSyncFiles(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - expect(mocks.asset.createAll.mock.calls).toEqual([ - [ - [ - expect.objectContaining({ - ownerId: mockUser.id, - libraryId: libraryStub.externalLibrary1.id, - originalPath: '/data/user1/photo.jpg', - deviceId: 'Library Import', - type: AssetType.IMAGE, - originalFileName: 'photo.jpg', - isExternal: true, - }), - ], - ], + expect(mocks.asset.createAll).toHaveBeenCalledWith([ + expect.objectContaining({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: '/data/user1/photo.jpg', + deviceId: 'Library Import', + type: AssetType.IMAGE, + originalFileName: 'photo.jpg', + isExternal: true, + }), ]); - expect(mocks.job.queueAll.mock.calls).toEqual([ - [ - [ - { - name: JobName.SIDECAR_DISCOVERY, - data: { - id: assetStub.image.id, - source: 'upload', - }, - }, - ], - ], + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { + name: JobName.SIDECAR_DISCOVERY, + data: { + id: assetStub.image.id, + source: 'upload', + }, + }, ]); }); it('should not import an asset to a soft deleted library', async () => { + const library = factory.library({ deletedAt: new Date() }); + const mockLibraryJob: ILibraryFileJob = { - libraryId: libraryStub.externalLibrary1.id, + libraryId: library.id, paths: ['/data/user1/photo.jpg'], }; - mocks.library.get.mockResolvedValue({ ...libraryStub.externalLibrary1, deletedAt: new Date() }); + mocks.library.get.mockResolvedValue(library); await expect(sut.handleSyncFiles(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); @@ -586,43 +584,45 @@ describe(LibraryService.name, () => { describe('delete', () => { it('should delete a library', async () => { + const library = factory.library(); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.get.mockResolvedValue(library); - await sut.delete(libraryStub.externalLibrary1.id); + await sut.delete(library.id); - expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.LIBRARY_DELETE, - data: { id: libraryStub.externalLibrary1.id }, - }); - - expect(mocks.library.softDelete).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.LIBRARY_DELETE, data: { id: library.id } }); + expect(mocks.library.softDelete).toHaveBeenCalledWith(library.id); }); it('should allow an external library to be deleted', async () => { - mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + const library = factory.library(); - await sut.delete(libraryStub.externalLibrary1.id); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.library.get.mockResolvedValue(library); + + await sut.delete(library.id); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.LIBRARY_DELETE, - data: { id: libraryStub.externalLibrary1.id }, + data: { id: library.id }, }); - expect(mocks.library.softDelete).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); + expect(mocks.library.softDelete).toHaveBeenCalledWith(library.id); }); it('should unwatch an external library when deleted', async () => { + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + mocks.library.get.mockResolvedValue(library); + mocks.library.getAll.mockResolvedValue([library]); const mockClose = vitest.fn(); mocks.storage.watch.mockImplementation(makeMockWatcher({ close: mockClose })); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); - await sut.delete(libraryStub.externalLibraryWithImportPaths1.id); + await sut.delete(library.id); expect(mockClose).toHaveBeenCalled(); }); @@ -630,52 +630,62 @@ describe(LibraryService.name, () => { describe('get', () => { it('should return a library', async () => { - mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); - await expect(sut.get(libraryStub.externalLibrary1.id)).resolves.toEqual( + const library = factory.library(); + + mocks.library.get.mockResolvedValue(library); + + await expect(sut.get(library.id)).resolves.toEqual( expect.objectContaining({ - id: libraryStub.externalLibrary1.id, - name: libraryStub.externalLibrary1.name, - ownerId: libraryStub.externalLibrary1.ownerId, + id: library.id, + name: library.name, + ownerId: library.ownerId, }), ); - expect(mocks.library.get).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); + expect(mocks.library.get).toHaveBeenCalledWith(library.id); }); it('should throw an error when a library is not found', async () => { - await expect(sut.get(libraryStub.externalLibrary1.id)).rejects.toBeInstanceOf(BadRequestException); - expect(mocks.library.get).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); + const library = factory.library(); + + await expect(sut.get(library.id)).rejects.toBeInstanceOf(BadRequestException); + + expect(mocks.library.get).toHaveBeenCalledWith(library.id); }); }); describe('getStatistics', () => { it('should return library statistics', async () => { + const library = factory.library(); + mocks.library.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 }); - await expect(sut.getStatistics(libraryStub.externalLibrary1.id)).resolves.toEqual({ + await expect(sut.getStatistics(library.id)).resolves.toEqual({ photos: 10, videos: 0, total: 10, usage: 1337, }); - expect(mocks.library.getStatistics).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); + expect(mocks.library.getStatistics).toHaveBeenCalledWith(library.id); }); }); describe('create', () => { describe('external library', () => { it('should create with default settings', async () => { - mocks.library.create.mockResolvedValue(libraryStub.externalLibrary1); + const library = factory.library(); + + mocks.library.create.mockResolvedValue(library); await expect(sut.create({ ownerId: authStub.admin.user.id })).resolves.toEqual( expect.objectContaining({ - id: libraryStub.externalLibrary1.id, - name: libraryStub.externalLibrary1.name, - ownerId: libraryStub.externalLibrary1.ownerId, + id: library.id, + name: library.name, + ownerId: library.ownerId, assetCount: 0, importPaths: [], exclusionPatterns: [], - createdAt: libraryStub.externalLibrary1.createdAt, - updatedAt: libraryStub.externalLibrary1.updatedAt, + createdAt: library.createdAt, + updatedAt: library.updatedAt, refreshedAt: null, }), ); @@ -690,17 +700,20 @@ describe(LibraryService.name, () => { }); it('should create with name', async () => { - mocks.library.create.mockResolvedValue(libraryStub.externalLibrary1); + const library = factory.library(); + + mocks.library.create.mockResolvedValue(library); + await expect(sut.create({ ownerId: authStub.admin.user.id, name: 'My Awesome Library' })).resolves.toEqual( expect.objectContaining({ - id: libraryStub.externalLibrary1.id, - name: libraryStub.externalLibrary1.name, - ownerId: libraryStub.externalLibrary1.ownerId, + id: library.id, + name: library.name, + ownerId: library.ownerId, assetCount: 0, importPaths: [], exclusionPatterns: [], - createdAt: libraryStub.externalLibrary1.createdAt, - updatedAt: libraryStub.externalLibrary1.updatedAt, + createdAt: library.createdAt, + updatedAt: library.updatedAt, refreshedAt: null, }), ); @@ -715,7 +728,9 @@ describe(LibraryService.name, () => { }); it('should create with import paths', async () => { - mocks.library.create.mockResolvedValue(libraryStub.externalLibrary1); + const library = factory.library(); + + mocks.library.create.mockResolvedValue(library); await expect( sut.create({ ownerId: authStub.admin.user.id, @@ -723,14 +738,14 @@ describe(LibraryService.name, () => { }), ).resolves.toEqual( expect.objectContaining({ - id: libraryStub.externalLibrary1.id, - name: libraryStub.externalLibrary1.name, - ownerId: libraryStub.externalLibrary1.ownerId, + id: library.id, + name: library.name, + ownerId: library.ownerId, assetCount: 0, importPaths: [], exclusionPatterns: [], - createdAt: libraryStub.externalLibrary1.createdAt, - updatedAt: libraryStub.externalLibrary1.updatedAt, + createdAt: library.createdAt, + updatedAt: library.updatedAt, refreshedAt: null, }), ); @@ -745,19 +760,20 @@ describe(LibraryService.name, () => { }); it('should create watched with import paths', async () => { - mocks.library.create.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.create.mockResolvedValue(library); + mocks.library.get.mockResolvedValue(library); mocks.library.getAll.mockResolvedValue([]); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); - await sut.create({ - ownerId: authStub.admin.user.id, - importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths, - }); + await sut.create({ ownerId: authStub.admin.user.id, importPaths: library.importPaths }); }); it('should create with exclusion patterns', async () => { - mocks.library.create.mockResolvedValue(libraryStub.externalLibrary1); + const library = factory.library(); + + mocks.library.create.mockResolvedValue(library); await expect( sut.create({ ownerId: authStub.admin.user.id, @@ -765,14 +781,14 @@ describe(LibraryService.name, () => { }), ).resolves.toEqual( expect.objectContaining({ - id: libraryStub.externalLibrary1.id, - name: libraryStub.externalLibrary1.name, - ownerId: libraryStub.externalLibrary1.ownerId, + id: library.id, + name: library.name, + ownerId: library.ownerId, assetCount: 0, importPaths: [], exclusionPatterns: [], - createdAt: libraryStub.externalLibrary1.createdAt, - updatedAt: libraryStub.externalLibrary1.updatedAt, + createdAt: library.createdAt, + updatedAt: library.updatedAt, refreshedAt: null, }), ); @@ -790,19 +806,25 @@ describe(LibraryService.name, () => { describe('getAll', () => { it('should get all libraries', async () => { - mocks.library.getAll.mockResolvedValue([libraryStub.externalLibrary1]); - await expect(sut.getAll()).resolves.toEqual([expect.objectContaining({ id: libraryStub.externalLibrary1.id })]); + const library = factory.library(); + + mocks.library.getAll.mockResolvedValue([library]); + + await expect(sut.getAll()).resolves.toEqual([expect.objectContaining({ id: library.id })]); }); }); describe('handleQueueCleanup', () => { it('should queue cleanup jobs', async () => { - mocks.library.getAllDeleted.mockResolvedValue([libraryStub.externalLibrary1, libraryStub.externalLibrary2]); + const library1 = factory.library({ deletedAt: new Date() }); + const library2 = factory.library({ deletedAt: new Date() }); + + mocks.library.getAllDeleted.mockResolvedValue([library1, library2]); await expect(sut.handleQueueCleanup()).resolves.toBe(JobStatus.SUCCESS); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.LIBRARY_DELETE, data: { id: libraryStub.externalLibrary1.id } }, - { name: JobName.LIBRARY_DELETE, data: { id: libraryStub.externalLibrary2.id } }, + { name: JobName.LIBRARY_DELETE, data: { id: library1.id } }, + { name: JobName.LIBRARY_DELETE, data: { id: library2.id } }, ]); }); }); @@ -815,24 +837,27 @@ describe(LibraryService.name, () => { }); it('should throw an error if an import path is invalid', async () => { - mocks.library.update.mockResolvedValue(libraryStub.externalLibrary1); - mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + const library = factory.library(); + + mocks.library.update.mockResolvedValue(library); + mocks.library.get.mockResolvedValue(library); await expect(sut.update('library-id', { importPaths: ['foo/bar'] })).rejects.toBeInstanceOf(BadRequestException); + expect(mocks.library.update).not.toHaveBeenCalled(); }); it('should update library', async () => { - mocks.library.update.mockResolvedValue(libraryStub.externalLibrary1); - mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + const library = factory.library(); + + mocks.library.update.mockResolvedValue(library); + mocks.library.get.mockResolvedValue(library); mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats); mocks.storage.checkFileExists.mockResolvedValue(true); const cwd = process.cwd(); - await expect(sut.update('library-id', { importPaths: [`${cwd}/foo/bar`] })).resolves.toEqual( - mapLibrary(libraryStub.externalLibrary1), - ); + await expect(sut.update('library-id', { importPaths: [`${cwd}/foo/bar`] })).resolves.toEqual(mapLibrary(library)); expect(mocks.library.update).toHaveBeenCalledWith( 'library-id', expect.objectContaining({ importPaths: [`${cwd}/foo/bar`] }), @@ -857,7 +882,9 @@ describe(LibraryService.name, () => { }); it('should not watch library', async () => { - mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.getAll.mockResolvedValue([library]); await sut.watchAll(); @@ -872,33 +899,35 @@ describe(LibraryService.name, () => { }); it('should watch library', async () => { - mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.get.mockResolvedValue(library); + mocks.library.getAll.mockResolvedValue([library]); await sut.watchAll(); - expect(mocks.storage.watch).toHaveBeenCalledWith( - libraryStub.externalLibraryWithImportPaths1.importPaths, - expect.anything(), - expect.anything(), - ); + expect(mocks.storage.watch).toHaveBeenCalledWith(library.importPaths, expect.anything(), expect.anything()); }); it('should watch and unwatch library', async () => { - mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.getAll.mockResolvedValue([library]); + mocks.library.get.mockResolvedValue(library); const mockClose = vitest.fn(); mocks.storage.watch.mockImplementation(makeMockWatcher({ close: mockClose })); await sut.watchAll(); - await sut.unwatch(libraryStub.externalLibraryWithImportPaths1.id); + await sut.unwatch(library.id); expect(mockClose).toHaveBeenCalled(); }); it('should not watch library without import paths', async () => { - mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); - mocks.library.getAll.mockResolvedValue([libraryStub.externalLibrary1]); + const library = factory.library(); + + mocks.library.get.mockResolvedValue(library); + mocks.library.getAll.mockResolvedValue([library]); await sut.watchAll(); @@ -906,8 +935,10 @@ describe(LibraryService.name, () => { }); it('should handle a new file event', async () => { - mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.get.mockResolvedValue(library); + mocks.library.getAll.mockResolvedValue([library]); mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); mocks.storage.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.jpg' }] })); @@ -916,15 +947,17 @@ describe(LibraryService.name, () => { expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.LIBRARY_SYNC_FILES, data: { - libraryId: libraryStub.externalLibraryWithImportPaths1.id, + libraryId: library.id, paths: ['/foo/photo.jpg'], }, }); }); it('should handle a file change event', async () => { - mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.get.mockResolvedValue(library); + mocks.library.getAll.mockResolvedValue([library]); mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); mocks.storage.watch.mockImplementation( makeMockWatcher({ items: [{ event: 'change', value: '/foo/photo.jpg' }] }), @@ -935,15 +968,17 @@ describe(LibraryService.name, () => { expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.LIBRARY_SYNC_FILES, data: { - libraryId: libraryStub.externalLibraryWithImportPaths1.id, + libraryId: library.id, paths: ['/foo/photo.jpg'], }, }); }); it('should handle a file unlink event', async () => { - mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.get.mockResolvedValue(library); + mocks.library.getAll.mockResolvedValue([library]); mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); mocks.storage.watch.mockImplementation( makeMockWatcher({ items: [{ event: 'unlink', value: assetStub.image.originalPath }] }), @@ -954,16 +989,18 @@ describe(LibraryService.name, () => { expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.LIBRARY_ASSET_REMOVAL, data: { - libraryId: libraryStub.externalLibraryWithImportPaths1.id, + libraryId: library.id, paths: [assetStub.image.originalPath], }, }); }); it('should handle an error event', async () => { - mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.get.mockResolvedValue(library); mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external); - mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + mocks.library.getAll.mockResolvedValue([library]); mocks.storage.watch.mockImplementation( makeMockWatcher({ items: [{ event: 'error', value: 'Error!' }], @@ -974,8 +1011,10 @@ describe(LibraryService.name, () => { }); it('should not import a file with unknown extension', async () => { - mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.get.mockResolvedValue(library); + mocks.library.getAll.mockResolvedValue([library]); mocks.storage.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.xyz' }] })); await sut.watchAll(); @@ -984,8 +1023,10 @@ describe(LibraryService.name, () => { }); it('should ignore excluded paths', async () => { - mocks.library.get.mockResolvedValue(libraryStub.patternPath); - mocks.library.getAll.mockResolvedValue([libraryStub.patternPath]); + const library = factory.library({ importPaths: ['/xyz', '/asdf'], exclusionPatterns: ['**/dir1/**'] }); + + mocks.library.get.mockResolvedValue(library); + mocks.library.getAll.mockResolvedValue([library]); mocks.storage.watch.mockImplementation( makeMockWatcher({ items: [{ event: 'add', value: '/dir1/photo.txt' }] }), ); @@ -996,8 +1037,13 @@ describe(LibraryService.name, () => { }); it('should ignore excluded paths without case sensitivity', async () => { - mocks.library.get.mockResolvedValue(libraryStub.patternPath); - mocks.library.getAll.mockResolvedValue([libraryStub.patternPath]); + const library = factory.library({ + importPaths: ['/xyz', '/asdf'], + exclusionPatterns: ['**/dir1/**'], + }); + + mocks.library.get.mockResolvedValue(library); + mocks.library.getAll.mockResolvedValue([library]); mocks.storage.watch.mockImplementation( makeMockWatcher({ items: [{ event: 'add', value: '/DIR1/photo.txt' }] }), ); @@ -1011,19 +1057,12 @@ describe(LibraryService.name, () => { describe('teardown', () => { it('should tear down all watchers', async () => { - mocks.library.getAll.mockResolvedValue([ - libraryStub.externalLibraryWithImportPaths1, - libraryStub.externalLibraryWithImportPaths2, - ]); - - mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + const library1 = factory.library({ importPaths: ['/foo', '/bar'] }); + const library2 = factory.library({ importPaths: ['/xyz', '/asdf'] }); + mocks.library.getAll.mockResolvedValue([library1, library2]); mocks.library.get.mockImplementation((id) => - Promise.resolve( - [libraryStub.externalLibraryWithImportPaths1, libraryStub.externalLibraryWithImportPaths2].find( - (library) => library.id === id, - ), - ), + Promise.resolve([library1, library2].find((library) => library.id === id)), ); const mockClose = vitest.fn(); @@ -1038,71 +1077,62 @@ describe(LibraryService.name, () => { describe('handleDeleteLibrary', () => { it('should delete an empty library', async () => { - mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + const library = factory.library(); + + mocks.library.get.mockResolvedValue(library); mocks.library.streamAssetIds.mockReturnValue(makeStream([])); - await expect(sut.handleDeleteLibrary({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleDeleteLibrary({ id: library.id })).resolves.toBe(JobStatus.SUCCESS); + expect(mocks.library.delete).toHaveBeenCalled(); }); it('should delete all assets in a library', async () => { - mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + const library = factory.library(); + + mocks.library.get.mockResolvedValue(library); mocks.library.streamAssetIds.mockReturnValue(makeStream([assetStub.image1])); mocks.asset.getById.mockResolvedValue(assetStub.image1); - await expect(sut.handleDeleteLibrary({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleDeleteLibrary({ id: library.id })).resolves.toBe(JobStatus.SUCCESS); }); }); describe('queueScan', () => { it('should queue a library scan', async () => { - mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + const library = factory.library(); - await sut.queueScan(libraryStub.externalLibrary1.id); + mocks.library.get.mockResolvedValue(library); - expect(mocks.job.queue.mock.calls).toEqual([ - [ - { - name: JobName.LIBRARY_QUEUE_SYNC_FILES, - data: { - id: libraryStub.externalLibrary1.id, - }, - }, - ], - [ - { - name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, - data: { - id: libraryStub.externalLibrary1.id, - }, - }, - ], - ]); + await sut.queueScan(library.id); + + expect(mocks.job.queue).toHaveBeenCalledTimes(2); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.LIBRARY_QUEUE_SYNC_FILES, + data: { id: library.id }, + }); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, + data: { id: library.id }, + }); }); }); describe('handleQueueAllScan', () => { it('should queue the refresh job', async () => { - mocks.library.getAll.mockResolvedValue([libraryStub.externalLibrary1]); + const library = factory.library(); + + mocks.library.getAll.mockResolvedValue([library]); await expect(sut.handleQueueScanAll()).resolves.toBe(JobStatus.SUCCESS); - expect(mocks.job.queue.mock.calls).toEqual([ - [ - { - name: JobName.LIBRARY_QUEUE_CLEANUP, - data: {}, - }, - ], - ]); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.LIBRARY_QUEUE_CLEANUP, + data: {}, + }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { - name: JobName.LIBRARY_QUEUE_SYNC_FILES, - data: { - id: libraryStub.externalLibrary1.id, - }, - }, + { name: JobName.LIBRARY_QUEUE_SYNC_FILES, data: { id: library.id } }, ]); }); }); @@ -1212,28 +1242,26 @@ describe(LibraryService.name, () => { }); it('should detect when import path is in immich media folder', async () => { + const importPaths = ['upload/thumbs', `${process.cwd()}/xyz`, 'upload/library']; + const library = factory.library({ importPaths }); + mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats); - const cwd = process.cwd(); - const validImport = `${cwd}/${libraryStub.hasImmichPaths.importPaths[1]}`; - mocks.storage.checkFileExists.mockImplementation((importPath) => Promise.resolve(importPath === validImport)); + mocks.storage.checkFileExists.mockImplementation((importPath) => Promise.resolve(importPath === importPaths[1])); - const pathStubs = libraryStub.hasImmichPaths.importPaths; - const importPaths = [pathStubs[0], validImport, pathStubs[2]]; - - await expect(sut.validate('library-id', { importPaths })).resolves.toEqual({ + await expect(sut.validate(library.id, { importPaths })).resolves.toEqual({ importPaths: [ { - importPath: libraryStub.hasImmichPaths.importPaths[0], + importPath: importPaths[0], isValid: false, message: 'Cannot use media upload folder for external libraries', }, { - importPath: validImport, + importPath: importPaths[1], isValid: true, }, { - importPath: libraryStub.hasImmichPaths.importPaths[2], + importPath: importPaths[2], isValid: false, message: 'Cannot use media upload folder for external libraries', }, diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index b7a8686b1f..c0902dddb3 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -6,7 +6,6 @@ import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { StorageAsset } from 'src/types'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; -import { libraryStub } from 'test/fixtures/library.stub'; import { userStub } from 'test/fixtures/user.stub'; const previewFile: AssetFileEntity = { @@ -396,7 +395,6 @@ export const assetStub = { livePhotoVideo: null, livePhotoVideoId: null, libraryId: 'library-id', - library: libraryStub.externalLibrary1, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -751,7 +749,6 @@ export const assetStub = { livePhotoVideo: null, livePhotoVideoId: null, libraryId: 'library-id', - library: libraryStub.externalLibrary1, tags: [], sharedLinks: [], originalFileName: 'photo.jpg', diff --git a/server/test/fixtures/library.stub.ts b/server/test/fixtures/library.stub.ts deleted file mode 100644 index bb40035dcc..0000000000 --- a/server/test/fixtures/library.stub.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { LibraryEntity } from 'src/entities/library.entity'; -import { userStub } from 'test/fixtures/user.stub'; - -export const libraryStub = { - externalLibrary1: Object.freeze({ - id: 'library-id', - name: 'test_library', - assets: [], - owner: userStub.admin, - ownerId: 'admin_id', - importPaths: [], - createdAt: new Date('2023-01-01'), - updatedAt: new Date('2023-01-01'), - refreshedAt: null, - exclusionPatterns: [], - }), - externalLibrary2: Object.freeze({ - id: 'library-id2', - name: 'test_library2', - assets: [], - owner: userStub.admin, - ownerId: 'admin_id', - importPaths: [], - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2022-01-01'), - refreshedAt: null, - exclusionPatterns: [], - }), - externalLibraryWithImportPaths1: Object.freeze({ - id: 'library-id-with-paths1', - name: 'library-with-import-paths1', - assets: [], - owner: userStub.admin, - ownerId: 'admin_id', - importPaths: ['/foo', '/bar'], - createdAt: new Date('2023-01-01'), - updatedAt: new Date('2023-01-01'), - refreshedAt: null, - exclusionPatterns: [], - }), - externalLibraryWithImportPaths2: Object.freeze({ - id: 'library-id-with-paths2', - name: 'library-with-import-paths2', - assets: [], - owner: userStub.admin, - ownerId: 'admin_id', - importPaths: ['/xyz', '/asdf'], - createdAt: new Date('2023-01-01'), - updatedAt: new Date('2023-01-01'), - refreshedAt: null, - exclusionPatterns: [], - }), - patternPath: Object.freeze({ - id: 'library-id1337', - name: 'importpath-exclusion-library1', - assets: [], - owner: userStub.admin, - ownerId: 'user-id', - importPaths: ['/xyz', '/asdf'], - createdAt: new Date('2023-01-01'), - updatedAt: new Date('2023-01-01'), - refreshedAt: null, - exclusionPatterns: ['**/dir1/**'], - }), - hasImmichPaths: Object.freeze({ - id: 'library-id1337', - name: 'importpath-exclusion-library1', - assets: [], - owner: userStub.admin, - ownerId: 'user-id', - importPaths: ['upload/thumbs', 'xyz', 'upload/library'], - createdAt: new Date('2023-01-01'), - updatedAt: new Date('2023-01-01'), - refreshedAt: null, - exclusionPatterns: ['**/dir1/**'], - }), -}; diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index c3455660ce..d93056713a 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { Asset, AuthUser, User } from 'src/database'; +import { Asset, AuthUser, Library, User } from 'src/database'; import { OnThisDayData } from 'src/entities/memory.entity'; import { AssetStatus, AssetType, MemoryType } from 'src/enum'; import { ActivityItem, MemoryItem } from 'src/types'; @@ -13,7 +13,11 @@ export const newDate = () => new Date(); export const newUpdateId = () => 'uuid-v7'; export const newSha1 = () => Buffer.from('this is a fake hash'); -const authUser = (authUser: Partial) => ({ +const authFactory = (user: Partial = {}) => ({ + user: authUserFactory(user), +}); + +const authUserFactory = (authUser: Partial) => ({ id: newUuid(), isAdmin: false, name: 'Test User', @@ -23,7 +27,7 @@ const authUser = (authUser: Partial) => ({ ...authUser, }); -const user = (user: Partial) => ({ +const userFactory = (user: Partial = {}) => ({ id: newUuid(), name: 'Test User', email: 'test@immich.cloud', @@ -32,75 +36,95 @@ const user = (user: Partial) => ({ ...user, }); -export const factory = { - auth: (user: Partial = {}) => ({ - user: authUser(user), - }), - authUser, - user, - asset: (asset: Partial = {}) => ({ - id: newUuid(), - createdAt: newDate(), - updatedAt: newDate(), - deletedAt: null, - updateId: newUpdateId(), - status: AssetStatus.ACTIVE, - checksum: newSha1(), - deviceAssetId: '', - deviceId: '', - duplicateId: null, - duration: null, - encodedVideoPath: null, - fileCreatedAt: newDate(), - fileModifiedAt: newDate(), - isArchived: false, - isExternal: false, - isFavorite: false, - isOffline: false, - isVisible: true, - libraryId: null, - livePhotoVideoId: null, - localDateTime: newDate(), - originalFileName: 'IMG_123.jpg', - originalPath: `upload/12/34/IMG_123.jpg`, - ownerId: newUuid(), - sidecarPath: null, - stackId: null, - thumbhash: null, - type: AssetType.IMAGE, - ...asset, - }), - activity: (activity: Partial = {}) => { - const userId = activity.userId || newUuid(); - return { - id: newUuid(), - comment: null, - isLiked: false, - userId, - user: user({ id: userId }), - assetId: newUuid(), - albumId: newUuid(), - createdAt: newDate(), - updatedAt: newDate(), - updateId: newUpdateId(), - ...activity, - }; - }, - memory: (memory: Partial = {}) => ({ +const assetFactory = (asset: Partial = {}) => ({ + id: newUuid(), + createdAt: newDate(), + updatedAt: newDate(), + deletedAt: null, + updateId: newUpdateId(), + status: AssetStatus.ACTIVE, + checksum: newSha1(), + deviceAssetId: '', + deviceId: '', + duplicateId: null, + duration: null, + encodedVideoPath: null, + fileCreatedAt: newDate(), + fileModifiedAt: newDate(), + isArchived: false, + isExternal: false, + isFavorite: false, + isOffline: false, + isVisible: true, + libraryId: null, + livePhotoVideoId: null, + localDateTime: newDate(), + originalFileName: 'IMG_123.jpg', + originalPath: `upload/12/34/IMG_123.jpg`, + ownerId: newUuid(), + sidecarPath: null, + stackId: null, + thumbhash: null, + type: AssetType.IMAGE, + ...asset, +}); + +const activityFactory = (activity: Partial = {}) => { + const userId = activity.userId || newUuid(); + return { id: newUuid(), + comment: null, + isLiked: false, + userId, + user: userFactory({ id: userId }), + assetId: newUuid(), + albumId: newUuid(), createdAt: newDate(), updatedAt: newDate(), updateId: newUpdateId(), - deletedAt: null, - ownerId: newUuid(), - type: MemoryType.ON_THIS_DAY, - data: { year: 2024 } as OnThisDayData, - isSaved: false, - memoryAt: newDate(), - seenAt: null, - showAt: newDate(), - hideAt: newDate(), - assets: [], - ...memory, - }), + ...activity, + }; +}; + +const libraryFactory = (library: Partial = {}) => ({ + id: newUuid(), + createdAt: newDate(), + updatedAt: newDate(), + updateId: newUpdateId(), + deletedAt: null, + refreshedAt: null, + name: 'Library', + assets: [], + ownerId: newUuid(), + importPaths: [], + exclusionPatterns: [], + ...library, +}); + +const memoryFactory = (memory: Partial = {}) => ({ + id: newUuid(), + createdAt: newDate(), + updatedAt: newDate(), + updateId: newUpdateId(), + deletedAt: null, + ownerId: newUuid(), + type: MemoryType.ON_THIS_DAY, + data: { year: 2024 } as OnThisDayData, + isSaved: false, + memoryAt: newDate(), + seenAt: null, + showAt: newDate(), + hideAt: newDate(), + assets: [], + ...memory, +}); + +export const factory = { + activity: activityFactory, + asset: assetFactory, + auth: authFactory, + authUser: authUserFactory, + library: libraryFactory, + memory: memoryFactory, + user: userFactory, };