diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 0dc225b90c..86f7be9ffd 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -5,11 +5,13 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { IConfigRepository, ILoggingRepository, ISystemMetadataRepository } from 'src/types'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { getAssetFiles } from 'src/utils/asset.util'; import { getConfig } from 'src/utils/config'; @@ -32,24 +34,24 @@ let instance: StorageCore | null; export class StorageCore { private constructor( private assetRepository: IAssetRepository, - private configRepository: IConfigRepository, - private cryptoRepository: ICryptoRepository, + private configRepository: ConfigRepository, + private cryptoRepository: CryptoRepository, private moveRepository: IMoveRepository, private personRepository: IPersonRepository, private storageRepository: IStorageRepository, - private systemMetadataRepository: ISystemMetadataRepository, - private logger: ILoggingRepository, + private systemMetadataRepository: SystemMetadataRepository, + private logger: LoggingRepository, ) {} static create( assetRepository: IAssetRepository, - configRepository: IConfigRepository, - cryptoRepository: ICryptoRepository, + configRepository: ConfigRepository, + cryptoRepository: CryptoRepository, moveRepository: IMoveRepository, personRepository: IPersonRepository, storageRepository: IStorageRepository, - systemMetadataRepository: ISystemMetadataRepository, - logger: ILoggingRepository, + systemMetadataRepository: SystemMetadataRepository, + logger: LoggingRepository, ) { if (!instance) { instance = new StorageCore( diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 201f295d49..2ac81bbf97 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -524,7 +524,7 @@ export class AssetRepository implements IAssetRepository { .executeTakeFirst() as Promise; } - getMapMarkers(ownerIds: string[], options: MapMarkerSearchOptions = {}): Promise { + private getMapMarkers(ownerIds: string[], options: MapMarkerSearchOptions = {}): Promise { const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options; return this.db diff --git a/server/src/repositories/logging.repository.spec.ts b/server/src/repositories/logging.repository.spec.ts index 11fa19e48b..10c1a6516c 100644 --- a/server/src/repositories/logging.repository.spec.ts +++ b/server/src/repositories/logging.repository.spec.ts @@ -1,14 +1,14 @@ import { ClsService } from 'nestjs-cls'; import { ImmichWorker } from 'src/enum'; +import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; -import { IConfigRepository } from 'src/types'; import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; import { Mocked } from 'vitest'; describe(LoggingRepository.name, () => { let sut: LoggingRepository; - let configMock: Mocked; + let configMock: Mocked; let clsMock: Mocked; beforeEach(() => { diff --git a/server/src/repositories/notification.repository.spec.ts b/server/src/repositories/notification.repository.spec.ts index 0d8e826c66..7707069dd9 100644 --- a/server/src/repositories/notification.repository.spec.ts +++ b/server/src/repositories/notification.repository.spec.ts @@ -1,17 +1,16 @@ import { LoggingRepository } from 'src/repositories/logging.repository'; import { EmailRenderRequest, EmailTemplate, NotificationRepository } from 'src/repositories/notification.repository'; -import { ILoggingRepository } from 'src/types'; -import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; import { Mocked } from 'vitest'; describe(NotificationRepository.name, () => { let sut: NotificationRepository; - let loggerMock: Mocked; + let loggerMock: Mocked; beforeEach(() => { - loggerMock = newLoggingRepositoryMock(); + loggerMock = newLoggingRepositoryMock() as ILoggingRepository as Mocked; - sut = new NotificationRepository(loggerMock as ILoggingRepository as LoggingRepository); + sut = new NotificationRepository(loggerMock as LoggingRepository); }); describe('renderEmail', () => { diff --git a/server/src/repositories/storage.repository.spec.ts b/server/src/repositories/storage.repository.spec.ts index 4c4a9d50b6..3ab9e615ec 100644 --- a/server/src/repositories/storage.repository.spec.ts +++ b/server/src/repositories/storage.repository.spec.ts @@ -2,8 +2,8 @@ import mockfs from 'mock-fs'; import { CrawlOptionsDto } from 'src/dtos/library.dto'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; -import { ILoggingRepository } from 'src/types'; -import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { Mocked } from 'vitest'; interface Test { test: string; @@ -182,11 +182,11 @@ const tests: Test[] = [ describe(StorageRepository.name, () => { let sut: StorageRepository; - let logger: ILoggingRepository; + let logger: Mocked; beforeEach(() => { logger = newLoggingRepositoryMock(); - sut = new StorageRepository(logger as LoggingRepository); + sut = new StorageRepository(logger as ILoggingRepository as LoggingRepository); }); afterEach(() => { diff --git a/server/src/services/activity.service.spec.ts b/server/src/services/activity.service.spec.ts index 4ee656abe5..bb63c7bf7b 100644 --- a/server/src/services/activity.service.spec.ts +++ b/server/src/services/activity.service.spec.ts @@ -1,21 +1,16 @@ import { BadRequestException } from '@nestjs/common'; import { ReactionType } from 'src/dtos/activity.dto'; import { ActivityService } from 'src/services/activity.service'; -import { IActivityRepository } from 'src/types'; import { activityStub } from 'test/fixtures/activity.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(ActivityService.name, () => { let sut: ActivityService; - - let accessMock: IAccessRepositoryMock; - let activityMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, activityMock } = newTestService(ActivityService)); + ({ sut, mocks } = newTestService(ActivityService)); }); it('should work', () => { @@ -24,12 +19,12 @@ describe(ActivityService.name, () => { describe('getAll', () => { it('should get all', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - activityMock.search.mockResolvedValue([]); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); + mocks.activity.search.mockResolvedValue([]); await expect(sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id' })).resolves.toEqual([]); - expect(activityMock.search).toHaveBeenCalledWith({ + expect(mocks.activity.search).toHaveBeenCalledWith({ assetId: 'asset-id', albumId: 'album-id', isLiked: undefined, @@ -37,14 +32,14 @@ describe(ActivityService.name, () => { }); it('should filter by type=like', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - activityMock.search.mockResolvedValue([]); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); + mocks.activity.search.mockResolvedValue([]); await expect( sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.LIKE }), ).resolves.toEqual([]); - expect(activityMock.search).toHaveBeenCalledWith({ + expect(mocks.activity.search).toHaveBeenCalledWith({ assetId: 'asset-id', albumId: 'album-id', isLiked: true, @@ -52,14 +47,14 @@ describe(ActivityService.name, () => { }); it('should filter by type=comment', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - activityMock.search.mockResolvedValue([]); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); + mocks.activity.search.mockResolvedValue([]); await expect( sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.COMMENT }), ).resolves.toEqual([]); - expect(activityMock.search).toHaveBeenCalledWith({ + expect(mocks.activity.search).toHaveBeenCalledWith({ assetId: 'asset-id', albumId: 'album-id', isLiked: false, @@ -69,8 +64,8 @@ describe(ActivityService.name, () => { describe('getStatistics', () => { it('should get the comment count', async () => { - activityMock.getStatistics.mockResolvedValue(1); - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([activityStub.oneComment.albumId])); + mocks.activity.getStatistics.mockResolvedValue(1); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([activityStub.oneComment.albumId])); await expect( sut.getStatistics(authStub.admin, { assetId: 'asset-id', @@ -93,8 +88,8 @@ describe(ActivityService.name, () => { }); it('should create a comment', async () => { - accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id'])); - activityMock.create.mockResolvedValue(activityStub.oneComment); + mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id'])); + mocks.activity.create.mockResolvedValue(activityStub.oneComment); await sut.create(authStub.admin, { albumId: 'album-id', @@ -103,7 +98,7 @@ describe(ActivityService.name, () => { comment: 'comment', }); - expect(activityMock.create).toHaveBeenCalledWith({ + expect(mocks.activity.create).toHaveBeenCalledWith({ userId: 'admin_id', albumId: 'album-id', assetId: 'asset-id', @@ -113,8 +108,8 @@ describe(ActivityService.name, () => { }); it('should fail because activity is disabled for the album', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - activityMock.create.mockResolvedValue(activityStub.oneComment); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); + mocks.activity.create.mockResolvedValue(activityStub.oneComment); await expect( sut.create(authStub.admin, { @@ -127,9 +122,9 @@ describe(ActivityService.name, () => { }); it('should create a like', async () => { - accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id'])); - activityMock.create.mockResolvedValue(activityStub.liked); - activityMock.search.mockResolvedValue([]); + mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id'])); + mocks.activity.create.mockResolvedValue(activityStub.liked); + mocks.activity.search.mockResolvedValue([]); await sut.create(authStub.admin, { albumId: 'album-id', @@ -137,7 +132,7 @@ describe(ActivityService.name, () => { type: ReactionType.LIKE, }); - expect(activityMock.create).toHaveBeenCalledWith({ + expect(mocks.activity.create).toHaveBeenCalledWith({ userId: 'admin_id', albumId: 'album-id', assetId: 'asset-id', @@ -146,9 +141,9 @@ describe(ActivityService.name, () => { }); it('should skip if like exists', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id'])); - activityMock.search.mockResolvedValue([activityStub.liked]); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); + mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id'])); + mocks.activity.search.mockResolvedValue([activityStub.liked]); await sut.create(authStub.admin, { albumId: 'album-id', @@ -156,26 +151,26 @@ describe(ActivityService.name, () => { type: ReactionType.LIKE, }); - expect(activityMock.create).not.toHaveBeenCalled(); + expect(mocks.activity.create).not.toHaveBeenCalled(); }); }); describe('delete', () => { it('should require access', async () => { await expect(sut.delete(authStub.admin, activityStub.oneComment.id)).rejects.toBeInstanceOf(BadRequestException); - expect(activityMock.delete).not.toHaveBeenCalled(); + expect(mocks.activity.delete).not.toHaveBeenCalled(); }); it('should let the activity owner delete a comment', async () => { - accessMock.activity.checkOwnerAccess.mockResolvedValue(new Set(['activity-id'])); + mocks.access.activity.checkOwnerAccess.mockResolvedValue(new Set(['activity-id'])); await sut.delete(authStub.admin, 'activity-id'); - expect(activityMock.delete).toHaveBeenCalledWith('activity-id'); + expect(mocks.activity.delete).toHaveBeenCalledWith('activity-id'); }); it('should let the album owner delete a comment', async () => { - accessMock.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set(['activity-id'])); + mocks.access.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set(['activity-id'])); await sut.delete(authStub.admin, 'activity-id'); - expect(activityMock.delete).toHaveBeenCalledWith('activity-id'); + expect(mocks.activity.delete).toHaveBeenCalledWith('activity-id'); }); }); }); diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 942615b0d9..832ed59dd5 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -2,29 +2,18 @@ import { BadRequestException } from '@nestjs/common'; import _ from 'lodash'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { AlbumUserRole } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; import { AlbumService } from 'src/services/album.service'; -import { IAlbumUserRepository } from 'src/types'; import { albumStub } from 'test/fixtures/album.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(AlbumService.name, () => { let sut: AlbumService; - - let accessMock: IAccessRepositoryMock; - let albumMock: Mocked; - let albumUserMock: Mocked; - let eventMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, albumMock, albumUserMock, eventMock, userMock } = newTestService(AlbumService)); + ({ sut, mocks } = newTestService(AlbumService)); }); it('should work', () => { @@ -33,25 +22,25 @@ describe(AlbumService.name, () => { describe('getStatistics', () => { it('should get the album count', async () => { - albumMock.getOwned.mockResolvedValue([]); - albumMock.getShared.mockResolvedValue([]); - albumMock.getNotShared.mockResolvedValue([]); + mocks.album.getOwned.mockResolvedValue([]); + mocks.album.getShared.mockResolvedValue([]); + mocks.album.getNotShared.mockResolvedValue([]); await expect(sut.getStatistics(authStub.admin)).resolves.toEqual({ owned: 0, shared: 0, notShared: 0, }); - expect(albumMock.getOwned).toHaveBeenCalledWith(authStub.admin.user.id); - expect(albumMock.getShared).toHaveBeenCalledWith(authStub.admin.user.id); - expect(albumMock.getNotShared).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.album.getOwned).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.album.getShared).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.album.getNotShared).toHaveBeenCalledWith(authStub.admin.user.id); }); }); describe('getAll', () => { it('gets list of albums for auth user', async () => { - albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]); - albumMock.getMetadataForIds.mockResolvedValue([ + mocks.album.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]); + mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null }, { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null }, ]); @@ -63,8 +52,8 @@ describe(AlbumService.name, () => { }); it('gets list of albums that have a specific asset', async () => { - albumMock.getByAssetId.mockResolvedValue([albumStub.oneAsset]); - albumMock.getMetadataForIds.mockResolvedValue([ + mocks.album.getByAssetId.mockResolvedValue([albumStub.oneAsset]); + mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.oneAsset.id, assetCount: 1, @@ -76,37 +65,37 @@ describe(AlbumService.name, () => { const result = await sut.getAll(authStub.admin, { assetId: albumStub.oneAsset.id }); expect(result).toHaveLength(1); expect(result[0].id).toEqual(albumStub.oneAsset.id); - expect(albumMock.getByAssetId).toHaveBeenCalledTimes(1); + expect(mocks.album.getByAssetId).toHaveBeenCalledTimes(1); }); it('gets list of albums that are shared', async () => { - albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]); - albumMock.getMetadataForIds.mockResolvedValue([ + mocks.album.getShared.mockResolvedValue([albumStub.sharedWithUser]); + mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null }, ]); const result = await sut.getAll(authStub.admin, { shared: true }); expect(result).toHaveLength(1); expect(result[0].id).toEqual(albumStub.sharedWithUser.id); - expect(albumMock.getShared).toHaveBeenCalledTimes(1); + expect(mocks.album.getShared).toHaveBeenCalledTimes(1); }); it('gets list of albums that are NOT shared', async () => { - albumMock.getNotShared.mockResolvedValue([albumStub.empty]); - albumMock.getMetadataForIds.mockResolvedValue([ + mocks.album.getNotShared.mockResolvedValue([albumStub.empty]); + mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null }, ]); const result = await sut.getAll(authStub.admin, { shared: false }); expect(result).toHaveLength(1); expect(result[0].id).toEqual(albumStub.empty.id); - expect(albumMock.getNotShared).toHaveBeenCalledTimes(1); + expect(mocks.album.getNotShared).toHaveBeenCalledTimes(1); }); }); it('counts assets correctly', async () => { - albumMock.getOwned.mockResolvedValue([albumStub.oneAsset]); - albumMock.getMetadataForIds.mockResolvedValue([ + mocks.album.getOwned.mockResolvedValue([albumStub.oneAsset]); + mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.oneAsset.id, assetCount: 1, @@ -119,14 +108,14 @@ describe(AlbumService.name, () => { expect(result).toHaveLength(1); expect(result[0].assetCount).toEqual(1); - expect(albumMock.getOwned).toHaveBeenCalledTimes(1); + expect(mocks.album.getOwned).toHaveBeenCalledTimes(1); }); describe('create', () => { it('creates album', async () => { - albumMock.create.mockResolvedValue(albumStub.empty); - userMock.get.mockResolvedValue(userStub.user1); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['123'])); + mocks.album.create.mockResolvedValue(albumStub.empty); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['123'])); await sut.create(authStub.admin, { albumName: 'Empty album', @@ -135,7 +124,7 @@ describe(AlbumService.name, () => { assetIds: ['123'], }); - expect(albumMock.create).toHaveBeenCalledWith( + expect(mocks.album.create).toHaveBeenCalledWith( { ownerId: authStub.admin.user.id, albumName: albumStub.empty.albumName, @@ -147,30 +136,30 @@ describe(AlbumService.name, () => { [{ userId: 'user-id', role: AlbumUserRole.EDITOR }], ); - expect(userMock.get).toHaveBeenCalledWith('user-id', {}); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123'])); - expect(eventMock.emit).toHaveBeenCalledWith('album.invite', { + expect(mocks.user.get).toHaveBeenCalledWith('user-id', {}); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123'])); + expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', { id: albumStub.empty.id, userId: 'user-id', }); }); it('should require valid userIds', async () => { - userMock.get.mockResolvedValue(void 0); + mocks.user.get.mockResolvedValue(void 0); await expect( sut.create(authStub.admin, { albumName: 'Empty album', albumUsers: [{ userId: 'user-3', role: AlbumUserRole.EDITOR }], }), ).rejects.toBeInstanceOf(BadRequestException); - expect(userMock.get).toHaveBeenCalledWith('user-3', {}); - expect(albumMock.create).not.toHaveBeenCalled(); + expect(mocks.user.get).toHaveBeenCalledWith('user-3', {}); + expect(mocks.album.create).not.toHaveBeenCalled(); }); it('should only add assets the user is allowed to access', async () => { - userMock.get.mockResolvedValue(userStub.user1); - albumMock.create.mockResolvedValue(albumStub.oneAsset); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.album.create.mockResolvedValue(albumStub.oneAsset); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.create(authStub.admin, { albumName: 'Test album', @@ -178,7 +167,7 @@ describe(AlbumService.name, () => { assetIds: ['asset-1', 'asset-2'], }); - expect(albumMock.create).toHaveBeenCalledWith( + expect(mocks.album.create).toHaveBeenCalledWith( { ownerId: authStub.admin.user.id, albumName: 'Test album', @@ -189,7 +178,7 @@ describe(AlbumService.name, () => { ['asset-1'], [], ); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set(['asset-1', 'asset-2']), ); @@ -198,7 +187,7 @@ describe(AlbumService.name, () => { describe('update', () => { it('should prevent updating an album that does not exist', async () => { - albumMock.getById.mockResolvedValue(void 0); + mocks.album.getById.mockResolvedValue(void 0); await expect( sut.update(authStub.user1, 'invalid-id', { @@ -206,7 +195,7 @@ describe(AlbumService.name, () => { }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should prevent updating a not owned album (shared with auth user)', async () => { @@ -218,10 +207,10 @@ describe(AlbumService.name, () => { }); it('should require a valid thumbnail asset id', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4'])); - albumMock.getById.mockResolvedValue(albumStub.oneAsset); - albumMock.update.mockResolvedValue(albumStub.oneAsset); - albumMock.getAssetIds.mockResolvedValue(new Set()); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4'])); + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); + mocks.album.update.mockResolvedValue(albumStub.oneAsset); + mocks.album.getAssetIds.mockResolvedValue(new Set()); await expect( sut.update(authStub.admin, albumStub.oneAsset.id, { @@ -229,22 +218,22 @@ describe(AlbumService.name, () => { }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.getAssetIds).toHaveBeenCalledWith('album-4', ['not-in-album']); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.getAssetIds).toHaveBeenCalledWith('album-4', ['not-in-album']); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should allow the owner to update the album', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4'])); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4'])); - albumMock.getById.mockResolvedValue(albumStub.oneAsset); - albumMock.update.mockResolvedValue(albumStub.oneAsset); + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); + mocks.album.update.mockResolvedValue(albumStub.oneAsset); await sut.update(authStub.admin, albumStub.oneAsset.id, { albumName: 'new album name', }); - expect(albumMock.update).toHaveBeenCalledTimes(1); - expect(albumMock.update).toHaveBeenCalledWith('album-4', { + expect(mocks.album.update).toHaveBeenCalledTimes(1); + expect(mocks.album.update).toHaveBeenCalledWith('album-4', { id: 'album-4', albumName: 'new album name', }); @@ -253,33 +242,33 @@ describe(AlbumService.name, () => { describe('delete', () => { it('should throw an error for an album not found', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf( BadRequestException, ); - expect(albumMock.delete).not.toHaveBeenCalled(); + expect(mocks.album.delete).not.toHaveBeenCalled(); }); it('should not let a shared user delete the album', async () => { - albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin); await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf( BadRequestException, ); - expect(albumMock.delete).not.toHaveBeenCalled(); + expect(mocks.album.delete).not.toHaveBeenCalled(); }); it('should let the owner delete an album', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.empty.id])); - albumMock.getById.mockResolvedValue(albumStub.empty); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.empty.id])); + mocks.album.getById.mockResolvedValue(albumStub.empty); await sut.delete(authStub.admin, albumStub.empty.id); - expect(albumMock.delete).toHaveBeenCalledTimes(1); - expect(albumMock.delete).toHaveBeenCalledWith(albumStub.empty.id); + expect(mocks.album.delete).toHaveBeenCalledTimes(1); + expect(mocks.album.delete).toHaveBeenCalledWith(albumStub.empty.id); }); }); @@ -288,47 +277,47 @@ describe(AlbumService.name, () => { await expect( sut.addUsers(authStub.admin, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: 'user-1' }] }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should throw an error if the userId is already added', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); - albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin); await expect( sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: authStub.admin.user.id }], }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should throw an error if the userId does not exist', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); - albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); - userMock.get.mockResolvedValue(void 0); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin); + mocks.user.get.mockResolvedValue(void 0); await expect( sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: 'user-3' }] }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should throw an error if the userId is the ownerId', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); - albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin); await expect( sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: userStub.user1.id }], }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should add valid shared users', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin)); - albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin); - userMock.get.mockResolvedValue(userStub.user2); - albumUserMock.create.mockResolvedValue({ + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin)); + mocks.album.update.mockResolvedValue(albumStub.sharedWithAdmin); + mocks.user.get.mockResolvedValue(userStub.user2); + mocks.albumUser.create.mockResolvedValue({ usersId: userStub.user2.id, albumsId: albumStub.sharedWithAdmin.id, role: AlbumUserRole.EDITOR, @@ -336,11 +325,11 @@ describe(AlbumService.name, () => { await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: authStub.user2.user.id }], }); - expect(albumUserMock.create).toHaveBeenCalledWith({ + expect(mocks.albumUser.create).toHaveBeenCalledWith({ usersId: authStub.user2.user.id, albumsId: albumStub.sharedWithAdmin.id, }); - expect(eventMock.emit).toHaveBeenCalledWith('album.invite', { + expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', { id: albumStub.sharedWithAdmin.id, userId: userStub.user2.id, }); @@ -349,94 +338,94 @@ describe(AlbumService.name, () => { describe('removeUser', () => { it('should require a valid album id', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1'])); - albumMock.getById.mockResolvedValue(void 0); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1'])); + mocks.album.getById.mockResolvedValue(void 0); await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should remove a shared user from an owned album', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithUser.id])); - albumMock.getById.mockResolvedValue(albumStub.sharedWithUser); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithUser.id])); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithUser); await expect( sut.removeUser(authStub.admin, albumStub.sharedWithUser.id, userStub.user1.id), ).resolves.toBeUndefined(); - expect(albumUserMock.delete).toHaveBeenCalledTimes(1); - expect(albumUserMock.delete).toHaveBeenCalledWith({ + expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); + expect(mocks.albumUser.delete).toHaveBeenCalledWith({ albumsId: albumStub.sharedWithUser.id, usersId: userStub.user1.id, }); - expect(albumMock.getById).toHaveBeenCalledWith(albumStub.sharedWithUser.id, { withAssets: false }); + expect(mocks.album.getById).toHaveBeenCalledWith(albumStub.sharedWithUser.id, { withAssets: false }); }); it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => { - albumMock.getById.mockResolvedValue(albumStub.sharedWithMultiple); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithMultiple); await expect( sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.user.id), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumUserMock.delete).not.toHaveBeenCalled(); - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith( + expect(mocks.albumUser.delete).not.toHaveBeenCalled(); + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith( authStub.user1.user.id, new Set([albumStub.sharedWithMultiple.id]), ); }); it('should allow a shared user to remove themselves', async () => { - albumMock.getById.mockResolvedValue(albumStub.sharedWithUser); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithUser); await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, authStub.user1.user.id); - expect(albumUserMock.delete).toHaveBeenCalledTimes(1); - expect(albumUserMock.delete).toHaveBeenCalledWith({ + expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); + expect(mocks.albumUser.delete).toHaveBeenCalledWith({ albumsId: albumStub.sharedWithUser.id, usersId: authStub.user1.user.id, }); }); it('should allow a shared user to remove themselves using "me"', async () => { - albumMock.getById.mockResolvedValue(albumStub.sharedWithUser); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithUser); await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, 'me'); - expect(albumUserMock.delete).toHaveBeenCalledTimes(1); - expect(albumUserMock.delete).toHaveBeenCalledWith({ + expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); + expect(mocks.albumUser.delete).toHaveBeenCalledWith({ albumsId: albumStub.sharedWithUser.id, usersId: authStub.user1.user.id, }); }); it('should not allow the owner to be removed', async () => { - albumMock.getById.mockResolvedValue(albumStub.empty); + mocks.album.getById.mockResolvedValue(albumStub.empty); await expect(sut.removeUser(authStub.admin, albumStub.empty.id, authStub.admin.user.id)).rejects.toBeInstanceOf( BadRequestException, ); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should throw an error for a user not in the album', async () => { - albumMock.getById.mockResolvedValue(albumStub.empty); + mocks.album.getById.mockResolvedValue(albumStub.empty); await expect(sut.removeUser(authStub.admin, albumStub.empty.id, 'user-3')).rejects.toBeInstanceOf( BadRequestException, ); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); }); describe('updateUser', () => { it('should update user role', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); await sut.updateUser(authStub.user1, albumStub.sharedWithAdmin.id, userStub.admin.id, { role: AlbumUserRole.EDITOR, }); - expect(albumUserMock.update).toHaveBeenCalledWith( + expect(mocks.albumUser.update).toHaveBeenCalledWith( { albumsId: albumStub.sharedWithAdmin.id, usersId: userStub.admin.id }, { role: AlbumUserRole.EDITOR }, ); @@ -445,9 +434,9 @@ describe(AlbumService.name, () => { describe('getAlbumInfo', () => { it('should get a shared album', async () => { - albumMock.getById.mockResolvedValue(albumStub.oneAsset); - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); - albumMock.getMetadataForIds.mockResolvedValue([ + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); + mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.oneAsset.id, assetCount: 1, @@ -458,17 +447,17 @@ describe(AlbumService.name, () => { await sut.get(authStub.admin, albumStub.oneAsset.id, {}); - expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: true }); - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith( + expect(mocks.album.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: true }); + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([albumStub.oneAsset.id]), ); }); it('should get a shared album via a shared link', async () => { - albumMock.getById.mockResolvedValue(albumStub.oneAsset); - accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); - albumMock.getMetadataForIds.mockResolvedValue([ + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); + mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); + mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.oneAsset.id, assetCount: 1, @@ -479,17 +468,17 @@ describe(AlbumService.name, () => { await sut.get(authStub.adminSharedLink, 'album-123', {}); - expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); - expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith( + expect(mocks.album.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); + expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLink?.id, new Set(['album-123']), ); }); it('should get a shared album via shared with user', async () => { - albumMock.getById.mockResolvedValue(albumStub.oneAsset); - accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); - albumMock.getMetadataForIds.mockResolvedValue([ + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); + mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); + mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.oneAsset.id, assetCount: 1, @@ -500,8 +489,8 @@ describe(AlbumService.name, () => { await sut.get(authStub.user1, 'album-123', {}); - expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); - expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith( + expect(mocks.album.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); + expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith( authStub.user1.user.id, new Set(['album-123']), AlbumUserRole.VIEWER, @@ -511,8 +500,8 @@ describe(AlbumService.name, () => { it('should throw an error for no access', async () => { await expect(sut.get(authStub.admin, 'album-123', {})).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-123'])); - expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith( + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-123'])); + expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set(['album-123']), AlbumUserRole.VIEWER, @@ -522,10 +511,10 @@ describe(AlbumService.name, () => { describe('addAssets', () => { it('should allow the owner to add assets', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.getAssetIds.mockResolvedValueOnce(new Set()); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect( sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), @@ -535,37 +524,37 @@ describe(AlbumService.name, () => { { success: true, id: 'asset-3' }, ]); - expect(albumMock.update).toHaveBeenCalledWith('album-123', { + expect(mocks.album.update).toHaveBeenCalledWith('album-123', { id: 'album-123', updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', }); - expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); }); it('should not set the thumbnail if the album has one already', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - albumMock.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' })); - albumMock.getAssetIds.mockResolvedValueOnce(new Set()); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' })); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ { success: true, id: 'asset-1' }, ]); - expect(albumMock.update).toHaveBeenCalledWith('album-123', { + expect(mocks.album.update).toHaveBeenCalledWith('album-123', { id: 'album-123', updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-id', }); - expect(albumMock.addAssetIds).toHaveBeenCalled(); + expect(mocks.album.addAssetIds).toHaveBeenCalled(); }); it('should allow a shared user to add assets', async () => { - accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); - albumMock.getAssetIds.mockResolvedValueOnce(new Set()); + mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect( sut.addAssets(authStub.user1, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), @@ -575,34 +564,34 @@ describe(AlbumService.name, () => { { success: true, id: 'asset-3' }, ]); - expect(albumMock.update).toHaveBeenCalledWith('album-123', { + expect(mocks.album.update).toHaveBeenCalledWith('album-123', { id: 'album-123', updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', }); - expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(eventMock.emit).toHaveBeenCalledWith('album.update', { + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.event.emit).toHaveBeenCalledWith('album.update', { id: 'album-123', recipientIds: ['admin_id'], }); }); it('should not allow a shared user with viewer access to add assets', async () => { - accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set([])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); + mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); await expect( sut.addAssets(authStub.user2, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should allow a shared link user to add assets', async () => { - accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.getAssetIds.mockResolvedValueOnce(new Set()); + mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect( sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), @@ -612,115 +601,115 @@ describe(AlbumService.name, () => { { success: true, id: 'asset-3' }, ]); - expect(albumMock.update).toHaveBeenCalledWith('album-123', { + expect(mocks.album.update).toHaveBeenCalledWith('album-123', { id: 'album-123', updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', }); - expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith( + expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLink?.id, new Set(['album-123']), ); }); it('should allow adding assets shared via partner sharing', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.getAssetIds.mockResolvedValueOnce(new Set()); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ { success: true, id: 'asset-1' }, ]); - expect(albumMock.update).toHaveBeenCalledWith('album-123', { + expect(mocks.album.update).toHaveBeenCalledWith('album-123', { id: 'album-123', updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', }); - expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); }); it('should skip duplicate assets', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id'])); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set(['asset-id'])); await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: false, id: 'asset-id', error: BulkIdErrorReason.DUPLICATE }, ]); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should skip assets not shared with user', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - albumMock.getById.mockResolvedValue(albumStub.oneAsset); - albumMock.getAssetIds.mockResolvedValueOnce(new Set()); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ { success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION }, ]); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); - expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); }); it('should not allow unauthorized access to the album', async () => { - albumMock.getById.mockResolvedValue(albumStub.oneAsset); + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); await expect( sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), ).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalled(); - expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalled(); + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalled(); + expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalled(); }); it('should not allow unauthorized shared link access to the album', async () => { - albumMock.getById.mockResolvedValue(albumStub.oneAsset); + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); await expect( sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), ).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalled(); + expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalled(); }); }); describe('removeAssets', () => { it('should allow the owner to remove assets', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id'])); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-id'])); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: true, id: 'asset-id' }, ]); - expect(albumMock.removeAssetIds).toHaveBeenCalledWith('album-123', ['asset-id']); + expect(mocks.album.removeAssetIds).toHaveBeenCalledWith('album-123', ['asset-id']); }); it('should skip assets not in the album', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.empty)); - albumMock.getAssetIds.mockResolvedValue(new Set()); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.empty)); + mocks.album.getAssetIds.mockResolvedValue(new Set()); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: false, id: 'asset-id', error: BulkIdErrorReason.NOT_FOUND }, ]); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should allow owner to remove all assets from the album', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id'])); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-id'])); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: true, id: 'asset-id' }, @@ -728,16 +717,16 @@ describe(AlbumService.name, () => { }); it('should reset the thumbnail if it is removed', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets)); - albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id'])); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets)); + mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-id'])); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: true, id: 'asset-id' }, ]); - expect(albumMock.updateThumbnails).toHaveBeenCalled(); + expect(mocks.album.updateThumbnails).toHaveBeenCalled(); }); }); diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index 928978b698..905bbede2a 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -1,50 +1,45 @@ import { BadRequestException } from '@nestjs/common'; import { Permission } from 'src/enum'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { APIKeyService } from 'src/services/api-key.service'; -import { IApiKeyRepository } from 'src/types'; import { keyStub } from 'test/fixtures/api-key.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(APIKeyService.name, () => { let sut: APIKeyService; - - let cryptoMock: Mocked; - let keyMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, cryptoMock, keyMock } = newTestService(APIKeyService)); + ({ sut, mocks } = newTestService(APIKeyService)); }); describe('create', () => { it('should create a new key', async () => { - keyMock.create.mockResolvedValue(keyStub.admin); + mocks.apiKey.create.mockResolvedValue(keyStub.admin); await sut.create(authStub.admin, { name: 'Test Key', permissions: [Permission.ALL] }); - expect(keyMock.create).toHaveBeenCalledWith({ + expect(mocks.apiKey.create).toHaveBeenCalledWith({ key: 'cmFuZG9tLWJ5dGVz (hashed)', name: 'Test Key', permissions: [Permission.ALL], userId: authStub.admin.user.id, }); - expect(cryptoMock.newPassword).toHaveBeenCalled(); - expect(cryptoMock.hashSha256).toHaveBeenCalled(); + expect(mocks.crypto.newPassword).toHaveBeenCalled(); + expect(mocks.crypto.hashSha256).toHaveBeenCalled(); }); it('should not require a name', async () => { - keyMock.create.mockResolvedValue(keyStub.admin); + mocks.apiKey.create.mockResolvedValue(keyStub.admin); await sut.create(authStub.admin, { permissions: [Permission.ALL] }); - expect(keyMock.create).toHaveBeenCalledWith({ + expect(mocks.apiKey.create).toHaveBeenCalledWith({ key: 'cmFuZG9tLWJ5dGVz (hashed)', name: 'API Key', permissions: [Permission.ALL], userId: authStub.admin.user.id, }); - expect(cryptoMock.newPassword).toHaveBeenCalled(); - expect(cryptoMock.hashSha256).toHaveBeenCalled(); + expect(mocks.crypto.newPassword).toHaveBeenCalled(); + expect(mocks.crypto.hashSha256).toHaveBeenCalled(); }); it('should throw an error if the api key does not have sufficient permissions', async () => { @@ -60,16 +55,16 @@ describe(APIKeyService.name, () => { BadRequestException, ); - expect(keyMock.update).not.toHaveBeenCalledWith('random-guid'); + expect(mocks.apiKey.update).not.toHaveBeenCalledWith('random-guid'); }); it('should update a key', async () => { - keyMock.getById.mockResolvedValue(keyStub.admin); - keyMock.update.mockResolvedValue(keyStub.admin); + mocks.apiKey.getById.mockResolvedValue(keyStub.admin); + mocks.apiKey.update.mockResolvedValue(keyStub.admin); await sut.update(authStub.admin, 'random-guid', { name: 'New Name' }); - expect(keyMock.update).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid', { name: 'New Name' }); + expect(mocks.apiKey.update).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid', { name: 'New Name' }); }); }); @@ -77,15 +72,15 @@ describe(APIKeyService.name, () => { it('should throw an error if the key is not found', async () => { await expect(sut.delete(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException); - expect(keyMock.delete).not.toHaveBeenCalledWith('random-guid'); + expect(mocks.apiKey.delete).not.toHaveBeenCalledWith('random-guid'); }); it('should delete a key', async () => { - keyMock.getById.mockResolvedValue(keyStub.admin); + mocks.apiKey.getById.mockResolvedValue(keyStub.admin); await sut.delete(authStub.admin, 'random-guid'); - expect(keyMock.delete).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); + expect(mocks.apiKey.delete).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); }); }); @@ -93,25 +88,25 @@ describe(APIKeyService.name, () => { it('should throw an error if the key is not found', async () => { await expect(sut.getById(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException); - expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); + expect(mocks.apiKey.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); }); it('should get a key by id', async () => { - keyMock.getById.mockResolvedValue(keyStub.admin); + mocks.apiKey.getById.mockResolvedValue(keyStub.admin); await sut.getById(authStub.admin, 'random-guid'); - expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); + expect(mocks.apiKey.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); }); }); describe('getAll', () => { it('should return all the keys for a user', async () => { - keyMock.getByUserId.mockResolvedValue([keyStub.admin]); + mocks.apiKey.getByUserId.mockResolvedValue([keyStub.admin]); await expect(sut.getAll(authStub.admin)).resolves.toHaveLength(1); - expect(keyMock.getByUserId).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.apiKey.getByUserId).toHaveBeenCalledWith(authStub.admin.user.id); }); }); }); diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 9ebaa80d21..e52f086df0 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -10,10 +10,7 @@ import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldN import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; import { AssetFileType, AssetStatus, AssetType, CacheControl } from 'src/enum'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { JobName } from 'src/interfaces/job.interface'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AssetMediaService } from 'src/services/asset-media.service'; import { ImmichFileResponse } from 'src/utils/file'; @@ -21,9 +18,7 @@ import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); @@ -203,15 +198,10 @@ const copiedAsset = Object.freeze({ describe(AssetMediaService.name, () => { let sut: AssetMediaService; - - let accessMock: IAccessRepositoryMock; - let assetMock: Mocked; - let jobMock: Mocked; - let storageMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, assetMock, jobMock, storageMock, userMock } = newTestService(AssetMediaService)); + ({ sut, mocks } = newTestService(AssetMediaService)); }); describe('getUploadAssetIdByChecksum', () => { @@ -221,25 +211,25 @@ describe(AssetMediaService.name, () => { it('should handle a non-existent asset', async () => { await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toBeUndefined(); - expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); + expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); }); it('should find an existing asset', async () => { - assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); + mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toEqual({ id: 'asset-id', status: AssetMediaStatus.DUPLICATE, }); - expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); + expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); }); it('should find an existing asset by base64', async () => { - assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); + mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('base64'))).resolves.toEqual({ id: 'asset-id', status: AssetMediaStatus.DUPLICATE, }); - expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); + expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); }); }); @@ -308,14 +298,14 @@ describe(AssetMediaService.name, () => { expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual( 'upload/profile/admin_id', ); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id'); }); it('should return upload for everything else', () => { expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual( 'upload/upload/admin_id/ra/nd', ); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id/ra/nd'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id/ra/nd'); }); }); @@ -330,7 +320,7 @@ describe(AssetMediaService.name, () => { size: 42, }; - assetMock.create.mockResolvedValue(assetEntity); + mocks.asset.create.mockResolvedValue(assetEntity); await expect( sut.uploadAsset( @@ -340,9 +330,9 @@ describe(AssetMediaService.name, () => { ), ).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.create).not.toHaveBeenCalled(); - expect(userMock.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.size); - expect(storageMock.utimes).not.toHaveBeenCalledWith( + expect(mocks.asset.create).not.toHaveBeenCalled(); + expect(mocks.user.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.size); + expect(mocks.storage.utimes).not.toHaveBeenCalledWith( file.originalPath, expect.any(Date), new Date(createDto.fileModifiedAt), @@ -359,16 +349,16 @@ describe(AssetMediaService.name, () => { size: 42, }; - assetMock.create.mockResolvedValue(assetEntity); + mocks.asset.create.mockResolvedValue(assetEntity); await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({ id: 'id_1', status: AssetMediaStatus.CREATED, }); - expect(assetMock.create).toHaveBeenCalled(); - expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size); - expect(storageMock.utimes).toHaveBeenCalledWith( + expect(mocks.asset.create).toHaveBeenCalled(); + expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size); + expect(mocks.storage.utimes).toHaveBeenCalledWith( file.originalPath, expect.any(Date), new Date(createDto.fileModifiedAt), @@ -387,19 +377,19 @@ describe(AssetMediaService.name, () => { const error = new Error('unique key violation'); (error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT; - assetMock.create.mockRejectedValue(error); - assetMock.getUploadAssetIdByChecksum.mockResolvedValue(assetEntity.id); + mocks.asset.create.mockRejectedValue(error); + mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(assetEntity.id); await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({ id: 'id_1', status: AssetMediaStatus.DUPLICATE, }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DELETE_FILES, data: { files: ['fake_path/asset_1.jpeg', undefined] }, }); - expect(userMock.updateUsage).not.toHaveBeenCalled(); + expect(mocks.user.updateUsage).not.toHaveBeenCalled(); }); it('should throw an error if the duplicate could not be found by checksum', async () => { @@ -414,22 +404,22 @@ describe(AssetMediaService.name, () => { const error = new Error('unique key violation'); (error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT; - assetMock.create.mockRejectedValue(error); + mocks.asset.create.mockRejectedValue(error); await expect(sut.uploadAsset(authStub.user1, createDto, file)).rejects.toBeInstanceOf( InternalServerErrorException, ); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DELETE_FILES, data: { files: ['fake_path/asset_1.jpeg', undefined] }, }); - expect(userMock.updateUsage).not.toHaveBeenCalled(); + expect(mocks.user.updateUsage).not.toHaveBeenCalled(); }); it('should handle a live photo', async () => { - assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); - assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); + mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); + mocks.asset.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); await expect( sut.uploadAsset( @@ -442,13 +432,13 @@ describe(AssetMediaService.name, () => { id: 'live-photo-still-asset', }); - expect(assetMock.getById).toHaveBeenCalledWith('live-photo-motion-asset'); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.getById).toHaveBeenCalledWith('live-photo-motion-asset'); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should hide the linked motion asset', async () => { - assetMock.getById.mockResolvedValueOnce({ ...assetStub.livePhotoMotionAsset, isVisible: true }); - assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); + mocks.asset.getById.mockResolvedValueOnce({ ...assetStub.livePhotoMotionAsset, isVisible: true }); + mocks.asset.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); await expect( sut.uploadAsset( @@ -461,25 +451,25 @@ describe(AssetMediaService.name, () => { id: 'live-photo-still-asset', }); - expect(assetMock.getById).toHaveBeenCalledWith('live-photo-motion-asset'); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'live-photo-motion-asset', isVisible: false }); + expect(mocks.asset.getById).toHaveBeenCalledWith('live-photo-motion-asset'); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'live-photo-motion-asset', isVisible: false }); }); it('should handle a sidecar file', async () => { - assetMock.getById.mockResolvedValueOnce(assetStub.image); - assetMock.create.mockResolvedValueOnce(assetStub.image); + mocks.asset.getById.mockResolvedValueOnce(assetStub.image); + mocks.asset.create.mockResolvedValueOnce(assetStub.image); await expect(sut.uploadAsset(authStub.user1, createDto, fileStub.photo, fileStub.photoSidecar)).resolves.toEqual({ status: AssetMediaStatus.CREATED, id: assetStub.image.id, }); - expect(storageMock.utimes).toHaveBeenCalledWith( + expect(mocks.storage.utimes).toHaveBeenCalledWith( fileStub.photoSidecar.originalPath, expect.any(Date), new Date(createDto.fileModifiedAt), ); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); }); @@ -487,22 +477,22 @@ describe(AssetMediaService.name, () => { it('should require the asset.download permission', async () => { await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); - expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); - expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); }); it('should throw an error if the asset is not found', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(NotFoundException); - expect(assetMock.getById).toHaveBeenCalledWith('asset-1', { files: true }); + expect(mocks.asset.getById).toHaveBeenCalledWith('asset-1', { files: true }); }); it('should download a file', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValue(assetStub.image); await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).resolves.toEqual( new ImmichFileResponse({ @@ -518,13 +508,13 @@ describe(AssetMediaService.name, () => { it('should require asset.view permissions', async () => { await expect(sut.viewThumbnail(authStub.admin, 'id', {})).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); - expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); - expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); }); it('should throw an error if the asset does not exist', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); await expect( sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }), @@ -532,8 +522,8 @@ describe(AssetMediaService.name, () => { }); it('should throw an error if the requested thumbnail file does not exist', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue({ ...assetStub.image, files: [] }); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image, files: [] }); await expect( sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), @@ -541,8 +531,8 @@ describe(AssetMediaService.name, () => { }); it('should throw an error if the requested preview file does not exist', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue({ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image, files: [ { @@ -561,8 +551,8 @@ describe(AssetMediaService.name, () => { }); it('should fall back to preview if the requested thumbnail file does not exist', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue({ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image, files: [ { @@ -589,8 +579,8 @@ describe(AssetMediaService.name, () => { }); it('should get preview file', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue({ ...assetStub.image }); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image }); await expect( sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }), ).resolves.toEqual( @@ -604,8 +594,8 @@ describe(AssetMediaService.name, () => { }); it('should get thumbnail file', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue({ ...assetStub.image }); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image }); await expect( sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), ).resolves.toEqual( @@ -623,27 +613,27 @@ describe(AssetMediaService.name, () => { it('should require asset.view permissions', async () => { await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); - expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); - expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); }); it('should throw an error if the asset does not exist', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(NotFoundException); }); it('should throw an error if the asset is not a video', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue(assetStub.image); await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); }); it('should return the encoded video path if available', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.hasEncodedVideo.id])); - assetMock.getById.mockResolvedValue(assetStub.hasEncodedVideo); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.hasEncodedVideo.id])); + mocks.asset.getById.mockResolvedValue(assetStub.hasEncodedVideo); await expect(sut.playbackVideo(authStub.admin, assetStub.hasEncodedVideo.id)).resolves.toEqual( new ImmichFileResponse({ @@ -655,8 +645,8 @@ describe(AssetMediaService.name, () => { }); it('should fall back to the original path', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.video.id])); - assetMock.getById.mockResolvedValue(assetStub.video); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.video.id])); + mocks.asset.getById.mockResolvedValue(assetStub.video); await expect(sut.playbackVideo(authStub.admin, assetStub.video.id)).resolves.toEqual( new ImmichFileResponse({ @@ -670,12 +660,12 @@ describe(AssetMediaService.name, () => { describe('checkExistingAssets', () => { it('should get existing asset ids', async () => { - assetMock.getByDeviceIds.mockResolvedValue(['42']); + mocks.asset.getByDeviceIds.mockResolvedValue(['42']); await expect( sut.checkExistingAssets(authStub.admin, { deviceId: '420', deviceAssetIds: ['69'] }), ).resolves.toEqual({ existingIds: ['42'] }); - expect(assetMock.getByDeviceIds).toHaveBeenCalledWith(userStub.admin.id, '420', ['69']); + expect(mocks.asset.getByDeviceIds).toHaveBeenCalledWith(userStub.admin.id, '420', ['69']); }); }); @@ -685,26 +675,26 @@ describe(AssetMediaService.name, () => { 'Not found or no asset.update access', ); - expect(assetMock.create).not.toHaveBeenCalled(); + expect(mocks.asset.create).not.toHaveBeenCalled(); }); it('should update a photo with no sidecar to photo with no sidecar', async () => { const updatedFile = fileStub.photo; const updatedAsset = { ...existingAsset, ...updatedFile }; - assetMock.getById.mockResolvedValueOnce(existingAsset); - assetMock.getById.mockResolvedValueOnce(updatedAsset); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); + mocks.asset.getById.mockResolvedValueOnce(existingAsset); + mocks.asset.getById.mockResolvedValueOnce(updatedAsset); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); // this is the original file size - storageMock.stat.mockResolvedValue({ size: 0 } as Stats); + mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); // this is for the clone call - assetMock.create.mockResolvedValue(copiedAsset); + mocks.asset.create.mockResolvedValue(copiedAsset); await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({ status: AssetMediaStatus.REPLACED, id: 'copied-asset', }); - expect(assetMock.update).toHaveBeenCalledWith( + expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ id: existingAsset.id, sidecarPath: null, @@ -712,7 +702,7 @@ describe(AssetMediaService.name, () => { originalPath: 'fake_path/photo1.jpeg', }), ); - expect(assetMock.create).toHaveBeenCalledWith( + expect(mocks.asset.create).toHaveBeenCalledWith( expect.objectContaining({ sidecarPath: null, originalFileName: 'existing-filename.jpeg', @@ -720,12 +710,12 @@ describe(AssetMediaService.name, () => { }), ); - expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], { deletedAt: expect.any(Date), status: AssetStatus.TRASHED, }); - expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); - expect(storageMock.utimes).toHaveBeenCalledWith( + expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); + expect(mocks.storage.utimes).toHaveBeenCalledWith( updatedFile.originalPath, expect.any(Date), new Date(replaceDto.fileModifiedAt), @@ -736,13 +726,13 @@ describe(AssetMediaService.name, () => { const updatedFile = fileStub.photo; const sidecarFile = fileStub.photoSidecar; const updatedAsset = { ...sidecarAsset, ...updatedFile }; - assetMock.getById.mockResolvedValueOnce(existingAsset); - assetMock.getById.mockResolvedValueOnce(updatedAsset); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); + mocks.asset.getById.mockResolvedValueOnce(existingAsset); + mocks.asset.getById.mockResolvedValueOnce(updatedAsset); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); // this is the original file size - storageMock.stat.mockResolvedValue({ size: 0 } as Stats); + mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); // this is for the clone call - assetMock.create.mockResolvedValue(copiedAsset); + mocks.asset.create.mockResolvedValue(copiedAsset); await expect( sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile, sidecarFile), @@ -751,12 +741,12 @@ describe(AssetMediaService.name, () => { id: 'copied-asset', }); - expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], { deletedAt: expect.any(Date), status: AssetStatus.TRASHED, }); - expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); - expect(storageMock.utimes).toHaveBeenCalledWith( + expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); + expect(mocks.storage.utimes).toHaveBeenCalledWith( updatedFile.originalPath, expect.any(Date), new Date(replaceDto.fileModifiedAt), @@ -767,25 +757,25 @@ describe(AssetMediaService.name, () => { const updatedFile = fileStub.photo; const updatedAsset = { ...sidecarAsset, ...updatedFile }; - assetMock.getById.mockResolvedValueOnce(sidecarAsset); - assetMock.getById.mockResolvedValueOnce(updatedAsset); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); + mocks.asset.getById.mockResolvedValueOnce(sidecarAsset); + mocks.asset.getById.mockResolvedValueOnce(updatedAsset); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); // this is the original file size - storageMock.stat.mockResolvedValue({ size: 0 } as Stats); + mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); // this is for the copy call - assetMock.create.mockResolvedValue(copiedAsset); + mocks.asset.create.mockResolvedValue(copiedAsset); await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({ status: AssetMediaStatus.REPLACED, id: 'copied-asset', }); - expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], { deletedAt: expect.any(Date), status: AssetStatus.TRASHED, }); - expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); - expect(storageMock.utimes).toHaveBeenCalledWith( + expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); + expect(mocks.storage.utimes).toHaveBeenCalledWith( updatedFile.originalPath, expect.any(Date), new Date(replaceDto.fileModifiedAt), @@ -797,27 +787,27 @@ describe(AssetMediaService.name, () => { const error = new Error('unique key violation'); (error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT; - assetMock.update.mockRejectedValue(error); - assetMock.getById.mockResolvedValueOnce(sidecarAsset); - assetMock.getUploadAssetIdByChecksum.mockResolvedValue(sidecarAsset.id); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); + mocks.asset.update.mockRejectedValue(error); + mocks.asset.getById.mockResolvedValueOnce(sidecarAsset); + mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(sidecarAsset.id); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); // this is the original file size - storageMock.stat.mockResolvedValue({ size: 0 } as Stats); + mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); // this is for the clone call - assetMock.create.mockResolvedValue(copiedAsset); + mocks.asset.create.mockResolvedValue(copiedAsset); await expect(sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile)).resolves.toEqual({ status: AssetMediaStatus.DUPLICATE, id: sidecarAsset.id, }); - expect(assetMock.create).not.toHaveBeenCalled(); - expect(assetMock.updateAll).not.toHaveBeenCalled(); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.asset.create).not.toHaveBeenCalled(); + expect(mocks.asset.updateAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DELETE_FILES, data: { files: [updatedFile.originalPath, undefined] }, }); - expect(userMock.updateUsage).not.toHaveBeenCalled(); + expect(mocks.user.updateUsage).not.toHaveBeenCalled(); }); }); @@ -826,7 +816,7 @@ describe(AssetMediaService.name, () => { const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex'); - assetMock.getByChecksums.mockResolvedValue([ + mocks.asset.getByChecksums.mockResolvedValue([ { id: 'asset-1', checksum: file1 } as AssetEntity, { id: 'asset-2', checksum: file2 } as AssetEntity, ]); @@ -857,14 +847,14 @@ describe(AssetMediaService.name, () => { ], }); - expect(assetMock.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]); + expect(mocks.asset.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]); }); it('should return non-duplicates as well', async () => { const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex'); - assetMock.getByChecksums.mockResolvedValue([{ id: 'asset-1', checksum: file1 } as AssetEntity]); + mocks.asset.getByChecksums.mockResolvedValue([{ id: 'asset-1', checksum: file1 } as AssetEntity]); await expect( sut.bulkUploadCheck(authStub.admin, { @@ -889,7 +879,7 @@ describe(AssetMediaService.name, () => { ], }); - expect(assetMock.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]); + expect(mocks.asset.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]); }); }); @@ -910,7 +900,7 @@ describe(AssetMediaService.name, () => { await sut.onUploadError(request, file); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DELETE_FILES, data: { files: ['upload/upload/user-id/ra/nd/random-uuid.jpg'] }, }); diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index f1e537a3d8..9d64aacf10 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -4,22 +4,16 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetStatus, AssetType } from 'src/enum'; -import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { IStackRepository } from 'src/interfaces/stack.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { AssetStats } from 'src/interfaces/asset.interface'; +import { JobName, JobStatus } from 'src/interfaces/job.interface'; import { AssetService } from 'src/services/asset.service'; -import { ISystemMetadataRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked, vitest } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; +import { vitest } from 'vitest'; const stats: AssetStats = { [AssetType.IMAGE]: 10, @@ -36,27 +30,18 @@ const statResponse: AssetStatsResponseDto = { describe(AssetService.name, () => { let sut: AssetService; - - let accessMock: IAccessRepositoryMock; - let assetMock: Mocked; - let eventMock: Mocked; - let jobMock: Mocked; - let partnerMock: Mocked; - let stackMock: Mocked; - let systemMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; it('should work', () => { expect(sut).toBeDefined(); }); const mockGetById = (assets: AssetEntity[]) => { - assetMock.getById.mockImplementation((assetId) => Promise.resolve(assets.find((asset) => asset.id === assetId))); + mocks.asset.getById.mockImplementation((assetId) => Promise.resolve(assets.find((asset) => asset.id === assetId))); }; beforeEach(() => { - ({ sut, accessMock, assetMock, eventMock, jobMock, partnerMock, stackMock, systemMock, userMock } = - newTestService(AssetService)); + ({ sut, mocks } = newTestService(AssetService)); mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]); }); @@ -77,8 +62,8 @@ describe(AssetService.name, () => { const image3 = { ...assetStub.image, localDateTime: new Date(2015, 1, 15) }; const image4 = { ...assetStub.image, localDateTime: new Date(2009, 1, 15) }; - partnerMock.getAll.mockResolvedValue([]); - assetMock.getByDayOfYear.mockResolvedValue([ + mocks.partner.getAll.mockResolvedValue([]); + mocks.asset.getByDayOfYear.mockResolvedValue([ { yearsAgo: 1, assets: [image1, image2], @@ -99,16 +84,16 @@ describe(AssetService.name, () => { { yearsAgo: 15, title: '15 years ago', assets: [mapAsset(image4)] }, ]); - expect(assetMock.getByDayOfYear.mock.calls).toEqual([[[authStub.admin.user.id], { day: 15, month: 1 }]]); + expect(mocks.asset.getByDayOfYear.mock.calls).toEqual([[[authStub.admin.user.id], { day: 15, month: 1 }]]); }); it('should get memories with partners with inTimeline enabled', async () => { - partnerMock.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]); - assetMock.getByDayOfYear.mockResolvedValue([]); + mocks.partner.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]); + mocks.asset.getByDayOfYear.mockResolvedValue([]); await sut.getMemoryLane(authStub.admin, { day: 15, month: 1 }); - expect(assetMock.getByDayOfYear.mock.calls).toEqual([ + expect(mocks.asset.getByDayOfYear.mock.calls).toEqual([ [[authStub.admin.user.id, userStub.user1.id], { day: 15, month: 1 }], ]); }); @@ -116,76 +101,76 @@ describe(AssetService.name, () => { describe('getStatistics', () => { it('should get the statistics for a user, excluding archived assets', async () => { - assetMock.getStatistics.mockResolvedValue(stats); + mocks.asset.getStatistics.mockResolvedValue(stats); await expect(sut.getStatistics(authStub.admin, { isArchived: false })).resolves.toEqual(statResponse); - expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: false }); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: false }); }); it('should get the statistics for a user for archived assets', async () => { - assetMock.getStatistics.mockResolvedValue(stats); + mocks.asset.getStatistics.mockResolvedValue(stats); await expect(sut.getStatistics(authStub.admin, { isArchived: true })).resolves.toEqual(statResponse); - expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: true }); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: true }); }); it('should get the statistics for a user for favorite assets', async () => { - assetMock.getStatistics.mockResolvedValue(stats); + mocks.asset.getStatistics.mockResolvedValue(stats); await expect(sut.getStatistics(authStub.admin, { isFavorite: true })).resolves.toEqual(statResponse); - expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isFavorite: true }); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isFavorite: true }); }); it('should get the statistics for a user for all assets', async () => { - assetMock.getStatistics.mockResolvedValue(stats); + mocks.asset.getStatistics.mockResolvedValue(stats); await expect(sut.getStatistics(authStub.admin, {})).resolves.toEqual(statResponse); - expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, {}); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, {}); }); }); describe('getRandom', () => { it('should get own random assets', async () => { - assetMock.getRandom.mockResolvedValue([assetStub.image]); + mocks.asset.getRandom.mockResolvedValue([assetStub.image]); await sut.getRandom(authStub.admin, 1); - expect(assetMock.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1); + expect(mocks.asset.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1); }); it('should not include partner assets if not in timeline', async () => { - assetMock.getRandom.mockResolvedValue([assetStub.image]); - partnerMock.getAll.mockResolvedValue([{ ...partnerStub.user1ToAdmin1, inTimeline: false }]); + mocks.asset.getRandom.mockResolvedValue([assetStub.image]); + mocks.partner.getAll.mockResolvedValue([{ ...partnerStub.user1ToAdmin1, inTimeline: false }]); await sut.getRandom(authStub.admin, 1); - expect(assetMock.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1); + expect(mocks.asset.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1); }); it('should include partner assets if in timeline', async () => { - assetMock.getRandom.mockResolvedValue([assetStub.image]); - partnerMock.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]); + mocks.asset.getRandom.mockResolvedValue([assetStub.image]); + mocks.partner.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]); await sut.getRandom(authStub.admin, 1); - expect(assetMock.getRandom).toHaveBeenCalledWith([userStub.admin.id, userStub.user1.id], 1); + expect(mocks.asset.getRandom).toHaveBeenCalledWith([userStub.admin.id, userStub.user1.id], 1); }); }); describe('get', () => { it('should allow owner access', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.get(authStub.admin, assetStub.image.id); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([assetStub.image.id]), ); }); it('should allow shared link access', async () => { - accessMock.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.get(authStub.adminSharedLink, assetStub.image.id); - expect(accessMock.asset.checkSharedLinkAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLink?.id, new Set([assetStub.image.id]), ); }); it('should strip metadata for shared link if exif is disabled', async () => { - accessMock.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue(assetStub.image); const result = await sut.get( { ...authStub.adminSharedLink, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } }, @@ -194,27 +179,27 @@ describe(AssetService.name, () => { expect(result).toEqual(expect.objectContaining({ hasMetadata: false })); expect(result).not.toHaveProperty('exifInfo'); - expect(accessMock.asset.checkSharedLinkAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLink?.id, new Set([assetStub.image.id]), ); }); it('should allow partner sharing access', async () => { - accessMock.asset.checkPartnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.get(authStub.admin, assetStub.image.id); - expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([assetStub.image.id]), ); }); it('should allow shared album access', async () => { - accessMock.asset.checkAlbumAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.access.asset.checkAlbumAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.get(authStub.admin, assetStub.image.id); - expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([assetStub.image.id]), ); @@ -222,17 +207,17 @@ describe(AssetService.name, () => { it('should throw an error for no access', async () => { await expect(sut.get(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.getById).not.toHaveBeenCalled(); + expect(mocks.asset.getById).not.toHaveBeenCalled(); }); it('should throw an error for an invalid shared link', async () => { await expect(sut.get(authStub.adminSharedLink, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.asset.checkOwnerAccess).not.toHaveBeenCalled(); - expect(assetMock.getById).not.toHaveBeenCalled(); + expect(mocks.access.asset.checkOwnerAccess).not.toHaveBeenCalled(); + expect(mocks.asset.getById).not.toHaveBeenCalled(); }); it('should throw an error if the asset could not be found', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); await expect(sut.get(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); }); }); @@ -242,40 +227,40 @@ describe(AssetService.name, () => { await expect(sut.update(authStub.admin, 'asset-1', { isArchived: false })).rejects.toBeInstanceOf( BadRequestException, ); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should update the asset', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.getById.mockResolvedValue(assetStub.image); - assetMock.update.mockResolvedValue(assetStub.image); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValue(assetStub.image); + mocks.asset.update.mockResolvedValue(assetStub.image); await sut.update(authStub.admin, 'asset-1', { isFavorite: true }); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true }); }); it('should update the exif description', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.getById.mockResolvedValue(assetStub.image); - assetMock.update.mockResolvedValue(assetStub.image); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValue(assetStub.image); + mocks.asset.update.mockResolvedValue(assetStub.image); await sut.update(authStub.admin, 'asset-1', { description: 'Test description' }); - expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' }); }); it('should update the exif rating', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.getById.mockResolvedValueOnce(assetStub.image); - assetMock.update.mockResolvedValueOnce(assetStub.image); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValueOnce(assetStub.image); + mocks.asset.update.mockResolvedValueOnce(assetStub.image); await sut.update(authStub.admin, 'asset-1', { rating: 3 }); - expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', rating: 3 }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', rating: 3 }); }); it('should fail linking a live video if the motion part could not be found', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); await expect( sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { @@ -283,20 +268,20 @@ describe(AssetService.name, () => { }), ).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.update).not.toHaveBeenCalledWith({ + expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); - expect(assetMock.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); - expect(eventMock.emit).not.toHaveBeenCalledWith('asset.show', { + expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); + expect(mocks.event.emit).not.toHaveBeenCalledWith('asset.show', { assetId: assetStub.livePhotoMotionAsset.id, userId: userStub.admin.id, }); }); it('should fail linking a live video if the motion part is not a video', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); - assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); await expect( sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { @@ -304,20 +289,20 @@ describe(AssetService.name, () => { }), ).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.update).not.toHaveBeenCalledWith({ + expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); - expect(assetMock.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); - expect(eventMock.emit).not.toHaveBeenCalledWith('asset.show', { + expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); + expect(mocks.event.emit).not.toHaveBeenCalledWith('asset.show', { assetId: assetStub.livePhotoMotionAsset.id, userId: userStub.admin.id, }); }); it('should fail linking a live video if the motion part has a different owner', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); - assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); await expect( sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { @@ -325,79 +310,79 @@ describe(AssetService.name, () => { }), ).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.update).not.toHaveBeenCalledWith({ + expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); - expect(assetMock.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); - expect(eventMock.emit).not.toHaveBeenCalledWith('asset.show', { + expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); + expect(mocks.event.emit).not.toHaveBeenCalledWith('asset.show', { assetId: assetStub.livePhotoMotionAsset.id, userId: userStub.admin.id, }); }); it('should link a live video', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); - assetMock.getById.mockResolvedValueOnce({ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + mocks.asset.getById.mockResolvedValueOnce({ ...assetStub.livePhotoMotionAsset, ownerId: authStub.admin.user.id, isVisible: true, }); - assetMock.getById.mockResolvedValueOnce(assetStub.image); - assetMock.update.mockResolvedValue(assetStub.image); + mocks.asset.getById.mockResolvedValueOnce(assetStub.image); + mocks.asset.update.mockResolvedValue(assetStub.image); await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false }); - expect(eventMock.emit).toHaveBeenCalledWith('asset.hide', { + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false }); + expect(mocks.event.emit).toHaveBeenCalledWith('asset.hide', { assetId: assetStub.livePhotoMotionAsset.id, userId: userStub.admin.id, }); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); }); it('should throw an error if asset could not be found after update', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await expect(sut.update(authStub.admin, 'asset-1', { isFavorite: true })).rejects.toBeInstanceOf( BadRequestException, ); }); it('should unlink a live video', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); - assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset); - assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); - assetMock.update.mockResolvedValueOnce(assetStub.image); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset); + mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); + mocks.asset.update.mockResolvedValueOnce(assetStub.image); await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: null, }); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); - expect(eventMock.emit).toHaveBeenCalledWith('asset.show', { + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); + expect(mocks.event.emit).toHaveBeenCalledWith('asset.show', { assetId: assetStub.livePhotoMotionAsset.id, userId: userStub.admin.id, }); }); it('should fail unlinking a live video if the asset could not be found', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); // eslint-disable-next-line unicorn/no-useless-undefined - assetMock.getById.mockResolvedValueOnce(undefined); + mocks.asset.getById.mockResolvedValueOnce(undefined); await expect( sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }), ).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(eventMock.emit).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.event.emit).not.toHaveBeenCalled(); }); }); @@ -412,13 +397,13 @@ describe(AssetService.name, () => { }); it('should update all assets', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true }); - expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); + expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); }); it('should not update Assets table if no relevant fields are provided', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.updateAll(authStub.admin, { ids: ['asset-1'], latitude: 0, @@ -428,11 +413,11 @@ describe(AssetService.name, () => { duplicateId: undefined, rating: undefined, }); - expect(assetMock.updateAll).not.toHaveBeenCalled(); + expect(mocks.asset.updateAll).not.toHaveBeenCalled(); }); it('should update Assets table if isArchived field is provided', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.updateAll(authStub.admin, { ids: ['asset-1'], latitude: 0, @@ -442,7 +427,7 @@ describe(AssetService.name, () => { duplicateId: undefined, rating: undefined, }); - expect(assetMock.updateAll).toHaveBeenCalled(); + expect(mocks.asset.updateAll).toHaveBeenCalled(); }); }); @@ -456,26 +441,26 @@ describe(AssetService.name, () => { }); it('should force delete a batch of assets', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: true }); - expect(eventMock.emit).toHaveBeenCalledWith('assets.delete', { + expect(mocks.event.emit).toHaveBeenCalledWith('assets.delete', { assetIds: ['asset1', 'asset2'], userId: 'user-id', }); }); it('should soft delete a batch of assets', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: false }); - expect(assetMock.updateAll).toHaveBeenCalledWith(['asset1', 'asset2'], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset1', 'asset2'], { deletedAt: expect.any(Date), status: AssetStatus.TRASHED, }); - expect(jobMock.queue.mock.calls).toEqual([]); + expect(mocks.job.queue.mock.calls).toEqual([]); }); }); @@ -489,27 +474,27 @@ describe(AssetService.name, () => { }); it('should immediately queue assets for deletion if trash is disabled', async () => { - assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); - systemMock.get.mockResolvedValue({ trash: { enabled: false } }); + mocks.asset.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); + mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: false } }); await expect(sut.handleAssetDeletionCheck()).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getAll).toHaveBeenCalledWith(expect.anything(), { trashedBefore: new Date() }); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).toHaveBeenCalledWith(expect.anything(), { trashedBefore: new Date() }); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.ASSET_DELETION, data: { id: assetStub.image.id, deleteOnDisk: true } }, ]); }); it('should queue assets for deletion after trash duration', async () => { - assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); - systemMock.get.mockResolvedValue({ trash: { enabled: true, days: 7 } }); + mocks.asset.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); + mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: true, days: 7 } }); await expect(sut.handleAssetDeletionCheck()).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getAll).toHaveBeenCalledWith(expect.anything(), { + expect(mocks.asset.getAll).toHaveBeenCalledWith(expect.anything(), { trashedBefore: DateTime.now().minus({ days: 7 }).toJSDate(), }); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.ASSET_DELETION, data: { id: assetStub.image.id, deleteOnDisk: true } }, ]); }); @@ -519,11 +504,11 @@ describe(AssetService.name, () => { it('should remove faces', async () => { const assetWithFace = { ...assetStub.image, faces: [faceStub.face1, faceStub.mergeFace1] }; - assetMock.getById.mockResolvedValue(assetWithFace); + mocks.asset.getById.mockResolvedValue(assetWithFace); await sut.handleAssetDeletion({ id: assetWithFace.id, deleteOnDisk: true }); - expect(jobMock.queue.mock.calls).toEqual([ + expect(mocks.job.queue.mock.calls).toEqual([ [ { name: JobName.DELETE_FILES, @@ -540,41 +525,41 @@ describe(AssetService.name, () => { ], ]); - expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace); + expect(mocks.asset.remove).toHaveBeenCalledWith(assetWithFace); }); it('should update stack primary asset if deleted asset was primary asset in a stack', async () => { - assetMock.getById.mockResolvedValue(assetStub.primaryImage as AssetEntity); + mocks.asset.getById.mockResolvedValue(assetStub.primaryImage as AssetEntity); await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true }); - expect(stackMock.update).toHaveBeenCalledWith('stack-1', { + expect(mocks.stack.update).toHaveBeenCalledWith('stack-1', { id: 'stack-1', primaryAssetId: 'stack-child-asset-1', }); }); it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => { - assetMock.getById.mockResolvedValue({ + mocks.asset.getById.mockResolvedValue({ ...assetStub.primaryImage, stack: { ...assetStub.primaryImage.stack, assets: assetStub.primaryImage.stack!.assets.slice(0, 2) }, } as AssetEntity); await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true }); - expect(stackMock.delete).toHaveBeenCalledWith('stack-1'); + expect(mocks.stack.delete).toHaveBeenCalledWith('stack-1'); }); it('should delete a live photo', async () => { - assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset); - assetMock.getLivePhotoCount.mockResolvedValue(0); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); + mocks.asset.getLivePhotoCount.mockResolvedValue(0); await sut.handleAssetDeletion({ id: assetStub.livePhotoStillAsset.id, deleteOnDisk: true, }); - expect(jobMock.queue.mock.calls).toEqual([ + expect(mocks.job.queue.mock.calls).toEqual([ [ { name: JobName.ASSET_DELETION, @@ -596,15 +581,15 @@ describe(AssetService.name, () => { }); it('should not delete a live motion part if it is being used by another asset', async () => { - assetMock.getLivePhotoCount.mockResolvedValue(2); - assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset); + mocks.asset.getLivePhotoCount.mockResolvedValue(2); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); await sut.handleAssetDeletion({ id: assetStub.livePhotoStillAsset.id, deleteOnDisk: true, }); - expect(jobMock.queue.mock.calls).toEqual([ + expect(mocks.job.queue.mock.calls).toEqual([ [ { name: JobName.DELETE_FILES, @@ -617,9 +602,9 @@ describe(AssetService.name, () => { }); it('should update usage', async () => { - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true }); - expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000); + expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000); }); it('should fail if asset could not be found', async () => { @@ -631,27 +616,27 @@ describe(AssetService.name, () => { describe('run', () => { it('should run the refresh faces job', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_FACES }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.FACE_DETECTION, data: { id: 'asset-1' } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.FACE_DETECTION, data: { id: 'asset-1' } }]); }); it('should run the refresh metadata job', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_METADATA }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } }]); }); it('should run the refresh thumbnails job', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }]); }); it('should run the transcode video', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.TRANSCODE_VIDEO }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } }]); }); }); @@ -659,7 +644,7 @@ describe(AssetService.name, () => { it('get assets by device id', async () => { const assets = [assetStub.image, assetStub.image1]; - assetMock.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId)); + mocks.asset.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId)); const deviceId = 'device-id'; const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId); diff --git a/server/src/services/audit.service.spec.ts b/server/src/services/audit.service.spec.ts index dd853042fb..b459ecb473 100644 --- a/server/src/services/audit.service.spec.ts +++ b/server/src/services/audit.service.spec.ts @@ -1,28 +1,18 @@ import { BadRequestException } from '@nestjs/common'; import { FileReportItemDto } from 'src/dtos/audit.dto'; import { AssetFileType, AssetPathType, DatabaseAction, EntityType, PersonPathType, UserPathType } from 'src/enum'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { JobStatus } from 'src/interfaces/job.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; import { AuditService } from 'src/services/audit.service'; -import { IAuditRepository } from 'src/types'; import { auditStub } from 'test/fixtures/audit.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(AuditService.name, () => { let sut: AuditService; - let auditMock: Mocked; - let assetMock: Mocked; - let cryptoMock: Mocked; - let personMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, auditMock, assetMock, cryptoMock, personMock, userMock } = newTestService(AuditService)); + ({ sut, mocks } = newTestService(AuditService)); }); it('should work', () => { @@ -32,13 +22,13 @@ describe(AuditService.name, () => { describe('handleCleanup', () => { it('should delete old audit entries', async () => { await expect(sut.handleCleanup()).resolves.toBe(JobStatus.SUCCESS); - expect(auditMock.removeBefore).toHaveBeenCalledWith(expect.any(Date)); + expect(mocks.audit.removeBefore).toHaveBeenCalledWith(expect.any(Date)); }); }); describe('getDeletes', () => { it('should require full sync if the request is older than 100 days', async () => { - auditMock.getAfter.mockResolvedValue([]); + mocks.audit.getAfter.mockResolvedValue([]); const date = new Date(2022, 0, 1); await expect(sut.getDeletes(authStub.admin, { after: date, entityType: EntityType.ASSET })).resolves.toEqual({ @@ -46,7 +36,7 @@ describe(AuditService.name, () => { ids: [], }); - expect(auditMock.getAfter).toHaveBeenCalledWith(date, { + expect(mocks.audit.getAfter).toHaveBeenCalledWith(date, { action: DatabaseAction.DELETE, userIds: [authStub.admin.user.id], entityType: EntityType.ASSET, @@ -54,7 +44,7 @@ describe(AuditService.name, () => { }); it('should get any new or updated assets and deleted ids', async () => { - auditMock.getAfter.mockResolvedValue([auditStub.delete.entityId]); + mocks.audit.getAfter.mockResolvedValue([auditStub.delete.entityId]); const date = new Date(); await expect(sut.getDeletes(authStub.admin, { after: date, entityType: EntityType.ASSET })).resolves.toEqual({ @@ -62,7 +52,7 @@ describe(AuditService.name, () => { ids: ['asset-deleted'], }); - expect(auditMock.getAfter).toHaveBeenCalledWith(date, { + expect(mocks.audit.getAfter).toHaveBeenCalledWith(date, { action: DatabaseAction.DELETE, userIds: [authStub.admin.user.id], entityType: EntityType.ASSET, @@ -74,7 +64,7 @@ describe(AuditService.name, () => { it('should fail if the file is not in the immich path', async () => { await expect(sut.getChecksums({ filenames: ['foo/bar'] })).rejects.toBeInstanceOf(BadRequestException); - expect(cryptoMock.hashFile).not.toHaveBeenCalled(); + expect(mocks.crypto.hashFile).not.toHaveBeenCalled(); }); it('should get checksum for valid file', async () => { @@ -82,7 +72,7 @@ describe(AuditService.name, () => { { filename: './upload/my-file.jpg', checksum: expect.any(String) }, ]); - expect(cryptoMock.hashFile).toHaveBeenCalledWith('./upload/my-file.jpg'); + expect(mocks.crypto.hashFile).toHaveBeenCalledWith('./upload/my-file.jpg'); }); }); @@ -94,10 +84,10 @@ describe(AuditService.name, () => { ]), ).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(assetMock.upsertFile).not.toHaveBeenCalled(); - expect(personMock.update).not.toHaveBeenCalled(); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should update encoded video path', async () => { @@ -109,10 +99,10 @@ describe(AuditService.name, () => { } as FileReportItemDto, ]); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'my-id', encodedVideoPath: './upload/my-video.mp4' }); - expect(assetMock.upsertFile).not.toHaveBeenCalled(); - expect(personMock.update).not.toHaveBeenCalled(); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', encodedVideoPath: './upload/my-video.mp4' }); + expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should update preview path', async () => { @@ -124,14 +114,14 @@ describe(AuditService.name, () => { } as FileReportItemDto, ]); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ + expect(mocks.asset.upsertFile).toHaveBeenCalledWith({ assetId: 'my-id', type: AssetFileType.PREVIEW, path: './upload/my-preview.png', }); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(personMock.update).not.toHaveBeenCalled(); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should update thumbnail path', async () => { @@ -143,14 +133,14 @@ describe(AuditService.name, () => { } as FileReportItemDto, ]); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ + expect(mocks.asset.upsertFile).toHaveBeenCalledWith({ assetId: 'my-id', type: AssetFileType.THUMBNAIL, path: './upload/my-thumbnail.webp', }); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(personMock.update).not.toHaveBeenCalled(); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should update original path', async () => { @@ -162,10 +152,10 @@ describe(AuditService.name, () => { } as FileReportItemDto, ]); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'my-id', originalPath: './upload/my-original.png' }); - expect(assetMock.upsertFile).not.toHaveBeenCalled(); - expect(personMock.update).not.toHaveBeenCalled(); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', originalPath: './upload/my-original.png' }); + expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should update sidecar path', async () => { @@ -177,10 +167,10 @@ describe(AuditService.name, () => { } as FileReportItemDto, ]); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'my-id', sidecarPath: './upload/my-sidecar.xmp' }); - expect(assetMock.upsertFile).not.toHaveBeenCalled(); - expect(personMock.update).not.toHaveBeenCalled(); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', sidecarPath: './upload/my-sidecar.xmp' }); + expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should update face path', async () => { @@ -192,10 +182,10 @@ describe(AuditService.name, () => { } as FileReportItemDto, ]); - expect(personMock.update).toHaveBeenCalledWith({ id: 'my-id', thumbnailPath: './upload/my-face.jpg' }); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(assetMock.upsertFile).not.toHaveBeenCalled(); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'my-id', thumbnailPath: './upload/my-face.jpg' }); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should update profile path', async () => { @@ -207,10 +197,10 @@ describe(AuditService.name, () => { } as FileReportItemDto, ]); - expect(userMock.update).toHaveBeenCalledWith('my-id', { profileImagePath: './upload/my-profile-pic.jpg' }); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(assetMock.upsertFile).not.toHaveBeenCalled(); - expect(personMock.update).not.toHaveBeenCalled(); + expect(mocks.user.update).toHaveBeenCalledWith('my-id', { profileImagePath: './upload/my-profile-pic.jpg' }); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); }); }); }); diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index e3b418d350..9fb1af128e 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -3,20 +3,14 @@ import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { AuthType, Permission } from 'src/enum'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; import { AuthService } from 'src/services/auth.service'; -import { IApiKeyRepository, IOAuthRepository, ISessionRepository, ISystemMetadataRepository } from 'src/types'; import { keyStub } from 'test/fixtures/api-key.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { sessionStub } from 'test/fixtures/session.stub'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; const oauthResponse = { accessToken: 'cmFuZG9tLWJ5dGVz', @@ -56,23 +50,14 @@ const oauthUserWithDefaultQuota = { describe('AuthService', () => { let sut: AuthService; - - let cryptoMock: Mocked; - let eventMock: Mocked; - let keyMock: Mocked; - let oauthMock: Mocked; - let sessionMock: Mocked; - let sharedLinkMock: Mocked; - let systemMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, cryptoMock, eventMock, keyMock, oauthMock, sessionMock, sharedLinkMock, systemMock, userMock } = - newTestService(AuthService)); + ({ sut, mocks } = newTestService(AuthService)); - oauthMock.authorize.mockResolvedValue('access-token'); - oauthMock.getProfile.mockResolvedValue({ sub, email }); - oauthMock.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint'); + mocks.oauth.authorize.mockResolvedValue('access-token'); + mocks.oauth.getProfile.mockResolvedValue({ sub, email }); + mocks.oauth.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint'); }); it('should be defined', () => { @@ -82,31 +67,31 @@ describe('AuthService', () => { describe('onBootstrap', () => { it('should init the repo', () => { sut.onBootstrap(); - expect(oauthMock.init).toHaveBeenCalled(); + expect(mocks.oauth.init).toHaveBeenCalled(); }); }); describe('login', () => { it('should throw an error if password login is disabled', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.disabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled); await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); }); it('should check the user exists', async () => { - userMock.getByEmail.mockResolvedValue(void 0); + mocks.user.getByEmail.mockResolvedValue(void 0); await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); - expect(userMock.getByEmail).toHaveBeenCalledTimes(1); + expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); }); it('should check the user has a password', async () => { - userMock.getByEmail.mockResolvedValue({} as UserEntity); + mocks.user.getByEmail.mockResolvedValue({} as UserEntity); await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); - expect(userMock.getByEmail).toHaveBeenCalledTimes(1); + expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); }); it('should successfully log the user in', async () => { - userMock.getByEmail.mockResolvedValue(userStub.user1); - sessionMock.create.mockResolvedValue(sessionStub.valid); + mocks.user.getByEmail.mockResolvedValue(userStub.user1); + mocks.session.create.mockResolvedValue(sessionStub.valid); await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({ accessToken: 'cmFuZG9tLWJ5dGVz', userId: 'user-id', @@ -116,7 +101,7 @@ describe('AuthService', () => { isAdmin: false, shouldChangePassword: false, }); - expect(userMock.getByEmail).toHaveBeenCalledTimes(1); + expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); }); }); @@ -125,23 +110,23 @@ describe('AuthService', () => { const auth = { user: { email: 'test@imimch.com' } } as AuthDto; const dto = { password: 'old-password', newPassword: 'new-password' }; - userMock.getByEmail.mockResolvedValue({ + mocks.user.getByEmail.mockResolvedValue({ email: 'test@immich.com', password: 'hash-password', } as UserEntity); - userMock.update.mockResolvedValue(userStub.user1); + mocks.user.update.mockResolvedValue(userStub.user1); await sut.changePassword(auth, dto); - expect(userMock.getByEmail).toHaveBeenCalledWith(auth.user.email, true); - expect(cryptoMock.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password'); + expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, true); + expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password'); }); it('should throw when auth user email is not found', async () => { const auth = { user: { email: 'test@imimch.com' } } as AuthDto; const dto = { password: 'old-password', newPassword: 'new-password' }; - userMock.getByEmail.mockResolvedValue(void 0); + mocks.user.getByEmail.mockResolvedValue(void 0); await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(UnauthorizedException); }); @@ -150,9 +135,9 @@ describe('AuthService', () => { const auth = { user: { email: 'test@imimch.com' } as UserEntity }; const dto = { password: 'old-password', newPassword: 'new-password' }; - cryptoMock.compareBcrypt.mockReturnValue(false); + mocks.crypto.compareBcrypt.mockReturnValue(false); - userMock.getByEmail.mockResolvedValue({ + mocks.user.getByEmail.mockResolvedValue({ email: 'test@immich.com', password: 'hash-password', } as UserEntity); @@ -164,7 +149,7 @@ describe('AuthService', () => { const auth = { user: { email: 'test@imimch.com' } } as AuthDto; const dto = { password: 'old-password', newPassword: 'new-password' }; - userMock.getByEmail.mockResolvedValue({ + mocks.user.getByEmail.mockResolvedValue({ email: 'test@immich.com', password: '', } as UserEntity); @@ -175,7 +160,7 @@ describe('AuthService', () => { describe('logout', () => { it('should return the end session endpoint', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.enabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); const auth = { user: { id: '123' } } as AuthDto; await expect(sut.logout(auth, AuthType.OAUTH)).resolves.toEqual({ successful: true, @@ -200,8 +185,8 @@ describe('AuthService', () => { redirectUri: '/auth/login?autoLaunch=0', }); - expect(sessionMock.delete).toHaveBeenCalledWith('token123'); - expect(eventMock.emit).toHaveBeenCalledWith('session.delete', { sessionId: 'token123' }); + expect(mocks.session.delete).toHaveBeenCalledWith('token123'); + expect(mocks.event.emit).toHaveBeenCalledWith('session.delete', { sessionId: 'token123' }); }); it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => { @@ -218,14 +203,14 @@ describe('AuthService', () => { const dto: SignUpDto = { email: 'test@immich.com', password: 'password', name: 'immich admin' }; it('should only allow one admin', async () => { - userMock.getAdmin.mockResolvedValue({} as UserEntity); + mocks.user.getAdmin.mockResolvedValue({} as UserEntity); await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException); - expect(userMock.getAdmin).toHaveBeenCalled(); + expect(mocks.user.getAdmin).toHaveBeenCalled(); }); it('should sign up the admin', async () => { - userMock.getAdmin.mockResolvedValue(void 0); - userMock.create.mockResolvedValue({ + mocks.user.getAdmin.mockResolvedValue(void 0); + mocks.user.create.mockResolvedValue({ ...dto, id: 'admin', createdAt: new Date('2021-01-01'), @@ -238,8 +223,8 @@ describe('AuthService', () => { email: 'test@immich.com', name: 'immich admin', }); - expect(userMock.getAdmin).toHaveBeenCalled(); - expect(userMock.create).toHaveBeenCalled(); + expect(mocks.user.getAdmin).toHaveBeenCalled(); + expect(mocks.user.create).toHaveBeenCalled(); }); }); @@ -255,8 +240,8 @@ describe('AuthService', () => { }); it('should validate using authorization header', async () => { - userMock.get.mockResolvedValue(userStub.user1); - sessionMock.getByToken.mockResolvedValue(sessionStub.valid as any); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); await expect( sut.authenticate({ headers: { authorization: 'Bearer auth_token' }, @@ -282,7 +267,7 @@ describe('AuthService', () => { }); it('should not accept an expired key', async () => { - sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.expired); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired); await expect( sut.authenticate({ headers: { 'x-immich-share-key': 'key' }, @@ -293,7 +278,7 @@ describe('AuthService', () => { }); it('should not accept a key on a non-shared route', async () => { - sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid); await expect( sut.authenticate({ headers: { 'x-immich-share-key': 'key' }, @@ -304,8 +289,8 @@ describe('AuthService', () => { }); it('should not accept a key without a user', async () => { - sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.expired); - userMock.get.mockResolvedValue(void 0); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired); + mocks.user.get.mockResolvedValue(void 0); await expect( sut.authenticate({ headers: { 'x-immich-share-key': 'key' }, @@ -316,8 +301,8 @@ describe('AuthService', () => { }); it('should accept a base64url key', async () => { - sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid); - userMock.get.mockResolvedValue(userStub.admin); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid); + mocks.user.get.mockResolvedValue(userStub.admin); await expect( sut.authenticate({ headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('base64url') }, @@ -328,12 +313,12 @@ describe('AuthService', () => { user: userStub.admin, sharedLink: sharedLinkStub.valid, }); - expect(sharedLinkMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); + expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); }); it('should accept a hex key', async () => { - sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid); - userMock.get.mockResolvedValue(userStub.admin); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid); + mocks.user.get.mockResolvedValue(userStub.admin); await expect( sut.authenticate({ headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('hex') }, @@ -344,13 +329,13 @@ describe('AuthService', () => { user: userStub.admin, sharedLink: sharedLinkStub.valid, }); - expect(sharedLinkMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); + expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); }); }); describe('validate - user token', () => { it('should throw if no token is found', async () => { - sessionMock.getByToken.mockResolvedValue(void 0); + mocks.session.getByToken.mockResolvedValue(void 0); await expect( sut.authenticate({ headers: { 'x-immich-user-token': 'auth_token' }, @@ -361,7 +346,7 @@ describe('AuthService', () => { }); it('should return an auth dto', async () => { - sessionMock.getByToken.mockResolvedValue(sessionStub.valid as any); + mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); await expect( sut.authenticate({ headers: { cookie: 'immich_access_token=auth_token' }, @@ -375,7 +360,7 @@ describe('AuthService', () => { }); it('should throw if admin route and not an admin', async () => { - sessionMock.getByToken.mockResolvedValue(sessionStub.valid as any); + mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); await expect( sut.authenticate({ headers: { cookie: 'immich_access_token=auth_token' }, @@ -386,8 +371,8 @@ describe('AuthService', () => { }); it('should update when access time exceeds an hour', async () => { - sessionMock.getByToken.mockResolvedValue(sessionStub.inactive as any); - sessionMock.update.mockResolvedValue(sessionStub.valid); + mocks.session.getByToken.mockResolvedValue(sessionStub.inactive as any); + mocks.session.update.mockResolvedValue(sessionStub.valid); await expect( sut.authenticate({ headers: { cookie: 'immich_access_token=auth_token' }, @@ -395,13 +380,13 @@ describe('AuthService', () => { metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, }), ).resolves.toBeDefined(); - expect(sessionMock.update.mock.calls[0][1]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) }); + expect(mocks.session.update.mock.calls[0][1]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) }); }); }); describe('validate - api key', () => { it('should throw an error if no api key is found', async () => { - keyMock.getKey.mockResolvedValue(void 0); + mocks.apiKey.getKey.mockResolvedValue(void 0); await expect( sut.authenticate({ headers: { 'x-api-key': 'auth_token' }, @@ -409,11 +394,11 @@ describe('AuthService', () => { metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, }), ).rejects.toBeInstanceOf(UnauthorizedException); - expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)'); + expect(mocks.apiKey.getKey).toHaveBeenCalledWith('auth_token (hashed)'); }); it('should throw an error if api key has insufficient permissions', async () => { - keyMock.getKey.mockResolvedValue(keyStub.authKey); + mocks.apiKey.getKey.mockResolvedValue(keyStub.authKey); await expect( sut.authenticate({ headers: { 'x-api-key': 'auth_token' }, @@ -424,7 +409,7 @@ describe('AuthService', () => { }); it('should return an auth dto', async () => { - keyMock.getKey.mockResolvedValue(keyStub.authKey); + mocks.apiKey.getKey.mockResolvedValue(keyStub.authKey); await expect( sut.authenticate({ headers: { 'x-api-key': 'auth_token' }, @@ -432,7 +417,7 @@ describe('AuthService', () => { metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, }), ).resolves.toEqual({ user: userStub.admin, apiKey: keyStub.authKey }); - expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)'); + expect(mocks.apiKey.getKey).toHaveBeenCalledWith('auth_token (hashed)'); }); }); @@ -450,14 +435,14 @@ describe('AuthService', () => { describe('authorize', () => { it('should fail if oauth is disabled', async () => { - systemMock.get.mockResolvedValue({ oauth: { enabled: false } }); + mocks.systemMetadata.get.mockResolvedValue({ oauth: { enabled: false } }); await expect(sut.authorize({ redirectUri: 'https://demo.immich.app' })).rejects.toBeInstanceOf( BadRequestException, ); }); it('should authorize the user', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); await sut.authorize({ redirectUri: 'https://demo.immich.app' }); }); }); @@ -468,71 +453,71 @@ describe('AuthService', () => { }); it('should not allow auto registering', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthEnabled); - userMock.getByEmail.mockResolvedValue(void 0); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); + mocks.user.getByEmail.mockResolvedValue(void 0); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf( BadRequestException, ); - expect(userMock.getByEmail).toHaveBeenCalledTimes(1); + expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); }); it('should link an existing user', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthEnabled); - userMock.getByEmail.mockResolvedValue(userStub.user1); - userMock.update.mockResolvedValue(userStub.user1); - sessionMock.create.mockResolvedValue(sessionStub.valid); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); + mocks.user.getByEmail.mockResolvedValue(userStub.user1); + mocks.user.update.mockResolvedValue(userStub.user1); + mocks.session.create.mockResolvedValue(sessionStub.valid); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, ); - expect(userMock.getByEmail).toHaveBeenCalledTimes(1); - expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { oauthId: sub }); + expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); + expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { oauthId: sub }); }); it('should not link to a user with a different oauth sub', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); - userMock.getByEmail.mockResolvedValueOnce({ ...userStub.user1, oauthId: 'existing-sub' }); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); + mocks.user.getByEmail.mockResolvedValueOnce({ ...userStub.user1, oauthId: 'existing-sub' }); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toThrow( BadRequestException, ); - expect(userMock.update).not.toHaveBeenCalled(); - expect(userMock.create).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); + expect(mocks.user.create).not.toHaveBeenCalled(); }); it('should allow auto registering by default', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.enabled); - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); - sessionMock.create.mockResolvedValue(sessionStub.valid); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); + mocks.session.create.mockResolvedValue(sessionStub.valid); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, ); - expect(userMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create - expect(userMock.create).toHaveBeenCalledTimes(1); + expect(mocks.user.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create + expect(mocks.user.create).toHaveBeenCalledTimes(1); }); it('should throw an error if user should be auto registered but the email claim does not exist', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.enabled); - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); - sessionMock.create.mockResolvedValue(sessionStub.valid); - oauthMock.getProfile.mockResolvedValue({ sub, email: undefined }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); + mocks.session.create.mockResolvedValue(sessionStub.valid); + mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf( BadRequestException, ); - expect(userMock.getByEmail).not.toHaveBeenCalled(); - expect(userMock.create).not.toHaveBeenCalled(); + expect(mocks.user.getByEmail).not.toHaveBeenCalled(); + expect(mocks.user.create).not.toHaveBeenCalled(); }); for (const url of [ @@ -544,68 +529,68 @@ describe('AuthService', () => { 'app.immich:///oauth-callback?code=abc123', ]) { it(`should use the mobile redirect override for a url of ${url}`, async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); - userMock.getByOAuthId.mockResolvedValue(userStub.user1); - sessionMock.create.mockResolvedValue(sessionStub.valid); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); + mocks.user.getByOAuthId.mockResolvedValue(userStub.user1); + mocks.session.create.mockResolvedValue(sessionStub.valid); await sut.callback({ url }, loginDetails); - expect(oauthMock.getProfile).toHaveBeenCalledWith(expect.objectContaining({}), url, 'http://mobile-redirect'); + expect(mocks.oauth.getProfile).toHaveBeenCalledWith(expect.objectContaining({}), url, 'http://mobile-redirect'); }); } it('should use the default quota', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, ); - expect(userMock.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); + expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); }); it('should ignore an invalid storage quota', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); - oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 'abc' }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); + mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 'abc' }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, ); - expect(userMock.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); + expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); }); it('should ignore a negative quota', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); - oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: -5 }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); + mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: -5 }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, ); - expect(userMock.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); + expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); }); it('should not set quota for 0 quota', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); - oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 0 }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); + mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 0 }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, ); - expect(userMock.create).toHaveBeenCalledWith({ + expect(mocks.user.create).toHaveBeenCalledWith({ email, name: ' ', oauthId: sub, @@ -615,17 +600,17 @@ describe('AuthService', () => { }); it('should use a valid storage quota', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); - oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 5 }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); + mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 5 }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, ); - expect(userMock.create).toHaveBeenCalledWith({ + expect(mocks.user.create).toHaveBeenCalledWith({ email, name: ' ', oauthId: sub, @@ -637,34 +622,34 @@ describe('AuthService', () => { describe('link', () => { it('should link an account', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.enabled); - userMock.update.mockResolvedValue(userStub.user1); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); + mocks.user.update.mockResolvedValue(userStub.user1); await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' }); - expect(userMock.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: sub }); + expect(mocks.user.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: sub }); }); it('should not link an already linked oauth.sub', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.enabled); - userMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); + mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity); await expect(sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); }); describe('unlink', () => { it('should unlink an account', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.enabled); - userMock.update.mockResolvedValue(userStub.user1); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); + mocks.user.update.mockResolvedValue(userStub.user1); await sut.unlink(authStub.user1); - expect(userMock.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: '' }); + expect(mocks.user.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: '' }); }); }); }); diff --git a/server/src/services/backup.service.spec.ts b/server/src/services/backup.service.spec.ts index 7b8454f61e..fbed87a6d3 100644 --- a/server/src/services/backup.service.spec.ts +++ b/server/src/services/backup.service.spec.ts @@ -2,27 +2,18 @@ import { PassThrough } from 'node:stream'; import { defaults, SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; import { ImmichWorker, StorageFolder } from 'src/enum'; -import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { JobStatus } from 'src/interfaces/job.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; import { BackupService } from 'src/services/backup.service'; -import { IConfigRepository, ICronRepository, IProcessRepository, ISystemMetadataRepository } from 'src/types'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { mockSpawn, newTestService } from 'test/utils'; -import { describe, Mocked } from 'vitest'; +import { mockSpawn, newTestService, ServiceMocks } from 'test/utils'; +import { describe } from 'vitest'; describe(BackupService.name, () => { let sut: BackupService; - - let databaseMock: Mocked; - let configMock: Mocked; - let cronMock: Mocked; - let processMock: Mocked; - let storageMock: Mocked; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, cronMock, configMock, databaseMock, processMock, storageMock, systemMock } = newTestService(BackupService)); + ({ sut, mocks } = newTestService(BackupService)); }); it('should work', () => { @@ -31,32 +22,32 @@ describe(BackupService.name, () => { describe('onBootstrapEvent', () => { it('should init cron job and handle config changes', async () => { - databaseMock.tryLock.mockResolvedValue(true); + mocks.database.tryLock.mockResolvedValue(true); await sut.onConfigInit({ newConfig: systemConfigStub.backupEnabled as SystemConfig }); - expect(cronMock.create).toHaveBeenCalled(); + expect(mocks.cron.create).toHaveBeenCalled(); }); it('should not initialize backup database cron job when lock is taken', async () => { - databaseMock.tryLock.mockResolvedValue(false); + mocks.database.tryLock.mockResolvedValue(false); await sut.onConfigInit({ newConfig: systemConfigStub.backupEnabled as SystemConfig }); - expect(cronMock.create).not.toHaveBeenCalled(); + expect(mocks.cron.create).not.toHaveBeenCalled(); }); it('should not initialise backup database job when running on microservices', async () => { - configMock.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); + mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); await sut.onConfigInit({ newConfig: systemConfigStub.backupEnabled as SystemConfig }); - expect(cronMock.create).not.toHaveBeenCalled(); + expect(mocks.cron.create).not.toHaveBeenCalled(); }); }); describe('onConfigUpdateEvent', () => { beforeEach(async () => { - databaseMock.tryLock.mockResolvedValue(true); + mocks.database.tryLock.mockResolvedValue(true); await sut.onConfigInit({ newConfig: defaults }); }); @@ -73,66 +64,66 @@ describe(BackupService.name, () => { } as SystemConfig, }); - expect(cronMock.update).toHaveBeenCalledWith({ name: 'backupDatabase', expression: '0 1 * * *', start: true }); - expect(cronMock.update).toHaveBeenCalled(); + expect(mocks.cron.update).toHaveBeenCalledWith({ name: 'backupDatabase', expression: '0 1 * * *', start: true }); + expect(mocks.cron.update).toHaveBeenCalled(); }); it('should do nothing if instance does not have the backup database lock', async () => { - databaseMock.tryLock.mockResolvedValue(false); + mocks.database.tryLock.mockResolvedValue(false); await sut.onConfigInit({ newConfig: defaults }); sut.onConfigUpdate({ newConfig: systemConfigStub.backupEnabled as SystemConfig, oldConfig: defaults }); - expect(cronMock.update).not.toHaveBeenCalled(); + expect(mocks.cron.update).not.toHaveBeenCalled(); }); }); describe('cleanupDatabaseBackups', () => { it('should do nothing if not reached keepLastAmount', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); - storageMock.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz']); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); + mocks.storage.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz']); await sut.cleanupDatabaseBackups(); - expect(storageMock.unlink).not.toHaveBeenCalled(); + expect(mocks.storage.unlink).not.toHaveBeenCalled(); }); it('should remove failed backup files', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); - storageMock.readdir.mockResolvedValue([ + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); + mocks.storage.readdir.mockResolvedValue([ 'immich-db-backup-123.sql.gz.tmp', 'immich-db-backup-234.sql.gz', 'immich-db-backup-345.sql.gz.tmp', ]); await sut.cleanupDatabaseBackups(); - expect(storageMock.unlink).toHaveBeenCalledTimes(2); - expect(storageMock.unlink).toHaveBeenCalledWith( + expect(mocks.storage.unlink).toHaveBeenCalledTimes(2); + expect(mocks.storage.unlink).toHaveBeenCalledWith( `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-123.sql.gz.tmp`, ); - expect(storageMock.unlink).toHaveBeenCalledWith( + expect(mocks.storage.unlink).toHaveBeenCalledWith( `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-345.sql.gz.tmp`, ); }); it('should remove old backup files over keepLastAmount', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); - storageMock.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz', 'immich-db-backup-2.sql.gz']); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); + mocks.storage.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz', 'immich-db-backup-2.sql.gz']); await sut.cleanupDatabaseBackups(); - expect(storageMock.unlink).toHaveBeenCalledTimes(1); - expect(storageMock.unlink).toHaveBeenCalledWith( + expect(mocks.storage.unlink).toHaveBeenCalledTimes(1); + expect(mocks.storage.unlink).toHaveBeenCalledWith( `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-1.sql.gz`, ); }); it('should remove old backup files over keepLastAmount and failed backups', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); - storageMock.readdir.mockResolvedValue([ + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); + mocks.storage.readdir.mockResolvedValue([ 'immich-db-backup-1.sql.gz.tmp', 'immich-db-backup-2.sql.gz', 'immich-db-backup-3.sql.gz', ]); await sut.cleanupDatabaseBackups(); - expect(storageMock.unlink).toHaveBeenCalledTimes(2); - expect(storageMock.unlink).toHaveBeenCalledWith( + expect(mocks.storage.unlink).toHaveBeenCalledTimes(2); + expect(mocks.storage.unlink).toHaveBeenCalledWith( `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-1.sql.gz.tmp`, ); - expect(storageMock.unlink).toHaveBeenCalledWith( + expect(mocks.storage.unlink).toHaveBeenCalledWith( `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-2.sql.gz`, ); }); @@ -140,57 +131,57 @@ describe(BackupService.name, () => { describe('handleBackupDatabase', () => { beforeEach(() => { - storageMock.readdir.mockResolvedValue([]); - processMock.spawn.mockReturnValue(mockSpawn(0, 'data', '')); - storageMock.rename.mockResolvedValue(); - storageMock.unlink.mockResolvedValue(); - systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); - storageMock.createWriteStream.mockReturnValue(new PassThrough()); + mocks.storage.readdir.mockResolvedValue([]); + mocks.process.spawn.mockReturnValue(mockSpawn(0, 'data', '')); + mocks.storage.rename.mockResolvedValue(); + mocks.storage.unlink.mockResolvedValue(); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); + mocks.storage.createWriteStream.mockReturnValue(new PassThrough()); }); it('should run a database backup successfully', async () => { const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.SUCCESS); - expect(storageMock.createWriteStream).toHaveBeenCalled(); + expect(mocks.storage.createWriteStream).toHaveBeenCalled(); }); it('should rename file on success', async () => { const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.SUCCESS); - expect(storageMock.rename).toHaveBeenCalled(); + expect(mocks.storage.rename).toHaveBeenCalled(); }); it('should fail if pg_dumpall fails', async () => { - processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); + mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.FAILED); }); it('should not rename file if pgdump fails and gzip succeeds', async () => { - processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); + mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.FAILED); - expect(storageMock.rename).not.toHaveBeenCalled(); + expect(mocks.storage.rename).not.toHaveBeenCalled(); }); it('should fail if gzip fails', async () => { - processMock.spawn.mockReturnValueOnce(mockSpawn(0, 'data', '')); - processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); + mocks.process.spawn.mockReturnValueOnce(mockSpawn(0, 'data', '')); + mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.FAILED); }); it('should fail if write stream fails', async () => { - storageMock.createWriteStream.mockImplementation(() => { + mocks.storage.createWriteStream.mockImplementation(() => { throw new Error('error'); }); const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.FAILED); }); it('should fail if rename fails', async () => { - storageMock.rename.mockRejectedValue(new Error('error')); + mocks.storage.rename.mockRejectedValue(new Error('error')); const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.FAILED); }); it('should ignore unlink failing and still return failed job status', async () => { - processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); - storageMock.unlink.mockRejectedValue(new Error('error')); + mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); + mocks.storage.unlink.mockRejectedValue(new Error('error')); const result = await sut.handleBackupDatabase(); - expect(storageMock.unlink).toHaveBeenCalled(); + expect(mocks.storage.unlink).toHaveBeenCalled(); expect(result).toBe(JobStatus.FAILED); }); it.each` @@ -204,9 +195,9 @@ describe(BackupService.name, () => { `( `should use pg_dumpall $expectedVersion with postgres version $postgresVersion`, async ({ postgresVersion, expectedVersion }) => { - databaseMock.getPostgresVersion.mockResolvedValue(postgresVersion); + mocks.database.getPostgresVersion.mockResolvedValue(postgresVersion); await sut.handleBackupDatabase(); - expect(processMock.spawn).toHaveBeenCalledWith( + expect(mocks.process.spawn).toHaveBeenCalledWith( `/usr/lib/postgresql/${expectedVersion}/bin/pg_dumpall`, expect.any(Array), expect.any(Object), @@ -218,9 +209,9 @@ describe(BackupService.name, () => { ${'13.99.99'} ${'18.0.0'} `(`should fail if postgres version $postgresVersion is not supported`, async ({ postgresVersion }) => { - databaseMock.getPostgresVersion.mockResolvedValue(postgresVersion); + mocks.database.getPostgresVersion.mockResolvedValue(postgresVersion); const result = await sut.handleBackupDatabase(); - expect(processMock.spawn).not.toHaveBeenCalled(); + expect(mocks.process.spawn).not.toHaveBeenCalled(); expect(result).toBe(JobStatus.FAILED); }); }); diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 8a690b752e..9285c69ced 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -30,6 +30,7 @@ import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AuditRepository } from 'src/repositories/audit.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; import { CronRepository } from 'src/repositories/cron.repository'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MapRepository } from 'src/repositories/map.repository'; import { MediaRepository } from 'src/repositories/media.repository'; @@ -61,7 +62,7 @@ export class BaseService { @Inject(IAssetRepository) protected assetRepository: IAssetRepository, protected configRepository: ConfigRepository, protected cronRepository: CronRepository, - @Inject(ICryptoRepository) protected cryptoRepository: ICryptoRepository, + @Inject(ICryptoRepository) protected cryptoRepository: CryptoRepository, @Inject(IDatabaseRepository) protected databaseRepository: IDatabaseRepository, @Inject(IEventRepository) protected eventRepository: IEventRepository, @Inject(IJobRepository) protected jobRepository: IJobRepository, diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index 987af3a287..c585142cbf 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -1,31 +1,27 @@ -import { IUserRepository } from 'src/interfaces/user.interface'; import { CliService } from 'src/services/cli.service'; -import { ISystemMetadataRepository } from 'src/types'; import { userStub } from 'test/fixtures/user.stub'; -import { newTestService } from 'test/utils'; -import { Mocked, describe, it } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; +import { describe, it } from 'vitest'; describe(CliService.name, () => { let sut: CliService; - - let userMock: Mocked; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, userMock, systemMock } = newTestService(CliService)); + ({ sut, mocks } = newTestService(CliService)); }); describe('listUsers', () => { it('should list users', async () => { - userMock.getList.mockResolvedValue([userStub.admin]); + mocks.user.getList.mockResolvedValue([userStub.admin]); await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]); - expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: true }); + expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true }); }); }); describe('resetAdminPassword', () => { it('should only work when there is an admin account', async () => { - userMock.getAdmin.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(void 0); const ask = vitest.fn().mockResolvedValue('new-password'); await expect(sut.resetAdminPassword(ask)).rejects.toThrowError('Admin account does not exist'); @@ -34,12 +30,12 @@ describe(CliService.name, () => { }); it('should default to a random password', async () => { - userMock.getAdmin.mockResolvedValue(userStub.admin); + mocks.user.getAdmin.mockResolvedValue(userStub.admin); const ask = vitest.fn().mockImplementation(() => {}); const response = await sut.resetAdminPassword(ask); - const [id, update] = userMock.update.mock.calls[0]; + const [id, update] = mocks.user.update.mock.calls[0]; expect(response.provided).toBe(false); expect(ask).toHaveBeenCalled(); @@ -48,12 +44,12 @@ describe(CliService.name, () => { }); it('should use the supplied password', async () => { - userMock.getAdmin.mockResolvedValue(userStub.admin); + mocks.user.getAdmin.mockResolvedValue(userStub.admin); const ask = vitest.fn().mockResolvedValue('new-password'); const response = await sut.resetAdminPassword(ask); - const [id, update] = userMock.update.mock.calls[0]; + const [id, update] = mocks.user.update.mock.calls[0]; expect(response.provided).toBe(true); expect(ask).toHaveBeenCalled(); @@ -65,28 +61,28 @@ describe(CliService.name, () => { describe('disablePasswordLogin', () => { it('should disable password login', async () => { await sut.disablePasswordLogin(); - expect(systemMock.set).toHaveBeenCalledWith('system-config', { passwordLogin: { enabled: false } }); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith('system-config', { passwordLogin: { enabled: false } }); }); }); describe('enablePasswordLogin', () => { it('should enable password login', async () => { await sut.enablePasswordLogin(); - expect(systemMock.set).toHaveBeenCalledWith('system-config', {}); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith('system-config', {}); }); }); describe('disableOAuthLogin', () => { it('should disable oauth login', async () => { await sut.disableOAuthLogin(); - expect(systemMock.set).toHaveBeenCalledWith('system-config', {}); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith('system-config', {}); }); }); describe('enableOAuthLogin', () => { it('should enable oauth login', async () => { await sut.enableOAuthLogin(); - expect(systemMock.set).toHaveBeenCalledWith('system-config', { oauth: { enabled: true } }); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith('system-config', { oauth: { enabled: true } }); }); }); }); diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts index edd2f9dc62..566cd32778 100644 --- a/server/src/services/database.service.spec.ts +++ b/server/src/services/database.service.spec.ts @@ -1,21 +1,12 @@ -import { - DatabaseExtension, - EXTENSION_NAMES, - IDatabaseRepository, - VectorExtension, -} from 'src/interfaces/database.interface'; +import { DatabaseExtension, EXTENSION_NAMES, VectorExtension } from 'src/interfaces/database.interface'; import { DatabaseService } from 'src/services/database.service'; -import { IConfigRepository, ILoggingRepository } from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(DatabaseService.name, () => { let sut: DatabaseService; + let mocks: ServiceMocks; - let configMock: Mocked; - let databaseMock: Mocked; - let loggerMock: Mocked; let extensionRange: string; let versionBelowRange: string; let minVersionInRange: string; @@ -23,16 +14,16 @@ describe(DatabaseService.name, () => { let versionAboveRange: string; beforeEach(() => { - ({ sut, configMock, databaseMock, loggerMock } = newTestService(DatabaseService)); + ({ sut, mocks } = newTestService(DatabaseService)); extensionRange = '0.2.x'; - databaseMock.getExtensionVersionRange.mockReturnValue(extensionRange); + mocks.database.getExtensionVersionRange.mockReturnValue(extensionRange); versionBelowRange = '0.1.0'; minVersionInRange = '0.2.0'; updateInRange = '0.2.1'; versionAboveRange = '0.3.0'; - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: minVersionInRange, availableVersion: minVersionInRange, }); @@ -44,11 +35,11 @@ describe(DatabaseService.name, () => { describe('onBootstrap', () => { it('should throw an error if PostgreSQL version is below minimum supported version', async () => { - databaseMock.getPostgresVersion.mockResolvedValueOnce('13.10.0'); + mocks.database.getPostgresVersion.mockResolvedValueOnce('13.10.0'); await expect(sut.onBootstrap()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0'); - expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1); + expect(mocks.database.getPostgresVersion).toHaveBeenCalledTimes(1); }); describe.each(>[ @@ -56,7 +47,7 @@ describe(DatabaseService.name, () => { { extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] }, ])('should work with $extensionName', ({ extension, extensionName }) => { beforeEach(() => { - configMock.getEnv.mockReturnValue( + mocks.config.getEnv.mockReturnValue( mockEnvData({ database: { config: { @@ -85,34 +76,34 @@ describe(DatabaseService.name, () => { }); it(`should start up successfully with ${extension}`, async () => { - databaseMock.getPostgresVersion.mockResolvedValue('14.0.0'); - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getPostgresVersion.mockResolvedValue('14.0.0'); + mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: minVersionInRange, }); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(databaseMock.getPostgresVersion).toHaveBeenCalled(); - expect(databaseMock.createExtension).toHaveBeenCalledWith(extension); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.getPostgresVersion).toHaveBeenCalled(); + expect(mocks.database.createExtension).toHaveBeenCalledWith(extension); + expect(mocks.database.createExtension).toHaveBeenCalledTimes(1); + expect(mocks.database.getExtensionVersion).toHaveBeenCalled(); + expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should throw an error if the ${extension} extension is not installed`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null }); + mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null }); const message = `The ${extensionName} extension is not available in this Postgres instance. If using a container image, ensure the image has the extension installed.`; await expect(sut.onBootstrap()).rejects.toThrow(message); - expect(databaseMock.createExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(mocks.database.createExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); it(`should throw an error if the ${extension} extension version is below minimum supported version`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: versionBelowRange, availableVersion: versionBelowRange, }); @@ -121,80 +112,80 @@ describe(DatabaseService.name, () => { `The ${extensionName} extension version is ${versionBelowRange}, but Immich only supports ${extensionRange}`, ); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); it(`should throw an error if ${extension} extension version is a nightly`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' }); + mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' }); await expect(sut.onBootstrap()).rejects.toThrow( `The ${extensionName} extension version is 0.0.0, which means it is a nightly release.`, ); - expect(databaseMock.createExtension).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(mocks.database.createExtension).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); it(`should do in-range update for ${extension} extension`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: updateInRange, installedVersion: minVersionInRange, }); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); + mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false }); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); + expect(mocks.database.updateVectorExtension).toHaveBeenCalledTimes(1); + expect(mocks.database.getExtensionVersion).toHaveBeenCalled(); + expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should not upgrade ${extension} if same version`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: minVersionInRange, installedVersion: minVersionInRange, }); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should throw error if ${extension} available version is below range`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: versionBelowRange, installedVersion: null, }); await expect(sut.onBootstrap()).rejects.toThrow(); - expect(databaseMock.createExtension).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.createExtension).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should throw error if ${extension} available version is above range`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: versionAboveRange, installedVersion: minVersionInRange, }); await expect(sut.onBootstrap()).rejects.toThrow(); - expect(databaseMock.createExtension).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.createExtension).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it('should throw error if available version is below installed version', async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: minVersionInRange, installedVersion: updateInRange, }); @@ -203,13 +194,13 @@ describe(DatabaseService.name, () => { `The database currently has ${extensionName} ${updateInRange} activated, but the Postgres instance only has ${minVersionInRange} available.`, ); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it('should throw error if installed version is not in version range', async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: minVersionInRange, installedVersion: versionAboveRange, }); @@ -218,84 +209,84 @@ describe(DatabaseService.name, () => { `The ${extensionName} extension version is ${versionAboveRange}, but Immich only supports`, ); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should raise error if ${extension} extension upgrade failed`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: updateInRange, installedVersion: minVersionInRange, }); - databaseMock.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension')); + mocks.database.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension')); await expect(sut.onBootstrap()).rejects.toThrow('Failed to update extension'); - expect(loggerMock.warn.mock.calls[0][0]).toContain( + expect(mocks.logger.warn.mock.calls[0][0]).toContain( `The ${extensionName} extension can be updated to ${updateInRange}.`, ); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); it(`should warn if ${extension} extension update requires restart`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: updateInRange, installedVersion: minVersionInRange, }); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: true }); + mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: true }); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(loggerMock.warn).toHaveBeenCalledTimes(1); - expect(loggerMock.warn.mock.calls[0][0]).toContain(extensionName); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.logger.warn).toHaveBeenCalledTimes(1); + expect(mocks.logger.warn.mock.calls[0][0]).toContain(extensionName); + expect(mocks.database.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); + expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should reindex ${extension} indices if needed`, async () => { - databaseMock.shouldReindex.mockResolvedValue(true); + mocks.database.shouldReindex.mockResolvedValue(true); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); - expect(databaseMock.reindex).toHaveBeenCalledTimes(2); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(2); + expect(mocks.database.reindex).toHaveBeenCalledTimes(2); + expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should throw an error if reindexing fails`, async () => { - databaseMock.shouldReindex.mockResolvedValue(true); - databaseMock.reindex.mockRejectedValue(new Error('Error reindexing')); + mocks.database.shouldReindex.mockResolvedValue(true); + mocks.database.reindex.mockRejectedValue(new Error('Error reindexing')); await expect(sut.onBootstrap()).rejects.toBeDefined(); - expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(1); - expect(databaseMock.reindex).toHaveBeenCalledTimes(1); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - expect(loggerMock.warn).toHaveBeenCalledWith( + expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(1); + expect(mocks.database.reindex).toHaveBeenCalledTimes(1); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); + expect(mocks.logger.warn).toHaveBeenCalledWith( expect.stringContaining('Could not run vector reindexing checks.'), ); }); it(`should not reindex ${extension} indices if not needed`, async () => { - databaseMock.shouldReindex.mockResolvedValue(false); + mocks.database.shouldReindex.mockResolvedValue(false); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); - expect(databaseMock.reindex).toHaveBeenCalledTimes(0); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(2); + expect(mocks.database.reindex).toHaveBeenCalledTimes(0); + expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); }); it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => { - configMock.getEnv.mockReturnValue( + mocks.config.getEnv.mockReturnValue( mockEnvData({ database: { config: { @@ -324,11 +315,11 @@ describe(DatabaseService.name, () => { await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); it(`should throw error if pgvector extension could not be created`, async () => { - configMock.getEnv.mockReturnValue( + mocks.config.getEnv.mockReturnValue( mockEnvData({ database: { config: { @@ -354,41 +345,41 @@ describe(DatabaseService.name, () => { }, }), ); - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: minVersionInRange, }); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); - databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); + mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false }); + mocks.database.createExtension.mockRejectedValue(new Error('Failed to create extension')); await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); - expect(loggerMock.fatal).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal.mock.calls[0][0]).toContain( + expect(mocks.logger.fatal).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal.mock.calls[0][0]).toContain( `Alternatively, if your Postgres instance has pgvecto.rs, you may use this instead`, ); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(mocks.database.createExtension).toHaveBeenCalledTimes(1); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); it(`should throw error if pgvecto.rs extension could not be created`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: minVersionInRange, }); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); - databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); + mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false }); + mocks.database.createExtension.mockRejectedValue(new Error('Failed to create extension')); await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); - expect(loggerMock.fatal).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal.mock.calls[0][0]).toContain( + expect(mocks.logger.fatal).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal.mock.calls[0][0]).toContain( `Alternatively, if your Postgres instance has pgvector, you may use this instead`, ); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(mocks.database.createExtension).toHaveBeenCalledTimes(1); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); }); @@ -403,38 +394,38 @@ describe(DatabaseService.name, () => { it('should not override interval', () => { sut.handleConnectionError(new Error('Error')); - expect(loggerMock.error).toHaveBeenCalled(); + expect(mocks.logger.error).toHaveBeenCalled(); sut.handleConnectionError(new Error('foo')); - expect(loggerMock.error).toHaveBeenCalledTimes(1); + expect(mocks.logger.error).toHaveBeenCalledTimes(1); }); it('should reconnect when interval elapses', async () => { - databaseMock.reconnect.mockResolvedValue(true); + mocks.database.reconnect.mockResolvedValue(true); sut.handleConnectionError(new Error('error')); await vi.advanceTimersByTimeAsync(5000); - expect(databaseMock.reconnect).toHaveBeenCalledTimes(1); - expect(loggerMock.log).toHaveBeenCalledWith('Database reconnected'); + expect(mocks.database.reconnect).toHaveBeenCalledTimes(1); + expect(mocks.logger.log).toHaveBeenCalledWith('Database reconnected'); await vi.advanceTimersByTimeAsync(5000); - expect(databaseMock.reconnect).toHaveBeenCalledTimes(1); + expect(mocks.database.reconnect).toHaveBeenCalledTimes(1); }); it('should try again when reconnection fails', async () => { - databaseMock.reconnect.mockResolvedValueOnce(false); + mocks.database.reconnect.mockResolvedValueOnce(false); sut.handleConnectionError(new Error('error')); await vi.advanceTimersByTimeAsync(5000); - expect(databaseMock.reconnect).toHaveBeenCalledTimes(1); - expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining('Database connection failed')); + expect(mocks.database.reconnect).toHaveBeenCalledTimes(1); + expect(mocks.logger.warn).toHaveBeenCalledWith(expect.stringContaining('Database connection failed')); - databaseMock.reconnect.mockResolvedValueOnce(true); + mocks.database.reconnect.mockResolvedValueOnce(true); await vi.advanceTimersByTimeAsync(5000); - expect(databaseMock.reconnect).toHaveBeenCalledTimes(2); - expect(loggerMock.log).toHaveBeenCalledWith('Database reconnected'); + expect(mocks.database.reconnect).toHaveBeenCalledTimes(2); + expect(mocks.logger.log).toHaveBeenCalledWith('Database reconnected'); }); }); }); diff --git a/server/src/services/download.service.spec.ts b/server/src/services/download.service.spec.ts index 12e3414ac3..d9e60dfdb4 100644 --- a/server/src/services/download.service.spec.ts +++ b/server/src/services/download.service.spec.ts @@ -1,16 +1,12 @@ import { BadRequestException } from '@nestjs/common'; import { DownloadResponseDto } from 'src/dtos/download.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; import { DownloadService } from 'src/services/download.service'; -import { ILoggingRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; +import { newTestService, ServiceMocks } from 'test/utils'; import { Readable } from 'typeorm/platform/PlatformTools.js'; -import { Mocked, vitest } from 'vitest'; +import { vitest } from 'vitest'; const downloadResponse: DownloadResponseDto = { totalSize: 105_000, @@ -24,17 +20,14 @@ const downloadResponse: DownloadResponseDto = { describe(DownloadService.name, () => { let sut: DownloadService; - let accessMock: IAccessRepositoryMock; - let assetMock: Mocked; - let loggerMock: Mocked; - let storageMock: Mocked; + let mocks: ServiceMocks; it('should work', () => { expect(sut).toBeDefined(); }); beforeEach(() => { - ({ sut, accessMock, assetMock, loggerMock, storageMock } = newTestService(DownloadService)); + ({ sut, mocks } = newTestService(DownloadService)); }); describe('downloadArchive', () => { @@ -45,9 +38,9 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - assetMock.getByIds.mockResolvedValue([{ ...assetStub.noResizePath, id: 'asset-1' }]); - storageMock.createZipStream.mockReturnValue(archiveMock); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.noResizePath, id: 'asset-1' }]); + mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ stream: archiveMock.stream, @@ -64,19 +57,19 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - storageMock.realpath.mockRejectedValue(new Error('Could not read file')); - assetMock.getByIds.mockResolvedValue([ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.storage.realpath.mockRejectedValue(new Error('Could not read file')); + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.noResizePath, id: 'asset-1' }, { ...assetStub.noWebpPath, id: 'asset-2' }, ]); - storageMock.createZipStream.mockReturnValue(archiveMock); + mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ stream: archiveMock.stream, }); - expect(loggerMock.warn).toHaveBeenCalledTimes(2); + expect(mocks.logger.warn).toHaveBeenCalledTimes(2); expect(archiveMock.addFile).toHaveBeenCalledTimes(2); expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_456.jpg', 'IMG_456.jpg'); @@ -89,12 +82,12 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - assetMock.getByIds.mockResolvedValue([ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.noResizePath, id: 'asset-1' }, { ...assetStub.noWebpPath, id: 'asset-2' }, ]); - storageMock.createZipStream.mockReturnValue(archiveMock); + mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ stream: archiveMock.stream, @@ -112,12 +105,12 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - assetMock.getByIds.mockResolvedValue([ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.noResizePath, id: 'asset-1' }, { ...assetStub.noResizePath, id: 'asset-2' }, ]); - storageMock.createZipStream.mockReturnValue(archiveMock); + mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ stream: archiveMock.stream, @@ -135,12 +128,12 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - assetMock.getByIds.mockResolvedValue([ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.noResizePath, id: 'asset-2' }, { ...assetStub.noResizePath, id: 'asset-1' }, ]); - storageMock.createZipStream.mockReturnValue(archiveMock); + mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ stream: archiveMock.stream, @@ -158,12 +151,12 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.getByIds.mockResolvedValue([ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.noResizePath, id: 'asset-1', originalPath: '/path/to/symlink.jpg' }, ]); - storageMock.realpath.mockResolvedValue('/path/to/realpath.jpg'); - storageMock.createZipStream.mockReturnValue(archiveMock); + mocks.storage.realpath.mockResolvedValue('/path/to/realpath.jpg'); + mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1'] })).resolves.toEqual({ stream: archiveMock.stream, @@ -179,30 +172,30 @@ describe(DownloadService.name, () => { }); it('should return a list of archives (assetIds)', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - assetMock.getByIds.mockResolvedValue([assetStub.image, assetStub.video]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.asset.getByIds.mockResolvedValue([assetStub.image, assetStub.video]); const assetIds = ['asset-1', 'asset-2']; await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual(downloadResponse); - expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1', 'asset-2'], { exifInfo: true }); + expect(mocks.asset.getByIds).toHaveBeenCalledWith(['asset-1', 'asset-2'], { exifInfo: true }); }); it('should return a list of archives (albumId)', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1'])); - assetMock.getByAlbumId.mockResolvedValue({ + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1'])); + mocks.asset.getByAlbumId.mockResolvedValue({ items: [assetStub.image, assetStub.video], hasNextPage: false, }); await expect(sut.getDownloadInfo(authStub.admin, { albumId: 'album-1' })).resolves.toEqual(downloadResponse); - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-1'])); - expect(assetMock.getByAlbumId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, 'album-1'); + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-1'])); + expect(mocks.asset.getByAlbumId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, 'album-1'); }); it('should return a list of archives (userId)', async () => { - assetMock.getByUserId.mockResolvedValue({ + mocks.asset.getByUserId.mockResolvedValue({ items: [assetStub.image, assetStub.video], hasNextPage: false, }); @@ -211,13 +204,13 @@ describe(DownloadService.name, () => { downloadResponse, ); - expect(assetMock.getByUserId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, authStub.admin.user.id, { + expect(mocks.asset.getByUserId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, authStub.admin.user.id, { isVisible: true, }); }); it('should split archives by size', async () => { - assetMock.getByUserId.mockResolvedValue({ + mocks.asset.getByUserId.mockResolvedValue({ items: [ { ...assetStub.image, id: 'asset-1' }, { ...assetStub.video, id: 'asset-2' }, @@ -245,8 +238,8 @@ describe(DownloadService.name, () => { const assetIds = [assetStub.livePhotoStillAsset.id]; const assets = [assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); - assetMock.getByIds.mockImplementation( + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); + mocks.asset.getByIds.mockImplementation( (ids) => Promise.resolve( ids.map((id) => assets.find((asset) => asset.id === id)).filter((asset) => !!asset), @@ -271,8 +264,8 @@ describe(DownloadService.name, () => { { ...assetStub.livePhotoMotionAsset, originalPath: 'upload/encoded-video/uuid-MP.mp4' }, ]; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); - assetMock.getByIds.mockImplementation( + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); + mocks.asset.getByIds.mockImplementation( (ids) => Promise.resolve( ids.map((id) => assets.find((asset) => asset.id === id)).filter((asset) => !!asset), diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 6fdb9c2b5c..0451f1f2b3 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -1,27 +1,20 @@ -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { ISearchRepository } from 'src/interfaces/search.interface'; +import { WithoutProperty } from 'src/interfaces/asset.interface'; +import { JobName, JobStatus } from 'src/interfaces/job.interface'; import { DuplicateService } from 'src/services/duplicate.service'; import { SearchService } from 'src/services/search.service'; -import { ILoggingRepository, ISystemMetadataRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { newTestService } from 'test/utils'; -import { Mocked, beforeEach, vitest } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; +import { beforeEach, vitest } from 'vitest'; vitest.useFakeTimers(); describe(SearchService.name, () => { let sut: DuplicateService; - - let assetMock: Mocked; - let jobMock: Mocked; - let loggerMock: Mocked; - let searchMock: Mocked; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, jobMock, loggerMock, searchMock, systemMock } = newTestService(DuplicateService)); + ({ sut, mocks } = newTestService(DuplicateService)); }); it('should work', () => { @@ -30,7 +23,7 @@ describe(SearchService.name, () => { describe('getDuplicates', () => { it('should get duplicates', async () => { - assetMock.getDuplicates.mockResolvedValue([ + mocks.asset.getDuplicates.mockResolvedValue([ { duplicateId: assetStub.hasDupe.duplicateId!, assets: [assetStub.hasDupe, assetStub.hasDupe], @@ -50,7 +43,7 @@ describe(SearchService.name, () => { describe('handleQueueSearchDuplicates', () => { beforeEach(() => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { enabled: true, duplicateDetection: { @@ -61,7 +54,7 @@ describe(SearchService.name, () => { }); it('should skip if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { enabled: false, duplicateDetection: { @@ -71,13 +64,13 @@ describe(SearchService.name, () => { }); await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(systemMock.get).toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); it('should skip if duplicate detection is disabled', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { enabled: true, duplicateDetection: { @@ -87,21 +80,21 @@ describe(SearchService.name, () => { }); await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(systemMock.get).toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); it('should queue missing assets', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); await sut.handleQueueSearchDuplicates({}); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.DUPLICATE); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.DUPLICATE); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.DUPLICATE_DETECTION, data: { id: assetStub.image.id }, @@ -110,15 +103,15 @@ describe(SearchService.name, () => { }); it('should queue all assets', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); await sut.handleQueueSearchDuplicates({ force: true }); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.DUPLICATE_DETECTION, data: { id: assetStub.image.id }, @@ -129,7 +122,7 @@ describe(SearchService.name, () => { describe('handleSearchDuplicates', () => { beforeEach(() => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { enabled: true, duplicateDetection: { @@ -140,7 +133,7 @@ describe(SearchService.name, () => { }); it('should skip if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { enabled: false, duplicateDetection: { @@ -149,7 +142,7 @@ describe(SearchService.name, () => { }, }); const id = assetStub.livePhotoMotionAsset.id; - assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); const result = await sut.handleSearchDuplicates({ id }); @@ -157,7 +150,7 @@ describe(SearchService.name, () => { }); it('should skip if duplicate detection is disabled', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { enabled: true, duplicateDetection: { @@ -166,7 +159,7 @@ describe(SearchService.name, () => { }, }); const id = assetStub.livePhotoMotionAsset.id; - assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); const result = await sut.handleSearchDuplicates({ id }); @@ -177,40 +170,40 @@ describe(SearchService.name, () => { const result = await sut.handleSearchDuplicates({ id: assetStub.image.id }); expect(result).toBe(JobStatus.FAILED); - expect(loggerMock.error).toHaveBeenCalledWith(`Asset ${assetStub.image.id} not found`); + expect(mocks.logger.error).toHaveBeenCalledWith(`Asset ${assetStub.image.id} not found`); }); it('should skip if asset is not visible', async () => { const id = assetStub.livePhotoMotionAsset.id; - assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); const result = await sut.handleSearchDuplicates({ id }); expect(result).toBe(JobStatus.SKIPPED); - expect(loggerMock.debug).toHaveBeenCalledWith(`Asset ${id} is not visible, skipping`); + expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${id} is not visible, skipping`); }); it('should fail if asset is missing preview image', async () => { - assetMock.getById.mockResolvedValue(assetStub.noResizePath); + mocks.asset.getById.mockResolvedValue(assetStub.noResizePath); const result = await sut.handleSearchDuplicates({ id: assetStub.noResizePath.id }); expect(result).toBe(JobStatus.FAILED); - expect(loggerMock.warn).toHaveBeenCalledWith(`Asset ${assetStub.noResizePath.id} is missing preview image`); + expect(mocks.logger.warn).toHaveBeenCalledWith(`Asset ${assetStub.noResizePath.id} is missing preview image`); }); it('should fail if asset is missing embedding', async () => { - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.asset.getById.mockResolvedValue(assetStub.image); const result = await sut.handleSearchDuplicates({ id: assetStub.image.id }); expect(result).toBe(JobStatus.FAILED); - expect(loggerMock.debug).toHaveBeenCalledWith(`Asset ${assetStub.image.id} is missing embedding`); + expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${assetStub.image.id} is missing embedding`); }); it('should search for duplicates and update asset with duplicateId', async () => { - assetMock.getById.mockResolvedValue(assetStub.hasEmbedding); - searchMock.searchDuplicates.mockResolvedValue([ + mocks.asset.getById.mockResolvedValue(assetStub.hasEmbedding); + mocks.search.searchDuplicates.mockResolvedValue([ { assetId: assetStub.image.id, distance: 0.01, duplicateId: null }, ]); const expectedAssetIds = [assetStub.image.id, assetStub.hasEmbedding.id]; @@ -218,58 +211,58 @@ describe(SearchService.name, () => { const result = await sut.handleSearchDuplicates({ id: assetStub.hasEmbedding.id }); expect(result).toBe(JobStatus.SUCCESS); - expect(searchMock.searchDuplicates).toHaveBeenCalledWith({ + expect(mocks.search.searchDuplicates).toHaveBeenCalledWith({ assetId: assetStub.hasEmbedding.id, embedding: assetStub.hasEmbedding.smartSearch!.embedding, maxDistance: 0.01, type: assetStub.hasEmbedding.type, userIds: [assetStub.hasEmbedding.ownerId], }); - expect(assetMock.updateDuplicates).toHaveBeenCalledWith({ + expect(mocks.asset.updateDuplicates).toHaveBeenCalledWith({ assetIds: expectedAssetIds, targetDuplicateId: expect.any(String), duplicateIds: [], }); - expect(assetMock.upsertJobStatus).toHaveBeenCalledWith( + expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith( ...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })), ); }); it('should use existing duplicate ID among matched duplicates', async () => { const duplicateId = assetStub.hasDupe.duplicateId; - assetMock.getById.mockResolvedValue(assetStub.hasEmbedding); - searchMock.searchDuplicates.mockResolvedValue([{ assetId: assetStub.hasDupe.id, distance: 0.01, duplicateId }]); + mocks.asset.getById.mockResolvedValue(assetStub.hasEmbedding); + mocks.search.searchDuplicates.mockResolvedValue([{ assetId: assetStub.hasDupe.id, distance: 0.01, duplicateId }]); const expectedAssetIds = [assetStub.hasEmbedding.id]; const result = await sut.handleSearchDuplicates({ id: assetStub.hasEmbedding.id }); expect(result).toBe(JobStatus.SUCCESS); - expect(searchMock.searchDuplicates).toHaveBeenCalledWith({ + expect(mocks.search.searchDuplicates).toHaveBeenCalledWith({ assetId: assetStub.hasEmbedding.id, embedding: assetStub.hasEmbedding.smartSearch!.embedding, maxDistance: 0.01, type: assetStub.hasEmbedding.type, userIds: [assetStub.hasEmbedding.ownerId], }); - expect(assetMock.updateDuplicates).toHaveBeenCalledWith({ + expect(mocks.asset.updateDuplicates).toHaveBeenCalledWith({ assetIds: expectedAssetIds, targetDuplicateId: assetStub.hasDupe.duplicateId, duplicateIds: [], }); - expect(assetMock.upsertJobStatus).toHaveBeenCalledWith( + expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith( ...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })), ); }); it('should remove duplicateId if no duplicates found and asset has duplicateId', async () => { - assetMock.getById.mockResolvedValue(assetStub.hasDupe); - searchMock.searchDuplicates.mockResolvedValue([]); + mocks.asset.getById.mockResolvedValue(assetStub.hasDupe); + mocks.search.searchDuplicates.mockResolvedValue([]); const result = await sut.handleSearchDuplicates({ id: assetStub.hasDupe.id }); expect(result).toBe(JobStatus.SUCCESS); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.hasDupe.id, duplicateId: null }); - expect(assetMock.upsertJobStatus).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.hasDupe.id, duplicateId: null }); + expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith({ assetId: assetStub.hasDupe.id, duplicatesDetectedAt: expect.any(Date), }); diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index 5d11f895a1..8b0b5c408c 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -1,27 +1,19 @@ import { BadRequestException } from '@nestjs/common'; import { defaults, SystemConfig } from 'src/config'; import { ImmichWorker } from 'src/enum'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IJobRepository, JobCommand, JobItem, JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { JobCommand, JobItem, JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; import { JobService } from 'src/services/job.service'; -import { IConfigRepository, ILoggingRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; -import { ITelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(JobService.name, () => { let sut: JobService; - let assetMock: Mocked; - let configMock: Mocked; - let jobMock: Mocked; - let loggerMock: Mocked; - let telemetryMock: ITelemetryRepositoryMock; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, configMock, jobMock, loggerMock, telemetryMock } = newTestService(JobService, {})); + ({ sut, mocks } = newTestService(JobService, {})); - configMock.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); + mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); }); it('should work', () => { @@ -32,11 +24,11 @@ describe(JobService.name, () => { it('should update concurrency', () => { sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig }); - expect(jobMock.setConcurrency).toHaveBeenCalledTimes(15); - expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FACIAL_RECOGNITION, 1); - expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DUPLICATE_DETECTION, 1); - expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BACKGROUND_TASK, 5); - expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.STORAGE_TEMPLATE_MIGRATION, 1); + expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(15); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FACIAL_RECOGNITION, 1); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DUPLICATE_DETECTION, 1); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BACKGROUND_TASK, 5); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.STORAGE_TEMPLATE_MIGRATION, 1); }); }); @@ -44,7 +36,7 @@ describe(JobService.name, () => { it('should run the scheduled jobs', async () => { await sut.handleNightlyJobs(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.ASSET_DELETION_CHECK }, { name: JobName.USER_DELETE_CHECK }, { name: JobName.PERSON_CLEANUP }, @@ -59,7 +51,7 @@ describe(JobService.name, () => { describe('getAllJobStatus', () => { it('should get all job statuses', async () => { - jobMock.getJobCounts.mockResolvedValue({ + mocks.job.getJobCounts.mockResolvedValue({ active: 1, completed: 1, failed: 1, @@ -67,7 +59,7 @@ describe(JobService.name, () => { waiting: 1, paused: 1, }); - jobMock.getQueueStatus.mockResolvedValue({ + mocks.job.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: true, }); @@ -111,121 +103,121 @@ describe(JobService.name, () => { it('should handle a pause command', async () => { await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.PAUSE, force: false }); - expect(jobMock.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); + expect(mocks.job.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); }); it('should handle a resume command', async () => { await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.RESUME, force: false }); - expect(jobMock.resume).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); + expect(mocks.job.resume).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); }); it('should handle an empty command', async () => { await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.EMPTY, force: false }); - expect(jobMock.empty).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); + expect(mocks.job.empty).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); }); it('should not start a job that is already running', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: false }); await expect( sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false }), ).rejects.toBeInstanceOf(BadRequestException); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); }); it('should handle a start video conversion command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force: false } }); }); it('should handle a start storage template migration command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.STORAGE_TEMPLATE_MIGRATION, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.STORAGE_TEMPLATE_MIGRATION }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.STORAGE_TEMPLATE_MIGRATION }); }); it('should handle a start smart search command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.SMART_SEARCH, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_SMART_SEARCH, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_SMART_SEARCH, data: { force: false } }); }); it('should handle a start metadata extraction command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force: false } }); }); it('should handle a start sidecar command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.SIDECAR, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_SIDECAR, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_SIDECAR, data: { force: false } }); }); it('should handle a start thumbnail generation command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.THUMBNAIL_GENERATION, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }); }); it('should handle a start face detection command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.FACE_DETECTION, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_FACE_DETECTION, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_FACE_DETECTION, data: { force: false } }); }); it('should handle a start facial recognition command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.FACIAL_RECOGNITION, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }); }); it('should throw a bad request when an invalid queue is used', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await expect( sut.handleCommand(QueueName.BACKGROUND_TASK, { command: JobCommand.START, force: false }), ).rejects.toBeInstanceOf(BadRequestException); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); }); }); describe('onJobStart', () => { it('should process a successful job', async () => { - jobMock.run.mockResolvedValue(JobStatus.SUCCESS); + mocks.job.run.mockResolvedValue(JobStatus.SUCCESS); await sut.onJobStart(QueueName.BACKGROUND_TASK, { name: JobName.DELETE_FILES, data: { files: ['path/to/file'] }, }); - expect(telemetryMock.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', 1); - expect(telemetryMock.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', -1); - expect(telemetryMock.jobs.addToCounter).toHaveBeenCalledWith('immich.jobs.delete_files.success', 1); - expect(loggerMock.error).not.toHaveBeenCalled(); + expect(mocks.telemetry.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', 1); + expect(mocks.telemetry.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', -1); + expect(mocks.telemetry.jobs.addToCounter).toHaveBeenCalledWith('immich.jobs.delete_files.success', 1); + expect(mocks.logger.error).not.toHaveBeenCalled(); }); const tests: Array<{ item: JobItem; jobs: JobName[] }> = [ @@ -287,34 +279,34 @@ describe(JobService.name, () => { it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => { if (item.name === JobName.GENERATE_THUMBNAILS && item.data.source === 'upload') { if (item.data.id === 'asset-live-image') { - assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoStillAsset]); + mocks.asset.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoStillAsset]); } else { - assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoMotionAsset]); + mocks.asset.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoMotionAsset]); } } - jobMock.run.mockResolvedValue(JobStatus.SUCCESS); + mocks.job.run.mockResolvedValue(JobStatus.SUCCESS); await sut.onJobStart(QueueName.BACKGROUND_TASK, item); if (jobs.length > 1) { - expect(jobMock.queueAll).toHaveBeenCalledWith( + expect(mocks.job.queueAll).toHaveBeenCalledWith( jobs.map((jobName) => ({ name: jobName, data: expect.anything() })), ); } else { - expect(jobMock.queue).toHaveBeenCalledTimes(jobs.length); + expect(mocks.job.queue).toHaveBeenCalledTimes(jobs.length); for (const jobName of jobs) { - expect(jobMock.queue).toHaveBeenCalledWith({ name: jobName, data: expect.anything() }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: jobName, data: expect.anything() }); } } }); it(`should not queue any jobs when ${item.name} fails`, async () => { - jobMock.run.mockResolvedValue(JobStatus.FAILED); + mocks.job.run.mockResolvedValue(JobStatus.FAILED); await sut.onJobStart(QueueName.BACKGROUND_TASK, item); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); }); } }); diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 9f60e35dcc..24b1265ae9 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -4,28 +4,22 @@ import { defaults, SystemConfig } from 'src/config'; import { mapLibrary } from 'src/dtos/library.dto'; import { UserEntity } from 'src/entities/user.entity'; import { AssetType, ImmichWorker } from 'src/enum'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { - IJobRepository, ILibraryAssetJob, ILibraryFileJob, JobName, JOBS_LIBRARY_PAGINATION_SIZE, JobStatus, } from 'src/interfaces/job.interface'; -import { ILibraryRepository } from 'src/interfaces/library.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; import { LibraryService } from 'src/services/library.service'; -import { IConfigRepository, ICronRepository } 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 { newTestService } from 'test/utils'; -import { Mocked, vitest } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; +import { vitest } from 'vitest'; async function* mockWalk() { yield await Promise.resolve(['/data/user1/photo.jpg']); @@ -33,21 +27,13 @@ async function* mockWalk() { describe(LibraryService.name, () => { let sut: LibraryService; - - let assetMock: Mocked; - let configMock: Mocked; - let cronMock: Mocked; - let databaseMock: Mocked; - let jobMock: Mocked; - let libraryMock: Mocked; - let storageMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, configMock, cronMock, databaseMock, jobMock, libraryMock, storageMock } = - newTestService(LibraryService)); + ({ sut, mocks } = newTestService(LibraryService)); - databaseMock.tryLock.mockResolvedValue(true); - configMock.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); + mocks.database.tryLock.mockResolvedValue(true); + mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); }); it('should work', () => { @@ -58,7 +44,7 @@ describe(LibraryService.name, () => { it('should init cron job and handle config changes', async () => { await sut.onConfigInit({ newConfig: defaults }); - expect(cronMock.create).toHaveBeenCalled(); + expect(mocks.cron.create).toHaveBeenCalled(); await sut.onConfigUpdate({ oldConfig: defaults, @@ -73,16 +59,16 @@ describe(LibraryService.name, () => { } as SystemConfig, }); - expect(cronMock.update).toHaveBeenCalledWith({ name: 'libraryScan', expression: '0 1 * * *', start: true }); + expect(mocks.cron.update).toHaveBeenCalledWith({ name: 'libraryScan', expression: '0 1 * * *', start: true }); }); it('should initialize watcher for all external libraries', async () => { - libraryMock.getAll.mockResolvedValue([ + mocks.library.getAll.mockResolvedValue([ libraryStub.externalLibraryWithImportPaths1, libraryStub.externalLibraryWithImportPaths2, ]); - libraryMock.get.mockImplementation((id) => + mocks.library.get.mockImplementation((id) => Promise.resolve( [libraryStub.externalLibraryWithImportPaths1, libraryStub.externalLibraryWithImportPaths2].find( (library) => library.id === id, @@ -92,7 +78,7 @@ describe(LibraryService.name, () => { await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); - expect(storageMock.watch.mock.calls).toEqual( + expect(mocks.storage.watch.mock.calls).toEqual( expect.arrayContaining([ (libraryStub.externalLibrary1.importPaths, expect.anything()), (libraryStub.externalLibrary2.importPaths, expect.anything()), @@ -103,47 +89,47 @@ describe(LibraryService.name, () => { it('should not initialize watcher when watching is disabled', async () => { await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchDisabled as SystemConfig }); - expect(storageMock.watch).not.toHaveBeenCalled(); + expect(mocks.storage.watch).not.toHaveBeenCalled(); }); it('should not initialize watcher when lock is taken', async () => { - databaseMock.tryLock.mockResolvedValue(false); + mocks.database.tryLock.mockResolvedValue(false); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); - expect(storageMock.watch).not.toHaveBeenCalled(); + expect(mocks.storage.watch).not.toHaveBeenCalled(); }); it('should not initialize library scan cron job when lock is taken', async () => { - databaseMock.tryLock.mockResolvedValue(false); + mocks.database.tryLock.mockResolvedValue(false); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); - expect(cronMock.create).not.toHaveBeenCalled(); + expect(mocks.cron.create).not.toHaveBeenCalled(); }); }); describe('onConfigUpdateEvent', () => { beforeEach(async () => { - databaseMock.tryLock.mockResolvedValue(true); + mocks.database.tryLock.mockResolvedValue(true); await sut.onConfigInit({ newConfig: defaults }); }); it('should do nothing if instance does not have the watch lock', async () => { - databaseMock.tryLock.mockResolvedValue(false); + mocks.database.tryLock.mockResolvedValue(false); await sut.onConfigInit({ newConfig: defaults }); await sut.onConfigUpdate({ newConfig: systemConfigStub.libraryScan as SystemConfig, oldConfig: defaults }); - expect(cronMock.update).not.toHaveBeenCalled(); + expect(mocks.cron.update).not.toHaveBeenCalled(); }); it('should update cron job and enable watching', async () => { - libraryMock.getAll.mockResolvedValue([]); + mocks.library.getAll.mockResolvedValue([]); await sut.onConfigUpdate({ newConfig: systemConfigStub.libraryScanAndWatch as SystemConfig, oldConfig: defaults, }); - expect(cronMock.update).toHaveBeenCalledWith({ + expect(mocks.cron.update).toHaveBeenCalledWith({ name: 'libraryScan', expression: systemConfigStub.libraryScan.library.scan.cronExpression, start: systemConfigStub.libraryScan.library.scan.enabled, @@ -151,7 +137,7 @@ describe(LibraryService.name, () => { }); it('should update cron job and disable watching', async () => { - libraryMock.getAll.mockResolvedValue([]); + mocks.library.getAll.mockResolvedValue([]); await sut.onConfigUpdate({ newConfig: systemConfigStub.libraryScanAndWatch as SystemConfig, oldConfig: defaults, @@ -161,7 +147,7 @@ describe(LibraryService.name, () => { oldConfig: defaults, }); - expect(cronMock.update).toHaveBeenCalledWith({ + expect(mocks.cron.update).toHaveBeenCalledWith({ name: 'libraryScan', expression: systemConfigStub.libraryScan.library.scan.cronExpression, start: systemConfigStub.libraryScan.library.scan.enabled, @@ -171,12 +157,12 @@ describe(LibraryService.name, () => { describe('handleQueueSyncFiles', () => { it('should queue refresh of a new asset', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - storageMock.walk.mockImplementation(mockWalk); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.storage.walk.mockImplementation(mockWalk); await sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id }); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_SYNC_FILE, data: { @@ -193,7 +179,7 @@ describe(LibraryService.name, () => { }); it('should ignore import paths that do not exist', async () => { - storageMock.stat.mockImplementation((path): Promise => { + mocks.storage.stat.mockImplementation((path): Promise => { if (path === libraryStub.externalLibraryWithImportPaths1.importPaths[0]) { const error = { code: 'ENOENT' } as any; throw error; @@ -203,13 +189,13 @@ describe(LibraryService.name, () => { } as Stats); }); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.storage.checkFileExists.mockResolvedValue(true); - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); await sut.handleQueueSyncFiles({ id: libraryStub.externalLibraryWithImportPaths1.id }); - expect(storageMock.walk).toHaveBeenCalledWith({ + expect(mocks.storage.walk).toHaveBeenCalledWith({ pathsToCrawl: [libraryStub.externalLibraryWithImportPaths1.importPaths[1]], exclusionPatterns: [], includeHidden: false, @@ -220,13 +206,13 @@ describe(LibraryService.name, () => { describe('handleQueueRemoveDeleted', () => { it('should queue online check of existing assets', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - storageMock.walk.mockImplementation(async function* generator() {}); - assetMock.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false }); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.storage.walk.mockImplementation(async function* generator() {}); + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false }); await sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id }); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_SYNC_ASSET, data: { @@ -253,7 +239,7 @@ describe(LibraryService.name, () => { await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.remove).not.toHaveBeenCalled(); + expect(mocks.asset.remove).not.toHaveBeenCalled(); }); it('should offline assets no longer on disk', async () => { @@ -263,12 +249,12 @@ describe(LibraryService.name, () => { exclusionPatterns: [], }; - assetMock.getById.mockResolvedValue(assetStub.external); - storageMock.stat.mockRejectedValue(new Error('ENOENT, no such file or directory')); + mocks.asset.getById.mockResolvedValue(assetStub.external); + mocks.storage.stat.mockRejectedValue(new Error('ENOENT, no such file or directory')); await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.external.id], { isOffline: true, deletedAt: expect.any(Date), }); @@ -281,10 +267,10 @@ describe(LibraryService.name, () => { exclusionPatterns: ['**/user1/**'], }; - assetMock.getById.mockResolvedValue(assetStub.external); + mocks.asset.getById.mockResolvedValue(assetStub.external); await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.external.id], { isOffline: true, deletedAt: expect.any(Date), }); @@ -297,12 +283,12 @@ describe(LibraryService.name, () => { exclusionPatterns: [], }; - assetMock.getById.mockResolvedValue(assetStub.external); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.asset.getById.mockResolvedValue(assetStub.external); + mocks.storage.checkFileExists.mockResolvedValue(true); await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.external.id], { isOffline: true, deletedAt: expect.any(Date), }); @@ -315,12 +301,12 @@ describe(LibraryService.name, () => { exclusionPatterns: [], }; - assetMock.getById.mockResolvedValue(assetStub.external); - storageMock.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); + mocks.asset.getById.mockResolvedValue(assetStub.external); + mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.updateAll).not.toHaveBeenCalled(); + expect(mocks.asset.updateAll).not.toHaveBeenCalled(); }); it('should un-trash an asset previously marked as offline', async () => { @@ -330,12 +316,12 @@ describe(LibraryService.name, () => { exclusionPatterns: [], }; - assetMock.getById.mockResolvedValue(assetStub.trashedOffline); - storageMock.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats); + mocks.asset.getById.mockResolvedValue(assetStub.trashedOffline); + mocks.storage.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats); await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.trashedOffline.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.trashedOffline.id], { deletedAt: null, fileModifiedAt: assetStub.trashedOffline.fileModifiedAt, isOffline: false, @@ -350,12 +336,12 @@ describe(LibraryService.name, () => { exclusionPatterns: [], }; - assetMock.getById.mockResolvedValue(assetStub.trashedOffline); - storageMock.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats); + mocks.asset.getById.mockResolvedValue(assetStub.trashedOffline); + mocks.storage.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats); await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.updateAll).toHaveBeenCalledWith( + expect(mocks.asset.updateAll).toHaveBeenCalledWith( [assetStub.trashedOffline.id], expect.not.objectContaining({ fileCreatedAt: expect.anything(), @@ -372,12 +358,12 @@ describe(LibraryService.name, () => { }; const newMTime = new Date(); - assetMock.getById.mockResolvedValue(assetStub.external); - storageMock.stat.mockResolvedValue({ mtime: newMTime } as Stats); + mocks.asset.getById.mockResolvedValue(assetStub.external); + mocks.storage.stat.mockResolvedValue({ mtime: newMTime } as Stats); await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.external.id], { fileModifiedAt: newMTime, isOffline: false, originalFileName: 'photo.jpg', @@ -391,7 +377,7 @@ describe(LibraryService.name, () => { beforeEach(() => { mockUser = userStub.admin; - storageMock.stat.mockResolvedValue({ + mocks.storage.stat.mockResolvedValue({ size: 100, mtime: new Date('2023-01-01'), ctime: new Date('2023-01-01'), @@ -405,12 +391,12 @@ describe(LibraryService.name, () => { assetPath: '/data/user1/photo.jpg', }; - assetMock.create.mockResolvedValue(assetStub.image); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.asset.create.mockResolvedValue(assetStub.image); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.create.mock.calls).toEqual([ + expect(mocks.asset.create.mock.calls).toEqual([ [ { ownerId: mockUser.id, @@ -429,7 +415,7 @@ describe(LibraryService.name, () => { ], ]); - expect(jobMock.queue.mock.calls).toEqual([ + expect(mocks.job.queue.mock.calls).toEqual([ [ { name: JobName.SIDECAR_DISCOVERY, @@ -449,12 +435,12 @@ describe(LibraryService.name, () => { assetPath: '/data/user1/video.mp4', }; - assetMock.create.mockResolvedValue(assetStub.video); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.asset.create.mockResolvedValue(assetStub.video); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.create.mock.calls).toEqual([ + expect(mocks.asset.create.mock.calls).toEqual([ [ { ownerId: mockUser.id, @@ -473,7 +459,7 @@ describe(LibraryService.name, () => { ], ]); - expect(jobMock.queue.mock.calls).toEqual([ + expect(mocks.job.queue.mock.calls).toEqual([ [ { name: JobName.SIDECAR_DISCOVERY, @@ -493,12 +479,12 @@ describe(LibraryService.name, () => { assetPath: '/data/user1/photo.jpg', }; - assetMock.create.mockResolvedValue(assetStub.image); - libraryMock.get.mockResolvedValue({ ...libraryStub.externalLibrary1, deletedAt: new Date() }); + mocks.asset.create.mockResolvedValue(assetStub.image); + mocks.library.get.mockResolvedValue({ ...libraryStub.externalLibrary1, deletedAt: new Date() }); await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); - expect(assetMock.create.mock.calls).toEqual([]); + expect(mocks.asset.create.mock.calls).toEqual([]); }); it('should not refresh a file whose mtime matches existing asset', async () => { @@ -508,18 +494,18 @@ describe(LibraryService.name, () => { assetPath: assetStub.hasFileExtension.originalPath, }; - storageMock.stat.mockResolvedValue({ + mocks.storage.stat.mockResolvedValue({ size: 100, mtime: assetStub.hasFileExtension.fileModifiedAt, ctime: new Date('2023-01-01'), } as Stats); - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension); await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); }); it('should skip existing asset', async () => { @@ -529,7 +515,7 @@ describe(LibraryService.name, () => { assetPath: '/data/user1/photo.jpg', }; - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); }); @@ -541,16 +527,16 @@ describe(LibraryService.name, () => { assetPath: assetStub.hasFileExtension.originalPath, }; - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.trashed); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.trashed); await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); }); it('should fail when the file could not be read', async () => { - storageMock.stat.mockRejectedValue(new Error('Could not read file')); + mocks.storage.stat.mockRejectedValue(new Error('Could not read file')); const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, @@ -558,17 +544,17 @@ describe(LibraryService.name, () => { assetPath: '/data/user1/photo.jpg', }; - assetMock.create.mockResolvedValue(assetStub.image); + mocks.asset.create.mockResolvedValue(assetStub.image); await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); - expect(libraryMock.get).not.toHaveBeenCalled(); - expect(assetMock.create).not.toHaveBeenCalled(); + expect(mocks.library.get).not.toHaveBeenCalled(); + expect(mocks.asset.create).not.toHaveBeenCalled(); }); it('should skip if the file could not be found', async () => { const error = new Error('File not found') as any; error.code = 'ENOENT'; - storageMock.stat.mockRejectedValue(error); + mocks.storage.stat.mockRejectedValue(error); const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, @@ -576,50 +562,50 @@ describe(LibraryService.name, () => { assetPath: '/data/user1/photo.jpg', }; - assetMock.create.mockResolvedValue(assetStub.image); + mocks.asset.create.mockResolvedValue(assetStub.image); await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); - expect(libraryMock.get).not.toHaveBeenCalled(); - expect(assetMock.create).not.toHaveBeenCalled(); + expect(mocks.library.get).not.toHaveBeenCalled(); + expect(mocks.asset.create).not.toHaveBeenCalled(); }); }); describe('delete', () => { it('should delete a library', async () => { - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); await sut.delete(libraryStub.externalLibrary1.id); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.LIBRARY_DELETE, data: { id: libraryStub.externalLibrary1.id }, }); - expect(libraryMock.softDelete).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); + expect(mocks.library.softDelete).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); }); it('should allow an external library to be deleted', async () => { - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); await sut.delete(libraryStub.externalLibrary1.id); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.LIBRARY_DELETE, data: { id: libraryStub.externalLibrary1.id }, }); - expect(libraryMock.softDelete).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); + expect(mocks.library.softDelete).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); }); it('should unwatch an external library when deleted', async () => { - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); const mockClose = vitest.fn(); - storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); + mocks.storage.watch.mockImplementation(makeMockWatcher({ close: mockClose })); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); await sut.delete(libraryStub.externalLibraryWithImportPaths1.id); @@ -630,7 +616,7 @@ describe(LibraryService.name, () => { describe('get', () => { it('should return a library', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); await expect(sut.get(libraryStub.externalLibrary1.id)).resolves.toEqual( expect.objectContaining({ id: libraryStub.externalLibrary1.id, @@ -639,18 +625,18 @@ describe(LibraryService.name, () => { }), ); - expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); + expect(mocks.library.get).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); }); it('should throw an error when a library is not found', async () => { await expect(sut.get(libraryStub.externalLibrary1.id)).rejects.toBeInstanceOf(BadRequestException); - expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); + expect(mocks.library.get).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); }); }); describe('getStatistics', () => { it('should return library statistics', async () => { - libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 }); + mocks.library.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 }); await expect(sut.getStatistics(libraryStub.externalLibrary1.id)).resolves.toEqual({ photos: 10, videos: 0, @@ -658,7 +644,7 @@ describe(LibraryService.name, () => { usage: 1337, }); - expect(libraryMock.getStatistics).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); + expect(mocks.library.getStatistics).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); }); it('should throw an error if the library could not be found', async () => { @@ -669,7 +655,7 @@ describe(LibraryService.name, () => { describe('create', () => { describe('external library', () => { it('should create with default settings', async () => { - libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.create.mockResolvedValue(libraryStub.externalLibrary1); await expect(sut.create({ ownerId: authStub.admin.user.id })).resolves.toEqual( expect.objectContaining({ id: libraryStub.externalLibrary1.id, @@ -684,7 +670,7 @@ describe(LibraryService.name, () => { }), ); - expect(libraryMock.create).toHaveBeenCalledWith( + expect(mocks.library.create).toHaveBeenCalledWith( expect.objectContaining({ name: expect.any(String), importPaths: [], @@ -694,7 +680,7 @@ describe(LibraryService.name, () => { }); it('should create with name', async () => { - libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.create.mockResolvedValue(libraryStub.externalLibrary1); await expect(sut.create({ ownerId: authStub.admin.user.id, name: 'My Awesome Library' })).resolves.toEqual( expect.objectContaining({ id: libraryStub.externalLibrary1.id, @@ -709,7 +695,7 @@ describe(LibraryService.name, () => { }), ); - expect(libraryMock.create).toHaveBeenCalledWith( + expect(mocks.library.create).toHaveBeenCalledWith( expect.objectContaining({ name: 'My Awesome Library', importPaths: [], @@ -719,7 +705,7 @@ describe(LibraryService.name, () => { }); it('should create with import paths', async () => { - libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.create.mockResolvedValue(libraryStub.externalLibrary1); await expect( sut.create({ ownerId: authStub.admin.user.id, @@ -739,7 +725,7 @@ describe(LibraryService.name, () => { }), ); - expect(libraryMock.create).toHaveBeenCalledWith( + expect(mocks.library.create).toHaveBeenCalledWith( expect.objectContaining({ name: expect.any(String), importPaths: ['/data/images', '/data/videos'], @@ -749,9 +735,9 @@ describe(LibraryService.name, () => { }); it('should create watched with import paths', async () => { - libraryMock.create.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([]); + mocks.library.create.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.getAll.mockResolvedValue([]); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); await sut.create({ @@ -761,7 +747,7 @@ describe(LibraryService.name, () => { }); it('should create with exclusion patterns', async () => { - libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.create.mockResolvedValue(libraryStub.externalLibrary1); await expect( sut.create({ ownerId: authStub.admin.user.id, @@ -781,7 +767,7 @@ describe(LibraryService.name, () => { }), ); - expect(libraryMock.create).toHaveBeenCalledWith( + expect(mocks.library.create).toHaveBeenCalledWith( expect.objectContaining({ name: expect.any(String), importPaths: [], @@ -794,17 +780,17 @@ describe(LibraryService.name, () => { describe('getAll', () => { it('should get all libraries', async () => { - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibrary1]); await expect(sut.getAll()).resolves.toEqual([expect.objectContaining({ id: libraryStub.externalLibrary1.id })]); }); }); describe('handleQueueCleanup', () => { it('should queue cleanup jobs', async () => { - libraryMock.getAllDeleted.mockResolvedValue([libraryStub.externalLibrary1, libraryStub.externalLibrary2]); + mocks.library.getAllDeleted.mockResolvedValue([libraryStub.externalLibrary1, libraryStub.externalLibrary2]); await expect(sut.handleQueueCleanup()).resolves.toBe(JobStatus.SUCCESS); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_DELETE, data: { id: libraryStub.externalLibrary1.id } }, { name: JobName.LIBRARY_DELETE, data: { id: libraryStub.externalLibrary2.id } }, ]); @@ -813,31 +799,31 @@ describe(LibraryService.name, () => { describe('update', () => { beforeEach(async () => { - libraryMock.getAll.mockResolvedValue([]); + mocks.library.getAll.mockResolvedValue([]); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); }); it('should throw an error if an import path is invalid', async () => { - libraryMock.update.mockResolvedValue(libraryStub.externalLibrary1); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.update.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); await expect(sut.update('library-id', { importPaths: ['foo/bar'] })).rejects.toBeInstanceOf(BadRequestException); - expect(libraryMock.update).not.toHaveBeenCalled(); + expect(mocks.library.update).not.toHaveBeenCalled(); }); it('should update library', async () => { - libraryMock.update.mockResolvedValue(libraryStub.externalLibrary1); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - storageMock.stat.mockResolvedValue({ isDirectory: () => true } as Stats); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.library.update.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + 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), ); - expect(libraryMock.update).toHaveBeenCalledWith( + expect(mocks.library.update).toHaveBeenCalledWith( 'library-id', expect.objectContaining({ importPaths: [`${cwd}/foo/bar`] }), ); @@ -861,27 +847,27 @@ describe(LibraryService.name, () => { }); it('should not watch library', async () => { - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); await sut.watchAll(); - expect(storageMock.watch).not.toHaveBeenCalled(); + expect(mocks.storage.watch).not.toHaveBeenCalled(); }); }); describe('watching enabled', () => { beforeEach(async () => { - libraryMock.getAll.mockResolvedValue([]); + mocks.library.getAll.mockResolvedValue([]); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); }); it('should watch library', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); await sut.watchAll(); - expect(storageMock.watch).toHaveBeenCalledWith( + expect(mocks.storage.watch).toHaveBeenCalledWith( libraryStub.externalLibraryWithImportPaths1.importPaths, expect.anything(), expect.anything(), @@ -889,10 +875,10 @@ describe(LibraryService.name, () => { }); it('should watch and unwatch library', async () => { - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); const mockClose = vitest.fn(); - storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); + mocks.storage.watch.mockImplementation(makeMockWatcher({ close: mockClose })); await sut.watchAll(); await sut.unwatch(libraryStub.externalLibraryWithImportPaths1.id); @@ -901,23 +887,23 @@ describe(LibraryService.name, () => { }); it('should not watch library without import paths', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibrary1]); await sut.watchAll(); - expect(storageMock.watch).not.toHaveBeenCalled(); + expect(mocks.storage.watch).not.toHaveBeenCalled(); }); it('should handle a new file event', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - storageMock.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.jpg' }] })); + mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.storage.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.jpg' }] })); await sut.watchAll(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_SYNC_FILE, data: { @@ -927,22 +913,22 @@ describe(LibraryService.name, () => { }, }, ]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_SYNC_ASSET, data: expect.objectContaining({ id: assetStub.image.id }) }, ]); }); it('should handle a file change event', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - storageMock.watch.mockImplementation( + mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.storage.watch.mockImplementation( makeMockWatcher({ items: [{ event: 'change', value: '/foo/photo.jpg' }] }), ); await sut.watchAll(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_SYNC_FILE, data: { @@ -952,31 +938,31 @@ describe(LibraryService.name, () => { }, }, ]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_SYNC_ASSET, data: expect.objectContaining({ id: assetStub.image.id }) }, ]); }); it('should handle a file unlink event', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - storageMock.watch.mockImplementation( + mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.storage.watch.mockImplementation( makeMockWatcher({ items: [{ event: 'unlink', value: '/foo/photo.jpg' }] }), ); await sut.watchAll(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_SYNC_ASSET, data: expect.objectContaining({ id: assetStub.image.id }) }, ]); }); it('should handle an error event', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - storageMock.watch.mockImplementation( + mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + mocks.storage.watch.mockImplementation( makeMockWatcher({ items: [{ event: 'error', value: 'Error!' }], }), @@ -986,47 +972,51 @@ describe(LibraryService.name, () => { }); it('should ignore unknown extensions', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - storageMock.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.jpg' }] })); + mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + mocks.storage.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.jpg' }] })); await sut.watchAll(); - expect(jobMock.queue).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); }); it('should ignore excluded paths', async () => { - libraryMock.get.mockResolvedValue(libraryStub.patternPath); - libraryMock.getAll.mockResolvedValue([libraryStub.patternPath]); - storageMock.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/dir1/photo.txt' }] })); + mocks.library.get.mockResolvedValue(libraryStub.patternPath); + mocks.library.getAll.mockResolvedValue([libraryStub.patternPath]); + mocks.storage.watch.mockImplementation( + makeMockWatcher({ items: [{ event: 'add', value: '/dir1/photo.txt' }] }), + ); await sut.watchAll(); - expect(jobMock.queue).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); }); it('should ignore excluded paths without case sensitivity', async () => { - libraryMock.get.mockResolvedValue(libraryStub.patternPath); - libraryMock.getAll.mockResolvedValue([libraryStub.patternPath]); - storageMock.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/DIR1/photo.txt' }] })); + mocks.library.get.mockResolvedValue(libraryStub.patternPath); + mocks.library.getAll.mockResolvedValue([libraryStub.patternPath]); + mocks.storage.watch.mockImplementation( + makeMockWatcher({ items: [{ event: 'add', value: '/DIR1/photo.txt' }] }), + ); await sut.watchAll(); - expect(jobMock.queue).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); }); }); }); describe('teardown', () => { it('should tear down all watchers', async () => { - libraryMock.getAll.mockResolvedValue([ + mocks.library.getAll.mockResolvedValue([ libraryStub.externalLibraryWithImportPaths1, libraryStub.externalLibraryWithImportPaths2, ]); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); - libraryMock.get.mockImplementation((id) => + mocks.library.get.mockImplementation((id) => Promise.resolve( [libraryStub.externalLibraryWithImportPaths1, libraryStub.externalLibraryWithImportPaths2].find( (library) => library.id === id, @@ -1035,7 +1025,7 @@ describe(LibraryService.name, () => { ); const mockClose = vitest.fn(); - storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); + mocks.storage.watch.mockImplementation(makeMockWatcher({ close: mockClose })); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); await sut.onShutdown(); @@ -1046,18 +1036,18 @@ describe(LibraryService.name, () => { describe('handleDeleteLibrary', () => { it('should delete an empty library', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - assetMock.getAll.mockResolvedValue({ items: [], hasNextPage: false }); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.asset.getAll.mockResolvedValue({ items: [], hasNextPage: false }); await expect(sut.handleDeleteLibrary({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); - expect(libraryMock.delete).toHaveBeenCalled(); + expect(mocks.library.delete).toHaveBeenCalled(); }); it('should delete all assets in a library', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - assetMock.getAll.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); - assetMock.getById.mockResolvedValue(assetStub.image1); + mocks.asset.getById.mockResolvedValue(assetStub.image1); await expect(sut.handleDeleteLibrary({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); }); @@ -1065,11 +1055,11 @@ describe(LibraryService.name, () => { describe('queueScan', () => { it('should queue a library scan', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); await sut.queueScan(libraryStub.externalLibrary1.id); - expect(jobMock.queue.mock.calls).toEqual([ + expect(mocks.job.queue.mock.calls).toEqual([ [ { name: JobName.LIBRARY_QUEUE_SYNC_FILES, @@ -1092,11 +1082,11 @@ describe(LibraryService.name, () => { describe('handleQueueAllScan', () => { it('should queue the refresh job', async () => { - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibrary1]); await expect(sut.handleQueueSyncAll()).resolves.toBe(JobStatus.SUCCESS); - expect(jobMock.queue.mock.calls).toEqual([ + expect(mocks.job.queue.mock.calls).toEqual([ [ { name: JobName.LIBRARY_QUEUE_CLEANUP, @@ -1104,7 +1094,7 @@ describe(LibraryService.name, () => { }, ], ]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_QUEUE_SYNC_FILES, data: { @@ -1117,13 +1107,13 @@ describe(LibraryService.name, () => { describe('handleQueueAssetOfflineCheck', () => { it('should queue removal jobs', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - assetMock.getAll.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); - assetMock.getById.mockResolvedValue(assetStub.image1); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); + mocks.asset.getById.mockResolvedValue(assetStub.image1); await expect(sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_SYNC_ASSET, data: { @@ -1142,11 +1132,11 @@ describe(LibraryService.name, () => { }); it('should validate directory', async () => { - storageMock.stat.mockResolvedValue({ + mocks.storage.stat.mockResolvedValue({ isDirectory: () => true, } as Stats); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.storage.checkFileExists.mockResolvedValue(true); await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({ importPaths: [ @@ -1160,7 +1150,7 @@ describe(LibraryService.name, () => { }); it('should detect when path does not exist', async () => { - storageMock.stat.mockImplementation(() => { + mocks.storage.stat.mockImplementation(() => { const error = { code: 'ENOENT' } as any; throw error; }); @@ -1177,7 +1167,7 @@ describe(LibraryService.name, () => { }); it('should detect when path is not a directory', async () => { - storageMock.stat.mockResolvedValue({ + mocks.storage.stat.mockResolvedValue({ isDirectory: () => false, } as Stats); @@ -1193,7 +1183,7 @@ describe(LibraryService.name, () => { }); it('should return an unknown exception from stat', async () => { - storageMock.stat.mockImplementation(() => { + mocks.storage.stat.mockImplementation(() => { throw new Error('Unknown error'); }); @@ -1209,11 +1199,11 @@ describe(LibraryService.name, () => { }); it('should detect when access rights are missing', async () => { - storageMock.stat.mockResolvedValue({ + mocks.storage.stat.mockResolvedValue({ isDirectory: () => true, } as Stats); - storageMock.checkFileExists.mockResolvedValue(false); + mocks.storage.checkFileExists.mockResolvedValue(false); await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({ importPaths: [ @@ -1241,11 +1231,11 @@ describe(LibraryService.name, () => { }); it('should detect when import path is in immich media folder', async () => { - storageMock.stat.mockResolvedValue({ isDirectory: () => true } as Stats); + mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats); const cwd = process.cwd(); const validImport = `${cwd}/${libraryStub.hasImmichPaths.importPaths[1]}`; - storageMock.checkFileExists.mockImplementation((importPath) => Promise.resolve(importPath === validImport)); + mocks.storage.checkFileExists.mockImplementation((importPath) => Promise.resolve(importPath === validImport)); const pathStubs = libraryStub.hasImmichPaths.importPaths; const importPaths = [pathStubs[0], validImport, pathStubs[2]]; diff --git a/server/src/services/map.service.spec.ts b/server/src/services/map.service.spec.ts index 30505f7f5b..e86ad92976 100644 --- a/server/src/services/map.service.spec.ts +++ b/server/src/services/map.service.spec.ts @@ -1,23 +1,16 @@ -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { MapService } from 'src/services/map.service'; -import { IMapRepository } from 'src/types'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(MapService.name, () => { let sut: MapService; - - let albumMock: Mocked; - let mapMock: Mocked; - let partnerMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, albumMock, mapMock, partnerMock } = newTestService(MapService)); + ({ sut, mocks } = newTestService(MapService)); }); describe('getMapMarkers', () => { @@ -31,8 +24,8 @@ describe(MapService.name, () => { state: asset.exifInfo!.state, country: asset.exifInfo!.country, }; - partnerMock.getAll.mockResolvedValue([]); - mapMock.getMapMarkers.mockResolvedValue([marker]); + mocks.partner.getAll.mockResolvedValue([]); + mocks.map.getMapMarkers.mockResolvedValue([marker]); const markers = await sut.getMapMarkers(authStub.user1, {}); @@ -50,12 +43,12 @@ describe(MapService.name, () => { state: asset.exifInfo!.state, country: asset.exifInfo!.country, }; - partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1]); - mapMock.getMapMarkers.mockResolvedValue([marker]); + mocks.partner.getAll.mockResolvedValue([partnerStub.adminToUser1]); + mocks.map.getMapMarkers.mockResolvedValue([marker]); const markers = await sut.getMapMarkers(authStub.user1, { withPartners: true }); - expect(mapMock.getMapMarkers).toHaveBeenCalledWith( + expect(mocks.map.getMapMarkers).toHaveBeenCalledWith( [authStub.user1.user.id, partnerStub.adminToUser1.sharedById], expect.arrayContaining([]), { withPartners: true }, @@ -74,10 +67,10 @@ describe(MapService.name, () => { state: asset.exifInfo!.state, country: asset.exifInfo!.country, }; - partnerMock.getAll.mockResolvedValue([]); - mapMock.getMapMarkers.mockResolvedValue([marker]); - albumMock.getOwned.mockResolvedValue([albumStub.empty]); - albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]); + mocks.partner.getAll.mockResolvedValue([]); + mocks.map.getMapMarkers.mockResolvedValue([marker]); + mocks.album.getOwned.mockResolvedValue([albumStub.empty]); + mocks.album.getShared.mockResolvedValue([albumStub.sharedWithUser]); const markers = await sut.getMapMarkers(authStub.user1, { withSharedAlbums: true }); @@ -88,13 +81,13 @@ describe(MapService.name, () => { describe('reverseGeocode', () => { it('should reverse geocode a location', async () => { - mapMock.reverseGeocode.mockResolvedValue({ city: 'foo', state: 'bar', country: 'baz' }); + mocks.map.reverseGeocode.mockResolvedValue({ city: 'foo', state: 'bar', country: 'baz' }); await expect(sut.reverseGeocode({ lat: 42, lon: 69 })).resolves.toEqual([ { city: 'foo', state: 'bar', country: 'baz' }, ]); - expect(mapMock.reverseGeocode).toHaveBeenCalledWith({ latitude: 42, longitude: 69 }); + expect(mocks.map.reverseGeocode).toHaveBeenCalledWith({ latitude: 42, longitude: 69 }); }); }); }); diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 9844aa7f0f..3f48d8534a 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -13,35 +13,22 @@ import { TranscodePolicy, VideoCodec, } from 'src/enum'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { IJobRepository, JobCounts, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { IMoveRepository } from 'src/interfaces/move.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; +import { WithoutProperty } from 'src/interfaces/asset.interface'; +import { JobCounts, JobName, JobStatus } from 'src/interfaces/job.interface'; import { MediaService } from 'src/services/media.service'; -import { ILoggingRepository, IMediaRepository, ISystemMetadataRepository, RawImageInfo } from 'src/types'; +import { RawImageInfo } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { probeStub } from 'test/fixtures/media.stub'; import { personStub } from 'test/fixtures/person.stub'; -import { makeStream, newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { makeStream, newTestService, ServiceMocks } from 'test/utils'; describe(MediaService.name, () => { let sut: MediaService; - - let assetMock: Mocked; - let jobMock: Mocked; - let loggerMock: Mocked; - let mediaMock: Mocked; - let moveMock: Mocked; - let personMock: Mocked; - let storageMock: Mocked; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, jobMock, loggerMock, mediaMock, moveMock, personMock, storageMock, systemMock } = - newTestService(MediaService)); + ({ sut, mocks } = newTestService(MediaService)); }); it('should be defined', () => { @@ -50,27 +37,27 @@ describe(MediaService.name, () => { describe('handleQueueGenerateThumbnails', () => { it('should queue all assets', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream([personStub.newThumbnail])); - personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); + mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail])); + mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]); await sut.handleQueueGenerateThumbnails({ force: true }); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(assetMock.getWithout).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.asset.getWithout).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); - expect(personMock.getAll).toHaveBeenCalledWith(undefined); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.getAll).toHaveBeenCalledWith(undefined); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personStub.newThumbnail.id }, @@ -79,20 +66,20 @@ describe(MediaService.name, () => { }); it('should queue trashed assets when force is true', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.trashed], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream()); + mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); - expect(assetMock.getAll).toHaveBeenCalledWith( + expect(mocks.asset.getAll).toHaveBeenCalledWith( { skip: 0, take: 1000 }, expect.objectContaining({ withDeleted: true }), ); - expect(assetMock.getWithout).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getWithout).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.trashed.id }, @@ -101,20 +88,20 @@ describe(MediaService.name, () => { }); it('should queue archived assets when force is true', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.archived], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream()); + mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); - expect(assetMock.getAll).toHaveBeenCalledWith( + expect(mocks.asset.getAll).toHaveBeenCalledWith( { skip: 0, take: 1000 }, expect.objectContaining({ withArchived: true }), ); - expect(assetMock.getWithout).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getWithout).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.archived.id }, @@ -123,22 +110,22 @@ describe(MediaService.name, () => { }); it('should queue all people with missing thumbnail path', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream([personStub.noThumbnail, personStub.noThumbnail])); - personMock.getRandomFace.mockResolvedValueOnce(faceStub.face1); + mocks.person.getAll.mockReturnValue(makeStream([personStub.noThumbnail, personStub.noThumbnail])); + mocks.person.getRandomFace.mockResolvedValueOnce(faceStub.face1); await sut.handleQueueGenerateThumbnails({ force: false }); - expect(assetMock.getAll).not.toHaveBeenCalled(); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); + expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); - expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); - expect(personMock.getRandomFace).toHaveBeenCalled(); - expect(personMock.update).toHaveBeenCalledTimes(1); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.person.getRandomFace).toHaveBeenCalled(); + expect(mocks.person.update).toHaveBeenCalledTimes(1); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, data: { @@ -149,79 +136,79 @@ describe(MediaService.name, () => { }); it('should queue all assets with missing resize path', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.noResizePath], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream()); + mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); - expect(assetMock.getAll).not.toHaveBeenCalled(); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); - expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); it('should queue all assets with missing webp path', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.noWebpPath], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream()); + mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); - expect(assetMock.getAll).not.toHaveBeenCalled(); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); - expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); it('should queue all assets with missing thumbhash', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.noThumbhash], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream()); + mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); - expect(assetMock.getAll).not.toHaveBeenCalled(); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); - expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); }); describe('handleQueueMigration', () => { it('should remove empty directories and queue jobs', async () => { - assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); - jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts); - personMock.getAll.mockReturnValue(makeStream([personStub.withName])); + mocks.asset.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); + mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts); + mocks.person.getAll.mockReturnValue(makeStream([personStub.withName])); await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.SUCCESS); - expect(storageMock.removeEmptyDirs).toHaveBeenCalledTimes(2); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.storage.removeEmptyDirs).toHaveBeenCalledTimes(2); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.MIGRATE_ASSET, data: { id: assetStub.image.id } }, ]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.MIGRATE_PERSON, data: { id: personStub.withName.id } }, ]); }); @@ -231,12 +218,12 @@ describe(MediaService.name, () => { it('should fail if asset does not exist', async () => { await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(moveMock.getByEntity).not.toHaveBeenCalled(); + expect(mocks.move.getByEntity).not.toHaveBeenCalled(); }); it('should move asset files', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - moveMock.create.mockResolvedValue({ + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.move.create.mockResolvedValue({ entityId: assetStub.image.id, id: 'move-id', newPath: '/new/path', @@ -245,7 +232,7 @@ describe(MediaService.name, () => { }); await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); - expect(moveMock.create).toHaveBeenCalledTimes(2); + expect(mocks.move.create).toHaveBeenCalledTimes(2); }); }); @@ -256,72 +243,72 @@ describe(MediaService.name, () => { beforeEach(() => { rawBuffer = Buffer.from('image data'); rawInfo = { width: 100, height: 100, channels: 3 }; - mediaMock.decodeImage.mockResolvedValue({ data: rawBuffer, info: rawInfo as OutputInfo }); + mocks.media.decodeImage.mockResolvedValue({ data: rawBuffer, info: rawInfo as OutputInfo }); }); it('should skip thumbnail generation if asset not found', async () => { await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should skip thumbnail generation if asset type is unknown', async () => { - assetMock.getById.mockResolvedValue({ ...assetStub.image, type: 'foo' } as never as AssetEntity); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image, type: 'foo' } as never as AssetEntity); await expect(sut.handleGenerateThumbnails({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED); - expect(mediaMock.probe).not.toHaveBeenCalled(); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); + expect(mocks.media.probe).not.toHaveBeenCalled(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should skip video thumbnail generation if no video stream', async () => { - mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams); - assetMock.getById.mockResolvedValue(assetStub.video); + mocks.media.probe.mockResolvedValue(probeStub.noVideoStreams); + mocks.asset.getById.mockResolvedValue(assetStub.video); await expect(sut.handleGenerateThumbnails({ id: assetStub.video.id })).rejects.toThrowError(); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should skip invisible assets', async () => { - assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); expect(await sut.handleGenerateThumbnails({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should delete previous preview if different path', async () => { - systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); + expect(mocks.storage.unlink).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); }); it('should generate P3 thumbnails for a wide gamut image', async () => { - assetMock.getById.mockResolvedValue({ + mocks.asset.getById.mockResolvedValue({ ...assetStub.image, exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity, }); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); - mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, }); - expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { colorspace: Colorspace.P3, @@ -333,7 +320,7 @@ describe(MediaService.name, () => { }, 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', ); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { colorspace: Colorspace.P3, @@ -346,14 +333,14 @@ describe(MediaService.name, () => { 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', ); - expect(mediaMock.generateThumbhash).toHaveBeenCalledOnce(); - expect(mediaMock.generateThumbhash).toHaveBeenCalledWith(rawBuffer, { + expect(mocks.media.generateThumbhash).toHaveBeenCalledOnce(); + expect(mocks.media.generateThumbhash).toHaveBeenCalledWith(rawBuffer, { colorspace: Colorspace.P3, processInvalidImages: false, raw: rawInfo, }); - expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ { assetId: 'asset-id', type: AssetFileType.PREVIEW, @@ -365,16 +352,16 @@ describe(MediaService.name, () => { path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', }, ]); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); }); it('should generate a thumbnail for a video', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - assetMock.getById.mockResolvedValue(assetStub.video); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.asset.getById.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', expect.objectContaining({ @@ -389,7 +376,7 @@ describe(MediaService.name, () => { twoPass: false, }), ); - expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ { assetId: 'asset-id', type: AssetFileType.PREVIEW, @@ -404,12 +391,12 @@ describe(MediaService.name, () => { }); it('should tonemap thumbnail for hdr video', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - assetMock.getById.mockResolvedValue(assetStub.video); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.asset.getById.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', expect.objectContaining({ @@ -424,7 +411,7 @@ describe(MediaService.name, () => { twoPass: false, }), ); - expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ { assetId: 'asset-id', type: AssetFileType.PREVIEW, @@ -439,14 +426,14 @@ describe(MediaService.name, () => { }); it('should always generate video thumbnail in one pass', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '5000k' }, }); - assetMock.getById.mockResolvedValue(assetStub.video); + mocks.asset.getById.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', expect.objectContaining({ @@ -463,11 +450,11 @@ describe(MediaService.name, () => { ); }); it('should not skip intra frames for MTS file', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamMTS); - assetMock.getById.mockResolvedValue(assetStub.video); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamMTS); + mocks.asset.getById.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', expect.objectContaining({ @@ -480,12 +467,12 @@ describe(MediaService.name, () => { }); it('should use scaling divisible by 2 even when using quick sync', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); - assetMock.getById.mockResolvedValue(assetStub.video); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); + mocks.asset.getById.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', expect.objectContaining({ @@ -497,25 +484,25 @@ describe(MediaService.name, () => { }); it.each(Object.values(ImageFormat))('should generate an image preview in %s format', async (format) => { - systemMock.get.mockResolvedValue({ image: { preview: { format } } }); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { format } } }); + mocks.asset.getById.mockResolvedValue(assetStub.image); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); - mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.webp`; await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { colorspace: Colorspace.SRGB, processInvalidImages: false, size: 1440, }); - expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { colorspace: Colorspace.SRGB, @@ -527,7 +514,7 @@ describe(MediaService.name, () => { }, previewPath, ); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { colorspace: Colorspace.SRGB, @@ -542,25 +529,25 @@ describe(MediaService.name, () => { }); it.each(Object.values(ImageFormat))('should generate an image thumbnail in %s format', async (format) => { - systemMock.get.mockResolvedValue({ image: { thumbnail: { format } } }); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format } } }); + mocks.asset.getById.mockResolvedValue(assetStub.image); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); - mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.jpeg`; const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { colorspace: Colorspace.SRGB, processInvalidImages: false, size: 1440, }); - expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { colorspace: Colorspace.SRGB, @@ -572,7 +559,7 @@ describe(MediaService.name, () => { }, previewPath, ); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { colorspace: Colorspace.SRGB, @@ -587,132 +574,132 @@ describe(MediaService.name, () => { }); it('should delete previous thumbnail if different path', async () => { - systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/webp/path.ext'); + expect(mocks.storage.unlink).toHaveBeenCalledWith('/uploads/user-id/webp/path.ext'); }); it('should extract embedded image if enabled and available', async () => { - mediaMock.extract.mockResolvedValue(true); - mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getById.mockResolvedValue(assetStub.imageDng); + mocks.media.extract.mockResolvedValue(true); + mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); + mocks.asset.getById.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(extractedPath, { + const extractedPath = mocks.media.extract.mock.calls.at(-1)?.[1].toString(); + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, }); expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); + expect(mocks.storage.unlink).toHaveBeenCalledWith(extractedPath); }); it('should resize original image if embedded image is too small', async () => { - mediaMock.extract.mockResolvedValue(true); - mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getById.mockResolvedValue(assetStub.imageDng); + mocks.media.extract.mockResolvedValue(true); + mocks.media.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); + mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); + mocks.asset.getById.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, }); - const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); + const extractedPath = mocks.media.extract.mock.calls.at(-1)?.[1].toString(); expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); + expect(mocks.storage.unlink).toHaveBeenCalledWith(extractedPath); }); it('should resize original image if embedded image not found', async () => { - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getById.mockResolvedValue(assetStub.imageDng); + mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); + mocks.asset.getById.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, }); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + expect(mocks.media.getImageDimensions).not.toHaveBeenCalled(); }); it('should resize original image if embedded image extraction is not enabled', async () => { - systemMock.get.mockResolvedValue({ image: { extractEmbedded: false } }); - assetMock.getById.mockResolvedValue(assetStub.imageDng); + mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: false } }); + mocks.asset.getById.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.extract).not.toHaveBeenCalled(); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + expect(mocks.media.extract).not.toHaveBeenCalled(); + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, }); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + expect(mocks.media.getImageDimensions).not.toHaveBeenCalled(); }); it('should process invalid images if enabled', async () => { vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true'); - assetMock.getById.mockResolvedValue(assetStub.imageDng); + mocks.asset.getById.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith( + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith( assetStub.imageDng.originalPath, expect.objectContaining({ processInvalidImages: true }), ); - expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, expect.objectContaining({ processInvalidImages: true }), 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', ); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, expect.objectContaining({ processInvalidImages: true }), 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', ); - expect(mediaMock.generateThumbhash).toHaveBeenCalledOnce(); - expect(mediaMock.generateThumbhash).toHaveBeenCalledWith( + expect(mocks.media.generateThumbhash).toHaveBeenCalledOnce(); + expect(mocks.media.generateThumbhash).toHaveBeenCalledWith( rawBuffer, expect.objectContaining({ processInvalidImages: true }), ); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + expect(mocks.media.getImageDimensions).not.toHaveBeenCalled(); vi.unstubAllEnvs(); }); }); describe('handleQueueVideoConversion', () => { it('should queue all video assets', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.video], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream()); + mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueVideoConversion({ force: true }); - expect(assetMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { type: AssetType.VIDEO }); - expect(assetMock.getWithout).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { type: AssetType.VIDEO }); + expect(mocks.asset.getWithout).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.VIDEO_CONVERSION, data: { id: assetStub.video.id }, @@ -721,16 +708,16 @@ describe(MediaService.name, () => { }); it('should queue all video assets without encoded videos', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.video], hasNextPage: false, }); await sut.handleQueueVideoConversion({}); - expect(assetMock.getAll).not.toHaveBeenCalled(); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.ENCODED_VIDEO); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.ENCODED_VIDEO); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.VIDEO_CONVERSION, data: { id: assetStub.video.id }, @@ -741,35 +728,35 @@ describe(MediaService.name, () => { describe('handleVideoConversion', () => { beforeEach(() => { - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); sut.videoInterfaces = { dri: ['renderD128'], mali: true }; }); it('should skip transcoding if asset not found', async () => { - assetMock.getByIds.mockResolvedValue([]); + mocks.asset.getByIds.mockResolvedValue([]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.probe).not.toHaveBeenCalled(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.probe).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should skip transcoding if non-video asset', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); await sut.handleVideoConversion({ id: assetStub.image.id }); - expect(mediaMock.probe).not.toHaveBeenCalled(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.probe).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should transcode the longest stream', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.video]); - loggerMock.isLevelEnabled.mockReturnValue(false); - mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.logger.isLevelEnabled.mockReturnValue(false); + mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext', { countFrames: false }); - expect(systemMock.get).toHaveBeenCalled(); - expect(storageMock.mkdirSync).toHaveBeenCalled(); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.probe).toHaveBeenCalledWith('/original/path.ext', { countFrames: false }); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); + expect(mocks.storage.mkdirSync).toHaveBeenCalled(); + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -781,46 +768,46 @@ describe(MediaService.name, () => { }); it('should skip a video without any streams', async () => { - mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.noVideoStreams); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should skip a video without any height', async () => { - mediaMock.probe.mockResolvedValue(probeStub.noHeight); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.noHeight); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should throw an error if an unknown transcode policy is configured', async () => { - mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: 'foo' } } as never as SystemConfig); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'foo' } } as never as SystemConfig); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should throw an error if transcoding fails and hw acceleration is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, accel: TranscodeHWAccel.DISABLED }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - mediaMock.transcode.mockRejectedValue(new Error('Error transcoding video')); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.transcode.mockRejectedValue(new Error('Error transcoding video')); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); - expect(mediaMock.transcode).toHaveBeenCalledTimes(1); + expect(mocks.media.transcode).toHaveBeenCalledTimes(1); }); it('should transcode when set to all', async () => { - mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -832,10 +819,10 @@ describe(MediaService.name, () => { }); it('should transcode when optimal and too big', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -847,10 +834,10 @@ describe(MediaService.name, () => { }); it('should transcode when policy bitrate and bitrate higher than max bitrate', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream40Mbps); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: '30M' } }); + mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: '30M' } }); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -862,10 +849,10 @@ describe(MediaService.name, () => { }); it('should transcode when max bitrate is not a number', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream40Mbps); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: 'foo' } }); + mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: 'foo' } }); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -877,10 +864,12 @@ describe(MediaService.name, () => { }); it('should not scale resolution if no target resolution', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } }); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' }, + }); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -892,11 +881,11 @@ describe(MediaService.name, () => { }); it('should scale horizontally when video is horizontal', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -908,11 +897,11 @@ describe(MediaService.name, () => { }); it('should scale vertically when video is vertical', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -924,11 +913,13 @@ describe(MediaService.name, () => { }); it('should always scale video if height is uneven', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamOddHeight); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamOddHeight); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' }, + }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -940,11 +931,13 @@ describe(MediaService.name, () => { }); it('should always scale video if width is uneven', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamOddWidth); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamOddWidth); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' }, + }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -956,13 +949,13 @@ describe(MediaService.name, () => { }); it('should copy video stream when video matches target', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.HEVC, acceptedAudioCodecs: [AudioCodec.AAC] }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -974,17 +967,17 @@ describe(MediaService.name, () => { }); it('should not include hevc tag when target is hevc and video stream is copied from a different codec', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamH264); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamH264); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.HEVC, acceptedVideoCodecs: [VideoCodec.H264, VideoCodec.HEVC], acceptedAudioCodecs: [AudioCodec.AAC], }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -996,17 +989,17 @@ describe(MediaService.name, () => { }); it('should include hevc tag when target is hevc and copying hevc video stream', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.HEVC, acceptedVideoCodecs: [VideoCodec.H264, VideoCodec.HEVC], acceptedAudioCodecs: [AudioCodec.AAC], }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1018,11 +1011,11 @@ describe(MediaService.name, () => { }); it('should copy audio stream when audio matches target', async () => { - mediaMock.probe.mockResolvedValue(probeStub.audioStreamAac); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.audioStreamAac); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1034,10 +1027,10 @@ describe(MediaService.name, () => { }); it('should remux when input is not an accepted container', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamAvi); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamAvi); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1049,58 +1042,58 @@ describe(MediaService.name, () => { }); it('should throw an exception if transcode value is invalid', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: 'invalid' as any } }); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'invalid' as any } }); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should not transcode if transcoding is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should not remux when input is not an accepted container and transcoding is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should not transcode if target codec is invalid', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: 'invalid' as any } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: 'invalid' as any } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should delete existing transcode if current policy does not require transcoding', async () => { const asset = assetStub.hasEncodedVideo; - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); - assetMock.getByIds.mockResolvedValue([asset]); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); + mocks.asset.getByIds.mockResolvedValue([asset]); await sut.handleVideoConversion({ id: asset.id }); - expect(mediaMock.transcode).not.toHaveBeenCalled(); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.media.transcode).not.toHaveBeenCalled(); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DELETE_FILES, data: { files: [asset.encodedVideoPath] }, }); }); it('should set max bitrate if above 0', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500k' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500k' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1112,11 +1105,11 @@ describe(MediaService.name, () => { }); it('should default max bitrate to kbps if no unit is provided', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1128,11 +1121,11 @@ describe(MediaService.name, () => { }); it('should transcode in two passes for h264/h265 when enabled and max bitrate is above 0', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '4500k' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '4500k' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1144,11 +1137,11 @@ describe(MediaService.name, () => { }); it('should fallback to one pass for h264/h265 if two-pass is enabled but no max bitrate is set', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { twoPass: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1160,17 +1153,17 @@ describe(MediaService.name, () => { }); it('should transcode by bitrate in two passes for vp9 when two pass mode and max bitrate are enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500k', twoPass: true, targetVideoCodec: VideoCodec.VP9, }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1182,17 +1175,17 @@ describe(MediaService.name, () => { }); it('should transcode by crf in two passes for vp9 when two pass mode is enabled and max bitrate is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '0', twoPass: true, targetVideoCodec: VideoCodec.VP9, }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1204,11 +1197,11 @@ describe(MediaService.name, () => { }); it('should configure preset for vp9', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, preset: 'slow' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, preset: 'slow' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1220,11 +1213,11 @@ describe(MediaService.name, () => { }); it('should not configure preset for vp9 if invalid', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { preset: 'invalid', targetVideoCodec: VideoCodec.VP9 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { preset: 'invalid', targetVideoCodec: VideoCodec.VP9 } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1236,11 +1229,11 @@ describe(MediaService.name, () => { }); it('should configure threads if above 0', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, threads: 2 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, threads: 2 } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1252,11 +1245,11 @@ describe(MediaService.name, () => { }); it('should disable thread pooling for h264 if thread limit is 1', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { threads: 1 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 1 } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1268,11 +1261,11 @@ describe(MediaService.name, () => { }); it('should omit thread flags for h264 if thread limit is at or below 0', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { threads: 0 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 0 } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1284,11 +1277,11 @@ describe(MediaService.name, () => { }); it('should disable thread pooling for hevc if thread limit is 1', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ ffmpeg: { threads: 1, targetVideoCodec: VideoCodec.HEVC } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 1, targetVideoCodec: VideoCodec.HEVC } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1300,11 +1293,11 @@ describe(MediaService.name, () => { }); it('should omit thread flags for hevc if thread limit is at or below 0', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ ffmpeg: { threads: 0, targetVideoCodec: VideoCodec.HEVC } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 0, targetVideoCodec: VideoCodec.HEVC } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1316,11 +1309,11 @@ describe(MediaService.name, () => { }); it('should use av1 if specified', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1 } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1342,11 +1335,11 @@ describe(MediaService.name, () => { }); it('should map `veryslow` preset to 4 for av1', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, preset: 'veryslow' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, preset: 'veryslow' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1358,11 +1351,11 @@ describe(MediaService.name, () => { }); it('should set max bitrate for av1 if specified', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, maxBitrate: '2M' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, maxBitrate: '2M' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1374,11 +1367,11 @@ describe(MediaService.name, () => { }); it('should set threads for av1 if specified', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4 } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1390,11 +1383,13 @@ describe(MediaService.name, () => { }); it('should set both bitrate and threads for av1 if specified', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4, maxBitrate: '2M' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4, maxBitrate: '2M' }, + }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1406,41 +1401,43 @@ describe(MediaService.name, () => { }); it('should skip transcoding for audioless videos with optimal policy if video codec is correct', async () => { - mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.HEVC, transcode: TranscodePolicy.OPTIMAL, targetResolution: '1080p', }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should fail if hwaccel is enabled for an unsupported codec', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, targetVideoCodec: VideoCodec.VP9 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHWAccel.NVENC, targetVideoCodec: VideoCodec.VP9 }, + }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should fail if hwaccel option is invalid', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: 'invalid' as any } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: 'invalid' as any } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should set options for nvenc', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1468,17 +1465,17 @@ describe(MediaService.name, () => { }); it('should set two pass options for nvenc when enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k', twoPass: true, }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1490,11 +1487,11 @@ describe(MediaService.name, () => { }); it('should set vbr options for nvenc when max bitrate is enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1506,11 +1503,11 @@ describe(MediaService.name, () => { }); it('should set cq options for nvenc when max bitrate is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1522,11 +1519,11 @@ describe(MediaService.name, () => { }); it('should omit preset for nvenc if invalid', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, preset: 'invalid' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, preset: 'invalid' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1538,11 +1535,11 @@ describe(MediaService.name, () => { }); it('should ignore two pass for nvenc if max bitrate is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1554,13 +1551,13 @@ describe(MediaService.name, () => { }); it('should use hardware decoding for nvenc if enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1577,13 +1574,13 @@ describe(MediaService.name, () => { }); it('should use hardware tone-mapping for nvenc if hardware decoding is enabled and should tone map', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1599,13 +1596,13 @@ describe(MediaService.name, () => { }); it('should set format to nv12 for nvenc if input is not yuv420p', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1617,11 +1614,11 @@ describe(MediaService.name, () => { }); it('should set options for qsv', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, maxBitrate: '10000k' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, maxBitrate: '10000k' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1652,17 +1649,17 @@ describe(MediaService.name, () => { }); it('should set options for qsv with custom dri node', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, maxBitrate: '10000k', preferredHwDevice: '/dev/dri/renderD128', }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1677,11 +1674,11 @@ describe(MediaService.name, () => { }); it('should omit preset for qsv if invalid', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, preset: 'invalid' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, preset: 'invalid' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1696,11 +1693,13 @@ describe(MediaService.name, () => { }); it('should set low power mode for qsv if target video codec is vp9', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, targetVideoCodec: VideoCodec.VP9 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHWAccel.QSV, targetVideoCodec: VideoCodec.VP9 }, + }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1716,22 +1715,22 @@ describe(MediaService.name, () => { it('should fail for qsv if no hw devices', async () => { sut.videoInterfaces = { dri: [], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should prefer higher index renderD* device for qsv', async () => { sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1746,15 +1745,15 @@ describe(MediaService.name, () => { }); it('should use hardware decoding for qsv if enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1775,15 +1774,15 @@ describe(MediaService.name, () => { }); it('should use hardware tone-mapping for qsv if hardware decoding is enabled and should tone map', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1805,14 +1804,14 @@ describe(MediaService.name, () => { it('should use preferred device for qsv when hardware decoding', async () => { sut.videoInterfaces = { dri: ['renderD128', 'renderD129', 'renderD130'], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true, preferredHwDevice: 'renderD129' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1824,15 +1823,15 @@ describe(MediaService.name, () => { }); it('should set format to nv12 for qsv if input is not yuv420p', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1849,11 +1848,11 @@ describe(MediaService.name, () => { }); it('should set options for vaapi', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1880,11 +1879,11 @@ describe(MediaService.name, () => { }); it('should set vbr options for vaapi when max bitrate is enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, maxBitrate: '10000k' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, maxBitrate: '10000k' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1905,11 +1904,11 @@ describe(MediaService.name, () => { }); it('should set cq options for vaapi when max bitrate is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1930,11 +1929,11 @@ describe(MediaService.name, () => { }); it('should omit preset for vaapi if invalid', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, preset: 'invalid' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, preset: 'invalid' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1950,11 +1949,11 @@ describe(MediaService.name, () => { it('should prefer higher index renderD* device for vaapi', async () => { sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1970,13 +1969,13 @@ describe(MediaService.name, () => { it('should select specific gpu node if selected', async () => { sut.videoInterfaces = { dri: ['renderD129', 'card1', 'card0', 'renderD128'], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, preferredHwDevice: '/dev/dri/renderD128' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1991,15 +1990,15 @@ describe(MediaService.name, () => { }); it('should use hardware decoding for vaapi if enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2019,15 +2018,15 @@ describe(MediaService.name, () => { }); it('should use hardware tone-mapping for vaapi if hardware decoding is enabled and should tone map', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2043,15 +2042,15 @@ describe(MediaService.name, () => { }); it('should set format to nv12 for vaapi if input is not yuv420p', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2064,14 +2063,14 @@ describe(MediaService.name, () => { it('should use preferred device for vaapi when hardware decoding', async () => { sut.videoInterfaces = { dri: ['renderD128', 'renderD129', 'renderD130'], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true, preferredHwDevice: 'renderD129' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2083,13 +2082,13 @@ describe(MediaService.name, () => { }); it('should fallback to hw encoding and sw decoding if hw transcoding fails and hw decoding is enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - mediaMock.transcode.mockRejectedValueOnce(new Error('error')); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.transcode.mockRejectedValueOnce(new Error('error')); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledTimes(2); - expect(mediaMock.transcode).toHaveBeenLastCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledTimes(2); + expect(mocks.media.transcode).toHaveBeenLastCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2104,14 +2103,14 @@ describe(MediaService.name, () => { }); it('should fallback to sw decoding if fallback to sw decoding + hw encoding fails', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - mediaMock.transcode.mockRejectedValueOnce(new Error('error')); - mediaMock.transcode.mockRejectedValueOnce(new Error('error')); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.transcode.mockRejectedValueOnce(new Error('error')); + mocks.media.transcode.mockRejectedValueOnce(new Error('error')); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledTimes(3); - expect(mediaMock.transcode).toHaveBeenLastCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledTimes(3); + expect(mocks.media.transcode).toHaveBeenLastCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2123,13 +2122,13 @@ describe(MediaService.name, () => { }); it('should fallback to sw transcoding if hw transcoding fails and hw decoding is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - mediaMock.transcode.mockRejectedValueOnce(new Error('error')); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.transcode.mockRejectedValueOnce(new Error('error')); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledTimes(2); - expect(mediaMock.transcode).toHaveBeenLastCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledTimes(2); + expect(mocks.media.transcode).toHaveBeenLastCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2142,19 +2141,19 @@ describe(MediaService.name, () => { it('should fail for vaapi if no hw devices', async () => { sut.videoInterfaces = { dri: [], mali: true }; - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should set options for rkmpp', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2184,8 +2183,8 @@ describe(MediaService.name, () => { }); it('should set vbr options for rkmpp when max bitrate is enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, @@ -2193,9 +2192,9 @@ describe(MediaService.name, () => { targetVideoCodec: VideoCodec.HEVC, }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2207,13 +2206,13 @@ describe(MediaService.name, () => { }); it('should set cqp options for rkmpp when max bitrate is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2225,13 +2224,13 @@ describe(MediaService.name, () => { }); it('should set OpenCL tonemapping options for rkmpp when OpenCL is available', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2248,13 +2247,13 @@ describe(MediaService.name, () => { it('should set hardware decoding options for rkmpp when hardware decoding is enabled with no OpenCL on non-HDR file', async () => { sut.videoInterfaces = { dri: ['renderD128'], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2268,13 +2267,13 @@ describe(MediaService.name, () => { }); it('should use software decoding and tone-mapping if hardware decoding is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: false, crf: 30, maxBitrate: '0' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2291,13 +2290,13 @@ describe(MediaService.name, () => { it('should use software tone-mapping if opencl is not available', async () => { sut.videoInterfaces = { dri: ['renderD128'], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2313,11 +2312,11 @@ describe(MediaService.name, () => { }); it('should tonemap when policy is required and video is hdr', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2333,11 +2332,11 @@ describe(MediaService.name, () => { }); it('should tonemap when policy is optimal and video is hdr', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2353,11 +2352,11 @@ describe(MediaService.name, () => { }); it('should transcode when policy is required and video is not yuv420p', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2369,14 +2368,14 @@ describe(MediaService.name, () => { }); it('should count frames for progress when log level is debug', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - loggerMock.isLevelEnabled.mockReturnValue(true); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.logger.isLevelEnabled.mockReturnValue(true); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: true }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: true }); + expect(mocks.media.transcode).toHaveBeenCalledWith( assetStub.video.originalPath, 'upload/encoded-video/user-id/as/se/asset-id.mp4', { @@ -2392,20 +2391,20 @@ describe(MediaService.name, () => { }); it('should not count frames for progress when log level is not debug', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - loggerMock.isLevelEnabled.mockReturnValue(false); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.logger.isLevelEnabled.mockReturnValue(false); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: false }); + expect(mocks.media.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: false }); }); it('should process unknown audio stream', async () => { - mediaMock.probe.mockResolvedValue(probeStub.audioStreamUnknown); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.audioStreamUnknown); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index a5fa6a9cab..54acfa7baa 100644 --- a/server/src/services/memory.service.spec.ts +++ b/server/src/services/memory.service.spec.ts @@ -1,22 +1,17 @@ import { BadRequestException } from '@nestjs/common'; import { MemoryType } from 'src/enum'; import { MemoryService } from 'src/services/memory.service'; -import { IMemoryRepository } from 'src/types'; import { authStub } from 'test/fixtures/auth.stub'; import { memoryStub } from 'test/fixtures/memory.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(MemoryService.name, () => { let sut: MemoryService; - - let accessMock: IAccessRepositoryMock; - let memoryMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, memoryMock } = newTestService(MemoryService)); + ({ sut, mocks } = newTestService(MemoryService)); }); it('should be defined', () => { @@ -25,7 +20,7 @@ describe(MemoryService.name, () => { describe('search', () => { it('should search memories', async () => { - memoryMock.search.mockResolvedValue([memoryStub.memory1, memoryStub.empty]); + mocks.memory.search.mockResolvedValue([memoryStub.memory1, memoryStub.empty]); await expect(sut.search(authStub.admin)).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'memory1', assets: expect.any(Array) }), @@ -45,22 +40,22 @@ describe(MemoryService.name, () => { }); it('should throw an error when the memory is not found', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['race-condition'])); + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['race-condition'])); await expect(sut.get(authStub.admin, 'race-condition')).rejects.toBeInstanceOf(BadRequestException); }); it('should get a memory by id', async () => { - memoryMock.get.mockResolvedValue(memoryStub.memory1); - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + mocks.memory.get.mockResolvedValue(memoryStub.memory1); + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); await expect(sut.get(authStub.admin, 'memory1')).resolves.toMatchObject({ id: 'memory1' }); - expect(memoryMock.get).toHaveBeenCalledWith('memory1'); - expect(accessMock.memory.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['memory1'])); + expect(mocks.memory.get).toHaveBeenCalledWith('memory1'); + expect(mocks.access.memory.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['memory1'])); }); }); describe('create', () => { it('should skip assets the user does not have access to', async () => { - memoryMock.create.mockResolvedValue(memoryStub.empty); + mocks.memory.create.mockResolvedValue(memoryStub.empty); await expect( sut.create(authStub.admin, { type: MemoryType.ON_THIS_DAY, @@ -69,7 +64,7 @@ describe(MemoryService.name, () => { memoryAt: new Date(2024), }), ).resolves.toMatchObject({ assets: [] }); - expect(memoryMock.create).toHaveBeenCalledWith( + expect(mocks.memory.create).toHaveBeenCalledWith( { ownerId: 'admin_id', memoryAt: expect.any(Date), @@ -83,8 +78,8 @@ describe(MemoryService.name, () => { }); it('should create a memory', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); - memoryMock.create.mockResolvedValue(memoryStub.memory1); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); + mocks.memory.create.mockResolvedValue(memoryStub.memory1); await expect( sut.create(authStub.admin, { type: MemoryType.ON_THIS_DAY, @@ -93,7 +88,7 @@ describe(MemoryService.name, () => { memoryAt: new Date(2024, 0, 1), }), ).resolves.toBeDefined(); - expect(memoryMock.create).toHaveBeenCalledWith( + expect(mocks.memory.create).toHaveBeenCalledWith( expect.objectContaining({ ownerId: userStub.admin.id, }), @@ -102,7 +97,7 @@ describe(MemoryService.name, () => { }); it('should create a memory without assets', async () => { - memoryMock.create.mockResolvedValue(memoryStub.memory1); + mocks.memory.create.mockResolvedValue(memoryStub.memory1); await expect( sut.create(authStub.admin, { type: MemoryType.ON_THIS_DAY, @@ -118,27 +113,27 @@ describe(MemoryService.name, () => { await expect(sut.update(authStub.admin, 'not-found', { isSaved: true })).rejects.toBeInstanceOf( BadRequestException, ); - expect(memoryMock.update).not.toHaveBeenCalled(); + expect(mocks.memory.update).not.toHaveBeenCalled(); }); it('should update a memory', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); - memoryMock.update.mockResolvedValue(memoryStub.memory1); + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + mocks.memory.update.mockResolvedValue(memoryStub.memory1); await expect(sut.update(authStub.admin, 'memory1', { isSaved: true })).resolves.toBeDefined(); - expect(memoryMock.update).toHaveBeenCalledWith('memory1', expect.objectContaining({ isSaved: true })); + expect(mocks.memory.update).toHaveBeenCalledWith('memory1', expect.objectContaining({ isSaved: true })); }); }); describe('remove', () => { it('should require access', async () => { await expect(sut.remove(authStub.admin, 'not-found')).rejects.toBeInstanceOf(BadRequestException); - expect(memoryMock.delete).not.toHaveBeenCalled(); + expect(mocks.memory.delete).not.toHaveBeenCalled(); }); it('should delete a memory', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); await expect(sut.remove(authStub.admin, 'memory1')).resolves.toBeUndefined(); - expect(memoryMock.delete).toHaveBeenCalledWith('memory1'); + expect(mocks.memory.delete).toHaveBeenCalledWith('memory1'); }); }); @@ -147,36 +142,36 @@ describe(MemoryService.name, () => { await expect(sut.addAssets(authStub.admin, 'not-found', { ids: ['asset1'] })).rejects.toBeInstanceOf( BadRequestException, ); - expect(memoryMock.addAssetIds).not.toHaveBeenCalled(); + expect(mocks.memory.addAssetIds).not.toHaveBeenCalled(); }); it('should require asset access', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); - memoryMock.get.mockResolvedValue(memoryStub.memory1); + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + mocks.memory.get.mockResolvedValue(memoryStub.memory1); await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['not-found'] })).resolves.toEqual([ { error: 'no_permission', id: 'not-found', success: false }, ]); - expect(memoryMock.addAssetIds).not.toHaveBeenCalled(); + expect(mocks.memory.addAssetIds).not.toHaveBeenCalled(); }); it('should skip assets already in the memory', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); - memoryMock.get.mockResolvedValue(memoryStub.memory1); - memoryMock.getAssetIds.mockResolvedValue(new Set(['asset1'])); + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + mocks.memory.get.mockResolvedValue(memoryStub.memory1); + mocks.memory.getAssetIds.mockResolvedValue(new Set(['asset1'])); await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([ { error: 'duplicate', id: 'asset1', success: false }, ]); - expect(memoryMock.addAssetIds).not.toHaveBeenCalled(); + expect(mocks.memory.addAssetIds).not.toHaveBeenCalled(); }); it('should add assets', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); - memoryMock.get.mockResolvedValue(memoryStub.memory1); + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); + mocks.memory.get.mockResolvedValue(memoryStub.memory1); await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([ { id: 'asset1', success: true }, ]); - expect(memoryMock.addAssetIds).toHaveBeenCalledWith('memory1', ['asset1']); + expect(mocks.memory.addAssetIds).toHaveBeenCalledWith('memory1', ['asset1']); }); }); @@ -185,25 +180,25 @@ describe(MemoryService.name, () => { await expect(sut.removeAssets(authStub.admin, 'not-found', { ids: ['asset1'] })).rejects.toBeInstanceOf( BadRequestException, ); - expect(memoryMock.removeAssetIds).not.toHaveBeenCalled(); + expect(mocks.memory.removeAssetIds).not.toHaveBeenCalled(); }); it('should skip assets not in the memory', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); await expect(sut.removeAssets(authStub.admin, 'memory1', { ids: ['not-found'] })).resolves.toEqual([ { error: 'not_found', id: 'not-found', success: false }, ]); - expect(memoryMock.removeAssetIds).not.toHaveBeenCalled(); + expect(mocks.memory.removeAssetIds).not.toHaveBeenCalled(); }); it('should remove assets', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); - memoryMock.getAssetIds.mockResolvedValue(new Set(['asset1'])); + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); + mocks.memory.getAssetIds.mockResolvedValue(new Set(['asset1'])); await expect(sut.removeAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([ { id: 'asset1', success: true }, ]); - expect(memoryMock.removeAssetIds).toHaveBeenCalledWith('memory1', ['asset1']); + expect(mocks.memory.removeAssetIds).toHaveBeenCalledWith('memory1', ['asset1']); }); }); }); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 6384c17a42..5657dd02b9 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -5,79 +5,34 @@ import { constants } from 'node:fs/promises'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { AssetType, ExifOrientation, ImmichWorker, SourceType } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ITagRepository } from 'src/interfaces/tag.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { WithoutProperty } from 'src/interfaces/asset.interface'; +import { JobName, JobStatus } from 'src/interfaces/job.interface'; import { ImmichTags } from 'src/repositories/metadata.repository'; import { MetadataService } from 'src/services/metadata.service'; -import { - IConfigRepository, - IMapRepository, - IMediaRepository, - IMetadataRepository, - ISystemMetadataRepository, -} from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { probeStub } from 'test/fixtures/media.stub'; import { metadataStub } from 'test/fixtures/metadata.stub'; import { personStub } from 'test/fixtures/person.stub'; import { tagStub } from 'test/fixtures/tag.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(MetadataService.name, () => { let sut: MetadataService; - - let albumMock: Mocked; - let assetMock: Mocked; - let configMock: Mocked; - let cryptoMock: Mocked; - let eventMock: Mocked; - let jobMock: Mocked; - let mapMock: Mocked; - let mediaMock: Mocked; - let metadataMock: Mocked; - let personMock: Mocked; - let storageMock: Mocked; - let systemMock: Mocked; - let tagMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; const mockReadTags = (exifData?: Partial, sidecarData?: Partial) => { - metadataMock.readTags.mockReset(); - metadataMock.readTags.mockResolvedValueOnce(exifData ?? {}); - metadataMock.readTags.mockResolvedValueOnce(sidecarData ?? {}); + mocks.metadata.readTags.mockReset(); + mocks.metadata.readTags.mockResolvedValueOnce(exifData ?? {}); + mocks.metadata.readTags.mockResolvedValueOnce(sidecarData ?? {}); }; beforeEach(() => { - ({ - sut, - albumMock, - assetMock, - configMock, - cryptoMock, - eventMock, - jobMock, - mapMock, - mediaMock, - metadataMock, - personMock, - storageMock, - systemMock, - tagMock, - userMock, - } = newTestService(MetadataService)); + ({ sut, mocks } = newTestService(MetadataService)); mockReadTags(); - configMock.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); + mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); delete process.env.TZ; }); @@ -94,43 +49,43 @@ describe(MetadataService.name, () => { it('should pause and resume queue during init', async () => { await sut.onBootstrap(); - expect(jobMock.pause).toHaveBeenCalledTimes(1); - expect(mapMock.init).toHaveBeenCalledTimes(1); - expect(jobMock.resume).toHaveBeenCalledTimes(1); + expect(mocks.job.pause).toHaveBeenCalledTimes(1); + expect(mocks.map.init).toHaveBeenCalledTimes(1); + expect(mocks.job.resume).toHaveBeenCalledTimes(1); }); }); describe('handleLivePhotoLinking', () => { it('should handle an asset that could not be found', async () => { await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); - expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(albumMock.removeAsset).not.toHaveBeenCalled(); + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); + expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.album.removeAsset).not.toHaveBeenCalled(); }); it('should handle an asset without exif info', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, exifInfo: undefined }]); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, exifInfo: undefined }]); await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); - expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(albumMock.removeAsset).not.toHaveBeenCalled(); + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); + expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.album.removeAsset).not.toHaveBeenCalled(); }); it('should handle livePhotoCID not set', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image }]); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image }]); await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); - expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(albumMock.removeAsset).not.toHaveBeenCalled(); + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); + expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.album.removeAsset).not.toHaveBeenCalled(); }); it('should handle not finding a match', async () => { - assetMock.getByIds.mockResolvedValue([ + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.livePhotoMotionAsset, exifInfo: { livePhotoCID: assetStub.livePhotoStillAsset.id } as ExifEntity, @@ -140,64 +95,64 @@ describe(MetadataService.name, () => { await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe( JobStatus.SKIPPED, ); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true }); - expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true }); + expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ livePhotoCID: assetStub.livePhotoStillAsset.id, ownerId: assetStub.livePhotoMotionAsset.ownerId, otherAssetId: assetStub.livePhotoMotionAsset.id, type: AssetType.IMAGE, }); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(albumMock.removeAsset).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.album.removeAsset).not.toHaveBeenCalled(); }); it('should link photo and video', async () => { - assetMock.getByIds.mockResolvedValue([ + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.livePhotoStillAsset, exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as ExifEntity, }, ]); - assetMock.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( JobStatus.SUCCESS, ); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true }); - expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true }); + expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ livePhotoCID: assetStub.livePhotoMotionAsset.id, ownerId: assetStub.livePhotoStillAsset.ownerId, otherAssetId: assetStub.livePhotoStillAsset.id, type: AssetType.VIDEO, }); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false }); - expect(albumMock.removeAsset).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false }); + expect(mocks.album.removeAsset).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id); }); it('should notify clients on live photo link', async () => { - assetMock.getByIds.mockResolvedValue([ + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.livePhotoStillAsset, exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as ExifEntity, }, ]); - assetMock.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( JobStatus.SUCCESS, ); - expect(eventMock.emit).toHaveBeenCalledWith('asset.hide', { + expect(mocks.event.emit).toHaveBeenCalledWith('asset.hide', { userId: assetStub.livePhotoMotionAsset.ownerId, assetId: assetStub.livePhotoMotionAsset.id, }); }); it('should search by libraryId', async () => { - assetMock.getByIds.mockResolvedValue([ + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.livePhotoStillAsset, libraryId: 'library-id', @@ -209,7 +164,7 @@ describe(MetadataService.name, () => { JobStatus.SKIPPED, ); - expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({ + expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ ownerId: 'user-id', otherAssetId: 'live-photo-still-asset', livePhotoCID: 'CID', @@ -221,11 +176,11 @@ describe(MetadataService.name, () => { describe('handleQueueMetadataExtraction', () => { it('should queue metadata extraction for all assets without exif values', async () => { - assetMock.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); await expect(sut.handleQueueMetadataExtraction({ force: false })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getWithout).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getWithout).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.METADATA_EXTRACTION, data: { id: assetStub.image.id }, @@ -234,11 +189,11 @@ describe(MetadataService.name, () => { }); it('should queue metadata extraction for all assets', async () => { - assetMock.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); await expect(sut.handleQueueMetadataExtraction({ force: true })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.METADATA_EXTRACTION, data: { id: assetStub.image.id }, @@ -249,27 +204,27 @@ describe(MetadataService.name, () => { describe('handleMetadataExtraction', () => { beforeEach(() => { - storageMock.stat.mockResolvedValue({ size: 123_456 } as Stats); + mocks.storage.stat.mockResolvedValue({ size: 123_456 } as Stats); }); it('should handle an asset that could not be found', async () => { await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should handle a date in a sidecar file', async () => { const originalDate = new Date('2023-11-21T16:13:17.517Z'); const sidecarDate = new Date('2022-01-01T00:00:00.000Z'); - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.sidecar.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate })); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.sidecar.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate })); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, duration: null, fileCreatedAt: sidecarDate, @@ -280,13 +235,15 @@ describe(MetadataService.name, () => { it('should take the file modification date when missing exif and earliest than creation date', async () => { const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z'); - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, fileCreatedAt, fileModifiedAt }]); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, fileCreatedAt, fileModifiedAt }]); mockReadTags(); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: fileModifiedAt })); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ dateTimeOriginal: fileModifiedAt }), + ); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, duration: null, fileCreatedAt: fileModifiedAt, @@ -297,13 +254,13 @@ describe(MetadataService.name, () => { it('should take the file creation date when missing exif and earliest than modification date', async () => { const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z'); - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, fileCreatedAt, fileModifiedAt }]); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, fileCreatedAt, fileModifiedAt }]); mockReadTags(); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: fileCreatedAt })); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: fileCreatedAt })); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, duration: null, fileCreatedAt, @@ -313,17 +270,17 @@ describe(MetadataService.name, () => { it('should account for the server being in a non-UTC timezone', async () => { process.env.TZ = 'America/Los_Angeles'; - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ dateTimeOriginal: new Date('2022-01-01T08:00:00.000Z'), }), ); - expect(assetMock.update).toHaveBeenCalledWith( + expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ localDateTime: new Date('2022-01-01T00:00:00.000Z'), }), @@ -331,13 +288,13 @@ describe(MetadataService.name, () => { }); it('should handle lists of numbers', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ ISO: [160] }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 })); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 })); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, duration: null, fileCreatedAt: assetStub.image.fileCreatedAt, @@ -346,20 +303,20 @@ describe(MetadataService.name, () => { }); it('should apply reverse geocoding', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.withLocation]); - systemMock.get.mockResolvedValue({ reverseGeocoding: { enabled: true } }); - mapMock.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); + mocks.asset.getByIds.mockResolvedValue([assetStub.withLocation]); + mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } }); + mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); mockReadTags({ GPSLatitude: assetStub.withLocation.exifInfo!.latitude!, GPSLongitude: assetStub.withLocation.exifInfo!.longitude!, }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }), ); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.withLocation.id, duration: null, fileCreatedAt: assetStub.withLocation.createdAt, @@ -368,37 +325,41 @@ describe(MetadataService.name, () => { }); it('should discard latitude and longitude on null island', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.withLocation]); + mocks.asset.getByIds.mockResolvedValue([assetStub.withLocation]); mockReadTags({ GPSLatitude: 0, GPSLongitude: 0, }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null })); + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null })); }); it('should extract tags from TagsList', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ TagsList: ['Parent'] }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); }); it('should extract hierarchy from TagsList', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ TagsList: ['Parent/Child'] }); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.child); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { + userId: 'user-id', + value: 'Parent', + parent: undefined, + }); + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { userId: 'user-id', value: 'Parent/Child', parent: tagStub.parent, @@ -406,45 +367,49 @@ describe(MetadataService.name, () => { }); it('should extract tags from Keywords as a string', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Keywords: 'Parent' }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); }); it('should extract tags from Keywords as a list', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Keywords: ['Parent'] }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); }); it('should extract tags from Keywords as a list with a number', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Keywords: ['Parent', 2024] }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined }); }); it('should extract hierarchal tags from Keywords', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Keywords: 'Parent/Child' }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { + userId: 'user-id', + value: 'Parent', + parent: undefined, + }); + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { userId: 'user-id', value: 'Parent/Child', parent: tagStub.parent, @@ -452,14 +417,18 @@ describe(MetadataService.name, () => { }); it('should ignore Keywords when TagsList is present', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { + userId: 'user-id', + value: 'Parent', + parent: undefined, + }); + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { userId: 'user-id', value: 'Parent/Child', parent: tagStub.parent, @@ -467,41 +436,45 @@ describe(MetadataService.name, () => { }); it('should extract hierarchy from HierarchicalSubject', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] }); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.child); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { + userId: 'user-id', + value: 'Parent', + parent: undefined, + }); + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { userId: 'user-id', value: 'Parent/Child', parent: tagStub.parent, }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(3, { userId: 'user-id', value: 'TagA', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(3, { userId: 'user-id', value: 'TagA', parent: undefined }); }); it('should extract tags from HierarchicalSubject as a list with a number', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ HierarchicalSubject: ['Parent', 2024] }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined }); }); it('should extract ignore / characters in a HierarchicalSubject tag', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ HierarchicalSubject: ['Mom/Dad'] }); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Mom|Dad', parent: undefined, @@ -509,14 +482,18 @@ describe(MetadataService.name, () => { }); it('should ignore HierarchicalSubject when TagsList is present', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { + userId: 'user-id', + value: 'Parent', + parent: undefined, + }); + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { userId: 'user-id', value: 'Parent/Child', parent: tagStub.parent, @@ -524,32 +501,32 @@ describe(MetadataService.name, () => { }); it('should remove existing tags', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({}); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertAssetTags).toHaveBeenCalledWith({ assetId: 'asset-id', tagIds: [] }); + expect(mocks.tag.upsertAssetTags).toHaveBeenCalledWith({ assetId: 'asset-id', tagIds: [] }); }); it('should not apply motion photos if asset is video', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]); - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { faces: { person: false }, }); - expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled(); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith( + expect(mocks.storage.createOrOverwriteFile).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalledWith( expect.objectContaining({ assetType: AssetType.VIDEO, isVisible: false }), ); }); it('should handle an invalid Directory Item', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ MotionPhoto: 1, ContainerDirectory: [{ Foo: 100 }], @@ -559,20 +536,20 @@ describe(MetadataService.name, () => { }); it('should extract the correct video orientation', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.video]); - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); mockReadTags({}); await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }), ); }); it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); mockReadTags({ Directory: 'foo/bar/', MotionPhotoVideo: new BinaryField(0, ''), @@ -581,21 +558,21 @@ describe(MetadataService.name, () => { EmbeddedVideoFile: new BinaryField(0, ''), EmbeddedVideoType: 'MotionPhoto_Data', }); - cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); - assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); - cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); + mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); + mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.crypto.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); const video = randomBytes(512); - metadataMock.extractBinaryTag.mockResolvedValue(video); + mocks.metadata.extractBinaryTag.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); - expect(metadataMock.extractBinaryTag).toHaveBeenCalledWith( + expect(mocks.metadata.extractBinaryTag).toHaveBeenCalledWith( assetStub.livePhotoWithOriginalFileName.originalPath, 'MotionPhotoVideo', ); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { faces: { person: false }, }); - expect(assetMock.create).toHaveBeenCalledWith({ + expect(mocks.asset.create).toHaveBeenCalledWith({ checksum: expect.any(Buffer), deviceAssetId: 'NONE', deviceId: 'NONE', @@ -610,36 +587,36 @@ describe(MetadataService.name, () => { ownerId: assetStub.livePhotoWithOriginalFileName.ownerId, type: AssetType.VIDEO, }); - expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); - expect(assetMock.update).toHaveBeenNthCalledWith(1, { + expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); + expect(mocks.storage.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(mocks.asset.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, }); }); it('should extract the EmbeddedVideo tag from Samsung JPEG motion photos', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); mockReadTags({ Directory: 'foo/bar/', EmbeddedVideoFile: new BinaryField(0, ''), EmbeddedVideoType: 'MotionPhoto_Data', }); - cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); - assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); - cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); + mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); + mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.crypto.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); const video = randomBytes(512); - metadataMock.extractBinaryTag.mockResolvedValue(video); + mocks.metadata.extractBinaryTag.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); - expect(metadataMock.extractBinaryTag).toHaveBeenCalledWith( + expect(mocks.metadata.extractBinaryTag).toHaveBeenCalledWith( assetStub.livePhotoWithOriginalFileName.originalPath, 'EmbeddedVideoFile', ); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { faces: { person: false }, }); - expect(assetMock.create).toHaveBeenCalledWith({ + expect(mocks.asset.create).toHaveBeenCalledWith({ checksum: expect.any(Buffer), deviceAssetId: 'NONE', deviceId: 'NONE', @@ -654,37 +631,37 @@ describe(MetadataService.name, () => { ownerId: assetStub.livePhotoWithOriginalFileName.ownerId, type: AssetType.VIDEO, }); - expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); - expect(assetMock.update).toHaveBeenNthCalledWith(1, { + expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); + expect(mocks.storage.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(mocks.asset.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, }); }); it('should extract the motion photo video from the XMP directory entry ', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); - assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); - cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); + mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); + mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.crypto.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); const video = randomBytes(512); - storageMock.readFile.mockResolvedValue(video); + mocks.storage.readFile.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { faces: { person: false }, }); - expect(storageMock.readFile).toHaveBeenCalledWith( + expect(mocks.storage.readFile).toHaveBeenCalledWith( assetStub.livePhotoWithOriginalFileName.originalPath, expect.any(Object), ); - expect(assetMock.create).toHaveBeenCalledWith({ + expect(mocks.asset.create).toHaveBeenCalledWith({ checksum: expect.any(Buffer), deviceAssetId: 'NONE', deviceId: 'NONE', @@ -699,88 +676,88 @@ describe(MetadataService.name, () => { ownerId: assetStub.livePhotoWithOriginalFileName.ownerId, type: AssetType.VIDEO, }); - expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); - expect(assetMock.update).toHaveBeenNthCalledWith(1, { + expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); + expect(mocks.storage.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(mocks.asset.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, }); }); it('should delete old motion photo video assets if they do not match what is extracted', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoWithOriginalFileName]); + mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoWithOriginalFileName]); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); - assetMock.create.mockImplementation( + mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); + mocks.asset.create.mockImplementation( (asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset }) as Promise, ); const video = randomBytes(512); - storageMock.readFile.mockResolvedValue(video); + mocks.storage.readFile.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); - expect(jobMock.queue).toHaveBeenNthCalledWith(1, { + expect(mocks.job.queue).toHaveBeenNthCalledWith(1, { name: JobName.ASSET_DELETION, data: { id: assetStub.livePhotoWithOriginalFileName.livePhotoVideoId, deleteOnDisk: true }, }); - expect(jobMock.queue).toHaveBeenNthCalledWith(2, { + expect(mocks.job.queue).toHaveBeenNthCalledWith(2, { name: JobName.METADATA_EXTRACTION, data: { id: 'random-uuid' }, }); }); it('should not create a new motion photo video asset if the hash of the extracted video matches an existing asset', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); + mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); - assetMock.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); + mocks.asset.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset); const video = randomBytes(512); - storageMock.readFile.mockResolvedValue(video); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.storage.readFile.mockResolvedValue(video); + mocks.storage.checkFileExists.mockResolvedValue(true); await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); - expect(assetMock.create).toHaveBeenCalledTimes(0); - expect(storageMock.createOrOverwriteFile).toHaveBeenCalledTimes(0); + expect(mocks.asset.create).toHaveBeenCalledTimes(0); + expect(mocks.storage.createOrOverwriteFile).toHaveBeenCalledTimes(0); // The still asset gets saved by handleMetadataExtraction, but not the video - expect(assetMock.update).toHaveBeenCalledTimes(1); - expect(jobMock.queue).toHaveBeenCalledTimes(0); + expect(mocks.asset.update).toHaveBeenCalledTimes(1); + expect(mocks.job.queue).toHaveBeenCalledTimes(0); }); it('should link and hide motion video asset to still asset if the hash of the extracted video matches an existing asset', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); - assetMock.getByChecksum.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true }); + mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); + mocks.asset.getByChecksum.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true }); const video = randomBytes(512); - storageMock.readFile.mockResolvedValue(video); + mocks.storage.readFile.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); - expect(assetMock.update).toHaveBeenNthCalledWith(1, { + expect(mocks.asset.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoMotionAsset.id, isVisible: false, }); - expect(assetMock.update).toHaveBeenNthCalledWith(2, { + expect(mocks.asset.update).toHaveBeenNthCalledWith(2, { id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); }); it('should not update storage usage if motion photo is external', async () => { - assetMock.getByIds.mockResolvedValue([ + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.livePhotoStillAsset, livePhotoVideoId: null, isExternal: true }, ]); mockReadTags({ @@ -789,13 +766,13 @@ describe(MetadataService.name, () => { MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); - assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); + mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); const video = randomBytes(512); - storageMock.readFile.mockResolvedValue(video); + mocks.storage.readFile.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); - expect(userMock.updateUsage).not.toHaveBeenCalled(); + expect(mocks.user.updateUsage).not.toHaveBeenCalled(); }); it('should save all metadata', async () => { @@ -824,12 +801,12 @@ describe(MetadataService.name, () => { tz: 'UTC-11:30', Rating: 3, }; - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags(tags); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: assetStub.image.id, bitsPerSample: expect.any(Number), autoStackId: null, @@ -860,7 +837,7 @@ describe(MetadataService.name, () => { state: null, city: null, }); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, duration: null, fileCreatedAt: dateForTest, @@ -882,12 +859,12 @@ describe(MetadataService.name, () => { DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'), tz: undefined, }; - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags(tags); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ timeZone: 'UTC+0', }), @@ -895,8 +872,8 @@ describe(MetadataService.name, () => { }); it('should extract duration', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]); - mediaMock.probe.mockResolvedValue({ + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { ...probeStub.videoStreamH264.format, @@ -906,9 +883,9 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalled(); - expect(assetMock.update).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalled(); + expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.image.id, duration: '00:00:06.210', @@ -917,8 +894,8 @@ describe(MetadataService.name, () => { }); it('should only extract duration for videos', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image }]); - mediaMock.probe.mockResolvedValue({ + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image }]); + mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { ...probeStub.videoStreamH264.format, @@ -927,9 +904,9 @@ describe(MetadataService.name, () => { }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalled(); - expect(assetMock.update).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalled(); + expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.image.id, duration: null, @@ -938,8 +915,8 @@ describe(MetadataService.name, () => { }); it('should omit duration of zero', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]); - mediaMock.probe.mockResolvedValue({ + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { ...probeStub.videoStreamH264.format, @@ -949,9 +926,9 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalled(); - expect(assetMock.update).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalled(); + expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.image.id, duration: null, @@ -960,8 +937,8 @@ describe(MetadataService.name, () => { }); it('should a handle duration of 1 week', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]); - mediaMock.probe.mockResolvedValue({ + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { ...probeStub.videoStreamH264.format, @@ -971,9 +948,9 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalled(); - expect(assetMock.update).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalled(); + expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.video.id, duration: '168:00:00.000', @@ -982,19 +959,19 @@ describe(MetadataService.name, () => { }); it('should ignore duration from exif data', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({}, { Duration: { Value: 123 } }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.update).toHaveBeenCalledWith(expect.objectContaining({ duration: null })); + expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: null })); }); it('should trim whitespace from description', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Description: '\t \v \f \n \r' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ description: '', }), @@ -1002,7 +979,7 @@ describe(MetadataService.name, () => { mockReadTags({ ImageDescription: ' my\n description' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ description: 'my\n description', }), @@ -1010,11 +987,11 @@ describe(MetadataService.name, () => { }); it('should handle a numeric description', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Description: 1000 }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ description: '1000', }), @@ -1022,57 +999,59 @@ describe(MetadataService.name, () => { }); it('should skip importing metadata when the feature is disabled', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); - systemMock.get.mockResolvedValue({ metadata: { faces: { import: false } } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: false } } }); mockReadTags(metadataStub.withFace); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(personMock.getDistinctNames).not.toHaveBeenCalled(); + expect(mocks.person.getDistinctNames).not.toHaveBeenCalled(); }); it('should skip importing metadata face for assets without tags.RegionInfo', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); - systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(metadataStub.empty); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(personMock.getDistinctNames).not.toHaveBeenCalled(); + expect(mocks.person.getDistinctNames).not.toHaveBeenCalled(); }); it('should skip importing faces without name', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); - systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(metadataStub.withFaceNoName); - personMock.getDistinctNames.mockResolvedValue([]); - personMock.createAll.mockResolvedValue([]); + mocks.person.getDistinctNames.mockResolvedValue([]); + mocks.person.createAll.mockResolvedValue([]); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(personMock.createAll).not.toHaveBeenCalled(); - expect(personMock.refreshFaces).not.toHaveBeenCalled(); - expect(personMock.updateAll).not.toHaveBeenCalled(); + expect(mocks.person.createAll).not.toHaveBeenCalled(); + expect(mocks.person.refreshFaces).not.toHaveBeenCalled(); + expect(mocks.person.updateAll).not.toHaveBeenCalled(); }); it('should skip importing faces with empty name', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); - systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(metadataStub.withFaceEmptyName); - personMock.getDistinctNames.mockResolvedValue([]); - personMock.createAll.mockResolvedValue([]); + mocks.person.getDistinctNames.mockResolvedValue([]); + mocks.person.createAll.mockResolvedValue([]); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(personMock.createAll).not.toHaveBeenCalled(); - expect(personMock.refreshFaces).not.toHaveBeenCalled(); - expect(personMock.updateAll).not.toHaveBeenCalled(); + expect(mocks.person.createAll).not.toHaveBeenCalled(); + expect(mocks.person.refreshFaces).not.toHaveBeenCalled(); + expect(mocks.person.updateAll).not.toHaveBeenCalled(); }); it('should apply metadata face tags creating new persons', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); - systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(metadataStub.withFace); - personMock.getDistinctNames.mockResolvedValue([]); - personMock.createAll.mockResolvedValue([personStub.withName.id]); - personMock.update.mockResolvedValue(personStub.withName); + mocks.person.getDistinctNames.mockResolvedValue([]); + mocks.person.createAll.mockResolvedValue([personStub.withName.id]); + mocks.person.update.mockResolvedValue(personStub.withName); await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } }); - expect(personMock.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); - expect(personMock.createAll).toHaveBeenCalledWith([expect.objectContaining({ name: personStub.withName.name })]); - expect(personMock.refreshFaces).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } }); + expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); + expect(mocks.person.createAll).toHaveBeenCalledWith([ + expect.objectContaining({ name: personStub.withName.name }), + ]); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith( [ { id: 'random-uuid', @@ -1089,10 +1068,10 @@ describe(MetadataService.name, () => { ], [], ); - expect(personMock.updateAll).toHaveBeenCalledWith([ + expect(mocks.person.updateAll).toHaveBeenCalledWith([ { id: 'random-uuid', ownerId: 'admin-id', faceAssetId: 'random-uuid' }, ]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personStub.withName.id }, @@ -1101,17 +1080,17 @@ describe(MetadataService.name, () => { }); it('should assign metadata face tags to existing persons', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); - systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(metadataStub.withFace); - personMock.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]); - personMock.createAll.mockResolvedValue([]); - personMock.update.mockResolvedValue(personStub.withName); + mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]); + mocks.person.createAll.mockResolvedValue([]); + mocks.person.update.mockResolvedValue(personStub.withName); await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } }); - expect(personMock.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); - expect(personMock.createAll).not.toHaveBeenCalled(); - expect(personMock.refreshFaces).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } }); + expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); + expect(mocks.person.createAll).not.toHaveBeenCalled(); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith( [ { id: 'random-uuid', @@ -1128,16 +1107,16 @@ describe(MetadataService.name, () => { ], [], ); - expect(personMock.updateAll).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalledWith(); + expect(mocks.person.updateAll).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); it('should handle invalid modify date', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ ModifyDate: '00:00:00.000' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ modifyDate: expect.any(Date), }), @@ -1145,11 +1124,11 @@ describe(MetadataService.name, () => { }); it('should handle invalid rating value', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Rating: 6 }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ rating: null, }), @@ -1157,22 +1136,22 @@ describe(MetadataService.name, () => { }); it('should handle valid rating value', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Rating: 5 }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ rating: 5, }), ); }); it('should handle valid negative rating value', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Rating: -1 }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ rating: -1, }), @@ -1182,13 +1161,13 @@ describe(MetadataService.name, () => { describe('handleQueueSidecar', () => { it('should queue assets with sidecar files', async () => { - assetMock.getAll.mockResolvedValue({ items: [assetStub.sidecar], hasNextPage: false }); + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.sidecar], hasNextPage: false }); await sut.handleQueueSidecar({ force: true }); - expect(assetMock.getAll).toHaveBeenCalledWith({ take: 1000, skip: 0 }); - expect(assetMock.getWithout).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).toHaveBeenCalledWith({ take: 1000, skip: 0 }); + expect(mocks.asset.getWithout).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.SIDECAR_SYNC, data: { id: assetStub.sidecar.id }, @@ -1197,13 +1176,13 @@ describe(MetadataService.name, () => { }); it('should queue assets without sidecar files', async () => { - assetMock.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); await sut.handleQueueSidecar({ force: false }); - expect(assetMock.getWithout).toHaveBeenCalledWith({ take: 1000, skip: 0 }, WithoutProperty.SIDECAR); - expect(assetMock.getAll).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ take: 1000, skip: 0 }, WithoutProperty.SIDECAR); + expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.SIDECAR_DISCOVERY, data: { id: assetStub.image.id }, @@ -1214,71 +1193,77 @@ describe(MetadataService.name, () => { describe('handleSidecarSync', () => { it('should do nothing if asset could not be found', async () => { - assetMock.getByIds.mockResolvedValue([]); + mocks.asset.getByIds.mockResolvedValue([]); await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should do nothing if asset has no sidecar path', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should set sidecar path if exists (sidecar named photo.ext.xmp)', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.storage.checkFileExists.mockResolvedValue(true); await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS); - expect(storageMock.checkFileExists).toHaveBeenCalledWith(`${assetStub.sidecar.originalPath}.xmp`, constants.R_OK); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.storage.checkFileExists).toHaveBeenCalledWith( + `${assetStub.sidecar.originalPath}.xmp`, + constants.R_OK, + ); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.sidecar.id, sidecarPath: assetStub.sidecar.sidecarPath, }); }); it('should set sidecar path if exists (sidecar named photo.xmp)', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.sidecarWithoutExt]); - storageMock.checkFileExists.mockResolvedValueOnce(false); - storageMock.checkFileExists.mockResolvedValueOnce(true); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecarWithoutExt]); + mocks.storage.checkFileExists.mockResolvedValueOnce(false); + mocks.storage.checkFileExists.mockResolvedValueOnce(true); await expect(sut.handleSidecarSync({ id: assetStub.sidecarWithoutExt.id })).resolves.toBe(JobStatus.SUCCESS); - expect(storageMock.checkFileExists).toHaveBeenNthCalledWith( + expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith( 2, assetStub.sidecarWithoutExt.sidecarPath, constants.R_OK, ); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.sidecarWithoutExt.id, sidecarPath: assetStub.sidecarWithoutExt.sidecarPath, }); }); it('should set sidecar path if exists (two sidecars named photo.ext.xmp and photo.xmp, should pick photo.ext.xmp)', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); - storageMock.checkFileExists.mockResolvedValueOnce(true); - storageMock.checkFileExists.mockResolvedValueOnce(true); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.storage.checkFileExists.mockResolvedValueOnce(true); + mocks.storage.checkFileExists.mockResolvedValueOnce(true); await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS); - expect(storageMock.checkFileExists).toHaveBeenNthCalledWith(1, assetStub.sidecar.sidecarPath, constants.R_OK); - expect(storageMock.checkFileExists).toHaveBeenNthCalledWith( + expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith(1, assetStub.sidecar.sidecarPath, constants.R_OK); + expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith( 2, assetStub.sidecarWithoutExt.sidecarPath, constants.R_OK, ); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.sidecar.id, sidecarPath: assetStub.sidecar.sidecarPath, }); }); it('should unset sidecar path if file does not exist anymore', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); - storageMock.checkFileExists.mockResolvedValue(false); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.storage.checkFileExists.mockResolvedValue(false); await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS); - expect(storageMock.checkFileExists).toHaveBeenCalledWith(`${assetStub.sidecar.originalPath}.xmp`, constants.R_OK); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.storage.checkFileExists).toHaveBeenCalledWith( + `${assetStub.sidecar.originalPath}.xmp`, + constants.R_OK, + ); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.sidecar.id, sidecarPath: null, }); @@ -1287,41 +1272,41 @@ describe(MetadataService.name, () => { describe('handleSidecarDiscovery', () => { it('should skip hidden assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); await sut.handleSidecarDiscovery({ id: assetStub.livePhotoMotionAsset.id }); - expect(storageMock.checkFileExists).not.toHaveBeenCalled(); + expect(mocks.storage.checkFileExists).not.toHaveBeenCalled(); }); it('should skip assets with a sidecar path', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); await sut.handleSidecarDiscovery({ id: assetStub.sidecar.id }); - expect(storageMock.checkFileExists).not.toHaveBeenCalled(); + expect(mocks.storage.checkFileExists).not.toHaveBeenCalled(); }); it('should do nothing when a sidecar is not found ', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - storageMock.checkFileExists.mockResolvedValue(false); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.storage.checkFileExists.mockResolvedValue(false); await sut.handleSidecarDiscovery({ id: assetStub.image.id }); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should update a image asset when a sidecar is found', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.storage.checkFileExists.mockResolvedValue(true); await sut.handleSidecarDiscovery({ id: assetStub.image.id }); - expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.jpg.xmp', constants.R_OK); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.jpg.xmp', constants.R_OK); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, sidecarPath: '/original/path.jpg.xmp', }); }); it('should update a video asset when a sidecar is found', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.video]); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.storage.checkFileExists.mockResolvedValue(true); await sut.handleSidecarDiscovery({ id: assetStub.video.id }); - expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.R_OK); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.R_OK); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, sidecarPath: '/original/path.ext.xmp', }); @@ -1330,15 +1315,15 @@ describe(MetadataService.name, () => { describe('handleSidecarWrite', () => { it('should skip assets that do not exist anymore', async () => { - assetMock.getByIds.mockResolvedValue([]); + mocks.asset.getByIds.mockResolvedValue([]); await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.FAILED); - expect(metadataMock.writeTags).not.toHaveBeenCalled(); + expect(mocks.metadata.writeTags).not.toHaveBeenCalled(); }); it('should skip jobs with not metadata', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); await expect(sut.handleSidecarWrite({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SKIPPED); - expect(metadataMock.writeTags).not.toHaveBeenCalled(); + expect(mocks.metadata.writeTags).not.toHaveBeenCalled(); }); it('should write tags', async () => { @@ -1346,7 +1331,7 @@ describe(MetadataService.name, () => { const gps = 12; const date = '2023-11-22T04:56:12.196Z'; - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); await expect( sut.handleSidecarWrite({ id: assetStub.sidecar.id, @@ -1356,7 +1341,7 @@ describe(MetadataService.name, () => { dateTimeOriginal: date, }), ).resolves.toBe(JobStatus.SUCCESS); - expect(metadataMock.writeTags).toHaveBeenCalledWith(assetStub.sidecar.sidecarPath, { + expect(mocks.metadata.writeTags).toHaveBeenCalledWith(assetStub.sidecar.sidecarPath, { Description: description, ImageDescription: description, DateTimeOriginal: date, diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 41e9e35ff8..35f1601c72 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -4,19 +4,13 @@ import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, UserMetadataKey } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, INotifyAlbumUpdateJob, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { INotifyAlbumUpdateJob, JobName, JobStatus } from 'src/interfaces/job.interface'; import { EmailTemplate } from 'src/repositories/notification.repository'; import { NotificationService } from 'src/services/notification.service'; -import { INotificationRepository, ISystemMetadataRepository } from 'src/types'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; const configs = { smtpDisabled: Object.freeze({ @@ -57,18 +51,10 @@ const configs = { describe(NotificationService.name, () => { let sut: NotificationService; - - let albumMock: Mocked; - let assetMock: Mocked; - let eventMock: Mocked; - let jobMock: Mocked; - let notificationMock: Mocked; - let systemMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, albumMock, assetMock, eventMock, jobMock, notificationMock, systemMock, userMock } = - newTestService(NotificationService)); + ({ sut, mocks } = newTestService(NotificationService)); }); it('should work', () => { @@ -79,8 +65,8 @@ describe(NotificationService.name, () => { it('should emit client and server events', () => { const update = { oldConfig: defaults, newConfig: defaults }; expect(sut.onConfigUpdate(update)).toBeUndefined(); - expect(eventMock.clientBroadcast).toHaveBeenCalledWith('on_config_update'); - expect(eventMock.serverSend).toHaveBeenCalledWith('config.update', update); + expect(mocks.event.clientBroadcast).toHaveBeenCalledWith('on_config_update'); + expect(mocks.event.serverSend).toHaveBeenCalledWith('config.update', update); }); }); @@ -89,18 +75,18 @@ describe(NotificationService.name, () => { const oldConfig = configs.smtpDisabled; const newConfig = configs.smtpEnabled; - notificationMock.verifySmtp.mockResolvedValue(true); + mocks.notification.verifySmtp.mockResolvedValue(true); await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); - expect(notificationMock.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); + expect(mocks.notification.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); }); it('validates smtp config when transport changes', async () => { const oldConfig = configs.smtpEnabled; const newConfig = configs.smtpTransport; - notificationMock.verifySmtp.mockResolvedValue(true); + mocks.notification.verifySmtp.mockResolvedValue(true); await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); - expect(notificationMock.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); + expect(mocks.notification.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); }); it('skips smtp validation when there are no changes', async () => { @@ -108,7 +94,7 @@ describe(NotificationService.name, () => { const newConfig = { ...configs.smtpEnabled }; await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); - expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); + expect(mocks.notification.verifySmtp).not.toHaveBeenCalled(); }); it('skips smtp validation with DTO when there are no changes', async () => { @@ -116,7 +102,7 @@ describe(NotificationService.name, () => { const newConfig = plainToInstance(SystemConfigDto, configs.smtpEnabled); await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); - expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); + expect(mocks.notification.verifySmtp).not.toHaveBeenCalled(); }); it('skips smtp validation when smtp is disabled', async () => { @@ -124,14 +110,14 @@ describe(NotificationService.name, () => { const newConfig = { ...configs.smtpDisabled }; await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); - expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); + expect(mocks.notification.verifySmtp).not.toHaveBeenCalled(); }); it('should fail if smtp configuration is invalid', async () => { const oldConfig = configs.smtpDisabled; const newConfig = configs.smtpEnabled; - notificationMock.verifySmtp.mockRejectedValue(new Error('Failed validating smtp')); + mocks.notification.verifySmtp.mockRejectedValue(new Error('Failed validating smtp')); await expect(sut.onConfigValidate({ oldConfig, newConfig })).rejects.toBeInstanceOf(Error); }); }); @@ -139,14 +125,14 @@ describe(NotificationService.name, () => { describe('onAssetHide', () => { it('should send connected clients an event', () => { sut.onAssetHide({ assetId: 'asset-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_hidden', 'user-id', 'asset-id'); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_hidden', 'user-id', 'asset-id'); }); }); describe('onAssetShow', () => { it('should queue the generate thumbnail job', async () => { await sut.onAssetShow({ assetId: 'asset-id', userId: 'user-id' }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-id', notify: true }, }); @@ -156,12 +142,12 @@ describe(NotificationService.name, () => { describe('onUserSignupEvent', () => { it('skips when notify is false', async () => { await sut.onUserSignup({ id: '', notify: false }); - expect(jobMock.queue).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); }); it('should queue notify signup event if notify is true', async () => { await sut.onUserSignup({ id: '', notify: true }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_SIGNUP, data: { id: '', tempPassword: undefined }, }); @@ -171,7 +157,7 @@ describe(NotificationService.name, () => { describe('onAlbumUpdateEvent', () => { it('should queue notify album update event', async () => { await sut.onAlbumUpdate({ id: 'album', recipientIds: ['42'] }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id: 'album', recipientIds: ['42'], delay: 300_000 }, }); @@ -181,7 +167,7 @@ describe(NotificationService.name, () => { describe('onAlbumInviteEvent', () => { it('should queue notify album invite event', async () => { await sut.onAlbumInvite({ id: '', userId: '42' }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id: '', recipientId: '42' }, }); @@ -192,67 +178,67 @@ describe(NotificationService.name, () => { it('should send a on_session_delete client event', () => { vi.useFakeTimers(); sut.onSessionDelete({ sessionId: 'id' }); - expect(eventMock.clientSend).not.toHaveBeenCalled(); + expect(mocks.event.clientSend).not.toHaveBeenCalled(); vi.advanceTimersByTime(500); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_session_delete', 'id', 'id'); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_session_delete', 'id', 'id'); }); }); describe('onAssetTrash', () => { it('should send connected clients an event', () => { sut.onAssetTrash({ assetId: 'asset-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']); }); }); describe('onAssetDelete', () => { it('should send connected clients an event', () => { sut.onAssetDelete({ assetId: 'asset-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_delete', 'user-id', 'asset-id'); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_delete', 'user-id', 'asset-id'); }); }); describe('onAssetsTrash', () => { it('should send connected clients an event', () => { sut.onAssetsTrash({ assetIds: ['asset-id'], userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']); }); }); describe('onAssetsRestore', () => { it('should send connected clients an event', () => { sut.onAssetsRestore({ assetIds: ['asset-id'], userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_restore', 'user-id', ['asset-id']); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_restore', 'user-id', ['asset-id']); }); }); describe('onStackCreate', () => { it('should send connected clients an event', () => { sut.onStackCreate({ stackId: 'stack-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); }); }); describe('onStackUpdate', () => { it('should send connected clients an event', () => { sut.onStackUpdate({ stackId: 'stack-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); }); }); describe('onStackDelete', () => { it('should send connected clients an event', () => { sut.onStackDelete({ stackId: 'stack-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); }); }); describe('onStacksDelete', () => { it('should send connected clients an event', () => { sut.onStacksDelete({ stackIds: ['stack-id'], userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); }); }); @@ -262,8 +248,8 @@ describe(NotificationService.name, () => { }); it('should throw error if smtp validation fails', async () => { - userMock.get.mockResolvedValue(userStub.admin); - notificationMock.verifySmtp.mockRejectedValue(''); + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.notification.verifySmtp.mockRejectedValue(''); await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow( 'Failed to verify SMTP configuration', @@ -271,16 +257,16 @@ describe(NotificationService.name, () => { }); it('should send email to default domain', async () => { - userMock.get.mockResolvedValue(userStub.admin); - notificationMock.verifySmtp.mockResolvedValue(true); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.notification.verifySmtp.mockResolvedValue(true); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow(); - expect(notificationMock.renderEmail).toHaveBeenCalledWith({ + expect(mocks.notification.renderEmail).toHaveBeenCalledWith({ template: EmailTemplate.TEST_EMAIL, data: { baseUrl: 'http://localhost:2283', displayName: userStub.admin.name }, }); - expect(notificationMock.sendEmail).toHaveBeenCalledWith( + expect(mocks.notification.sendEmail).toHaveBeenCalledWith( expect.objectContaining({ subject: 'Test email from Immich', smtp: configs.smtpTransport.notifications.smtp.transport, @@ -289,17 +275,17 @@ describe(NotificationService.name, () => { }); it('should send email to external domain', async () => { - userMock.get.mockResolvedValue(userStub.admin); - notificationMock.verifySmtp.mockResolvedValue(true); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); - systemMock.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } }); + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.notification.verifySmtp.mockResolvedValue(true); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.systemMetadata.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } }); await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow(); - expect(notificationMock.renderEmail).toHaveBeenCalledWith({ + expect(mocks.notification.renderEmail).toHaveBeenCalledWith({ template: EmailTemplate.TEST_EMAIL, data: { baseUrl: 'https://demo.immich.app', displayName: userStub.admin.name }, }); - expect(notificationMock.sendEmail).toHaveBeenCalledWith( + expect(mocks.notification.sendEmail).toHaveBeenCalledWith( expect.objectContaining({ subject: 'Test email from Immich', smtp: configs.smtpTransport.notifications.smtp.transport, @@ -308,18 +294,18 @@ describe(NotificationService.name, () => { }); it('should send email with replyTo', async () => { - userMock.get.mockResolvedValue(userStub.admin); - notificationMock.verifySmtp.mockResolvedValue(true); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.notification.verifySmtp.mockResolvedValue(true); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await expect( sut.sendTestEmail('', { ...configs.smtpTransport.notifications.smtp, replyTo: 'demo@immich.app' }), ).resolves.not.toThrow(); - expect(notificationMock.renderEmail).toHaveBeenCalledWith({ + expect(mocks.notification.renderEmail).toHaveBeenCalledWith({ template: EmailTemplate.TEST_EMAIL, data: { baseUrl: 'http://localhost:2283', displayName: userStub.admin.name }, }); - expect(notificationMock.sendEmail).toHaveBeenCalledWith( + expect(mocks.notification.sendEmail).toHaveBeenCalledWith( expect.objectContaining({ subject: 'Test email from Immich', smtp: configs.smtpTransport.notifications.smtp.transport, @@ -335,12 +321,12 @@ describe(NotificationService.name, () => { }); it('should be successful', async () => { - userMock.get.mockResolvedValue(userStub.admin); - systemMock.get.mockResolvedValue({ server: {} }); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.systemMetadata.get.mockResolvedValue({ server: {} }); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await expect(sut.handleUserSignup({ id: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ subject: 'Welcome to Immich' }), }); @@ -350,19 +336,19 @@ describe(NotificationService.name, () => { describe('handleAlbumInvite', () => { it('should skip if album could not be found', async () => { await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SKIPPED); - expect(userMock.get).not.toHaveBeenCalled(); + expect(mocks.user.get).not.toHaveBeenCalled(); }); it('should skip if recipient could not be found', async () => { - albumMock.getById.mockResolvedValue(albumStub.empty); + mocks.album.getById.mockResolvedValue(albumStub.empty); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.getById).not.toHaveBeenCalled(); + expect(mocks.asset.getById).not.toHaveBeenCalled(); }); it('should skip if the recipient has email notifications disabled', async () => { - albumMock.getById.mockResolvedValue(albumStub.empty); - userMock.get.mockResolvedValue({ + mocks.album.getById.mockResolvedValue(albumStub.empty); + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -378,8 +364,8 @@ describe(NotificationService.name, () => { }); it('should skip if the recipient has email notifications for album invite disabled', async () => { - albumMock.getById.mockResolvedValue(albumStub.empty); - userMock.get.mockResolvedValue({ + mocks.album.getById.mockResolvedValue(albumStub.empty); + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -395,8 +381,8 @@ describe(NotificationService.name, () => { }); it('should send invite email', async () => { - albumMock.getById.mockResolvedValue(albumStub.empty); - userMock.get.mockResolvedValue({ + mocks.album.getById.mockResolvedValue(albumStub.empty); + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -407,19 +393,19 @@ describe(NotificationService.name, () => { }, ], }); - systemMock.get.mockResolvedValue({ server: {} }); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.systemMetadata.get.mockResolvedValue({ server: {} }); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album') }), }); }); it('should send invite email without album thumbnail if thumbnail asset does not exist', async () => { - albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); - userMock.get.mockResolvedValue({ + mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -430,14 +416,14 @@ describe(NotificationService.name, () => { }, ], }); - systemMock.get.mockResolvedValue({ server: {} }); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.systemMetadata.get.mockResolvedValue({ server: {} }); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { + expect(mocks.asset.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { files: true, }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album'), @@ -447,8 +433,8 @@ describe(NotificationService.name, () => { }); it('should send invite email with album thumbnail as jpeg', async () => { - albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); - userMock.get.mockResolvedValue({ + mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -459,18 +445,18 @@ describe(NotificationService.name, () => { }, ], }); - systemMock.get.mockResolvedValue({ server: {} }); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); - assetMock.getById.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ server: {} }); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image, files: [{ assetId: 'asset-id', type: AssetFileType.THUMBNAIL, path: 'path-to-thumb.jpg' } as AssetFileEntity], }); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { + expect(mocks.asset.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { files: true, }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album'), @@ -480,8 +466,8 @@ describe(NotificationService.name, () => { }); it('should send invite email with album thumbnail and arbitrary extension', async () => { - albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); - userMock.get.mockResolvedValue({ + mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -492,15 +478,15 @@ describe(NotificationService.name, () => { }, ], }); - systemMock.get.mockResolvedValue({ server: {} }); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.systemMetadata.get.mockResolvedValue({ server: {} }); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.asset.getById.mockResolvedValue(assetStub.image); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { + expect(mocks.asset.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { files: true, }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album'), @@ -513,35 +499,35 @@ describe(NotificationService.name, () => { describe('handleAlbumUpdate', () => { it('should skip if album could not be found', async () => { await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED); - expect(userMock.get).not.toHaveBeenCalled(); + expect(mocks.user.get).not.toHaveBeenCalled(); }); it('should skip if owner could not be found', async () => { - albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); + mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED); - expect(systemMock.get).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).not.toHaveBeenCalled(); }); it('should skip recipient that could not be looked up', async () => { - albumMock.getById.mockResolvedValue({ + mocks.album.getById.mockResolvedValue({ ...albumStub.emptyWithValidThumbnail, albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], }); - userMock.get.mockResolvedValueOnce(userStub.user1); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.user.get.mockResolvedValueOnce(userStub.user1); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); - expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); - expect(notificationMock.renderEmail).not.toHaveBeenCalled(); + expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); + expect(mocks.notification.renderEmail).not.toHaveBeenCalled(); }); it('should skip recipient with disabled email notifications', async () => { - albumMock.getById.mockResolvedValue({ + mocks.album.getById.mockResolvedValue({ ...albumStub.emptyWithValidThumbnail, albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], }); - userMock.get.mockResolvedValue({ + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -552,19 +538,19 @@ describe(NotificationService.name, () => { }, ], }); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); - expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); - expect(notificationMock.renderEmail).not.toHaveBeenCalled(); + expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); + expect(mocks.notification.renderEmail).not.toHaveBeenCalled(); }); it('should skip recipient with disabled email notifications for the album update event', async () => { - albumMock.getById.mockResolvedValue({ + mocks.album.getById.mockResolvedValue({ ...albumStub.emptyWithValidThumbnail, albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], }); - userMock.get.mockResolvedValue({ + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -575,31 +561,31 @@ describe(NotificationService.name, () => { }, ], }); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); - expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); - expect(notificationMock.renderEmail).not.toHaveBeenCalled(); + expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); + expect(mocks.notification.renderEmail).not.toHaveBeenCalled(); }); it('should send email', async () => { - albumMock.getById.mockResolvedValue({ + mocks.album.getById.mockResolvedValue({ ...albumStub.emptyWithValidThumbnail, albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], }); - userMock.get.mockResolvedValue(userStub.user1); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); - expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); - expect(notificationMock.renderEmail).toHaveBeenCalled(); - expect(jobMock.queue).toHaveBeenCalled(); + expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); + expect(mocks.notification.renderEmail).toHaveBeenCalled(); + expect(mocks.job.queue).toHaveBeenCalled(); }); it('should add new recipients for new images if job is already queued', async () => { - jobMock.removeJob.mockResolvedValue({ id: '1', recipientIds: ['2', '3', '4'] } as INotifyAlbumUpdateJob); + mocks.job.removeJob.mockResolvedValue({ id: '1', recipientIds: ['2', '3', '4'] } as INotifyAlbumUpdateJob); await sut.onAlbumUpdate({ id: '1', recipientIds: ['1', '2', '3'] } as INotifyAlbumUpdateJob); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id: '1', @@ -612,26 +598,32 @@ describe(NotificationService.name, () => { describe('handleSendEmail', () => { it('should skip if smtp notifications are disabled', async () => { - systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: false } } }); + mocks.systemMetadata.get.mockResolvedValue({ notifications: { smtp: { enabled: false } } }); await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SKIPPED); }); it('should send mail successfully', async () => { - systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: true, from: 'test@immich.app' } } }); - notificationMock.sendEmail.mockResolvedValue({ messageId: '', response: '' }); + mocks.systemMetadata.get.mockResolvedValue({ + notifications: { smtp: { enabled: true, from: 'test@immich.app' } }, + }); + mocks.notification.sendEmail.mockResolvedValue({ messageId: '', response: '' }); await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(notificationMock.sendEmail).toHaveBeenCalledWith(expect.objectContaining({ replyTo: 'test@immich.app' })); + expect(mocks.notification.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ replyTo: 'test@immich.app' }), + ); }); it('should send mail with replyTo successfully', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ notifications: { smtp: { enabled: true, from: 'test@immich.app', replyTo: 'demo@immich.app' } }, }); - notificationMock.sendEmail.mockResolvedValue({ messageId: '', response: '' }); + mocks.notification.sendEmail.mockResolvedValue({ messageId: '', response: '' }); await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(notificationMock.sendEmail).toHaveBeenCalledWith(expect.objectContaining({ replyTo: 'demo@immich.app' })); + expect(mocks.notification.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ replyTo: 'demo@immich.app' }), + ); }); }); }); diff --git a/server/src/services/partner.service.spec.ts b/server/src/services/partner.service.spec.ts index e7b7348e98..02dff32a72 100644 --- a/server/src/services/partner.service.spec.ts +++ b/server/src/services/partner.service.spec.ts @@ -1,20 +1,16 @@ import { BadRequestException } from '@nestjs/common'; -import { IPartnerRepository, PartnerDirection } from 'src/interfaces/partner.interface'; +import { PartnerDirection } from 'src/interfaces/partner.interface'; import { PartnerService } from 'src/services/partner.service'; import { authStub } from 'test/fixtures/auth.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(PartnerService.name, () => { let sut: PartnerService; - - let accessMock: IAccessRepositoryMock; - let partnerMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, partnerMock } = newTestService(PartnerService)); + ({ sut, mocks } = newTestService(PartnerService)); }); it('should work', () => { @@ -23,55 +19,55 @@ describe(PartnerService.name, () => { describe('search', () => { it("should return a list of partners with whom I've shared my library", async () => { - partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]); + mocks.partner.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]); await expect(sut.search(authStub.user1, { direction: PartnerDirection.SharedBy })).resolves.toBeDefined(); - expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); + expect(mocks.partner.getAll).toHaveBeenCalledWith(authStub.user1.user.id); }); it('should return a list of partners who have shared their libraries with me', async () => { - partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]); + mocks.partner.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]); await expect(sut.search(authStub.user1, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined(); - expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); + expect(mocks.partner.getAll).toHaveBeenCalledWith(authStub.user1.user.id); }); }); describe('create', () => { it('should create a new partner', async () => { - partnerMock.get.mockResolvedValue(void 0); - partnerMock.create.mockResolvedValue(partnerStub.adminToUser1); + mocks.partner.get.mockResolvedValue(void 0); + mocks.partner.create.mockResolvedValue(partnerStub.adminToUser1); await expect(sut.create(authStub.admin, authStub.user1.user.id)).resolves.toBeDefined(); - expect(partnerMock.create).toHaveBeenCalledWith({ + expect(mocks.partner.create).toHaveBeenCalledWith({ sharedById: authStub.admin.user.id, sharedWithId: authStub.user1.user.id, }); }); it('should throw an error when the partner already exists', async () => { - partnerMock.get.mockResolvedValue(partnerStub.adminToUser1); + mocks.partner.get.mockResolvedValue(partnerStub.adminToUser1); await expect(sut.create(authStub.admin, authStub.user1.user.id)).rejects.toBeInstanceOf(BadRequestException); - expect(partnerMock.create).not.toHaveBeenCalled(); + expect(mocks.partner.create).not.toHaveBeenCalled(); }); }); describe('remove', () => { it('should remove a partner', async () => { - partnerMock.get.mockResolvedValue(partnerStub.adminToUser1); + mocks.partner.get.mockResolvedValue(partnerStub.adminToUser1); await sut.remove(authStub.admin, authStub.user1.user.id); - expect(partnerMock.remove).toHaveBeenCalledWith(partnerStub.adminToUser1); + expect(mocks.partner.remove).toHaveBeenCalledWith(partnerStub.adminToUser1); }); it('should throw an error when the partner does not exist', async () => { - partnerMock.get.mockResolvedValue(void 0); + mocks.partner.get.mockResolvedValue(void 0); await expect(sut.remove(authStub.admin, authStub.user1.user.id)).rejects.toBeInstanceOf(BadRequestException); - expect(partnerMock.remove).not.toHaveBeenCalled(); + expect(mocks.partner.remove).not.toHaveBeenCalled(); }); }); @@ -83,11 +79,11 @@ describe(PartnerService.name, () => { }); it('should update partner', async () => { - accessMock.partner.checkUpdateAccess.mockResolvedValue(new Set(['shared-by-id'])); - partnerMock.update.mockResolvedValue(partnerStub.adminToUser1); + mocks.access.partner.checkUpdateAccess.mockResolvedValue(new Set(['shared-by-id'])); + mocks.partner.update.mockResolvedValue(partnerStub.adminToUser1); await expect(sut.update(authStub.admin, 'shared-by-id', { inTimeline: true })).resolves.toBeDefined(); - expect(partnerMock.update).toHaveBeenCalledWith( + expect(mocks.partner.update).toHaveBeenCalledWith( { sharedById: 'shared-by-id', sharedWithId: authStub.admin.user.id }, { inTimeline: true }, ); diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 65cd8815f8..0b3adec571 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -1,26 +1,20 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; -import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto'; +import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { CacheControl, Colorspace, ImageFormat, SourceType, SystemMetadataKey } from 'src/enum'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { DetectedFaces, IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { FaceSearchResult, ISearchRepository } from 'src/interfaces/search.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; +import { WithoutProperty } from 'src/interfaces/asset.interface'; +import { JobName, JobStatus } from 'src/interfaces/job.interface'; +import { DetectedFaces } from 'src/interfaces/machine-learning.interface'; +import { FaceSearchResult } from 'src/interfaces/search.interface'; import { PersonService } from 'src/services/person.service'; -import { IMediaRepository, ISystemMetadataRepository } from 'src/types'; import { ImmichFileResponse } from 'src/utils/file'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { personStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { makeStream, newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { makeStream, newTestService, ServiceMocks } from 'test/utils'; const responseDto: PersonResponseDto = { id: 'person-1', @@ -65,32 +59,10 @@ const detectFaceMock: DetectedFaces = { describe(PersonService.name, () => { let sut: PersonService; - - let accessMock: IAccessRepositoryMock; - let assetMock: Mocked; - let cryptoMock: Mocked; - let jobMock: Mocked; - let machineLearningMock: Mocked; - let mediaMock: Mocked; - let personMock: Mocked; - let searchMock: Mocked; - let storageMock: Mocked; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ - sut, - accessMock, - assetMock, - cryptoMock, - jobMock, - machineLearningMock, - mediaMock, - personMock, - searchMock, - storageMock, - systemMock, - } = newTestService(PersonService)); + ({ sut, mocks } = newTestService(PersonService)); }); it('should be defined', () => { @@ -99,11 +71,11 @@ describe(PersonService.name, () => { describe('getAll', () => { it('should get all hidden and visible people with thumbnails', async () => { - personMock.getAllForUser.mockResolvedValue({ + mocks.person.getAllForUser.mockResolvedValue({ items: [personStub.withName, personStub.hidden], hasNextPage: false, }); - personMock.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); + mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); await expect(sut.getAll(authStub.admin, { withHidden: true, page: 1, size: 10 })).resolves.toEqual({ hasNextPage: false, total: 2, @@ -121,18 +93,18 @@ describe(PersonService.name, () => { }, ], }); - expect(personMock.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, { + expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, { minimumFaceCount: 3, withHidden: true, }); }); it('should get all visible people and favorites should be first in the array', async () => { - personMock.getAllForUser.mockResolvedValue({ + mocks.person.getAllForUser.mockResolvedValue({ items: [personStub.isFavorite, personStub.withName], hasNextPage: false, }); - personMock.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); + mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); await expect(sut.getAll(authStub.admin, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({ hasNextPage: false, total: 2, @@ -150,7 +122,7 @@ describe(PersonService.name, () => { responseDto, ], }); - expect(personMock.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, { + expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, { minimumFaceCount: 3, withHidden: false, }); @@ -159,54 +131,54 @@ describe(PersonService.name, () => { describe('getById', () => { it('should require person.read permission', async () => { - personMock.getById.mockResolvedValue(personStub.withName); + mocks.person.getById.mockResolvedValue(personStub.withName); await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw a bad request when person is not found', async () => { - personMock.getById.mockResolvedValue(null); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(null); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should get a person by id', async () => { - personMock.getById.mockResolvedValue(personStub.withName); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(personStub.withName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getById(authStub.admin, 'person-1')).resolves.toEqual(responseDto); - expect(personMock.getById).toHaveBeenCalledWith('person-1'); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.getById).toHaveBeenCalledWith('person-1'); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); describe('getThumbnail', () => { it('should require person.read permission', async () => { - personMock.getById.mockResolvedValue(personStub.noName); + mocks.person.getById.mockResolvedValue(personStub.noName); await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); - expect(storageMock.createReadStream).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw an error when personId is invalid', async () => { - personMock.getById.mockResolvedValue(null); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(null); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException); - expect(storageMock.createReadStream).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw an error when person has no thumbnail', async () => { - personMock.getById.mockResolvedValue(personStub.noThumbnail); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(personStub.noThumbnail); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException); - expect(storageMock.createReadStream).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should serve the thumbnail', async () => { - personMock.getById.mockResolvedValue(personStub.noName); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(personStub.noName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getThumbnail(authStub.admin, 'person-1')).resolves.toEqual( new ImmichFileResponse({ path: '/path/to/thumbnail.jpg', @@ -214,42 +186,42 @@ describe(PersonService.name, () => { cacheControl: CacheControl.PRIVATE_WITHOUT_CACHE, }), ); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); describe('update', () => { it('should require person.write permission', async () => { - personMock.getById.mockResolvedValue(personStub.noName); + mocks.person.getById.mockResolvedValue(personStub.noName); await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(personMock.update).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw an error when personId is invalid', async () => { - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(personMock.update).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it("should update a person's name", async () => { - personMock.update.mockResolvedValue(personStub.withName); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.update.mockResolvedValue(personStub.withName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it("should update a person's date of birth", async () => { - personMock.update.mockResolvedValue(personStub.withBirthDate); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.update.mockResolvedValue(personStub.withBirthDate); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { birthDate: '1976-06-30' })).resolves.toEqual({ id: 'person-1', @@ -260,103 +232,106 @@ describe(PersonService.name, () => { isFavorite: false, updatedAt: expect.any(Date), }); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' }); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' }); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should update a person visibility', async () => { - personMock.update.mockResolvedValue(personStub.withName); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.update.mockResolvedValue(personStub.withName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should update a person favorite status', async () => { - personMock.update.mockResolvedValue(personStub.withName); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.update.mockResolvedValue(personStub.withName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { isFavorite: true })).resolves.toEqual(responseDto); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isFavorite: true }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', isFavorite: true }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it("should update a person's thumbnailPath", async () => { - personMock.update.mockResolvedValue(personStub.withName); - personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.update.mockResolvedValue(personStub.withName); + mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect( sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }), ).resolves.toEqual(responseDto); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id }); - expect(personMock.getFacesByIds).toHaveBeenCalledWith([ + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id }); + expect(mocks.person.getFacesByIds).toHaveBeenCalledWith([ { assetId: faceStub.face1.assetId, personId: 'person-1', }, ]); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: 'person-1' } }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.GENERATE_PERSON_THUMBNAIL, + data: { id: 'person-1' }, + }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw an error when the face feature assetId is invalid', async () => { - personMock.getById.mockResolvedValue(personStub.withName); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(personStub.withName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { featureFaceAssetId: '-1' })).rejects.toThrow( BadRequestException, ); - expect(personMock.update).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); describe('updateAll', () => { it('should throw an error when personId is invalid', async () => { - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.updateAll(authStub.admin, { people: [{ id: 'person-1', name: 'Person 1' }] })).resolves.toEqual([ { error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false }, ]); - expect(personMock.update).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); describe('reassignFaces', () => { it('should throw an error if user has no access to the person', async () => { - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set()); await expect( sut.reassignFaces(authStub.admin, personStub.noName.id, { data: [{ personId: 'asset-face-1', assetId: '' }], }), ).rejects.toBeInstanceOf(BadRequestException); - expect(jobMock.queue).not.toHaveBeenCalledWith(); - expect(jobMock.queueAll).not.toHaveBeenCalledWith(); + expect(mocks.job.queue).not.toHaveBeenCalledWith(); + expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); it('should reassign a face', async () => { - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.withName.id])); - personMock.getById.mockResolvedValue(personStub.noName); - accessMock.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); - personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); - personMock.reassignFace.mockResolvedValue(1); - personMock.getRandomFace.mockResolvedValue(faceStub.primaryFace1); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.withName.id])); + mocks.person.getById.mockResolvedValue(personStub.noName); + mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); + mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]); + mocks.person.reassignFace.mockResolvedValue(1); + mocks.person.getRandomFace.mockResolvedValue(faceStub.primaryFace1); await expect( sut.reassignFaces(authStub.admin, personStub.noName.id, { data: [{ personId: personStub.withName.id, assetId: assetStub.image.id }], }), ).resolves.toBeDefined(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personStub.newThumbnail.id }, @@ -367,22 +342,22 @@ describe(PersonService.name, () => { describe('handlePersonMigration', () => { it('should not move person files', async () => { - personMock.getById.mockResolvedValue(null); + mocks.person.getById.mockResolvedValue(null); await expect(sut.handlePersonMigration(personStub.noName)).resolves.toBe(JobStatus.FAILED); }); }); describe('getFacesById', () => { it('should get the bounding boxes for an asset', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([faceStub.face1.assetId])); - personMock.getFaces.mockResolvedValue([faceStub.primaryFace1]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([faceStub.face1.assetId])); + mocks.person.getFaces.mockResolvedValue([faceStub.primaryFace1]); await expect(sut.getFacesById(authStub.admin, { id: faceStub.face1.assetId })).resolves.toStrictEqual([ mapFaces(faceStub.primaryFace1, authStub.admin), ]); }); it('should reject if the user has not access to the asset', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set()); - personMock.getFaces.mockResolvedValue([faceStub.primaryFace1]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.person.getFaces.mockResolvedValue([faceStub.primaryFace1]); await expect(sut.getFacesById(authStub.admin, { id: faceStub.primaryFace1.assetId })).rejects.toBeInstanceOf( BadRequestException, ); @@ -391,9 +366,9 @@ describe(PersonService.name, () => { describe('createNewFeaturePhoto', () => { it('should change person feature photo', async () => { - personMock.getRandomFace.mockResolvedValue(faceStub.primaryFace1); + mocks.person.getRandomFace.mockResolvedValue(faceStub.primaryFace1); await sut.createNewFeaturePhoto([personStub.newThumbnail.id]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personStub.newThumbnail.id }, @@ -404,11 +379,11 @@ describe(PersonService.name, () => { describe('reassignFacesById', () => { it('should create a new person', async () => { - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); - accessMock.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); - personMock.getFaceById.mockResolvedValue(faceStub.face1); - personMock.reassignFace.mockResolvedValue(1); - personMock.getById.mockResolvedValue(personStub.noName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); + mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); + mocks.person.getFaceById.mockResolvedValue(faceStub.face1); + mocks.person.reassignFace.mockResolvedValue(1); + mocks.person.getById.mockResolvedValue(personStub.noName); await expect( sut.reassignFacesById(authStub.admin, personStub.noName.id, { id: faceStub.face1.id, @@ -423,67 +398,67 @@ describe(PersonService.name, () => { updatedAt: expect.any(Date), }); - expect(jobMock.queue).not.toHaveBeenCalledWith(); - expect(jobMock.queueAll).not.toHaveBeenCalledWith(); + expect(mocks.job.queue).not.toHaveBeenCalledWith(); + expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); it('should fail if user has not the correct permissions on the asset', async () => { - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); - personMock.getFaceById.mockResolvedValue(faceStub.face1); - personMock.reassignFace.mockResolvedValue(1); - personMock.getById.mockResolvedValue(personStub.noName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); + mocks.person.getFaceById.mockResolvedValue(faceStub.face1); + mocks.person.reassignFace.mockResolvedValue(1); + mocks.person.getById.mockResolvedValue(personStub.noName); await expect( sut.reassignFacesById(authStub.admin, personStub.noName.id, { id: faceStub.face1.id, }), ).rejects.toBeInstanceOf(BadRequestException); - expect(jobMock.queue).not.toHaveBeenCalledWith(); - expect(jobMock.queueAll).not.toHaveBeenCalledWith(); + expect(mocks.job.queue).not.toHaveBeenCalledWith(); + expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); }); describe('createPerson', () => { it('should create a new person', async () => { - personMock.create.mockResolvedValue(personStub.primaryPerson); + mocks.person.create.mockResolvedValue(personStub.primaryPerson); await expect(sut.create(authStub.admin, {})).resolves.toBeDefined(); - expect(personMock.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id }); + expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id }); }); }); describe('handlePersonCleanup', () => { it('should delete people without faces', async () => { - personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]); + mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.noName]); await sut.handlePersonCleanup(); - expect(personMock.delete).toHaveBeenCalledWith([personStub.noName]); - expect(storageMock.unlink).toHaveBeenCalledWith(personStub.noName.thumbnailPath); + expect(mocks.person.delete).toHaveBeenCalledWith([personStub.noName]); + expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.noName.thumbnailPath); }); }); describe('handleQueueDetectFaces', () => { it('should skip if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await expect(sut.handleQueueDetectFaces({})).resolves.toBe(JobStatus.SKIPPED); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(systemMock.get).toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); it('should queue missing assets', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); await sut.handleQueueDetectFaces({ force: false }); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.FACES); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.FACES); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACE_DETECTION, data: { id: assetStub.image.id }, @@ -492,19 +467,19 @@ describe(PersonService.name, () => { }); it('should queue all assets', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - personMock.getAllWithoutFaces.mockResolvedValue([personStub.withName]); + mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.withName]); await sut.handleQueueDetectFaces({ force: true }); - expect(personMock.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); - expect(personMock.delete).toHaveBeenCalledWith([personStub.withName]); - expect(storageMock.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); + expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName]); + expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath); + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACE_DETECTION, data: { id: assetStub.image.id }, @@ -513,127 +488,165 @@ describe(PersonService.name, () => { }); it('should refresh all assets', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); await sut.handleQueueDetectFaces({ force: undefined }); - expect(personMock.delete).not.toHaveBeenCalled(); - expect(personMock.deleteFaces).not.toHaveBeenCalled(); - expect(storageMock.unlink).not.toHaveBeenCalled(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.delete).not.toHaveBeenCalled(); + expect(mocks.person.deleteFaces).not.toHaveBeenCalled(); + expect(mocks.storage.unlink).not.toHaveBeenCalled(); + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACE_DETECTION, data: { id: assetStub.image.id }, }, ]); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.PERSON_CLEANUP }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.PERSON_CLEANUP }); }); it('should delete existing people and faces if forced', async () => { - personMock.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); - personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - assetMock.getAll.mockResolvedValue({ + mocks.person.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); + mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - personMock.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); + mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); await sut.handleQueueDetectFaces({ force: true }); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACE_DETECTION, data: { id: assetStub.image.id }, }, ]); - expect(personMock.delete).toHaveBeenCalledWith([personStub.randomPerson]); - expect(storageMock.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); + expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson]); + expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); }); }); describe('handleQueueRecognizeFaces', () => { it('should skip if machine learning is disabled', async () => { - jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.job.getJobCounts.mockResolvedValue({ + active: 1, + waiting: 0, + paused: 0, + completed: 0, + failed: 0, + delayed: 0, + }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(JobStatus.SKIPPED); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(systemMock.get).toHaveBeenCalled(); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); it('should skip if recognition jobs are already queued', async () => { - jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 1, paused: 0, completed: 0, failed: 0, delayed: 0 }); + mocks.job.getJobCounts.mockResolvedValue({ + active: 1, + waiting: 1, + paused: 0, + completed: 0, + failed: 0, + delayed: 0, + }); await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(JobStatus.SKIPPED); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); it('should queue missing assets', async () => { - jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - personMock.getAllWithoutFaces.mockResolvedValue([]); + mocks.job.getJobCounts.mockResolvedValue({ + active: 1, + waiting: 0, + paused: 0, + completed: 0, + failed: 0, + delayed: 0, + }); + mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({}); - expect(personMock.getAllFaces).toHaveBeenCalledWith({ personId: null, sourceType: SourceType.MACHINE_LEARNING }); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.getAllFaces).toHaveBeenCalledWith({ + personId: null, + sourceType: SourceType.MACHINE_LEARNING, + }); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, data: { id: faceStub.face1.id, deferred: false }, }, ]); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun: expect.any(String), }); }); it('should queue all assets', async () => { - jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - personMock.getAll.mockReturnValue(makeStream()); - personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - personMock.getAllWithoutFaces.mockResolvedValue([]); + mocks.job.getJobCounts.mockResolvedValue({ + active: 1, + waiting: 0, + paused: 0, + completed: 0, + failed: 0, + delayed: 0, + }); + mocks.person.getAll.mockReturnValue(makeStream()); + mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true }); - expect(personMock.getAllFaces).toHaveBeenCalledWith(undefined); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.getAllFaces).toHaveBeenCalledWith(undefined); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, data: { id: faceStub.face1.id, deferred: false }, }, ]); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun: expect.any(String), }); }); it('should run nightly if new face has been added since last run', async () => { - personMock.getLatestFaceDate.mockResolvedValue(new Date().toISOString()); - personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - personMock.getAll.mockReturnValue(makeStream()); - personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - personMock.getAllWithoutFaces.mockResolvedValue([]); + mocks.person.getLatestFaceDate.mockResolvedValue(new Date().toISOString()); + mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.job.getJobCounts.mockResolvedValue({ + active: 1, + waiting: 0, + paused: 0, + completed: 0, + failed: 0, + delayed: 0, + }); + mocks.person.getAll.mockReturnValue(makeStream()); + mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); - expect(systemMock.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); - expect(personMock.getLatestFaceDate).toHaveBeenCalledOnce(); - expect(personMock.getAllFaces).toHaveBeenCalledWith(undefined); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.systemMetadata.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); + expect(mocks.person.getLatestFaceDate).toHaveBeenCalledOnce(); + expect(mocks.person.getAllFaces).toHaveBeenCalledWith(undefined); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, data: { id: faceStub.face1.id, deferred: false }, }, ]); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun: expect.any(String), }); }); @@ -641,62 +654,69 @@ describe(PersonService.name, () => { it('should skip nightly if no new face has been added since last run', async () => { const lastRun = new Date(); - systemMock.get.mockResolvedValue({ lastRun: lastRun.toISOString() }); - personMock.getLatestFaceDate.mockResolvedValue(new Date(lastRun.getTime() - 1).toISOString()); - personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - personMock.getAllWithoutFaces.mockResolvedValue([]); + mocks.systemMetadata.get.mockResolvedValue({ lastRun: lastRun.toISOString() }); + mocks.person.getLatestFaceDate.mockResolvedValue(new Date(lastRun.getTime() - 1).toISOString()); + mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); - expect(systemMock.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); - expect(personMock.getLatestFaceDate).toHaveBeenCalledOnce(); - expect(personMock.getAllFaces).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); + expect(mocks.person.getLatestFaceDate).toHaveBeenCalledOnce(); + expect(mocks.person.getAllFaces).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); it('should delete existing people if forced', async () => { - jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - personMock.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); - personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - personMock.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); + mocks.job.getJobCounts.mockResolvedValue({ + active: 1, + waiting: 0, + paused: 0, + completed: 0, + failed: 0, + delayed: 0, + }); + mocks.person.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); + mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); await sut.handleQueueRecognizeFaces({ force: true }); - expect(personMock.deleteFaces).not.toHaveBeenCalled(); - expect(personMock.unassignFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.deleteFaces).not.toHaveBeenCalled(); + expect(mocks.person.unassignFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, data: { id: faceStub.face1.id, deferred: false }, }, ]); - expect(personMock.delete).toHaveBeenCalledWith([personStub.randomPerson]); - expect(storageMock.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); + expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson]); + expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); }); }); describe('handleDetectFaces', () => { beforeEach(() => { - cryptoMock.randomUUID.mockReturnValue(faceId); + mocks.crypto.randomUUID.mockReturnValue(faceId); }); it('should skip if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await expect(sut.handleDetectFaces({ id: 'foo' })).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.getByIds).not.toHaveBeenCalled(); - expect(systemMock.get).toHaveBeenCalled(); + expect(mocks.asset.getByIds).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); it('should skip when no resize path', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); + mocks.asset.getByIds.mockResolvedValue([assetStub.noResizePath]); await sut.handleDetectFaces({ id: assetStub.noResizePath.id }); - expect(machineLearningMock.detectFaces).not.toHaveBeenCalled(); + expect(mocks.machineLearning.detectFaces).not.toHaveBeenCalled(); }); it('should skip it the asset has already been processed', async () => { - assetMock.getByIds.mockResolvedValue([ + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.noResizePath, faces: [ @@ -709,103 +729,103 @@ describe(PersonService.name, () => { }, ]); await sut.handleDetectFaces({ id: assetStub.noResizePath.id }); - expect(machineLearningMock.detectFaces).not.toHaveBeenCalled(); + expect(mocks.machineLearning.detectFaces).not.toHaveBeenCalled(); }); it('should handle no results', async () => { const start = Date.now(); - machineLearningMock.detectFaces.mockResolvedValue({ imageHeight: 500, imageWidth: 400, faces: [] }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.machineLearning.detectFaces.mockResolvedValue({ imageHeight: 500, imageWidth: 400, faces: [] }); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); await sut.handleDetectFaces({ id: assetStub.image.id }); - expect(machineLearningMock.detectFaces).toHaveBeenCalledWith( + expect(mocks.machineLearning.detectFaces).toHaveBeenCalledWith( ['http://immich-machine-learning:3003'], '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }), ); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); - expect(assetMock.upsertJobStatus).toHaveBeenCalledWith({ + expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith({ assetId: assetStub.image.id, facesRecognizedAt: expect.any(Date), }); - const facesRecognizedAt = assetMock.upsertJobStatus.mock.calls[0][0].facesRecognizedAt as Date; + const facesRecognizedAt = mocks.asset.upsertJobStatus.mock.calls[0][0].facesRecognizedAt as Date; expect(facesRecognizedAt.getTime()).toBeGreaterThan(start); }); it('should create a face with no person and queue recognition job', async () => { - machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); - searchMock.searchFaces.mockResolvedValue([{ ...faceStub.face1, distance: 0.7 }]); - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); + mocks.search.searchFaces.mockResolvedValue([{ ...faceStub.face1, distance: 0.7 }]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); await sut.handleDetectFaces({ id: assetStub.image.id }); - expect(personMock.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }, { name: JobName.FACIAL_RECOGNITION, data: { id: faceId } }, ]); - expect(personMock.reassignFace).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.reassignFace).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should delete an existing face not among the new detected faces', async () => { - machineLearningMock.detectFaces.mockResolvedValue({ faces: [], imageHeight: 500, imageWidth: 400 }); - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.primaryFace1] }]); + mocks.machineLearning.detectFaces.mockResolvedValue({ faces: [], imageHeight: 500, imageWidth: 400 }); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.primaryFace1] }]); await sut.handleDetectFaces({ id: assetStub.image.id }); - expect(personMock.refreshFaces).toHaveBeenCalledWith([], [faceStub.primaryFace1.id], []); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(personMock.reassignFace).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith([], [faceStub.primaryFace1.id], []); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.person.reassignFace).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should add new face and delete an existing face not among the new detected faces', async () => { - machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.primaryFace1] }]); + mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.primaryFace1] }]); await sut.handleDetectFaces({ id: assetStub.image.id }); - expect(personMock.refreshFaces).toHaveBeenCalledWith([face], [faceStub.primaryFace1.id], [faceSearch]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.refreshFaces).toHaveBeenCalledWith([face], [faceStub.primaryFace1.id], [faceSearch]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }, { name: JobName.FACIAL_RECOGNITION, data: { id: faceId } }, ]); - expect(personMock.reassignFace).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.reassignFace).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should add embedding to matching metadata face', async () => { - machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.fromExif1] }]); + mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.fromExif1] }]); await sut.handleDetectFaces({ id: assetStub.image.id }); - expect(personMock.refreshFaces).toHaveBeenCalledWith( + expect(mocks.person.refreshFaces).toHaveBeenCalledWith( [], [], [{ faceId: faceStub.fromExif1.id, embedding: faceSearch.embedding }], ); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(personMock.reassignFace).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.person.reassignFace).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should not add embedding to non-matching metadata face', async () => { - machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.fromExif2] }]); + mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.fromExif2] }]); await sut.handleDetectFaces({ id: assetStub.image.id }); - expect(personMock.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }, { name: JobName.FACIAL_RECOGNITION, data: { id: faceId } }, ]); - expect(personMock.reassignFace).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.reassignFace).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); }); @@ -813,27 +833,27 @@ describe(PersonService.name, () => { it('should fail if face does not exist', async () => { expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.FAILED); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); - expect(personMock.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.create).not.toHaveBeenCalled(); }); it('should fail if face does not have asset', async () => { const face = { ...faceStub.face1, asset: null } as AssetFaceEntity & { asset: null }; - personMock.getFaceByIdWithAssets.mockResolvedValue(face); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(face); expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.FAILED); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); - expect(personMock.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.create).not.toHaveBeenCalled(); }); it('should skip if face already has an assigned person', async () => { - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.SKIPPED); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); - expect(personMock.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.create).not.toHaveBeenCalled(); }); it('should match existing person', async () => { @@ -848,20 +868,20 @@ describe(PersonService.name, () => { { ...faceStub.face1, distance: 0.4 }, ] as FaceSearchResult[]; - systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); - searchMock.searchFaces.mockResolvedValue(faces); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue(faceStub.primaryFace1.person); + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); + mocks.search.searchFaces.mockResolvedValue(faces); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); - expect(personMock.create).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).toHaveBeenCalledTimes(1); - expect(personMock.reassignFaces).toHaveBeenCalledWith({ + expect(mocks.person.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1); + expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: expect.arrayContaining([faceStub.noPerson1.id]), newPersonId: faceStub.primaryFace1.person.id, }); - expect(personMock.reassignFaces).toHaveBeenCalledWith({ + expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: expect.not.arrayContaining([faceStub.face1.id]), newPersonId: faceStub.primaryFace1.person.id, }); @@ -873,18 +893,18 @@ describe(PersonService.name, () => { { ...faceStub.noPerson2, distance: 0.3 }, ] as FaceSearchResult[]; - systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); - searchMock.searchFaces.mockResolvedValue(faces); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue(personStub.withName); + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); + mocks.search.searchFaces.mockResolvedValue(faces); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); - expect(personMock.create).toHaveBeenCalledWith({ + expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: faceStub.noPerson1.asset.ownerId, faceAssetId: faceStub.noPerson1.id, }); - expect(personMock.reassignFaces).toHaveBeenCalledWith({ + expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: [faceStub.noPerson1.id], newPersonId: personStub.withName.id, }); @@ -893,16 +913,16 @@ describe(PersonService.name, () => { it('should not queue face with no matches', async () => { const faces = [{ ...faceStub.noPerson1, distance: 0 }] as FaceSearchResult[]; - searchMock.searchFaces.mockResolvedValue(faces); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue(personStub.withName); + mocks.search.searchFaces.mockResolvedValue(faces); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(searchMock.searchFaces).toHaveBeenCalledTimes(1); - expect(personMock.create).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.search.searchFaces).toHaveBeenCalledTimes(1); + expect(mocks.person.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should defer non-core faces to end of queue', async () => { @@ -911,20 +931,20 @@ describe(PersonService.name, () => { { ...faceStub.noPerson2, distance: 0.4 }, ] as FaceSearchResult[]; - systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); - searchMock.searchFaces.mockResolvedValue(faces); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue(personStub.withName); + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); + mocks.search.searchFaces.mockResolvedValue(faces); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FACIAL_RECOGNITION, data: { id: faceStub.noPerson1.id, deferred: true }, }); - expect(searchMock.searchFaces).toHaveBeenCalledTimes(1); - expect(personMock.create).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.search.searchFaces).toHaveBeenCalledTimes(1); + expect(mocks.person.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should not assign person to deferred non-core face with no matching person', async () => { @@ -933,66 +953,66 @@ describe(PersonService.name, () => { { ...faceStub.noPerson2, distance: 0.4 }, ] as FaceSearchResult[]; - systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); - searchMock.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue(personStub.withName); + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); + mocks.search.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true }); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(searchMock.searchFaces).toHaveBeenCalledTimes(2); - expect(personMock.create).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.search.searchFaces).toHaveBeenCalledTimes(2); + expect(mocks.person.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); }); describe('handleGeneratePersonThumbnail', () => { it('should skip if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.getByIds).not.toHaveBeenCalled(); - expect(systemMock.get).toHaveBeenCalled(); + expect(mocks.asset.getByIds).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); it('should skip a person not found', async () => { - personMock.getById.mockResolvedValue(null); + mocks.person.getById.mockResolvedValue(null); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); it('should skip a person without a face asset id', async () => { - personMock.getById.mockResolvedValue(personStub.noThumbnail); + mocks.person.getById.mockResolvedValue(personStub.noThumbnail); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); it('should skip a person with a face asset id not found', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.id }); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); + mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.id }); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); it('should skip a person with a face asset id without a thumbnail', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); - assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); + mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); + mocks.asset.getByIds.mockResolvedValue([assetStub.noResizePath]); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); it('should generate a thumbnail', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.middle); - assetMock.getById.mockResolvedValue(assetStub.primaryImage); + mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.middle); + mocks.asset.getById.mockResolvedValue(assetStub.primaryImage); await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); - expect(assetMock.getById).toHaveBeenCalledWith(faceStub.middle.assetId, { exifInfo: true, files: true }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.asset.getById).toHaveBeenCalledWith(faceStub.middle.assetId, { exifInfo: true, files: true }); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs'); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, { colorspace: Colorspace.P3, @@ -1009,20 +1029,20 @@ describe(PersonService.name, () => { }, 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', ); - expect(personMock.update).toHaveBeenCalledWith({ + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', }); }); it('should generate a thumbnail without going negative', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.start.assetId }); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.start); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.start.assetId }); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.start); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, { colorspace: Colorspace.P3, @@ -1042,13 +1062,13 @@ describe(PersonService.name, () => { }); it('should generate a thumbnail without overflowing', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId }); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.end); - assetMock.getById.mockResolvedValue(assetStub.primaryImage); + mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId }); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.end); + mocks.asset.getById.mockResolvedValue(assetStub.primaryImage); await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, { colorspace: Colorspace.P3, @@ -1070,117 +1090,117 @@ describe(PersonService.name, () => { describe('mergePerson', () => { it('should require person.write and person.merge permission', async () => { - personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); - personMock.getById.mockResolvedValueOnce(personStub.mergePerson); + mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); + mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf( BadRequestException, ); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); - expect(personMock.delete).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.delete).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should merge two people without smart merge', async () => { - personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); - personMock.getById.mockResolvedValueOnce(personStub.mergePerson); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); + mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); + mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ { id: 'person-2', success: true }, ]); - expect(personMock.reassignFaces).toHaveBeenCalledWith({ + expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ newPersonId: personStub.primaryPerson.id, oldPersonId: personStub.mergePerson.id, }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should merge two people with smart merge', async () => { - personMock.getById.mockResolvedValueOnce(personStub.randomPerson); - personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); - personMock.update.mockResolvedValue({ ...personStub.randomPerson, name: personStub.primaryPerson.name }); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-3'])); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); + mocks.person.getById.mockResolvedValueOnce(personStub.randomPerson); + mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); + mocks.person.update.mockResolvedValue({ ...personStub.randomPerson, name: personStub.primaryPerson.name }); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-3'])); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); await expect(sut.mergePerson(authStub.admin, 'person-3', { ids: ['person-1'] })).resolves.toEqual([ { id: 'person-1', success: true }, ]); - expect(personMock.reassignFaces).toHaveBeenCalledWith({ + expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ newPersonId: personStub.randomPerson.id, oldPersonId: personStub.primaryPerson.id, }); - expect(personMock.update).toHaveBeenCalledWith({ + expect(mocks.person.update).toHaveBeenCalledWith({ id: personStub.randomPerson.id, name: personStub.primaryPerson.name, }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw an error when the primary person is not found', async () => { - personMock.getById.mockResolvedValue(null); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(null); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf( BadRequestException, ); - expect(personMock.delete).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.delete).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should handle invalid merge ids', async () => { - personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); - personMock.getById.mockResolvedValueOnce(null); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); + mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); + mocks.person.getById.mockResolvedValueOnce(null); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ { id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND }, ]); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); - expect(personMock.delete).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.delete).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should handle an error reassigning faces', async () => { - personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); - personMock.getById.mockResolvedValueOnce(personStub.mergePerson); - personMock.reassignFaces.mockRejectedValue(new Error('update failed')); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); + mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); + mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson); + mocks.person.reassignFaces.mockRejectedValue(new Error('update failed')); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ { id: 'person-2', success: false, error: BulkIdErrorReason.UNKNOWN }, ]); - expect(personMock.delete).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.delete).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); describe('getStatistics', () => { it('should get correct number of person', async () => { - personMock.getById.mockResolvedValue(personStub.primaryPerson); - personMock.getStatistics.mockResolvedValue(statistics); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(personStub.primaryPerson); + mocks.person.getStatistics.mockResolvedValue(statistics); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getStatistics(authStub.admin, 'person-1')).resolves.toEqual({ assets: 3 }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should require person.read permission', async () => { - personMock.getById.mockResolvedValue(personStub.primaryPerson); + mocks.person.getById.mockResolvedValue(personStub.primaryPerson); await expect(sut.getStatistics(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index 9f16ddf82d..34f4c7b39f 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -1,26 +1,20 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { SearchSuggestionType } from 'src/dtos/search.dto'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { ISearchRepository } from 'src/interfaces/search.interface'; import { SearchService } from 'src/services/search.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { personStub } from 'test/fixtures/person.stub'; -import { newTestService } from 'test/utils'; -import { Mocked, beforeEach, vitest } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; +import { beforeEach, vitest } from 'vitest'; vitest.useFakeTimers(); describe(SearchService.name, () => { let sut: SearchService; - - let assetMock: Mocked; - let personMock: Mocked; - let searchMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, personMock, searchMock } = newTestService(SearchService)); + ({ sut, mocks } = newTestService(SearchService)); }); it('should work', () => { @@ -31,25 +25,25 @@ describe(SearchService.name, () => { it('should pass options to search', async () => { const { name } = personStub.withName; - personMock.getByName.mockResolvedValue([]); + mocks.person.getByName.mockResolvedValue([]); await sut.searchPerson(authStub.user1, { name, withHidden: false }); - expect(personMock.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: false }); + expect(mocks.person.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: false }); await sut.searchPerson(authStub.user1, { name, withHidden: true }); - expect(personMock.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: true }); + expect(mocks.person.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: true }); }); }); describe('getExploreData', () => { it('should get assets by city and tag', async () => { - assetMock.getAssetIdByCity.mockResolvedValue({ + mocks.asset.getAssetIdByCity.mockResolvedValue({ fieldName: 'exifInfo.city', items: [{ value: 'test-city', data: assetStub.withLocation.id }], }); - assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.withLocation]); + mocks.asset.getByIdsWithAllRelations.mockResolvedValue([assetStub.withLocation]); const expectedResponse = [ { fieldName: 'exifInfo.city', items: [{ value: 'test-city', data: mapAsset(assetStub.withLocation) }] }, ]; @@ -62,83 +56,83 @@ describe(SearchService.name, () => { describe('getSearchSuggestions', () => { it('should return search suggestions for country', async () => { - searchMock.getCountries.mockResolvedValue(['USA']); + mocks.search.getCountries.mockResolvedValue(['USA']); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }), ).resolves.toEqual(['USA']); - expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); + expect(mocks.search.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); }); it('should return search suggestions for country (including null)', async () => { - searchMock.getCountries.mockResolvedValue(['USA']); + mocks.search.getCountries.mockResolvedValue(['USA']); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.COUNTRY }), ).resolves.toEqual(['USA', null]); - expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); + expect(mocks.search.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); }); it('should return search suggestions for state', async () => { - searchMock.getStates.mockResolvedValue(['California']); + mocks.search.getStates.mockResolvedValue(['California']); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.STATE }), ).resolves.toEqual(['California']); - expect(searchMock.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); it('should return search suggestions for state (including null)', async () => { - searchMock.getStates.mockResolvedValue(['California']); + mocks.search.getStates.mockResolvedValue(['California']); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.STATE }), ).resolves.toEqual(['California', null]); - expect(searchMock.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); it('should return search suggestions for city', async () => { - searchMock.getCities.mockResolvedValue(['Denver']); + mocks.search.getCities.mockResolvedValue(['Denver']); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CITY }), ).resolves.toEqual(['Denver']); - expect(searchMock.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); it('should return search suggestions for city (including null)', async () => { - searchMock.getCities.mockResolvedValue(['Denver']); + mocks.search.getCities.mockResolvedValue(['Denver']); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CITY }), ).resolves.toEqual(['Denver', null]); - expect(searchMock.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); it('should return search suggestions for camera make', async () => { - searchMock.getCameraMakes.mockResolvedValue(['Nikon']); + mocks.search.getCameraMakes.mockResolvedValue(['Nikon']); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CAMERA_MAKE }), ).resolves.toEqual(['Nikon']); - expect(searchMock.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); it('should return search suggestions for camera make (including null)', async () => { - searchMock.getCameraMakes.mockResolvedValue(['Nikon']); + mocks.search.getCameraMakes.mockResolvedValue(['Nikon']); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CAMERA_MAKE }), ).resolves.toEqual(['Nikon', null]); - expect(searchMock.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); it('should return search suggestions for camera model', async () => { - searchMock.getCameraModels.mockResolvedValue(['Fujifilm X100VI']); + mocks.search.getCameraModels.mockResolvedValue(['Fujifilm X100VI']); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CAMERA_MODEL }), ).resolves.toEqual(['Fujifilm X100VI']); - expect(searchMock.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); it('should return search suggestions for camera model (including null)', async () => { - searchMock.getCameraModels.mockResolvedValue(['Fujifilm X100VI']); + mocks.search.getCameraModels.mockResolvedValue(['Fujifilm X100VI']); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CAMERA_MODEL }), ).resolves.toEqual(['Fujifilm X100VI', null]); - expect(searchMock.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); }); }); diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index 4a405629c9..05ebda6a94 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -1,20 +1,13 @@ import { SystemMetadataKey } from 'src/enum'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; import { ServerService } from 'src/services/server.service'; -import { ISystemMetadataRepository } from 'src/types'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(ServerService.name, () => { let sut: ServerService; - - let storageMock: Mocked; - let systemMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, storageMock, systemMock, userMock } = newTestService(ServerService)); + ({ sut, mocks } = newTestService(ServerService)); }); it('should work', () => { @@ -23,7 +16,7 @@ describe(ServerService.name, () => { describe('getStorage', () => { it('should return the disk space as B', async () => { - storageMock.checkDiskUsage.mockResolvedValue({ free: 200, available: 300, total: 500 }); + mocks.storage.checkDiskUsage.mockResolvedValue({ free: 200, available: 300, total: 500 }); await expect(sut.getStorage()).resolves.toEqual({ diskAvailable: '300 B', @@ -35,11 +28,11 @@ describe(ServerService.name, () => { diskUseRaw: 300, }); - expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); }); it('should return the disk space as KiB', async () => { - storageMock.checkDiskUsage.mockResolvedValue({ free: 200_000, available: 300_000, total: 500_000 }); + mocks.storage.checkDiskUsage.mockResolvedValue({ free: 200_000, available: 300_000, total: 500_000 }); await expect(sut.getStorage()).resolves.toEqual({ diskAvailable: '293.0 KiB', @@ -51,11 +44,11 @@ describe(ServerService.name, () => { diskUseRaw: 300_000, }); - expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); }); it('should return the disk space as MiB', async () => { - storageMock.checkDiskUsage.mockResolvedValue({ free: 200_000_000, available: 300_000_000, total: 500_000_000 }); + mocks.storage.checkDiskUsage.mockResolvedValue({ free: 200_000_000, available: 300_000_000, total: 500_000_000 }); await expect(sut.getStorage()).resolves.toEqual({ diskAvailable: '286.1 MiB', @@ -67,11 +60,11 @@ describe(ServerService.name, () => { diskUseRaw: 300_000_000, }); - expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); }); it('should return the disk space as GiB', async () => { - storageMock.checkDiskUsage.mockResolvedValue({ + mocks.storage.checkDiskUsage.mockResolvedValue({ free: 200_000_000_000, available: 300_000_000_000, total: 500_000_000_000, @@ -87,11 +80,11 @@ describe(ServerService.name, () => { diskUseRaw: 300_000_000_000, }); - expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); }); it('should return the disk space as TiB', async () => { - storageMock.checkDiskUsage.mockResolvedValue({ + mocks.storage.checkDiskUsage.mockResolvedValue({ free: 200_000_000_000_000, available: 300_000_000_000_000, total: 500_000_000_000_000, @@ -107,11 +100,11 @@ describe(ServerService.name, () => { diskUseRaw: 300_000_000_000_000, }); - expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); }); it('should return the disk space as PiB', async () => { - storageMock.checkDiskUsage.mockResolvedValue({ + mocks.storage.checkDiskUsage.mockResolvedValue({ free: 200_000_000_000_000_000, available: 300_000_000_000_000_000, total: 500_000_000_000_000_000, @@ -127,7 +120,7 @@ describe(ServerService.name, () => { diskUseRaw: 300_000_000_000_000_000, }); - expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); }); }); @@ -155,7 +148,7 @@ describe(ServerService.name, () => { trash: true, email: false, }); - expect(systemMock.get).toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); }); @@ -173,13 +166,13 @@ describe(ServerService.name, () => { mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', }); - expect(systemMock.get).toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); }); describe('getStats', () => { it('should total up usage by user', async () => { - userMock.getUserStats.mockResolvedValue([ + mocks.user.getUserStats.mockResolvedValue([ { userId: 'user1', userName: '1 User', @@ -252,36 +245,36 @@ describe(ServerService.name, () => { ], }); - expect(userMock.getUserStats).toHaveBeenCalled(); + expect(mocks.user.getUserStats).toHaveBeenCalled(); }); }); describe('setLicense', () => { it('should save license if valid', async () => { - systemMock.set.mockResolvedValue(); + mocks.systemMetadata.set.mockResolvedValue(); const license = { licenseKey: 'IMSV-license-key', activationKey: 'activation-key' }; await sut.setLicense(license); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.LICENSE, expect.any(Object)); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.LICENSE, expect.any(Object)); }); it('should not save license if invalid', async () => { - userMock.upsertMetadata.mockResolvedValue(); + mocks.user.upsertMetadata.mockResolvedValue(); const license = { licenseKey: 'license-key', activationKey: 'activation-key' }; const call = sut.setLicense(license); await expect(call).rejects.toThrowError('Invalid license key'); - expect(userMock.upsertMetadata).not.toHaveBeenCalled(); + expect(mocks.user.upsertMetadata).not.toHaveBeenCalled(); }); }); describe('deleteLicense', () => { it('should delete license', async () => { - userMock.upsertMetadata.mockResolvedValue(); + mocks.user.upsertMetadata.mockResolvedValue(); await sut.deleteLicense(); - expect(userMock.upsertMetadata).not.toHaveBeenCalled(); + expect(mocks.user.upsertMetadata).not.toHaveBeenCalled(); }); }); }); diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index 8d989db5df..c25c0feb82 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -1,20 +1,15 @@ import { JobStatus } from 'src/interfaces/job.interface'; import { SessionService } from 'src/services/session.service'; -import { ISessionRepository } from 'src/types'; import { authStub } from 'test/fixtures/auth.stub'; import { sessionStub } from 'test/fixtures/session.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe('SessionService', () => { let sut: SessionService; - - let accessMock: Mocked; - let sessionMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, sessionMock } = newTestService(SessionService)); + ({ sut, mocks } = newTestService(SessionService)); }); it('should be defined', () => { @@ -23,13 +18,13 @@ describe('SessionService', () => { describe('handleCleanup', () => { it('should return skipped if nothing is to be deleted', async () => { - sessionMock.search.mockResolvedValue([]); + mocks.session.search.mockResolvedValue([]); await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SKIPPED); - expect(sessionMock.search).toHaveBeenCalled(); + expect(mocks.session.search).toHaveBeenCalled(); }); it('should delete sessions', async () => { - sessionMock.search.mockResolvedValue([ + mocks.session.search.mockResolvedValue([ { createdAt: new Date('1970-01-01T00:00:00.00Z'), updatedAt: new Date('1970-01-02T00:00:00.00Z'), @@ -42,13 +37,13 @@ describe('SessionService', () => { ]); await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SUCCESS); - expect(sessionMock.delete).toHaveBeenCalledWith('123'); + expect(mocks.session.delete).toHaveBeenCalledWith('123'); }); }); describe('getAll', () => { it('should get the devices', async () => { - sessionMock.getByUserId.mockResolvedValue([sessionStub.valid as any, sessionStub.inactive]); + mocks.session.getByUserId.mockResolvedValue([sessionStub.valid as any, sessionStub.inactive]); await expect(sut.getAll(authStub.user1)).resolves.toEqual([ { createdAt: '2021-01-01T00:00:00.000Z', @@ -68,30 +63,33 @@ describe('SessionService', () => { }, ]); - expect(sessionMock.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); + expect(mocks.session.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); }); }); describe('logoutDevices', () => { it('should logout all devices', async () => { - sessionMock.getByUserId.mockResolvedValue([sessionStub.inactive, sessionStub.valid] as any[]); + mocks.session.getByUserId.mockResolvedValue([sessionStub.inactive, sessionStub.valid] as any[]); await sut.deleteAll(authStub.user1); - expect(sessionMock.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); - expect(sessionMock.delete).toHaveBeenCalledWith('not_active'); - expect(sessionMock.delete).not.toHaveBeenCalledWith('token-id'); + expect(mocks.session.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); + expect(mocks.session.delete).toHaveBeenCalledWith('not_active'); + expect(mocks.session.delete).not.toHaveBeenCalledWith('token-id'); }); }); describe('logoutDevice', () => { it('should logout the device', async () => { - accessMock.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1'])); + mocks.access.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1'])); await sut.delete(authStub.user1, 'token-1'); - expect(accessMock.authDevice.checkOwnerAccess).toHaveBeenCalledWith(authStub.user1.user.id, new Set(['token-1'])); - expect(sessionMock.delete).toHaveBeenCalledWith('token-1'); + expect(mocks.access.authDevice.checkOwnerAccess).toHaveBeenCalledWith( + authStub.user1.user.id, + new Set(['token-1']), + ); + expect(mocks.session.delete).toHaveBeenCalledWith('token-1'); }); }); }); diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index 0e29012876..4d6cdee6cb 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -2,24 +2,19 @@ import { BadRequestException, ForbiddenException, UnauthorizedException } from ' import _ from 'lodash'; import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { SharedLinkType } from 'src/enum'; -import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { SharedLinkService } from 'src/services/shared-link.service'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(SharedLinkService.name, () => { let sut: SharedLinkService; - - let accessMock: IAccessRepositoryMock; - let sharedLinkMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, sharedLinkMock } = newTestService(SharedLinkService)); + ({ sut, mocks } = newTestService(SharedLinkService)); }); it('should work', () => { @@ -28,46 +23,46 @@ describe(SharedLinkService.name, () => { describe('getAll', () => { it('should return all shared links for a user', async () => { - sharedLinkMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]); + mocks.sharedLink.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]); await expect(sut.getAll(authStub.user1, {})).resolves.toEqual([ sharedLinkResponseStub.expired, sharedLinkResponseStub.valid, ]); - expect(sharedLinkMock.getAll).toHaveBeenCalledWith({ userId: authStub.user1.user.id }); + expect(mocks.sharedLink.getAll).toHaveBeenCalledWith({ userId: authStub.user1.user.id }); }); }); describe('getMine', () => { it('should only work for a public user', async () => { await expect(sut.getMine(authStub.admin, {})).rejects.toBeInstanceOf(ForbiddenException); - expect(sharedLinkMock.get).not.toHaveBeenCalled(); + expect(mocks.sharedLink.get).not.toHaveBeenCalled(); }); it('should return the shared link for the public user', async () => { const authDto = authStub.adminSharedLink; - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.valid); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); it('should not return metadata', async () => { const authDto = authStub.adminSharedLinkNoExif; - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); it('should throw an error for an invalid password protected shared link', async () => { const authDto = authStub.adminSharedLink; - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.passwordRequired); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.passwordRequired); await expect(sut.getMine(authDto, {})).rejects.toBeInstanceOf(UnauthorizedException); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); it('should allow a correct password on a password protected shared link', async () => { - sharedLinkMock.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' }); + mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' }); await expect(sut.getMine(authStub.adminSharedLink, { password: '123' })).resolves.toBeDefined(); - expect(sharedLinkMock.get).toHaveBeenCalledWith( + expect(mocks.sharedLink.get).toHaveBeenCalledWith( authStub.adminSharedLink.user.id, authStub.adminSharedLink.sharedLink?.id, ); @@ -77,14 +72,14 @@ describe(SharedLinkService.name, () => { describe('get', () => { it('should throw an error for an invalid shared link', async () => { await expect(sut.get(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); - expect(sharedLinkMock.update).not.toHaveBeenCalled(); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); + expect(mocks.sharedLink.update).not.toHaveBeenCalled(); }); it('should get a shared link by id', async () => { - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); await expect(sut.get(authStub.user1, sharedLinkStub.valid.id)).resolves.toEqual(sharedLinkResponseStub.valid); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); }); }); @@ -114,16 +109,16 @@ describe(SharedLinkService.name, () => { }); it('should create an album shared link', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); - sharedLinkMock.create.mockResolvedValue(sharedLinkStub.valid); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); + mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.valid); await sut.create(authStub.admin, { type: SharedLinkType.ALBUM, albumId: albumStub.oneAsset.id }); - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith( + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([albumStub.oneAsset.id]), ); - expect(sharedLinkMock.create).toHaveBeenCalledWith({ + expect(mocks.sharedLink.create).toHaveBeenCalledWith({ type: SharedLinkType.ALBUM, userId: authStub.admin.user.id, albumId: albumStub.oneAsset.id, @@ -137,8 +132,8 @@ describe(SharedLinkService.name, () => { }); it('should create an individual shared link', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); await sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, @@ -148,11 +143,11 @@ describe(SharedLinkService.name, () => { allowUpload: true, }); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([assetStub.image.id]), ); - expect(sharedLinkMock.create).toHaveBeenCalledWith({ + expect(mocks.sharedLink.create).toHaveBeenCalledWith({ type: SharedLinkType.INDIVIDUAL, userId: authStub.admin.user.id, albumId: null, @@ -167,8 +162,8 @@ describe(SharedLinkService.name, () => { }); it('should create a shared link with allowDownload set to false when showMetadata is false', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); await sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, @@ -178,11 +173,11 @@ describe(SharedLinkService.name, () => { allowUpload: true, }); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([assetStub.image.id]), ); - expect(sharedLinkMock.create).toHaveBeenCalledWith({ + expect(mocks.sharedLink.create).toHaveBeenCalledWith({ type: SharedLinkType.INDIVIDUAL, userId: authStub.admin.user.id, albumId: null, @@ -200,16 +195,16 @@ describe(SharedLinkService.name, () => { describe('update', () => { it('should throw an error for an invalid shared link', async () => { await expect(sut.update(authStub.user1, 'missing-id', {})).rejects.toBeInstanceOf(BadRequestException); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); - expect(sharedLinkMock.update).not.toHaveBeenCalled(); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); + expect(mocks.sharedLink.update).not.toHaveBeenCalled(); }); it('should update a shared link', async () => { - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); - sharedLinkMock.update.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid); await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false }); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); - expect(sharedLinkMock.update).toHaveBeenCalledWith({ + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); + expect(mocks.sharedLink.update).toHaveBeenCalledWith({ id: sharedLinkStub.valid.id, userId: authStub.user1.user.id, allowDownload: false, @@ -220,30 +215,30 @@ describe(SharedLinkService.name, () => { describe('remove', () => { it('should throw an error for an invalid shared link', async () => { await expect(sut.remove(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); - expect(sharedLinkMock.update).not.toHaveBeenCalled(); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); + expect(mocks.sharedLink.update).not.toHaveBeenCalled(); }); it('should remove a key', async () => { - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); await sut.remove(authStub.user1, sharedLinkStub.valid.id); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); - expect(sharedLinkMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); + expect(mocks.sharedLink.remove).toHaveBeenCalledWith(sharedLinkStub.valid); }); }); describe('addAssets', () => { it('should not work on album shared links', async () => { - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( BadRequestException, ); }); it('should add assets to a shared link', async () => { - sharedLinkMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); - sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-3'])); + mocks.sharedLink.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); + mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-3'])); await expect( sut.addAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2', 'asset-3'] }), @@ -253,9 +248,9 @@ describe(SharedLinkService.name, () => { { assetId: 'asset-3', success: true }, ]); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledTimes(1); - expect(sharedLinkMock.update).toHaveBeenCalled(); - expect(sharedLinkMock.update).toHaveBeenCalledWith({ + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledTimes(1); + expect(mocks.sharedLink.update).toHaveBeenCalled(); + expect(mocks.sharedLink.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assetIds: ['asset-3'], }); @@ -264,15 +259,15 @@ describe(SharedLinkService.name, () => { describe('removeAssets', () => { it('should not work on album shared links', async () => { - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( BadRequestException, ); }); it('should remove assets from a shared link', async () => { - sharedLinkMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); - sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); + mocks.sharedLink.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); + mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); await expect( sut.removeAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2'] }), @@ -281,39 +276,39 @@ describe(SharedLinkService.name, () => { { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND }, ]); - expect(sharedLinkMock.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] }); + expect(mocks.sharedLink.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] }); }); }); describe('getMetadataTags', () => { it('should return null when auth is not a shared link', async () => { await expect(sut.getMetadataTags(authStub.admin)).resolves.toBe(null); - expect(sharedLinkMock.get).not.toHaveBeenCalled(); + expect(mocks.sharedLink.get).not.toHaveBeenCalled(); }); it('should return null when shared link has a password', async () => { await expect(sut.getMetadataTags(authStub.passwordSharedLink)).resolves.toBe(null); - expect(sharedLinkMock.get).not.toHaveBeenCalled(); + expect(mocks.sharedLink.get).not.toHaveBeenCalled(); }); it('should return metadata tags', async () => { - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.individual); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.individual); await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ description: '1 shared photos & videos', imageUrl: `http://localhost:2283/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`, title: 'Public Share', }); - expect(sharedLinkMock.get).toHaveBeenCalled(); + expect(mocks.sharedLink.get).toHaveBeenCalled(); }); it('should return metadata tags with a default image path if the asset id is not set', async () => { - sharedLinkMock.get.mockResolvedValue({ ...sharedLinkStub.individual, album: undefined, assets: [] }); + mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, album: undefined, assets: [] }); await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ description: '0 shared photos & videos', imageUrl: `http://localhost:2283/feature-panel.png`, title: 'Public Share', }); - expect(sharedLinkMock.get).toHaveBeenCalled(); + expect(mocks.sharedLink.get).toHaveBeenCalled(); }); }); }); diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index 1b985ab421..79e13ea7ab 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -1,35 +1,22 @@ import { SystemConfig } from 'src/config'; import { ImmichWorker } from 'src/enum'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { IDatabaseRepository } from 'src/interfaces/database.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { ISearchRepository } from 'src/interfaces/search.interface'; +import { WithoutProperty } from 'src/interfaces/asset.interface'; +import { JobName, JobStatus } from 'src/interfaces/job.interface'; import { SmartInfoService } from 'src/services/smart-info.service'; -import { IConfigRepository, ISystemMetadataRepository } from 'src/types'; import { getCLIPModelInfo } from 'src/utils/misc'; import { assetStub } from 'test/fixtures/asset.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(SmartInfoService.name, () => { let sut: SmartInfoService; - - let assetMock: Mocked; - let databaseMock: Mocked; - let jobMock: Mocked; - let machineLearningMock: Mocked; - let searchMock: Mocked; - let systemMock: Mocked; - let configMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, databaseMock, jobMock, machineLearningMock, searchMock, systemMock, configMock } = - newTestService(SmartInfoService)); + ({ sut, mocks } = newTestService(SmartInfoService)); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - configMock.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); }); it('should work', () => { @@ -69,79 +56,79 @@ describe(SmartInfoService.name, () => { it('should return if machine learning is disabled', async () => { await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningDisabled as SystemConfig }); - expect(searchMock.getDimensionSize).not.toHaveBeenCalled(); - expect(searchMock.setDimensionSize).not.toHaveBeenCalled(); - expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); - expect(jobMock.getQueueStatus).not.toHaveBeenCalled(); - expect(jobMock.pause).not.toHaveBeenCalled(); - expect(jobMock.waitForQueueCompletion).not.toHaveBeenCalled(); - expect(jobMock.resume).not.toHaveBeenCalled(); + expect(mocks.search.getDimensionSize).not.toHaveBeenCalled(); + expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); + expect(mocks.job.getQueueStatus).not.toHaveBeenCalled(); + expect(mocks.job.pause).not.toHaveBeenCalled(); + expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled(); + expect(mocks.job.resume).not.toHaveBeenCalled(); }); it('should return if model and DB dimension size are equal', async () => { - searchMock.getDimensionSize.mockResolvedValue(512); + mocks.search.getDimensionSize.mockResolvedValue(512); await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig }); - expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); - expect(searchMock.setDimensionSize).not.toHaveBeenCalled(); - expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); - expect(jobMock.getQueueStatus).not.toHaveBeenCalled(); - expect(jobMock.pause).not.toHaveBeenCalled(); - expect(jobMock.waitForQueueCompletion).not.toHaveBeenCalled(); - expect(jobMock.resume).not.toHaveBeenCalled(); + expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); + expect(mocks.job.getQueueStatus).not.toHaveBeenCalled(); + expect(mocks.job.pause).not.toHaveBeenCalled(); + expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled(); + expect(mocks.job.resume).not.toHaveBeenCalled(); }); it('should update DB dimension size if model and DB have different values', async () => { - searchMock.getDimensionSize.mockResolvedValue(768); - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.search.getDimensionSize.mockResolvedValue(768); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig }); - expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); - expect(searchMock.setDimensionSize).toHaveBeenCalledWith(512); - expect(jobMock.getQueueStatus).toHaveBeenCalledTimes(1); - expect(jobMock.pause).toHaveBeenCalledTimes(1); - expect(jobMock.waitForQueueCompletion).toHaveBeenCalledTimes(1); - expect(jobMock.resume).toHaveBeenCalledTimes(1); + expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(512); + expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); + expect(mocks.job.pause).toHaveBeenCalledTimes(1); + expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); + expect(mocks.job.resume).toHaveBeenCalledTimes(1); }); it('should skip pausing and resuming queue if already paused', async () => { - searchMock.getDimensionSize.mockResolvedValue(768); - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); + mocks.search.getDimensionSize.mockResolvedValue(768); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig }); - expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); - expect(searchMock.setDimensionSize).toHaveBeenCalledWith(512); - expect(jobMock.getQueueStatus).toHaveBeenCalledTimes(1); - expect(jobMock.pause).not.toHaveBeenCalled(); - expect(jobMock.waitForQueueCompletion).toHaveBeenCalledTimes(1); - expect(jobMock.resume).not.toHaveBeenCalled(); + expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(512); + expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); + expect(mocks.job.pause).not.toHaveBeenCalled(); + expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); + expect(mocks.job.resume).not.toHaveBeenCalled(); }); }); describe('onConfigUpdateEvent', () => { it('should return if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await sut.onConfigUpdate({ newConfig: systemConfigStub.machineLearningDisabled as SystemConfig, oldConfig: systemConfigStub.machineLearningDisabled as SystemConfig, }); - expect(systemMock.get).not.toHaveBeenCalled(); - expect(searchMock.getDimensionSize).not.toHaveBeenCalled(); - expect(searchMock.setDimensionSize).not.toHaveBeenCalled(); - expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); - expect(jobMock.getQueueStatus).not.toHaveBeenCalled(); - expect(jobMock.pause).not.toHaveBeenCalled(); - expect(jobMock.waitForQueueCompletion).not.toHaveBeenCalled(); - expect(jobMock.resume).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).not.toHaveBeenCalled(); + expect(mocks.search.getDimensionSize).not.toHaveBeenCalled(); + expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); + expect(mocks.job.getQueueStatus).not.toHaveBeenCalled(); + expect(mocks.job.pause).not.toHaveBeenCalled(); + expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled(); + expect(mocks.job.resume).not.toHaveBeenCalled(); }); it('should return if model and DB dimension size are equal', async () => { - searchMock.getDimensionSize.mockResolvedValue(512); + mocks.search.getDimensionSize.mockResolvedValue(512); await sut.onConfigUpdate({ newConfig: { @@ -152,18 +139,18 @@ describe(SmartInfoService.name, () => { } as SystemConfig, }); - expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); - expect(searchMock.setDimensionSize).not.toHaveBeenCalled(); - expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); - expect(jobMock.getQueueStatus).not.toHaveBeenCalled(); - expect(jobMock.pause).not.toHaveBeenCalled(); - expect(jobMock.waitForQueueCompletion).not.toHaveBeenCalled(); - expect(jobMock.resume).not.toHaveBeenCalled(); + expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); + expect(mocks.job.getQueueStatus).not.toHaveBeenCalled(); + expect(mocks.job.pause).not.toHaveBeenCalled(); + expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled(); + expect(mocks.job.resume).not.toHaveBeenCalled(); }); it('should update DB dimension size if model and DB have different values', async () => { - searchMock.getDimensionSize.mockResolvedValue(512); - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.search.getDimensionSize.mockResolvedValue(512); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.onConfigUpdate({ newConfig: { @@ -174,17 +161,17 @@ describe(SmartInfoService.name, () => { } as SystemConfig, }); - expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); - expect(searchMock.setDimensionSize).toHaveBeenCalledWith(768); - expect(jobMock.getQueueStatus).toHaveBeenCalledTimes(1); - expect(jobMock.pause).toHaveBeenCalledTimes(1); - expect(jobMock.waitForQueueCompletion).toHaveBeenCalledTimes(1); - expect(jobMock.resume).toHaveBeenCalledTimes(1); + expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(768); + expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); + expect(mocks.job.pause).toHaveBeenCalledTimes(1); + expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); + expect(mocks.job.resume).toHaveBeenCalledTimes(1); }); it('should clear embeddings if old and new models are different', async () => { - searchMock.getDimensionSize.mockResolvedValue(512); - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.search.getDimensionSize.mockResolvedValue(512); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.onConfigUpdate({ newConfig: { @@ -195,18 +182,18 @@ describe(SmartInfoService.name, () => { } as SystemConfig, }); - expect(searchMock.deleteAllSearchEmbeddings).toHaveBeenCalled(); - expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); - expect(searchMock.setDimensionSize).not.toHaveBeenCalled(); - expect(jobMock.getQueueStatus).toHaveBeenCalledTimes(1); - expect(jobMock.pause).toHaveBeenCalledTimes(1); - expect(jobMock.waitForQueueCompletion).toHaveBeenCalledTimes(1); - expect(jobMock.resume).toHaveBeenCalledTimes(1); + expect(mocks.search.deleteAllSearchEmbeddings).toHaveBeenCalled(); + expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); + expect(mocks.job.pause).toHaveBeenCalledTimes(1); + expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); + expect(mocks.job.resume).toHaveBeenCalledTimes(1); }); it('should skip pausing and resuming queue if already paused', async () => { - searchMock.getDimensionSize.mockResolvedValue(512); - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); + mocks.search.getDimensionSize.mockResolvedValue(512); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); await sut.onConfigUpdate({ newConfig: { @@ -217,115 +204,119 @@ describe(SmartInfoService.name, () => { } as SystemConfig, }); - expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); - expect(searchMock.setDimensionSize).not.toHaveBeenCalled(); - expect(jobMock.getQueueStatus).toHaveBeenCalledTimes(1); - expect(jobMock.pause).not.toHaveBeenCalled(); - expect(jobMock.waitForQueueCompletion).toHaveBeenCalledTimes(1); - expect(jobMock.resume).not.toHaveBeenCalled(); + expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); + expect(mocks.job.pause).not.toHaveBeenCalled(); + expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); + expect(mocks.job.resume).not.toHaveBeenCalled(); }); }); describe('handleQueueEncodeClip', () => { it('should do nothing if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await sut.handleQueueEncodeClip({}); - expect(assetMock.getAll).not.toHaveBeenCalled(); - expect(assetMock.getWithout).not.toHaveBeenCalled(); + expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.asset.getWithout).not.toHaveBeenCalled(); }); it('should queue the assets without clip embeddings', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); await sut.handleQueueEncodeClip({ force: false }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }]); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.SMART_SEARCH); - expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }, + ]); + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.SMART_SEARCH); + expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); }); it('should queue all the assets', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); await sut.handleQueueEncodeClip({ force: true }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }]); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(searchMock.deleteAllSearchEmbeddings).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }, + ]); + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.search.deleteAllSearchEmbeddings).toHaveBeenCalled(); }); }); describe('handleEncodeClip', () => { it('should do nothing if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); expect(await sut.handleEncodeClip({ id: '123' })).toEqual(JobStatus.SKIPPED); - expect(assetMock.getByIds).not.toHaveBeenCalled(); - expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); + expect(mocks.asset.getByIds).not.toHaveBeenCalled(); + expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); }); it('should skip assets without a resize path', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); + mocks.asset.getByIds.mockResolvedValue([assetStub.noResizePath]); expect(await sut.handleEncodeClip({ id: assetStub.noResizePath.id })).toEqual(JobStatus.FAILED); - expect(searchMock.upsert).not.toHaveBeenCalled(); - expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); + expect(mocks.search.upsert).not.toHaveBeenCalled(); + expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); }); it('should save the returned objects', async () => { - machineLearningMock.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]'); + mocks.machineLearning.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]'); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); - expect(machineLearningMock.encodeImage).toHaveBeenCalledWith( + expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith( ['http://immich-machine-learning:3003'], '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ modelName: 'ViT-B-32__openai' }), ); - expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]'); + expect(mocks.search.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]'); }); it('should skip invisible assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); expect(await sut.handleEncodeClip({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); - expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); - expect(searchMock.upsert).not.toHaveBeenCalled(); + expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); + expect(mocks.search.upsert).not.toHaveBeenCalled(); }); it('should fail if asset could not be found', async () => { - assetMock.getByIds.mockResolvedValue([]); + mocks.asset.getByIds.mockResolvedValue([]); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.FAILED); - expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); - expect(searchMock.upsert).not.toHaveBeenCalled(); + expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); + expect(mocks.search.upsert).not.toHaveBeenCalled(); }); it('should wait for database', async () => { - machineLearningMock.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]'); - databaseMock.isBusy.mockReturnValue(true); + mocks.machineLearning.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]'); + mocks.database.isBusy.mockReturnValue(true); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); - expect(databaseMock.wait).toHaveBeenCalledWith(512); - expect(machineLearningMock.encodeImage).toHaveBeenCalledWith( + expect(mocks.database.wait).toHaveBeenCalledWith(512); + expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith( ['http://immich-machine-learning:3003'], '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ modelName: 'ViT-B-32__openai' }), ); - expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]'); + expect(mocks.search.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]'); }); }); diff --git a/server/src/services/stack.service.spec.ts b/server/src/services/stack.service.spec.ts index f37e2c4af4..5fbc0e185d 100644 --- a/server/src/services/stack.service.spec.ts +++ b/server/src/services/stack.service.spec.ts @@ -1,22 +1,15 @@ import { BadRequestException } from '@nestjs/common'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IStackRepository } from 'src/interfaces/stack.interface'; import { StackService } from 'src/services/stack.service'; import { assetStub, stackStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(StackService.name, () => { let sut: StackService; - - let accessMock: IAccessRepositoryMock; - let eventMock: Mocked; - let stackMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, eventMock, stackMock } = newTestService(StackService)); + ({ sut, mocks } = newTestService(StackService)); }); it('should be defined', () => { @@ -25,10 +18,10 @@ describe(StackService.name, () => { describe('search', () => { it('should search stacks', async () => { - stackMock.search.mockResolvedValue([stackStub('stack-id', [assetStub.image])]); + mocks.stack.search.mockResolvedValue([stackStub('stack-id', [assetStub.image])]); await sut.search(authStub.admin, { primaryAssetId: assetStub.image.id }); - expect(stackMock.search).toHaveBeenCalledWith({ + expect(mocks.stack.search).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id, primaryAssetId: assetStub.image.id, }); @@ -41,13 +34,13 @@ describe(StackService.name, () => { sut.create(authStub.admin, { assetIds: [assetStub.image.id, assetStub.image1.id] }), ).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalled(); - expect(stackMock.create).not.toHaveBeenCalled(); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalled(); + expect(mocks.stack.create).not.toHaveBeenCalled(); }); it('should create a stack', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id, assetStub.image1.id])); - stackMock.create.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id, assetStub.image1.id])); + mocks.stack.create.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); await expect( sut.create(authStub.admin, { assetIds: [assetStub.image.id, assetStub.image1.id] }), ).resolves.toEqual({ @@ -59,11 +52,11 @@ describe(StackService.name, () => { ], }); - expect(eventMock.emit).toHaveBeenCalledWith('stack.create', { + expect(mocks.event.emit).toHaveBeenCalledWith('stack.create', { stackId: 'stack-id', userId: authStub.admin.user.id, }); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalled(); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalled(); }); }); @@ -71,22 +64,22 @@ describe(StackService.name, () => { it('should require stack.read permissions', async () => { await expect(sut.get(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.stack.checkOwnerAccess).toHaveBeenCalled(); - expect(stackMock.getById).not.toHaveBeenCalled(); + expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled(); + expect(mocks.stack.getById).not.toHaveBeenCalled(); }); it('should fail if stack could not be found', async () => { - accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); await expect(sut.get(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(Error); - expect(accessMock.stack.checkOwnerAccess).toHaveBeenCalled(); - expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); + expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled(); + expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); }); it('should get stack', async () => { - accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - stackMock.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); await expect(sut.get(authStub.admin, 'stack-id')).resolves.toEqual({ id: 'stack-id', @@ -96,8 +89,8 @@ describe(StackService.name, () => { expect.objectContaining({ id: assetStub.image1.id }), ], }); - expect(accessMock.stack.checkOwnerAccess).toHaveBeenCalled(); - expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); + expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled(); + expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); }); }); @@ -105,47 +98,47 @@ describe(StackService.name, () => { it('should require stack.update permissions', async () => { await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(BadRequestException); - expect(stackMock.getById).not.toHaveBeenCalled(); - expect(stackMock.update).not.toHaveBeenCalled(); - expect(eventMock.emit).not.toHaveBeenCalled(); + expect(mocks.stack.getById).not.toHaveBeenCalled(); + expect(mocks.stack.update).not.toHaveBeenCalled(); + expect(mocks.event.emit).not.toHaveBeenCalled(); }); it('should fail if stack could not be found', async () => { - accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(Error); - expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); - expect(stackMock.update).not.toHaveBeenCalled(); - expect(eventMock.emit).not.toHaveBeenCalled(); + expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); + expect(mocks.stack.update).not.toHaveBeenCalled(); + expect(mocks.event.emit).not.toHaveBeenCalled(); }); it('should fail if the provided primary asset id is not in the stack', async () => { - accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - stackMock.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); await expect(sut.update(authStub.admin, 'stack-id', { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); - expect(stackMock.update).not.toHaveBeenCalled(); - expect(eventMock.emit).not.toHaveBeenCalled(); + expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); + expect(mocks.stack.update).not.toHaveBeenCalled(); + expect(mocks.event.emit).not.toHaveBeenCalled(); }); it('should update stack', async () => { - accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - stackMock.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); - stackMock.update.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + mocks.stack.update.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); await sut.update(authStub.admin, 'stack-id', { primaryAssetId: assetStub.image1.id }); - expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); - expect(stackMock.update).toHaveBeenCalledWith('stack-id', { + expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); + expect(mocks.stack.update).toHaveBeenCalledWith('stack-id', { id: 'stack-id', primaryAssetId: assetStub.image1.id, }); - expect(eventMock.emit).toHaveBeenCalledWith('stack.update', { + expect(mocks.event.emit).toHaveBeenCalledWith('stack.update', { stackId: 'stack-id', userId: authStub.admin.user.id, }); @@ -156,17 +149,17 @@ describe(StackService.name, () => { it('should require stack.delete permissions', async () => { await expect(sut.delete(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(BadRequestException); - expect(stackMock.delete).not.toHaveBeenCalled(); - expect(eventMock.emit).not.toHaveBeenCalled(); + expect(mocks.stack.delete).not.toHaveBeenCalled(); + expect(mocks.event.emit).not.toHaveBeenCalled(); }); it('should delete stack', async () => { - accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); await sut.delete(authStub.admin, 'stack-id'); - expect(stackMock.delete).toHaveBeenCalledWith('stack-id'); - expect(eventMock.emit).toHaveBeenCalledWith('stack.delete', { + expect(mocks.stack.delete).toHaveBeenCalledWith('stack-id'); + expect(mocks.event.emit).toHaveBeenCalledWith('stack.delete', { stackId: 'stack-id', userId: authStub.admin.user.id, }); @@ -177,17 +170,17 @@ describe(StackService.name, () => { it('should require stack.delete permissions', async () => { await expect(sut.deleteAll(authStub.admin, { ids: ['stack-id'] })).rejects.toBeInstanceOf(BadRequestException); - expect(stackMock.deleteAll).not.toHaveBeenCalled(); - expect(eventMock.emit).not.toHaveBeenCalled(); + expect(mocks.stack.deleteAll).not.toHaveBeenCalled(); + expect(mocks.event.emit).not.toHaveBeenCalled(); }); it('should delete all stacks', async () => { - accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); await sut.deleteAll(authStub.admin, { ids: ['stack-id'] }); - expect(stackMock.deleteAll).toHaveBeenCalledWith(['stack-id']); - expect(eventMock.emit).toHaveBeenCalledWith('stacks.delete', { + expect(mocks.stack.deleteAll).toHaveBeenCalledWith(['stack-id']); + expect(mocks.event.emit).toHaveBeenCalledWith('stacks.delete', { stackIds: ['stack-id'], userId: authStub.admin.user.id, }); diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 467456d5aa..cf272c7e5f 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -1,42 +1,26 @@ import { Stats } from 'node:fs'; -import { SystemConfig, defaults } from 'src/config'; +import { defaults, SystemConfig } from 'src/config'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { JobStatus } from 'src/interfaces/job.interface'; -import { IMoveRepository } from 'src/interfaces/move.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; import { StorageTemplateService } from 'src/services/storage-template.service'; -import { ISystemMetadataRepository } from 'src/types'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(StorageTemplateService.name, () => { let sut: StorageTemplateService; - - let albumMock: Mocked; - let assetMock: Mocked; - let cryptoMock: Mocked; - let moveMock: Mocked; - let storageMock: Mocked; - let systemMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; it('should work', () => { expect(sut).toBeDefined(); }); beforeEach(() => { - ({ sut, albumMock, assetMock, cryptoMock, moveMock, storageMock, systemMock, userMock } = - newTestService(StorageTemplateService)); + ({ sut, mocks } = newTestService(StorageTemplateService)); - systemMock.get.mockResolvedValue({ storageTemplate: { enabled: true } }); + mocks.systemMetadata.get.mockResolvedValue({ storageTemplate: { enabled: true } }); sut.onConfigInit({ newConfig: defaults }); }); @@ -107,31 +91,31 @@ describe(StorageTemplateService.name, () => { describe('handleMigrationSingle', () => { it('should skip when storage template is disabled', async () => { - systemMock.get.mockResolvedValue({ storageTemplate: { enabled: false } }); + mocks.systemMetadata.get.mockResolvedValue({ storageTemplate: { enabled: false } }); await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.getByIds).not.toHaveBeenCalled(); - expect(storageMock.checkFileExists).not.toHaveBeenCalled(); - expect(storageMock.rename).not.toHaveBeenCalled(); - expect(storageMock.copyFile).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(moveMock.create).not.toHaveBeenCalled(); - expect(moveMock.update).not.toHaveBeenCalled(); - expect(storageMock.stat).not.toHaveBeenCalled(); + expect(mocks.asset.getByIds).not.toHaveBeenCalled(); + expect(mocks.storage.checkFileExists).not.toHaveBeenCalled(); + expect(mocks.storage.rename).not.toHaveBeenCalled(); + expect(mocks.storage.copyFile).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.move.create).not.toHaveBeenCalled(); + expect(mocks.move.update).not.toHaveBeenCalled(); + expect(mocks.storage.stat).not.toHaveBeenCalled(); }); it('should migrate single moving picture', async () => { - userMock.get.mockResolvedValue(userStub.user1); + mocks.user.get.mockResolvedValue(userStub.user1); const newMotionPicturePath = `upload/library/${userStub.user1.id}/2022/2022-06-19/${assetStub.livePhotoStillAsset.id}.mp4`; const newStillPicturePath = `upload/library/${userStub.user1.id}/2022/2022-06-19/${assetStub.livePhotoStillAsset.id}.jpeg`; - assetMock.getByIds.mockImplementation((ids) => { + mocks.asset.getByIds.mockImplementation((ids) => { const assets = [assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]; return Promise.resolve( ids.map((id) => assets.find((asset) => asset.id === id)).filter((asset) => !!asset), ) as Promise; }); - moveMock.create.mockResolvedValueOnce({ + mocks.move.create.mockResolvedValueOnce({ id: '123', entityId: assetStub.livePhotoStillAsset.id, pathType: AssetPathType.ORIGINAL, @@ -139,7 +123,7 @@ describe(StorageTemplateService.name, () => { newPath: newStillPicturePath, }); - moveMock.create.mockResolvedValueOnce({ + mocks.move.create.mockResolvedValueOnce({ id: '124', entityId: assetStub.livePhotoMotionAsset.id, pathType: AssetPathType.ORIGINAL, @@ -151,14 +135,14 @@ describe(StorageTemplateService.name, () => { JobStatus.SUCCESS, ); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true }); - expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true }); + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true }); + expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, originalPath: newStillPicturePath, }); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, originalPath: newMotionPicturePath, }); @@ -173,13 +157,13 @@ describe(StorageTemplateService.name, () => { sut.onConfigInit({ newConfig: config }); - userMock.get.mockResolvedValue(user); - assetMock.getByIds.mockResolvedValueOnce([asset]); - albumMock.getByAssetId.mockResolvedValueOnce([album]); + mocks.user.get.mockResolvedValue(user); + mocks.asset.getByIds.mockResolvedValueOnce([asset]); + mocks.album.getByAssetId.mockResolvedValueOnce([album]); expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS); - expect(moveMock.create).toHaveBeenCalledWith({ + expect(mocks.move.create).toHaveBeenCalledWith({ entityId: asset.id, newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${album.albumName}/${asset.originalFileName}`, oldPath: asset.originalPath, @@ -194,13 +178,13 @@ describe(StorageTemplateService.name, () => { config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}'; sut.onConfigInit({ newConfig: config }); - userMock.get.mockResolvedValue(user); - assetMock.getByIds.mockResolvedValueOnce([asset]); + mocks.user.get.mockResolvedValue(user); + mocks.asset.getByIds.mockResolvedValueOnce([asset]); expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS); const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0'); - expect(moveMock.create).toHaveBeenCalledWith({ + expect(mocks.move.create).toHaveBeenCalledWith({ entityId: asset.id, newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/other/${month}/${asset.originalFileName}`, oldPath: asset.originalPath, @@ -209,20 +193,22 @@ describe(StorageTemplateService.name, () => { }); it('should migrate previously failed move from original path when it still exists', async () => { - userMock.get.mockResolvedValue(userStub.user1); + mocks.user.get.mockResolvedValue(userStub.user1); const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`; - storageMock.checkFileExists.mockImplementation((path) => Promise.resolve(path === assetStub.image.originalPath)); - moveMock.getByEntity.mockResolvedValue({ + mocks.storage.checkFileExists.mockImplementation((path) => + Promise.resolve(path === assetStub.image.originalPath), + ); + mocks.move.getByEntity.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, oldPath: assetStub.image.originalPath, newPath: previousFailedNewPath, }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - moveMock.update.mockResolvedValue({ + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.move.update.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, @@ -232,37 +218,37 @@ describe(StorageTemplateService.name, () => { await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); - expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3); - expect(storageMock.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); - expect(moveMock.update).toHaveBeenCalledWith('123', { + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); + expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(3); + expect(mocks.storage.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); + expect(mocks.move.update).toHaveBeenCalledWith('123', { id: '123', oldPath: assetStub.image.originalPath, newPath, }); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, originalPath: newPath, }); }); it('should migrate previously failed move from previous new path when old path no longer exists, should validate file size still matches before moving', async () => { - userMock.get.mockResolvedValue(userStub.user1); + mocks.user.get.mockResolvedValue(userStub.user1); const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`; - storageMock.checkFileExists.mockImplementation((path) => Promise.resolve(path === previousFailedNewPath)); - storageMock.stat.mockResolvedValue({ size: 5000 } as Stats); - cryptoMock.hashFile.mockResolvedValue(assetStub.image.checksum); - moveMock.getByEntity.mockResolvedValue({ + mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(path === previousFailedNewPath)); + mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats); + mocks.crypto.hashFile.mockResolvedValue(assetStub.image.checksum); + mocks.move.getByEntity.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, oldPath: assetStub.image.originalPath, newPath: previousFailedNewPath, }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - moveMock.update.mockResolvedValue({ + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.move.update.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, @@ -272,31 +258,31 @@ describe(StorageTemplateService.name, () => { await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); - expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3); - expect(storageMock.stat).toHaveBeenCalledWith(previousFailedNewPath); - expect(storageMock.rename).toHaveBeenCalledWith(previousFailedNewPath, newPath); - expect(storageMock.copyFile).not.toHaveBeenCalled(); - expect(moveMock.update).toHaveBeenCalledWith('123', { + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); + expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(3); + expect(mocks.storage.stat).toHaveBeenCalledWith(previousFailedNewPath); + expect(mocks.storage.rename).toHaveBeenCalledWith(previousFailedNewPath, newPath); + expect(mocks.storage.copyFile).not.toHaveBeenCalled(); + expect(mocks.move.update).toHaveBeenCalledWith('123', { id: '123', oldPath: previousFailedNewPath, newPath, }); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, originalPath: newPath, }); }); it('should fail move if copying and hash of asset and the new file do not match', async () => { - userMock.get.mockResolvedValue(userStub.user1); + mocks.user.get.mockResolvedValue(userStub.user1); const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`; - storageMock.rename.mockRejectedValue({ code: 'EXDEV' }); - storageMock.stat.mockResolvedValue({ size: 5000 } as Stats); - cryptoMock.hashFile.mockResolvedValue(Buffer.from('different-hash', 'utf8')); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - moveMock.create.mockResolvedValue({ + mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); + mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats); + mocks.crypto.hashFile.mockResolvedValue(Buffer.from('different-hash', 'utf8')); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.move.create.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, @@ -306,20 +292,20 @@ describe(StorageTemplateService.name, () => { await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); - expect(storageMock.checkFileExists).toHaveBeenCalledTimes(1); - expect(storageMock.stat).toHaveBeenCalledWith(newPath); - expect(moveMock.create).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); + expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(1); + expect(mocks.storage.stat).toHaveBeenCalledWith(newPath); + expect(mocks.move.create).toHaveBeenCalledWith({ entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, oldPath: assetStub.image.originalPath, newPath, }); - expect(storageMock.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); - expect(storageMock.copyFile).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); - expect(storageMock.unlink).toHaveBeenCalledWith(newPath); - expect(storageMock.unlink).toHaveBeenCalledTimes(1); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.storage.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); + expect(mocks.storage.copyFile).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); + expect(mocks.storage.unlink).toHaveBeenCalledWith(newPath); + expect(mocks.storage.unlink).toHaveBeenCalledTimes(1); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it.each` @@ -329,22 +315,22 @@ describe(StorageTemplateService.name, () => { `( 'should fail to migrate previously failed move from previous new path when old path no longer exists if $reason validation fails', async ({ failedPathChecksum, failedPathSize }) => { - userMock.get.mockResolvedValue(userStub.user1); + mocks.user.get.mockResolvedValue(userStub.user1); const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`; - storageMock.checkFileExists.mockImplementation((path) => Promise.resolve(previousFailedNewPath === path)); - storageMock.stat.mockResolvedValue({ size: failedPathSize } as Stats); - cryptoMock.hashFile.mockResolvedValue(failedPathChecksum); - moveMock.getByEntity.mockResolvedValue({ + mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(previousFailedNewPath === path)); + mocks.storage.stat.mockResolvedValue({ size: failedPathSize } as Stats); + mocks.crypto.hashFile.mockResolvedValue(failedPathChecksum); + mocks.move.getByEntity.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, oldPath: assetStub.image.originalPath, newPath: previousFailedNewPath, }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - moveMock.update.mockResolvedValue({ + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.move.update.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, @@ -354,37 +340,37 @@ describe(StorageTemplateService.name, () => { await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); - expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3); - expect(storageMock.stat).toHaveBeenCalledWith(previousFailedNewPath); - expect(storageMock.rename).not.toHaveBeenCalled(); - expect(storageMock.copyFile).not.toHaveBeenCalled(); - expect(moveMock.update).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); + expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(3); + expect(mocks.storage.stat).toHaveBeenCalledWith(previousFailedNewPath); + expect(mocks.storage.rename).not.toHaveBeenCalled(); + expect(mocks.storage.copyFile).not.toHaveBeenCalled(); + expect(mocks.move.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }, ); }); describe('handle template migration', () => { it('should handle no assets', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [], hasNextPage: false, }); - userMock.getList.mockResolvedValue([]); + mocks.user.getList.mockResolvedValue([]); await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); + expect(mocks.asset.getAll).toHaveBeenCalled(); }); it('should handle an asset with a duplicate destination', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - userMock.getList.mockResolvedValue([userStub.user1]); - moveMock.create.mockResolvedValue({ + mocks.user.getList.mockResolvedValue([userStub.user1]); + mocks.move.create.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, @@ -392,22 +378,22 @@ describe(StorageTemplateService.name, () => { newPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg', }); - storageMock.checkFileExists.mockResolvedValueOnce(true); - storageMock.checkFileExists.mockResolvedValueOnce(false); + mocks.storage.checkFileExists.mockResolvedValueOnce(true); + mocks.storage.checkFileExists.mockResolvedValueOnce(false); await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg', }); - expect(userMock.getList).toHaveBeenCalled(); + expect(mocks.user.getList).toHaveBeenCalled(); }); it('should skip when an asset already matches the template', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [ { ...assetStub.image, @@ -416,19 +402,19 @@ describe(StorageTemplateService.name, () => { ], hasNextPage: false, }); - userMock.getList.mockResolvedValue([userStub.user1]); + mocks.user.getList.mockResolvedValue([userStub.user1]); await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.rename).not.toHaveBeenCalled(); - expect(storageMock.copyFile).not.toHaveBeenCalled(); - expect(storageMock.checkFileExists).not.toHaveBeenCalledTimes(2); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.storage.rename).not.toHaveBeenCalled(); + expect(mocks.storage.copyFile).not.toHaveBeenCalled(); + expect(mocks.storage.checkFileExists).not.toHaveBeenCalledTimes(2); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should skip when an asset is probably a duplicate', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [ { ...assetStub.image, @@ -437,24 +423,24 @@ describe(StorageTemplateService.name, () => { ], hasNextPage: false, }); - userMock.getList.mockResolvedValue([userStub.user1]); + mocks.user.getList.mockResolvedValue([userStub.user1]); await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.rename).not.toHaveBeenCalled(); - expect(storageMock.copyFile).not.toHaveBeenCalled(); - expect(storageMock.checkFileExists).not.toHaveBeenCalledTimes(2); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.storage.rename).not.toHaveBeenCalled(); + expect(mocks.storage.copyFile).not.toHaveBeenCalled(); + expect(mocks.storage.checkFileExists).not.toHaveBeenCalledTimes(2); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should move an asset', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - userMock.getList.mockResolvedValue([userStub.user1]); - moveMock.create.mockResolvedValue({ + mocks.user.getList.mockResolvedValue([userStub.user1]); + mocks.move.create.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, @@ -464,24 +450,24 @@ describe(StorageTemplateService.name, () => { await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.rename).toHaveBeenCalledWith( + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.storage.rename).toHaveBeenCalledWith( '/original/path.jpg', 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', ); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', }); }); it('should use the user storage label', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - userMock.getList.mockResolvedValue([userStub.storageLabel]); - moveMock.create.mockResolvedValue({ + mocks.user.getList.mockResolvedValue([userStub.storageLabel]); + mocks.move.create.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, @@ -491,12 +477,12 @@ describe(StorageTemplateService.name, () => { await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.rename).toHaveBeenCalledWith( + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.storage.rename).toHaveBeenCalledWith( '/original/path.jpg', 'upload/library/label-1/2023/2023-02-23/asset-id.jpg', ); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, originalPath: 'upload/library/label-1/2023/2023-02-23/asset-id.jpg', }); @@ -504,105 +490,105 @@ describe(StorageTemplateService.name, () => { it('should copy the file if rename fails due to EXDEV (rename across filesystems)', async () => { const newPath = 'upload/library/user-id/2023/2023-02-23/asset-id.jpg'; - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - storageMock.rename.mockRejectedValue({ code: 'EXDEV' }); - userMock.getList.mockResolvedValue([userStub.user1]); - moveMock.create.mockResolvedValue({ + mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); + mocks.user.getList.mockResolvedValue([userStub.user1]); + mocks.move.create.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, oldPath: assetStub.image.originalPath, newPath, }); - storageMock.stat.mockResolvedValueOnce({ + mocks.storage.stat.mockResolvedValueOnce({ atime: new Date(), mtime: new Date(), } as Stats); - storageMock.stat.mockResolvedValueOnce({ + mocks.storage.stat.mockResolvedValueOnce({ size: 5000, } as Stats); - storageMock.stat.mockResolvedValueOnce({ + mocks.storage.stat.mockResolvedValueOnce({ atime: new Date(), mtime: new Date(), } as Stats); - cryptoMock.hashFile.mockResolvedValue(assetStub.image.checksum); + mocks.crypto.hashFile.mockResolvedValue(assetStub.image.checksum); await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.rename).toHaveBeenCalledWith('/original/path.jpg', newPath); - expect(storageMock.copyFile).toHaveBeenCalledWith('/original/path.jpg', newPath); - expect(storageMock.stat).toHaveBeenCalledWith(newPath); - expect(storageMock.stat).toHaveBeenCalledWith(assetStub.image.originalPath); - expect(storageMock.utimes).toHaveBeenCalledWith(newPath, expect.any(Date), expect.any(Date)); - expect(storageMock.unlink).toHaveBeenCalledWith(assetStub.image.originalPath); - expect(storageMock.unlink).toHaveBeenCalledTimes(1); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.storage.rename).toHaveBeenCalledWith('/original/path.jpg', newPath); + expect(mocks.storage.copyFile).toHaveBeenCalledWith('/original/path.jpg', newPath); + expect(mocks.storage.stat).toHaveBeenCalledWith(newPath); + expect(mocks.storage.stat).toHaveBeenCalledWith(assetStub.image.originalPath); + expect(mocks.storage.utimes).toHaveBeenCalledWith(newPath, expect.any(Date), expect.any(Date)); + expect(mocks.storage.unlink).toHaveBeenCalledWith(assetStub.image.originalPath); + expect(mocks.storage.unlink).toHaveBeenCalledTimes(1); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, originalPath: newPath, }); }); it('should not update the database if the move fails due to incorrect newPath filesize', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - storageMock.rename.mockRejectedValue({ code: 'EXDEV' }); - userMock.getList.mockResolvedValue([userStub.user1]); - moveMock.create.mockResolvedValue({ + mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); + mocks.user.getList.mockResolvedValue([userStub.user1]); + mocks.move.create.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, oldPath: assetStub.image.originalPath, newPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', }); - storageMock.stat.mockResolvedValue({ + mocks.storage.stat.mockResolvedValue({ size: 100, } as Stats); await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.rename).toHaveBeenCalledWith( + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.storage.rename).toHaveBeenCalledWith( '/original/path.jpg', 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', ); - expect(storageMock.copyFile).toHaveBeenCalledWith( + expect(mocks.storage.copyFile).toHaveBeenCalledWith( '/original/path.jpg', 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', ); - expect(storageMock.stat).toHaveBeenCalledWith('upload/library/user-id/2023/2023-02-23/asset-id.jpg'); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.storage.stat).toHaveBeenCalledWith('upload/library/user-id/2023/2023-02-23/asset-id.jpg'); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should not update the database if the move fails', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - storageMock.rename.mockRejectedValue(new Error('Read only system')); - storageMock.copyFile.mockRejectedValue(new Error('Read only system')); - moveMock.create.mockResolvedValue({ + mocks.storage.rename.mockRejectedValue(new Error('Read only system')); + mocks.storage.copyFile.mockRejectedValue(new Error('Read only system')); + mocks.move.create.mockResolvedValue({ id: 'move-123', entityId: '123', pathType: AssetPathType.ORIGINAL, oldPath: assetStub.image.originalPath, newPath: '', }); - userMock.getList.mockResolvedValue([userStub.user1]); + mocks.user.getList.mockResolvedValue([userStub.user1]); await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.rename).toHaveBeenCalledWith( + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.storage.rename).toHaveBeenCalledWith( '/original/path.jpg', 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', ); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); }); }); diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts index d92fd09e53..2d28489fae 100644 --- a/server/src/services/storage.service.spec.ts +++ b/server/src/services/storage.service.spec.ts @@ -1,22 +1,15 @@ import { SystemMetadataKey } from 'src/enum'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; import { StorageService } from 'src/services/storage.service'; -import { IConfigRepository, ILoggingRepository, ISystemMetadataRepository } from 'src/types'; import { ImmichStartupError } from 'src/utils/misc'; import { mockEnvData } from 'test/repositories/config.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(StorageService.name, () => { let sut: StorageService; - - let configMock: Mocked; - let loggerMock: Mocked; - let storageMock: Mocked; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, configMock, loggerMock, storageMock, systemMock } = newTestService(StorageService)); + ({ sut, mocks } = newTestService(StorageService)); }); it('should work', () => { @@ -25,11 +18,11 @@ describe(StorageService.name, () => { describe('onBootstrap', () => { it('should enable mount folder checking', async () => { - systemMock.get.mockResolvedValue(null); + mocks.systemMetadata.get.mockResolvedValue(null); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { mountChecks: { backups: true, 'encoded-video': true, @@ -39,22 +32,22 @@ describe(StorageService.name, () => { upload: true, }, }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/encoded-video'); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library'); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile'); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs'); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload'); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/backups'); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/encoded-video/.immich', expect.any(Buffer)); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/profile/.immich', expect.any(Buffer)); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/thumbs/.immich', expect.any(Buffer)); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/upload/.immich', expect.any(Buffer)); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer)); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/encoded-video'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/profile'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/upload'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/backups'); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/encoded-video/.immich', expect.any(Buffer)); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/profile/.immich', expect.any(Buffer)); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/thumbs/.immich', expect.any(Buffer)); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/upload/.immich', expect.any(Buffer)); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer)); }); it('should enable mount folder checking for a new folder type', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ mountChecks: { backups: false, 'encoded-video': true, @@ -67,7 +60,7 @@ describe(StorageService.name, () => { await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { mountChecks: { backups: true, 'encoded-video': true, @@ -77,64 +70,68 @@ describe(StorageService.name, () => { upload: true, }, }); - expect(storageMock.mkdirSync).toHaveBeenCalledTimes(2); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library'); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/backups'); - expect(storageMock.createFile).toHaveBeenCalledTimes(2); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer)); + expect(mocks.storage.mkdirSync).toHaveBeenCalledTimes(2); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/backups'); + expect(mocks.storage.createFile).toHaveBeenCalledTimes(2); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer)); }); it('should throw an error if .immich is missing', async () => { - systemMock.get.mockResolvedValue({ mountChecks: { upload: true } }); - storageMock.readFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); + mocks.systemMetadata.get.mockResolvedValue({ mountChecks: { upload: true } }); + mocks.storage.readFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); await expect(sut.onBootstrap()).rejects.toThrow('Failed to read'); - expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled(); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.storage.createOrOverwriteFile).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); it('should throw an error if .immich is present but read-only', async () => { - systemMock.get.mockResolvedValue({ mountChecks: { upload: true } }); - storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); + mocks.systemMetadata.get.mockResolvedValue({ mountChecks: { upload: true } }); + mocks.storage.overwriteFile.mockRejectedValue( + new Error("ENOENT: no such file or directory, open '/app/.immich'"), + ); await expect(sut.onBootstrap()).rejects.toThrow('Failed to write'); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); it('should skip mount file creation if file already exists', async () => { const error = new Error('Error creating file') as any; error.code = 'EEXIST'; - systemMock.get.mockResolvedValue({ mountChecks: {} }); - storageMock.createFile.mockRejectedValue(error); + mocks.systemMetadata.get.mockResolvedValue({ mountChecks: {} }); + mocks.storage.createFile.mockRejectedValue(error); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(loggerMock.warn).toHaveBeenCalledWith('Found existing mount file, skipping creation'); + expect(mocks.logger.warn).toHaveBeenCalledWith('Found existing mount file, skipping creation'); }); it('should throw an error if mount file could not be created', async () => { - systemMock.get.mockResolvedValue({ mountChecks: {} }); - storageMock.createFile.mockRejectedValue(new Error('Error creating file')); + mocks.systemMetadata.get.mockResolvedValue({ mountChecks: {} }); + mocks.storage.createFile.mockRejectedValue(new Error('Error creating file')); await expect(sut.onBootstrap()).rejects.toBeInstanceOf(ImmichStartupError); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); it('should startup if checks are disabled', async () => { - systemMock.get.mockResolvedValue({ mountChecks: { upload: true } }); - configMock.getEnv.mockReturnValue( + mocks.systemMetadata.get.mockResolvedValue({ mountChecks: { upload: true } }); + mocks.config.getEnv.mockReturnValue( mockEnvData({ storage: { ignoreMountCheckErrors: true }, }), ); - storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); + mocks.storage.overwriteFile.mockRejectedValue( + new Error("ENOENT: no such file or directory, open '/app/.immich'"), + ); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); }); @@ -142,21 +139,21 @@ describe(StorageService.name, () => { it('should handle null values', async () => { await sut.handleDeleteFiles({ files: [undefined, null] }); - expect(storageMock.unlink).not.toHaveBeenCalled(); + expect(mocks.storage.unlink).not.toHaveBeenCalled(); }); it('should handle an error removing a file', async () => { - storageMock.unlink.mockRejectedValue(new Error('something-went-wrong')); + mocks.storage.unlink.mockRejectedValue(new Error('something-went-wrong')); await sut.handleDeleteFiles({ files: ['path/to/something'] }); - expect(storageMock.unlink).toHaveBeenCalledWith('path/to/something'); + expect(mocks.storage.unlink).toHaveBeenCalledWith('path/to/something'); }); it('should remove the file', async () => { await sut.handleDeleteFiles({ files: ['path/to/something'] }); - expect(storageMock.unlink).toHaveBeenCalledWith('path/to/something'); + expect(mocks.storage.unlink).toHaveBeenCalledWith('path/to/something'); }); }); }); diff --git a/server/src/services/sync.service.spec.ts b/server/src/services/sync.service.spec.ts index 3bedd13d8f..d5e53c83a2 100644 --- a/server/src/services/sync.service.spec.ts +++ b/server/src/services/sync.service.spec.ts @@ -1,27 +1,20 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { SyncService } from 'src/services/sync.service'; -import { IAuditRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; const untilDate = new Date(2024); const mapAssetOpts = { auth: authStub.user1, stripMetadata: false, withStack: true }; describe(SyncService.name, () => { let sut: SyncService; - - let assetMock: Mocked; - let auditMock: Mocked; - let partnerMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, auditMock, partnerMock } = newTestService(SyncService)); + ({ sut, mocks } = newTestService(SyncService)); }); it('should exist', () => { @@ -30,12 +23,12 @@ describe(SyncService.name, () => { describe('getAllAssetsForUserFullSync', () => { it('should return a list of all assets owned by the user', async () => { - assetMock.getAllForUserFullSync.mockResolvedValue([assetStub.external, assetStub.hasEncodedVideo]); + mocks.asset.getAllForUserFullSync.mockResolvedValue([assetStub.external, assetStub.hasEncodedVideo]); await expect(sut.getFullSync(authStub.user1, { limit: 2, updatedUntil: untilDate })).resolves.toEqual([ mapAsset(assetStub.external, mapAssetOpts), mapAsset(assetStub.hasEncodedVideo, mapAssetOpts), ]); - expect(assetMock.getAllForUserFullSync).toHaveBeenCalledWith({ + expect(mocks.asset.getAllForUserFullSync).toHaveBeenCalledWith({ ownerId: authStub.user1.user.id, updatedUntil: untilDate, limit: 2, @@ -45,39 +38,39 @@ describe(SyncService.name, () => { describe('getChangesForDeltaSync', () => { it('should return a response requiring a full sync when partners are out of sync', async () => { - partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1]); + mocks.partner.getAll.mockResolvedValue([partnerStub.adminToUser1]); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), ).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] }); - expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(0); - expect(auditMock.getAfter).toHaveBeenCalledTimes(0); + expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(0); + expect(mocks.audit.getAfter).toHaveBeenCalledTimes(0); }); it('should return a response requiring a full sync when last sync was too long ago', async () => { - partnerMock.getAll.mockResolvedValue([]); + mocks.partner.getAll.mockResolvedValue([]); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(2000), userIds: [authStub.user1.user.id] }), ).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] }); - expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(0); - expect(auditMock.getAfter).toHaveBeenCalledTimes(0); + expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(0); + expect(mocks.audit.getAfter).toHaveBeenCalledTimes(0); }); it('should return a response requiring a full sync when there are too many changes', async () => { - partnerMock.getAll.mockResolvedValue([]); - assetMock.getChangedDeltaSync.mockResolvedValue( + mocks.partner.getAll.mockResolvedValue([]); + mocks.asset.getChangedDeltaSync.mockResolvedValue( Array.from({ length: 10_000 }).fill(assetStub.image), ); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), ).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] }); - expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(1); - expect(auditMock.getAfter).toHaveBeenCalledTimes(0); + expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1); + expect(mocks.audit.getAfter).toHaveBeenCalledTimes(0); }); it('should return a response with changes and deletions', async () => { - partnerMock.getAll.mockResolvedValue([]); - assetMock.getChangedDeltaSync.mockResolvedValue([assetStub.image1]); - auditMock.getAfter.mockResolvedValue([assetStub.external.id]); + mocks.partner.getAll.mockResolvedValue([]); + mocks.asset.getChangedDeltaSync.mockResolvedValue([assetStub.image1]); + mocks.audit.getAfter.mockResolvedValue([assetStub.external.id]); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), ).resolves.toEqual({ @@ -85,8 +78,8 @@ describe(SyncService.name, () => { upserted: [mapAsset(assetStub.image1, mapAssetOpts)], deleted: [assetStub.external.id], }); - expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(1); - expect(auditMock.getAfter).toHaveBeenCalledTimes(1); + expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1); + expect(mocks.audit.getAfter).toHaveBeenCalledTimes(1); }); }); }); diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 537ef21056..027bcc1c15 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -12,13 +12,11 @@ import { VideoCodec, VideoContainer, } from 'src/enum'; -import { IEventRepository } from 'src/interfaces/event.interface'; import { QueueName } from 'src/interfaces/job.interface'; import { SystemConfigService } from 'src/services/system-config.service'; -import { DeepPartial, IConfigRepository, ILoggingRepository, ISystemMetadataRepository } from 'src/types'; +import { DeepPartial } from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; const partialConfig = { ffmpeg: { crf: 30 }, @@ -198,14 +196,10 @@ const updatedConfig = Object.freeze({ describe(SystemConfigService.name, () => { let sut: SystemConfigService; - - let configMock: Mocked; - let eventMock: Mocked; - let loggerMock: Mocked; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, configMock, eventMock, loggerMock, systemMock } = newTestService(SystemConfigService)); + ({ sut, mocks } = newTestService(SystemConfigService)); }); it('should work', () => { @@ -214,22 +208,22 @@ describe(SystemConfigService.name, () => { describe('getDefaults', () => { it('should return the default config', () => { - systemMock.get.mockResolvedValue(partialConfig); + mocks.systemMetadata.get.mockResolvedValue(partialConfig); expect(sut.getDefaults()).toEqual(defaults); - expect(systemMock.get).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).not.toHaveBeenCalled(); }); }); describe('getConfig', () => { it('should return the default config', async () => { - systemMock.get.mockResolvedValue({}); + mocks.systemMetadata.get.mockResolvedValue({}); await expect(sut.getSystemConfig()).resolves.toEqual(defaults); }); it('should merge the overrides', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { crf: 30 }, oauth: { autoLaunch: true }, trash: { days: 10 }, @@ -240,17 +234,17 @@ describe(SystemConfigService.name, () => { }); it('should load the config from a json file', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); - expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); + expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json'); }); it('should transform booleans', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { twoPass: 'false' } })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { twoPass: 'false' } })); await expect(sut.getSystemConfig()).resolves.toMatchObject({ ffmpeg: expect.objectContaining({ twoPass: false }), @@ -258,8 +252,8 @@ describe(SystemConfigService.name, () => { }); it('should transform numbers', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { threads: '42' } })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { threads: '42' } })); await expect(sut.getSystemConfig()).resolves.toMatchObject({ ffmpeg: expect.objectContaining({ threads: 42 }), @@ -267,8 +261,10 @@ describe(SystemConfigService.name, () => { }); it('should accept valid cron expressions', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify({ library: { scan: { cronExpression: '0 0 * * *' } } })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue( + JSON.stringify({ library: { scan: { cronExpression: '0 0 * * *' } } }), + ); await expect(sut.getSystemConfig()).resolves.toMatchObject({ library: { @@ -281,8 +277,8 @@ describe(SystemConfigService.name, () => { }); it('should reject invalid cron expressions', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify({ library: { scan: { cronExpression: 'foo' } } })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ library: { scan: { cronExpression: 'foo' } } })); await expect(sut.getSystemConfig()).rejects.toThrow( 'library.scan.cronExpression has failed the following constraints: cronValidator', @@ -290,22 +286,22 @@ describe(SystemConfigService.name, () => { }); it('should log errors with the config file', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(`{ "ffmpeg2": true, "ffmpeg2": true }`); + mocks.systemMetadata.readFile.mockResolvedValue(`{ "ffmpeg2": true, "ffmpeg2": true }`); await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error); - expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); - expect(loggerMock.error).toHaveBeenCalledTimes(2); - expect(loggerMock.error.mock.calls[0][0]).toEqual('Unable to load configuration file: immich-config.json'); - expect(loggerMock.error.mock.calls[1][0].toString()).toEqual( + expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json'); + expect(mocks.logger.error).toHaveBeenCalledTimes(2); + expect(mocks.logger.error.mock.calls[0][0]).toEqual('Unable to load configuration file: immich-config.json'); + expect(mocks.logger.error.mock.calls[1][0].toString()).toEqual( expect.stringContaining('YAMLException: duplicated mapping key (1:20)'), ); }); it('should load the config from a yaml file', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' })); const partialConfig = ` ffmpeg: crf: 30 @@ -316,26 +312,26 @@ describe(SystemConfigService.name, () => { user: deleteDelay: 15 `; - systemMock.readFile.mockResolvedValue(partialConfig); + mocks.systemMetadata.readFile.mockResolvedValue(partialConfig); await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); - expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.yaml'); + expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.yaml'); }); it('should accept an empty configuration file', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify({})); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({})); await expect(sut.getSystemConfig()).resolves.toEqual(defaults); - expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); + expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json'); }); it('should allow underscores in the machine learning url', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); const partialConfig = { machineLearning: { urls: ['immich_machine_learning'] } }; - systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); const config = await sut.getSystemConfig(); expect(config.machineLearning.urls).toEqual(['immich_machine_learning']); @@ -349,9 +345,9 @@ describe(SystemConfigService.name, () => { for (const { should, externalDomain, result } of externalDomainTests) { it(`should normalize an external domain ${should}`, async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); const partialConfig = { server: { externalDomain } }; - systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); const config = await sut.getSystemConfig(); expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app'); @@ -359,14 +355,14 @@ describe(SystemConfigService.name, () => { } it('should warn for unknown options in yaml', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' })); const partialConfig = ` unknownOption: true `; - systemMock.readFile.mockResolvedValue(partialConfig); + mocks.systemMetadata.readFile.mockResolvedValue(partialConfig); await sut.getSystemConfig(); - expect(loggerMock.warn).toHaveBeenCalled(); + expect(mocks.logger.warn).toHaveBeenCalled(); }); const tests = [ @@ -380,12 +376,12 @@ describe(SystemConfigService.name, () => { for (const test of tests) { it(`should ${test.should}`, async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify(test.config)); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(test.config)); if (test.warn) { await sut.getSystemConfig(); - expect(loggerMock.warn).toHaveBeenCalled(); + expect(mocks.logger.warn).toHaveBeenCalled(); } else { await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error); } @@ -395,19 +391,19 @@ describe(SystemConfigService.name, () => { describe('updateConfig', () => { it('should update the config and emit an event', async () => { - systemMock.get.mockResolvedValue(partialConfig); + mocks.systemMetadata.get.mockResolvedValue(partialConfig); await expect(sut.updateSystemConfig(updatedConfig)).resolves.toEqual(updatedConfig); - expect(eventMock.emit).toHaveBeenCalledWith( + expect(mocks.event.emit).toHaveBeenCalledWith( 'config.update', expect.objectContaining({ oldConfig: expect.any(Object), newConfig: updatedConfig }), ); }); it('should throw an error if a config file is in use', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify({})); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({})); await expect(sut.updateSystemConfig(defaults)).rejects.toBeInstanceOf(BadRequestException); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/system-metadata.service.spec.ts b/server/src/services/system-metadata.service.spec.ts index 071626d593..a8d6c0cdcc 100644 --- a/server/src/services/system-metadata.service.spec.ts +++ b/server/src/services/system-metadata.service.spec.ts @@ -1,15 +1,13 @@ import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataService } from 'src/services/system-metadata.service'; -import { ISystemMetadataRepository } from 'src/types'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(SystemMetadataService.name, () => { let sut: SystemMetadataService; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, systemMock } = newTestService(SystemMetadataService)); + ({ sut, mocks } = newTestService(SystemMetadataService)); }); it('should work', () => { @@ -18,32 +16,32 @@ describe(SystemMetadataService.name, () => { describe('getAdminOnboarding', () => { it('should get isOnboarded state', async () => { - systemMock.get.mockResolvedValue({ isOnboarded: true }); + mocks.systemMetadata.get.mockResolvedValue({ isOnboarded: true }); await expect(sut.getAdminOnboarding()).resolves.toEqual({ isOnboarded: true }); - expect(systemMock.get).toHaveBeenCalledWith('admin-onboarding'); + expect(mocks.systemMetadata.get).toHaveBeenCalledWith('admin-onboarding'); }); it('should default isOnboarded to false', async () => { await expect(sut.getAdminOnboarding()).resolves.toEqual({ isOnboarded: false }); - expect(systemMock.get).toHaveBeenCalledWith('admin-onboarding'); + expect(mocks.systemMetadata.get).toHaveBeenCalledWith('admin-onboarding'); }); }); describe('updateAdminOnboarding', () => { it('should update isOnboarded to true', async () => { await expect(sut.updateAdminOnboarding({ isOnboarded: true })).resolves.toBeUndefined(); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true }); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true }); }); it('should update isOnboarded to false', async () => { await expect(sut.updateAdminOnboarding({ isOnboarded: false })).resolves.toBeUndefined(); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: false }); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: false }); }); }); describe('getReverseGeocodingState', () => { it('should get reverse geocoding state', async () => { - systemMock.get.mockResolvedValue({ lastUpdate: '2024-01-01', lastImportFileName: 'foo.bar' }); + mocks.systemMetadata.get.mockResolvedValue({ lastUpdate: '2024-01-01', lastImportFileName: 'foo.bar' }); await expect(sut.getReverseGeocodingState()).resolves.toEqual({ lastUpdate: '2024-01-01', lastImportFileName: 'foo.bar', diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts index 8d54eb31db..48d1b00379 100644 --- a/server/src/services/tag.service.spec.ts +++ b/server/src/services/tag.service.spec.ts @@ -1,24 +1,19 @@ import { BadRequestException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { JobStatus } from 'src/interfaces/job.interface'; -import { ITagRepository } from 'src/interfaces/tag.interface'; import { TagService } from 'src/services/tag.service'; import { authStub } from 'test/fixtures/auth.stub'; import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(TagService.name, () => { let sut: TagService; - - let accessMock: IAccessRepositoryMock; - let tagMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, tagMock } = newTestService(TagService)); + ({ sut, mocks } = newTestService(TagService)); - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); }); it('should work', () => { @@ -27,76 +22,76 @@ describe(TagService.name, () => { describe('getAll', () => { it('should return all tags for a user', async () => { - tagMock.getAll.mockResolvedValue([tagStub.tag1]); + mocks.tag.getAll.mockResolvedValue([tagStub.tag1]); await expect(sut.getAll(authStub.admin)).resolves.toEqual([tagResponseStub.tag1]); - expect(tagMock.getAll).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.tag.getAll).toHaveBeenCalledWith(authStub.admin.user.id); }); }); describe('get', () => { it('should throw an error for an invalid id', async () => { - tagMock.get.mockResolvedValue(null); + mocks.tag.get.mockResolvedValue(null); await expect(sut.get(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException); - expect(tagMock.get).toHaveBeenCalledWith('tag-1'); + expect(mocks.tag.get).toHaveBeenCalledWith('tag-1'); }); it('should return a tag for a user', async () => { - tagMock.get.mockResolvedValue(tagStub.tag1); + mocks.tag.get.mockResolvedValue(tagStub.tag1); await expect(sut.get(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1); - expect(tagMock.get).toHaveBeenCalledWith('tag-1'); + expect(mocks.tag.get).toHaveBeenCalledWith('tag-1'); }); }); describe('create', () => { it('should throw an error for no parent tag access', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.create(authStub.admin, { name: 'tag', parentId: 'tag-parent' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(tagMock.create).not.toHaveBeenCalled(); + expect(mocks.tag.create).not.toHaveBeenCalled(); }); it('should create a tag with a parent', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent'])); - tagMock.create.mockResolvedValue(tagStub.tag1); - tagMock.get.mockResolvedValueOnce(tagStub.parent); - tagMock.get.mockResolvedValueOnce(tagStub.child); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent'])); + mocks.tag.create.mockResolvedValue(tagStub.tag1); + mocks.tag.get.mockResolvedValueOnce(tagStub.parent); + mocks.tag.get.mockResolvedValueOnce(tagStub.child); await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).resolves.toBeDefined(); - expect(tagMock.create).toHaveBeenCalledWith(expect.objectContaining({ value: 'Parent/tagA' })); + expect(mocks.tag.create).toHaveBeenCalledWith(expect.objectContaining({ value: 'Parent/tagA' })); }); it('should handle invalid parent ids', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent'])); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent'])); await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(tagMock.create).not.toHaveBeenCalled(); + expect(mocks.tag.create).not.toHaveBeenCalled(); }); }); describe('create', () => { it('should throw an error for a duplicate tag', async () => { - tagMock.getByValue.mockResolvedValue(tagStub.tag1); + mocks.tag.getByValue.mockResolvedValue(tagStub.tag1); await expect(sut.create(authStub.admin, { name: 'tag-1' })).rejects.toBeInstanceOf(BadRequestException); - expect(tagMock.getByValue).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.create).not.toHaveBeenCalled(); + expect(mocks.tag.getByValue).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); + expect(mocks.tag.create).not.toHaveBeenCalled(); }); it('should create a new tag', async () => { - tagMock.create.mockResolvedValue(tagStub.tag1); + mocks.tag.create.mockResolvedValue(tagStub.tag1); await expect(sut.create(authStub.admin, { name: 'tag-1' })).resolves.toEqual(tagResponseStub.tag1); - expect(tagMock.create).toHaveBeenCalledWith({ + expect(mocks.tag.create).toHaveBeenCalledWith({ userId: authStub.admin.user.id, value: 'tag-1', }); }); it('should create a new tag with optional color', async () => { - tagMock.create.mockResolvedValue(tagStub.color1); + mocks.tag.create.mockResolvedValue(tagStub.color1); await expect(sut.create(authStub.admin, { name: 'tag-1', color: '#000000' })).resolves.toEqual( tagResponseStub.color1, ); - expect(tagMock.create).toHaveBeenCalledWith({ + expect(mocks.tag.create).toHaveBeenCalledWith({ userId: authStub.admin.user.id, value: 'tag-1', color: '#000000', @@ -106,26 +101,26 @@ describe(TagService.name, () => { describe('update', () => { it('should throw an error for no update permission', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(tagMock.update).not.toHaveBeenCalled(); + expect(mocks.tag.update).not.toHaveBeenCalled(); }); it('should update a tag', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); - tagMock.update.mockResolvedValue(tagStub.color1); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); + mocks.tag.update.mockResolvedValue(tagStub.color1); await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).resolves.toEqual(tagResponseStub.color1); - expect(tagMock.update).toHaveBeenCalledWith({ id: 'tag-1', color: '#000000' }); + expect(mocks.tag.update).toHaveBeenCalledWith({ id: 'tag-1', color: '#000000' }); }); }); describe('upsert', () => { it('should upsert a new tag', async () => { - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parent); await expect(sut.upsert(authStub.admin, { tags: ['Parent'] })).resolves.toBeDefined(); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ value: 'Parent', userId: 'admin_id', parentId: undefined, @@ -133,16 +128,16 @@ describe(TagService.name, () => { }); it('should upsert a nested tag', async () => { - tagMock.getByValue.mockResolvedValueOnce(null); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); + mocks.tag.getByValue.mockResolvedValueOnce(null); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.child); await expect(sut.upsert(authStub.admin, { tags: ['Parent/Child'] })).resolves.toBeDefined(); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { value: 'Parent', userId: 'admin_id', parent: undefined, }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { value: 'Parent/Child', userId: 'admin_id', parent: expect.objectContaining({ id: 'tag-parent' }), @@ -150,16 +145,16 @@ describe(TagService.name, () => { }); it('should upsert a tag and ignore leading and trailing slashes', async () => { - tagMock.getByValue.mockResolvedValueOnce(null); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); + mocks.tag.getByValue.mockResolvedValueOnce(null); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.child); await expect(sut.upsert(authStub.admin, { tags: ['/Parent/Child/'] })).resolves.toBeDefined(); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { value: 'Parent', userId: 'admin_id', parent: undefined, }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { value: 'Parent/Child', userId: 'admin_id', parent: expect.objectContaining({ id: 'tag-parent' }), @@ -169,32 +164,32 @@ describe(TagService.name, () => { describe('remove', () => { it('should throw an error for an invalid id', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException); - expect(tagMock.delete).not.toHaveBeenCalled(); + expect(mocks.tag.delete).not.toHaveBeenCalled(); }); it('should remove a tag', async () => { - tagMock.get.mockResolvedValue(tagStub.tag1); + mocks.tag.get.mockResolvedValue(tagStub.tag1); await sut.remove(authStub.admin, 'tag-1'); - expect(tagMock.delete).toHaveBeenCalledWith('tag-1'); + expect(mocks.tag.delete).toHaveBeenCalledWith('tag-1'); }); }); describe('bulkTagAssets', () => { it('should handle invalid requests', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); - tagMock.upsertAssetIds.mockResolvedValue([]); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.tag.upsertAssetIds.mockResolvedValue([]); await expect(sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1'], assetIds: ['asset-1'] })).resolves.toEqual({ count: 0, }); - expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([]); + expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([]); }); it('should upsert records', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - tagMock.upsertAssetIds.mockResolvedValue([ + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.tag.upsertAssetIds.mockResolvedValue([ { tagId: 'tag-1', assetId: 'asset-1' }, { tagId: 'tag-1', assetId: 'asset-2' }, { tagId: 'tag-1', assetId: 'asset-3' }, @@ -207,7 +202,7 @@ describe(TagService.name, () => { ).resolves.toEqual({ count: 6, }); - expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([ + expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([ { tagId: 'tag-1', assetId: 'asset-1' }, { tagId: 'tag-1', assetId: 'asset-2' }, { tagId: 'tag-1', assetId: 'asset-3' }, @@ -220,19 +215,19 @@ describe(TagService.name, () => { describe('addAssets', () => { it('should handle invalid ids', async () => { - tagMock.get.mockResolvedValue(null); - tagMock.getAssetIds.mockResolvedValue(new Set([])); + mocks.tag.get.mockResolvedValue(null); + mocks.tag.getAssetIds.mockResolvedValue(new Set([])); await expect(sut.addAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([ { id: 'asset-1', success: false, error: 'no_permission' }, ]); - expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']); - expect(tagMock.addAssetIds).not.toHaveBeenCalled(); + expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']); + expect(mocks.tag.addAssetIds).not.toHaveBeenCalled(); }); it('should accept accept ids that are new and reject the rest', async () => { - tagMock.get.mockResolvedValue(tagStub.tag1); - tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2'])); + mocks.tag.get.mockResolvedValue(tagStub.tag1); + mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2'])); await expect( sut.addAssets(authStub.admin, 'tag-1', { @@ -243,23 +238,23 @@ describe(TagService.name, () => { { id: 'asset-2', success: true }, ]); - expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); - expect(tagMock.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']); + expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); + expect(mocks.tag.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']); }); }); describe('removeAssets', () => { it('should throw an error for an invalid id', async () => { - tagMock.get.mockResolvedValue(null); - tagMock.getAssetIds.mockResolvedValue(new Set()); + mocks.tag.get.mockResolvedValue(null); + mocks.tag.getAssetIds.mockResolvedValue(new Set()); await expect(sut.removeAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([ { id: 'asset-1', success: false, error: 'not_found' }, ]); }); it('should accept accept ids that are tagged and reject the rest', async () => { - tagMock.get.mockResolvedValue(tagStub.tag1); - tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1'])); + mocks.tag.get.mockResolvedValue(tagStub.tag1); + mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1'])); await expect( sut.removeAssets(authStub.admin, 'tag-1', { @@ -270,15 +265,15 @@ describe(TagService.name, () => { { id: 'asset-2', success: false, error: BulkIdErrorReason.NOT_FOUND }, ]); - expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); - expect(tagMock.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']); + expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); + expect(mocks.tag.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']); }); }); describe('handleTagCleanup', () => { it('should delete empty tags', async () => { await expect(sut.handleTagCleanup()).resolves.toBe(JobStatus.SUCCESS); - expect(tagMock.deleteEmptyTags).toHaveBeenCalled(); + expect(mocks.tag.deleteEmptyTags).toHaveBeenCalled(); }); }); }); diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts index 41f9919189..15dab6bc05 100644 --- a/server/src/services/timeline.service.spec.ts +++ b/server/src/services/timeline.service.spec.ts @@ -1,32 +1,28 @@ import { BadRequestException } from '@nestjs/common'; -import { IAssetRepository, TimeBucketSize } from 'src/interfaces/asset.interface'; +import { TimeBucketSize } from 'src/interfaces/asset.interface'; import { TimelineService } from 'src/services/timeline.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(TimelineService.name, () => { let sut: TimelineService; - - let accessMock: IAccessRepositoryMock; - let assetMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, assetMock } = newTestService(TimelineService)); + ({ sut, mocks } = newTestService(TimelineService)); }); describe('getTimeBuckets', () => { it("should return buckets if userId and albumId aren't set", async () => { - assetMock.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]); + mocks.asset.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]); await expect( sut.getTimeBuckets(authStub.admin, { size: TimeBucketSize.DAY, }), ).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }])); - expect(assetMock.getTimeBuckets).toHaveBeenCalledWith({ + expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({ size: TimeBucketSize.DAY, userIds: [authStub.admin.user.id], }); @@ -35,15 +31,15 @@ describe(TimelineService.name, () => { describe('getTimeBucket', () => { it('should return the assets for a album time bucket if user has album.read', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); + mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); await expect( sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }), ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id'])); - expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id'])); + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id', @@ -51,7 +47,7 @@ describe(TimelineService.name, () => { }); it('should return the assets for a archive time bucket if user has archive.read', async () => { - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); await expect( sut.getTimeBucket(authStub.admin, { @@ -61,7 +57,7 @@ describe(TimelineService.name, () => { userId: authStub.admin.user.id, }), ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(assetMock.getTimeBucket).toHaveBeenCalledWith( + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( 'bucket', expect.objectContaining({ size: TimeBucketSize.DAY, @@ -73,7 +69,7 @@ describe(TimelineService.name, () => { }); it('should include partner shared assets', async () => { - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); await expect( sut.getTimeBucket(authStub.admin, { @@ -84,7 +80,7 @@ describe(TimelineService.name, () => { withPartners: true, }), ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { size: TimeBucketSize.DAY, timeBucket: 'bucket', isArchived: false, @@ -94,8 +90,8 @@ describe(TimelineService.name, () => { }); it('should check permissions to read tag', async () => { - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-123'])); + mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-123'])); await expect( sut.getTimeBucket(authStub.admin, { @@ -105,7 +101,7 @@ describe(TimelineService.name, () => { tagId: 'tag-123', }), ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { size: TimeBucketSize.DAY, tagId: 'tag-123', timeBucket: 'bucket', @@ -114,8 +110,8 @@ describe(TimelineService.name, () => { }); it('should strip metadata if showExif is disabled', async () => { - accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id'])); - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id'])); + mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); const buckets = await sut.getTimeBucket( { ...authStub.admin, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } }, @@ -128,7 +124,7 @@ describe(TimelineService.name, () => { ); expect(buckets).toEqual([expect.objectContaining({ id: 'asset-id' })]); expect(buckets[0]).not.toHaveProperty('exif'); - expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { size: TimeBucketSize.DAY, timeBucket: 'bucket', isArchived: true, @@ -137,7 +133,7 @@ describe(TimelineService.name, () => { }); it('should return the assets for a library time bucket if user has library.read', async () => { - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); await expect( sut.getTimeBucket(authStub.admin, { @@ -146,7 +142,7 @@ describe(TimelineService.name, () => { userId: authStub.admin.user.id, }), ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(assetMock.getTimeBucket).toHaveBeenCalledWith( + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( 'bucket', expect.objectContaining({ size: TimeBucketSize.DAY, diff --git a/server/src/services/trash.service.spec.ts b/server/src/services/trash.service.spec.ts index 8b93e899e7..536fb65f9a 100644 --- a/server/src/services/trash.service.spec.ts +++ b/server/src/services/trash.service.spec.ts @@ -1,11 +1,8 @@ import { BadRequestException } from '@nestjs/common'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { JobName, JobStatus } from 'src/interfaces/job.interface'; import { TrashService } from 'src/services/trash.service'; -import { ITrashRepository } from 'src/types'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; async function* makeAssetIdStream(count: number): AsyncIterableIterator<{ id: string }> { for (let i = 0; i < count; i++) { @@ -16,17 +13,14 @@ async function* makeAssetIdStream(count: number): AsyncIterableIterator<{ id: st describe(TrashService.name, () => { let sut: TrashService; - - let accessMock: IAccessRepositoryMock; - let jobMock: Mocked; - let trashMock: Mocked; + let mocks: ServiceMocks; it('should work', () => { expect(sut).toBeDefined(); }); beforeEach(() => { - ({ sut, accessMock, jobMock, trashMock } = newTestService(TrashService)); + ({ sut, mocks } = newTestService(TrashService)); }); describe('restoreAssets', () => { @@ -40,64 +34,64 @@ describe(TrashService.name, () => { it('should handle an empty list', async () => { await expect(sut.restoreAssets(authStub.user1, { ids: [] })).resolves.toEqual({ count: 0 }); - expect(accessMock.asset.checkOwnerAccess).not.toHaveBeenCalled(); + expect(mocks.access.asset.checkOwnerAccess).not.toHaveBeenCalled(); }); it('should restore a batch of assets', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); await sut.restoreAssets(authStub.user1, { ids: ['asset1', 'asset2'] }); - expect(trashMock.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']); - expect(jobMock.queue.mock.calls).toEqual([]); + expect(mocks.trash.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']); + expect(mocks.job.queue.mock.calls).toEqual([]); }); }); describe('restore', () => { it('should handle an empty trash', async () => { - trashMock.getDeletedIds.mockResolvedValue(makeAssetIdStream(0)); - trashMock.restore.mockResolvedValue(0); + mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(0)); + mocks.trash.restore.mockResolvedValue(0); await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 0 }); - expect(trashMock.restore).toHaveBeenCalledWith('user-id'); + expect(mocks.trash.restore).toHaveBeenCalledWith('user-id'); }); it('should restore', async () => { - trashMock.getDeletedIds.mockResolvedValue(makeAssetIdStream(1)); - trashMock.restore.mockResolvedValue(1); + mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(1)); + mocks.trash.restore.mockResolvedValue(1); await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 1 }); - expect(trashMock.restore).toHaveBeenCalledWith('user-id'); + expect(mocks.trash.restore).toHaveBeenCalledWith('user-id'); }); }); describe('empty', () => { it('should handle an empty trash', async () => { - trashMock.getDeletedIds.mockResolvedValue(makeAssetIdStream(0)); - trashMock.empty.mockResolvedValue(0); + mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(0)); + mocks.trash.empty.mockResolvedValue(0); await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 0 }); - expect(jobMock.queue).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); }); it('should empty the trash', async () => { - trashMock.getDeletedIds.mockResolvedValue(makeAssetIdStream(1)); - trashMock.empty.mockResolvedValue(1); + mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(1)); + mocks.trash.empty.mockResolvedValue(1); await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 1 }); - expect(trashMock.empty).toHaveBeenCalledWith('user-id'); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); + expect(mocks.trash.empty).toHaveBeenCalledWith('user-id'); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); }); }); describe('onAssetsDelete', () => { it('should queue the empty trash job', async () => { await expect(sut.onAssetsDelete()).resolves.toBeUndefined(); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); }); }); describe('handleQueueEmptyTrash', () => { it('should queue asset delete jobs', async () => { - trashMock.getDeletedIds.mockReturnValue(makeAssetIdStream(1)); + mocks.trash.getDeletedIds.mockReturnValue(makeAssetIdStream(1)); await expect(sut.handleQueueEmptyTrash()).resolves.toEqual(JobStatus.SUCCESS); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.ASSET_DELETION, data: { id: 'asset-1', deleteOnDisk: true }, diff --git a/server/src/services/user-admin.service.spec.ts b/server/src/services/user-admin.service.spec.ts index b14f1c8655..604062c97a 100644 --- a/server/src/services/user-admin.service.spec.ts +++ b/server/src/services/user-admin.service.spec.ts @@ -1,31 +1,28 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { mapUserAdmin } from 'src/dtos/user.dto'; import { UserStatus } from 'src/enum'; -import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { JobName } from 'src/interfaces/job.interface'; import { UserAdminService } from 'src/services/user-admin.service'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newTestService } from 'test/utils'; -import { Mocked, describe } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; +import { describe } from 'vitest'; describe(UserAdminService.name, () => { let sut: UserAdminService; - - let jobMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, jobMock, userMock } = newTestService(UserAdminService)); + ({ sut, mocks } = newTestService(UserAdminService)); - userMock.get.mockImplementation((userId) => + mocks.user.get.mockImplementation((userId) => Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? undefined), ); }); describe('create', () => { it('should not create a user if there is no local admin account', async () => { - userMock.getAdmin.mockResolvedValueOnce(void 0); + mocks.user.getAdmin.mockResolvedValueOnce(void 0); await expect( sut.create({ @@ -37,8 +34,8 @@ describe(UserAdminService.name, () => { }); it('should create user', async () => { - userMock.getAdmin.mockResolvedValue(userStub.admin); - userMock.create.mockResolvedValue(userStub.user1); + mocks.user.getAdmin.mockResolvedValue(userStub.admin); + mocks.user.create.mockResolvedValue(userStub.user1); await expect( sut.create({ @@ -49,8 +46,8 @@ describe(UserAdminService.name, () => { }), ).resolves.toEqual(mapUserAdmin(userStub.user1)); - expect(userMock.getAdmin).toBeCalled(); - expect(userMock.create).toBeCalledWith({ + expect(mocks.user.getAdmin).toBeCalled(); + expect(mocks.user.create).toBeCalledWith({ email: userStub.user1.email, name: userStub.user1.name, storageLabel: 'label', @@ -66,20 +63,20 @@ describe(UserAdminService.name, () => { email: 'immich@test.com', storageLabel: 'storage_label', }; - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getByStorageLabel.mockResolvedValue(void 0); - userMock.update.mockResolvedValue(userStub.user1); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getByStorageLabel.mockResolvedValue(void 0); + mocks.user.update.mockResolvedValue(userStub.user1); await sut.update(authStub.user1, userStub.user1.id, update); - expect(userMock.getByEmail).toHaveBeenCalledWith(update.email); - expect(userMock.getByStorageLabel).toHaveBeenCalledWith(update.storageLabel); + expect(mocks.user.getByEmail).toHaveBeenCalledWith(update.email); + expect(mocks.user.getByStorageLabel).toHaveBeenCalledWith(update.storageLabel); }); it('should not set an empty string for storage label', async () => { - userMock.update.mockResolvedValue(userStub.user1); + mocks.user.update.mockResolvedValue(userStub.user1); await sut.update(authStub.admin, userStub.user1.id, { storageLabel: '' }); - expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { + expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { storageLabel: null, updatedAt: expect.any(Date), }); @@ -88,27 +85,27 @@ describe(UserAdminService.name, () => { it('should not change an email to one already in use', async () => { const dto = { id: userStub.user1.id, email: 'updated@test.com' }; - userMock.get.mockResolvedValue(userStub.user1); - userMock.getByEmail.mockResolvedValue(userStub.admin); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.user.getByEmail.mockResolvedValue(userStub.admin); await expect(sut.update(authStub.admin, userStub.user1.id, dto)).rejects.toBeInstanceOf(BadRequestException); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should not let the admin change the storage label to one already in use', async () => { const dto = { id: userStub.user1.id, storageLabel: 'admin' }; - userMock.get.mockResolvedValue(userStub.user1); - userMock.getByStorageLabel.mockResolvedValue(userStub.admin); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.user.getByStorageLabel.mockResolvedValue(userStub.admin); await expect(sut.update(authStub.admin, userStub.user1.id, dto)).rejects.toBeInstanceOf(BadRequestException); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('update user information should throw error if user not found', async () => { - userMock.get.mockResolvedValueOnce(void 0); + mocks.user.get.mockResolvedValueOnce(void 0); await expect( sut.update(authStub.admin, userStub.user1.id, { shouldChangePassword: true }), @@ -118,10 +115,10 @@ describe(UserAdminService.name, () => { describe('delete', () => { it('should throw error if user could not be found', async () => { - userMock.get.mockResolvedValue(void 0); + mocks.user.get.mockResolvedValue(void 0); await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toThrowError(BadRequestException); - expect(userMock.delete).not.toHaveBeenCalled(); + expect(mocks.user.delete).not.toHaveBeenCalled(); }); it('cannot delete admin user', async () => { @@ -131,33 +128,33 @@ describe(UserAdminService.name, () => { it('should require the auth user be an admin', async () => { await expect(sut.delete(authStub.user1, authStub.admin.user.id, {})).rejects.toBeInstanceOf(ForbiddenException); - expect(userMock.delete).not.toHaveBeenCalled(); + expect(mocks.user.delete).not.toHaveBeenCalled(); }); it('should delete user', async () => { - userMock.get.mockResolvedValue(userStub.user1); - userMock.update.mockResolvedValue(userStub.user1); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.user.update.mockResolvedValue(userStub.user1); await expect(sut.delete(authStub.admin, userStub.user1.id, {})).resolves.toEqual(mapUserAdmin(userStub.user1)); - expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { + expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { status: UserStatus.DELETED, deletedAt: expect.any(Date), }); }); it('should force delete user', async () => { - userMock.get.mockResolvedValue(userStub.user1); - userMock.update.mockResolvedValue(userStub.user1); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.user.update.mockResolvedValue(userStub.user1); await expect(sut.delete(authStub.admin, userStub.user1.id, { force: true })).resolves.toEqual( mapUserAdmin(userStub.user1), ); - expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { + expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { status: UserStatus.REMOVING, deletedAt: expect.any(Date), }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.USER_DELETION, data: { id: userStub.user1.id, force: true }, }); @@ -166,16 +163,16 @@ describe(UserAdminService.name, () => { describe('restore', () => { it('should throw error if user could not be found', async () => { - userMock.get.mockResolvedValue(void 0); + mocks.user.get.mockResolvedValue(void 0); await expect(sut.restore(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should restore an user', async () => { - userMock.get.mockResolvedValue(userStub.user1); - userMock.restore.mockResolvedValue(userStub.user1); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.user.restore.mockResolvedValue(userStub.user1); await expect(sut.restore(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUserAdmin(userStub.user1)); - expect(userMock.restore).toHaveBeenCalledWith(userStub.user1.id); + expect(mocks.user.restore).toHaveBeenCalledWith(userStub.user1.id); }); }); }); diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index 9ed941c36c..8762c7c766 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -1,18 +1,13 @@ import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { UserEntity } from 'src/entities/user.entity'; import { CacheControl, UserMetadataKey } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { JobName } from 'src/interfaces/job.interface'; import { UserService } from 'src/services/user.service'; -import { ISystemMetadataRepository } from 'src/types'; import { ImmichFileResponse } from 'src/utils/file'; import { authStub } from 'test/fixtures/auth.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; const makeDeletedAt = (daysAgo: number) => { const deletedAt = new Date(); @@ -22,68 +17,63 @@ const makeDeletedAt = (daysAgo: number) => { describe(UserService.name, () => { let sut: UserService; - - let albumMock: Mocked; - let jobMock: Mocked; - let storageMock: Mocked; - let systemMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, albumMock, jobMock, storageMock, systemMock, userMock } = newTestService(UserService)); + ({ sut, mocks } = newTestService(UserService)); - userMock.get.mockImplementation((userId) => + mocks.user.get.mockImplementation((userId) => Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? undefined), ); }); describe('getAll', () => { it('admin should get all users', async () => { - userMock.getList.mockResolvedValue([userStub.admin]); + mocks.user.getList.mockResolvedValue([userStub.admin]); await expect(sut.search(authStub.admin)).resolves.toEqual([ expect.objectContaining({ id: authStub.admin.user.id, email: authStub.admin.user.email, }), ]); - expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false }); + expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: false }); }); it('non-admin should get all users when publicUsers enabled', async () => { - userMock.getList.mockResolvedValue([userStub.user1]); + mocks.user.getList.mockResolvedValue([userStub.user1]); await expect(sut.search(authStub.user1)).resolves.toEqual([ expect.objectContaining({ id: authStub.user1.user.id, email: authStub.user1.user.email, }), ]); - expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false }); + expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: false }); }); it('non-admin user should only receive itself when publicUsers is disabled', async () => { - userMock.getList.mockResolvedValue([userStub.user1]); - systemMock.get.mockResolvedValue(systemConfigStub.publicUsersDisabled); + mocks.user.getList.mockResolvedValue([userStub.user1]); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.publicUsersDisabled); await expect(sut.search(authStub.user1)).resolves.toEqual([ expect.objectContaining({ id: authStub.user1.user.id, email: authStub.user1.user.email, }), ]); - expect(userMock.getList).not.toHaveBeenCalledWith({ withDeleted: false }); + expect(mocks.user.getList).not.toHaveBeenCalledWith({ withDeleted: false }); }); }); describe('get', () => { it('should get a user by id', async () => { - userMock.get.mockResolvedValue(userStub.admin); + mocks.user.get.mockResolvedValue(userStub.admin); await sut.get(authStub.admin.user.id); - expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false }); + expect(mocks.user.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false }); }); it('should throw an error if a user is not found', async () => { - userMock.get.mockResolvedValue(void 0); + mocks.user.get.mockResolvedValue(void 0); await expect(sut.get(authStub.admin.user.id)).rejects.toBeInstanceOf(BadRequestException); - expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false }); + expect(mocks.user.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false }); }); }); @@ -100,78 +90,78 @@ describe(UserService.name, () => { describe('createProfileImage', () => { it('should throw an error if the user does not exist', async () => { const file = { path: '/profile/path' } as Express.Multer.File; - userMock.get.mockResolvedValue(void 0); - userMock.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); + mocks.user.get.mockResolvedValue(void 0); + mocks.user.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(BadRequestException); }); it('should throw an error if the user profile could not be updated with the new image', async () => { const file = { path: '/profile/path' } as Express.Multer.File; - userMock.get.mockResolvedValue(userStub.profilePath); - userMock.update.mockRejectedValue(new InternalServerErrorException('mocked error')); + mocks.user.get.mockResolvedValue(userStub.profilePath); + mocks.user.update.mockRejectedValue(new InternalServerErrorException('mocked error')); await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(InternalServerErrorException); }); it('should delete the previous profile image', async () => { const file = { path: '/profile/path' } as Express.Multer.File; - userMock.get.mockResolvedValue(userStub.profilePath); + mocks.user.get.mockResolvedValue(userStub.profilePath); const files = [userStub.profilePath.profileImagePath]; - userMock.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); + mocks.user.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); await sut.createProfileImage(authStub.admin, file); - expect(jobMock.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]); + expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]); }); it('should not delete the profile image if it has not been set', async () => { const file = { path: '/profile/path' } as Express.Multer.File; - userMock.get.mockResolvedValue(userStub.admin); - userMock.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.user.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); await sut.createProfileImage(authStub.admin, file); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); }); }); describe('deleteProfileImage', () => { it('should send an http error has no profile image', async () => { - userMock.get.mockResolvedValue(userStub.admin); + mocks.user.get.mockResolvedValue(userStub.admin); await expect(sut.deleteProfileImage(authStub.admin)).rejects.toBeInstanceOf(BadRequestException); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); }); it('should delete the profile image if user has one', async () => { - userMock.get.mockResolvedValue(userStub.profilePath); + mocks.user.get.mockResolvedValue(userStub.profilePath); const files = [userStub.profilePath.profileImagePath]; await sut.deleteProfileImage(authStub.admin); - expect(jobMock.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]); + expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]); }); }); describe('getUserProfileImage', () => { it('should throw an error if the user does not exist', async () => { - userMock.get.mockResolvedValue(void 0); + mocks.user.get.mockResolvedValue(void 0); await expect(sut.getProfileImage(userStub.admin.id)).rejects.toBeInstanceOf(BadRequestException); - expect(userMock.get).toHaveBeenCalledWith(userStub.admin.id, {}); + expect(mocks.user.get).toHaveBeenCalledWith(userStub.admin.id, {}); }); it('should throw an error if the user does not have a picture', async () => { - userMock.get.mockResolvedValue(userStub.admin); + mocks.user.get.mockResolvedValue(userStub.admin); await expect(sut.getProfileImage(userStub.admin.id)).rejects.toBeInstanceOf(NotFoundException); - expect(userMock.get).toHaveBeenCalledWith(userStub.admin.id, {}); + expect(mocks.user.get).toHaveBeenCalledWith(userStub.admin.id, {}); }); it('should return the profile picture', async () => { - userMock.get.mockResolvedValue(userStub.profilePath); + mocks.user.get.mockResolvedValue(userStub.profilePath); await expect(sut.getProfileImage(userStub.profilePath.id)).resolves.toEqual( new ImmichFileResponse({ @@ -181,13 +171,13 @@ describe(UserService.name, () => { }), ); - expect(userMock.get).toHaveBeenCalledWith(userStub.profilePath.id, {}); + expect(mocks.user.get).toHaveBeenCalledWith(userStub.profilePath.id, {}); }); }); describe('handleQueueUserDelete', () => { it('should skip users not ready for deletion', async () => { - userMock.getDeletedUsers.mockResolvedValue([ + mocks.user.getDeletedUsers.mockResolvedValue([ {}, { deletedAt: undefined }, { deletedAt: null }, @@ -196,14 +186,14 @@ describe(UserService.name, () => { await sut.handleUserDeleteCheck(); - expect(userMock.getDeletedUsers).toHaveBeenCalled(); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([]); + expect(mocks.user.getDeletedUsers).toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([]); }); it('should skip users not ready for deletion - deleteDelay30', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.deleteDelay30); - userMock.getDeletedUsers.mockResolvedValue([ + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.deleteDelay30); + mocks.user.getDeletedUsers.mockResolvedValue([ {}, { deletedAt: undefined }, { deletedAt: null }, @@ -212,120 +202,120 @@ describe(UserService.name, () => { await sut.handleUserDeleteCheck(); - expect(userMock.getDeletedUsers).toHaveBeenCalled(); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([]); + expect(mocks.user.getDeletedUsers).toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([]); }); it('should queue user ready for deletion', async () => { const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) }; - userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]); + mocks.user.getDeletedUsers.mockResolvedValue([user] as UserEntity[]); await sut.handleUserDeleteCheck(); - expect(userMock.getDeletedUsers).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]); + expect(mocks.user.getDeletedUsers).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]); }); it('should queue user ready for deletion - deleteDelay30', async () => { const user = { id: 'deleted-user', deletedAt: makeDeletedAt(31) }; - userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]); + mocks.user.getDeletedUsers.mockResolvedValue([user] as UserEntity[]); await sut.handleUserDeleteCheck(); - expect(userMock.getDeletedUsers).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]); + expect(mocks.user.getDeletedUsers).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]); }); }); describe('handleUserDelete', () => { it('should skip users not ready for deletion', async () => { const user = { id: 'user-1', deletedAt: makeDeletedAt(5) } as UserEntity; - userMock.get.mockResolvedValue(user); + mocks.user.get.mockResolvedValue(user); await sut.handleUserDelete({ id: user.id }); - expect(storageMock.unlinkDir).not.toHaveBeenCalled(); - expect(userMock.delete).not.toHaveBeenCalled(); + expect(mocks.storage.unlinkDir).not.toHaveBeenCalled(); + expect(mocks.user.delete).not.toHaveBeenCalled(); }); it('should delete the user and associated assets', async () => { const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity; - userMock.get.mockResolvedValue(user); + mocks.user.get.mockResolvedValue(user); await sut.handleUserDelete({ id: user.id }); const options = { force: true, recursive: true }; - expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/library/deleted-user', options); - expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/upload/deleted-user', options); - expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/profile/deleted-user', options); - expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/thumbs/deleted-user', options); - expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/encoded-video/deleted-user', options); - expect(albumMock.deleteAll).toHaveBeenCalledWith(user.id); - expect(userMock.delete).toHaveBeenCalledWith(user, true); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/library/deleted-user', options); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/upload/deleted-user', options); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/profile/deleted-user', options); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/thumbs/deleted-user', options); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/encoded-video/deleted-user', options); + expect(mocks.album.deleteAll).toHaveBeenCalledWith(user.id); + expect(mocks.user.delete).toHaveBeenCalledWith(user, true); }); it('should delete the library path for a storage label', async () => { const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10), storageLabel: 'admin' } as UserEntity; - userMock.get.mockResolvedValue(user); + mocks.user.get.mockResolvedValue(user); await sut.handleUserDelete({ id: user.id }); const options = { force: true, recursive: true }; - expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/library/admin', options); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/library/admin', options); }); }); describe('setLicense', () => { it('should save client license if valid', async () => { - userMock.upsertMetadata.mockResolvedValue(); + mocks.user.upsertMetadata.mockResolvedValue(); const license = { licenseKey: 'IMCL-license-key', activationKey: 'activation-key' }; await sut.setLicense(authStub.user1, license); - expect(userMock.upsertMetadata).toHaveBeenCalledWith(authStub.user1.user.id, { + expect(mocks.user.upsertMetadata).toHaveBeenCalledWith(authStub.user1.user.id, { key: UserMetadataKey.LICENSE, value: expect.any(Object), }); }); it('should save server license as client if valid', async () => { - userMock.upsertMetadata.mockResolvedValue(); + mocks.user.upsertMetadata.mockResolvedValue(); const license = { licenseKey: 'IMSV-license-key', activationKey: 'activation-key' }; await sut.setLicense(authStub.user1, license); - expect(userMock.upsertMetadata).toHaveBeenCalledWith(authStub.user1.user.id, { + expect(mocks.user.upsertMetadata).toHaveBeenCalledWith(authStub.user1.user.id, { key: UserMetadataKey.LICENSE, value: expect.any(Object), }); }); it('should not save license if invalid', async () => { - userMock.upsertMetadata.mockResolvedValue(); + mocks.user.upsertMetadata.mockResolvedValue(); const license = { licenseKey: 'license-key', activationKey: 'activation-key' }; const call = sut.setLicense(authStub.admin, license); await expect(call).rejects.toThrowError('Invalid license key'); - expect(userMock.upsertMetadata).not.toHaveBeenCalled(); + expect(mocks.user.upsertMetadata).not.toHaveBeenCalled(); }); }); describe('deleteLicense', () => { it('should delete license', async () => { - userMock.upsertMetadata.mockResolvedValue(); + mocks.user.upsertMetadata.mockResolvedValue(); await sut.deleteLicense(authStub.admin); - expect(userMock.upsertMetadata).not.toHaveBeenCalled(); + expect(mocks.user.upsertMetadata).not.toHaveBeenCalled(); }); }); describe('handleUserSyncUsage', () => { it('should sync usage', async () => { await sut.handleUserSyncUsage(); - expect(userMock.syncUsage).toHaveBeenCalledTimes(1); + expect(mocks.user.syncUsage).toHaveBeenCalledTimes(1); }); }); }); diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index 1fe55afc45..32378c52df 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -2,19 +2,10 @@ import { DateTime } from 'luxon'; import { SemVer } from 'semver'; import { serverVersion } from 'src/constants'; import { ImmichEnvironment, SystemMetadataKey } from 'src/enum'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { JobName, JobStatus } from 'src/interfaces/job.interface'; import { VersionService } from 'src/services/version.service'; -import { - IConfigRepository, - ILoggingRepository, - IServerInfoRepository, - ISystemMetadataRepository, - IVersionHistoryRepository, -} from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; const mockRelease = (version: string) => ({ id: 1, @@ -28,18 +19,10 @@ const mockRelease = (version: string) => ({ describe(VersionService.name, () => { let sut: VersionService; - - let configMock: Mocked; - let eventMock: Mocked; - let jobMock: Mocked; - let loggerMock: Mocked; - let serverInfoMock: Mocked; - let systemMock: Mocked; - let versionHistoryMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, configMock, eventMock, jobMock, loggerMock, serverInfoMock, systemMock, versionHistoryMock } = - newTestService(VersionService)); + ({ sut, mocks } = newTestService(VersionService)); }); it('should work', () => { @@ -49,17 +32,17 @@ describe(VersionService.name, () => { describe('onBootstrap', () => { it('should record a new version', async () => { await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(versionHistoryMock.create).toHaveBeenCalledWith({ version: expect.any(String) }); + expect(mocks.versionHistory.create).toHaveBeenCalledWith({ version: expect.any(String) }); }); it('should skip a duplicate version', async () => { - versionHistoryMock.getLatest.mockResolvedValue({ + mocks.versionHistory.getLatest.mockResolvedValue({ id: 'version-1', createdAt: new Date(), version: serverVersion.toString(), }); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(versionHistoryMock.create).not.toHaveBeenCalled(); + expect(mocks.versionHistory.create).not.toHaveBeenCalled(); }); }); @@ -76,7 +59,7 @@ describe(VersionService.name, () => { describe('getVersionHistory', () => { it('should respond the server version history', async () => { const upgrade = { id: 'upgrade-1', createdAt: new Date(), version: '1.0.0' }; - versionHistoryMock.getAll.mockResolvedValue([upgrade]); + mocks.versionHistory.getAll.mockResolvedValue([upgrade]); await expect(sut.getVersionHistory()).resolves.toEqual([upgrade]); }); }); @@ -84,22 +67,22 @@ describe(VersionService.name, () => { describe('handQueueVersionCheck', () => { it('should queue a version check job', async () => { await expect(sut.handleQueueVersionCheck()).resolves.toBeUndefined(); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VERSION_CHECK, data: {} }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.VERSION_CHECK, data: {} }); }); }); describe('handVersionCheck', () => { beforeEach(() => { - configMock.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.PRODUCTION })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.PRODUCTION })); }); it('should not run in dev mode', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.DEVELOPMENT })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.DEVELOPMENT })); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED); }); it('should not run if the last check was < 60 minutes ago', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ checkedAt: DateTime.utc().minus({ minutes: 5 }).toISO(), releaseVersion: '1.0.0', }); @@ -107,53 +90,53 @@ describe(VersionService.name, () => { }); it('should not run if version check is disabled', async () => { - systemMock.get.mockResolvedValue({ newVersionCheck: { enabled: false } }); + mocks.systemMetadata.get.mockResolvedValue({ newVersionCheck: { enabled: false } }); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED); }); it('should run if it has been > 60 minutes', async () => { - serverInfoMock.getGitHubRelease.mockResolvedValue(mockRelease('v100.0.0')); - systemMock.get.mockResolvedValue({ + mocks.serverInfo.getGitHubRelease.mockResolvedValue(mockRelease('v100.0.0')); + mocks.systemMetadata.get.mockResolvedValue({ checkedAt: DateTime.utc().minus({ minutes: 65 }).toISO(), releaseVersion: '1.0.0', }); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SUCCESS); - expect(systemMock.set).toHaveBeenCalled(); - expect(loggerMock.log).toHaveBeenCalled(); - expect(eventMock.clientBroadcast).toHaveBeenCalled(); + expect(mocks.systemMetadata.set).toHaveBeenCalled(); + expect(mocks.logger.log).toHaveBeenCalled(); + expect(mocks.event.clientBroadcast).toHaveBeenCalled(); }); it('should not notify if the version is equal', async () => { - serverInfoMock.getGitHubRelease.mockResolvedValue(mockRelease(serverVersion.toString())); + mocks.serverInfo.getGitHubRelease.mockResolvedValue(mockRelease(serverVersion.toString())); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SUCCESS); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.VERSION_CHECK_STATE, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.VERSION_CHECK_STATE, { checkedAt: expect.any(String), releaseVersion: serverVersion.toString(), }); - expect(eventMock.clientBroadcast).not.toHaveBeenCalled(); + expect(mocks.event.clientBroadcast).not.toHaveBeenCalled(); }); it('should handle a github error', async () => { - serverInfoMock.getGitHubRelease.mockRejectedValue(new Error('GitHub is down')); + mocks.serverInfo.getGitHubRelease.mockRejectedValue(new Error('GitHub is down')); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.FAILED); - expect(systemMock.set).not.toHaveBeenCalled(); - expect(eventMock.clientBroadcast).not.toHaveBeenCalled(); - expect(loggerMock.warn).toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); + expect(mocks.event.clientBroadcast).not.toHaveBeenCalled(); + expect(mocks.logger.warn).toHaveBeenCalled(); }); }); describe('onWebsocketConnectionEvent', () => { it('should send on_server_version client event', async () => { await sut.onWebsocketConnection({ userId: '42' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); - expect(eventMock.clientSend).toHaveBeenCalledTimes(1); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); + expect(mocks.event.clientSend).toHaveBeenCalledTimes(1); }); it('should also send a new release notification', async () => { - systemMock.get.mockResolvedValue({ checkedAt: '2024-01-01', releaseVersion: 'v1.42.0' }); + mocks.systemMetadata.get.mockResolvedValue({ checkedAt: '2024-01-01', releaseVersion: 'v1.42.0' }); await sut.onWebsocketConnection({ userId: '42' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_new_release', '42', expect.any(Object)); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_new_release', '42', expect.any(Object)); }); }); }); diff --git a/server/src/services/view.service.spec.ts b/server/src/services/view.service.spec.ts index e033ec0dc8..86bfcef734 100644 --- a/server/src/services/view.service.spec.ts +++ b/server/src/services/view.service.spec.ts @@ -1,18 +1,15 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { ViewService } from 'src/services/view.service'; -import { IViewRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { newTestService } from 'test/utils'; - -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(ViewService.name, () => { let sut: ViewService; - let viewMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, viewMock } = newTestService(ViewService)); + ({ sut, mocks } = newTestService(ViewService)); }); it('should work', () => { @@ -22,12 +19,12 @@ describe(ViewService.name, () => { describe('getUniqueOriginalPaths', () => { it('should return unique original paths', async () => { const mockPaths = ['path1', 'path2', 'path3']; - viewMock.getUniqueOriginalPaths.mockResolvedValue(mockPaths); + mocks.view.getUniqueOriginalPaths.mockResolvedValue(mockPaths); const result = await sut.getUniqueOriginalPaths(authStub.admin); expect(result).toEqual(mockPaths); - expect(viewMock.getUniqueOriginalPaths).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.view.getUniqueOriginalPaths).toHaveBeenCalledWith(authStub.admin.user.id); }); }); @@ -42,11 +39,11 @@ describe(ViewService.name, () => { const mockAssetReponseDto = mockAssets.map((a) => mapAsset(a, { auth: authStub.admin })); - viewMock.getAssetsByOriginalPath.mockResolvedValue(mockAssets as any); + mocks.view.getAssetsByOriginalPath.mockResolvedValue(mockAssets as any); const result = await sut.getAssetsByOriginalPath(authStub.admin, path); expect(result).toEqual(mockAssetReponseDto); - await expect(viewMock.getAssetsByOriginalPath(authStub.admin.user.id, path)).resolves.toEqual(mockAssets); + await expect(mocks.view.getAssetsByOriginalPath(authStub.admin.user.id, path)).resolves.toEqual(mockAssets); }); }); }); diff --git a/server/src/types.ts b/server/src/types.ts index 8e8e329b8b..e0523333d8 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,27 +1,9 @@ import { UserEntity } from 'src/entities/user.entity'; import { ExifOrientation, ImageFormat, Permission, TranscodeTarget, VideoCodec } from 'src/enum'; -import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; -import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; -import { AuditRepository } from 'src/repositories/audit.repository'; -import { ConfigRepository } from 'src/repositories/config.repository'; -import { CronRepository } from 'src/repositories/cron.repository'; -import { LoggingRepository } from 'src/repositories/logging.repository'; -import { MapRepository } from 'src/repositories/map.repository'; -import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; -import { MetadataRepository } from 'src/repositories/metadata.repository'; -import { NotificationRepository } from 'src/repositories/notification.repository'; -import { OAuthRepository } from 'src/repositories/oauth.repository'; -import { ProcessRepository } from 'src/repositories/process.repository'; -import { ServerInfoRepository } from 'src/repositories/server-info.repository'; import { SessionRepository } from 'src/repositories/session.repository'; -import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; -import { MetricGroupRepository, TelemetryRepository } from 'src/repositories/telemetry.repository'; -import { TrashRepository } from 'src/repositories/trash.repository'; -import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; -import { ViewRepository } from 'src/repositories/view-repository'; export type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial } : T; @@ -34,41 +16,10 @@ export type AuthApiKey = { export type RepositoryInterface = Pick; -export type IActivityRepository = RepositoryInterface; -export type IAccessRepository = { [K in keyof AccessRepository]: RepositoryInterface }; -export type IAlbumUserRepository = RepositoryInterface; -export type IApiKeyRepository = RepositoryInterface; -export type IAuditRepository = RepositoryInterface; -export type IConfigRepository = RepositoryInterface; -export type ICronRepository = RepositoryInterface; -export type ILoggingRepository = Pick< - LoggingRepository, - | 'verbose' - | 'log' - | 'debug' - | 'warn' - | 'error' - | 'fatal' - | 'isLevelEnabled' - | 'setLogLevel' - | 'setContext' - | 'setAppName' ->; -export type IMapRepository = RepositoryInterface; -export type IMediaRepository = RepositoryInterface; -export type IMemoryRepository = RepositoryInterface; -export type IMetadataRepository = RepositoryInterface; -export type IMetricGroupRepository = RepositoryInterface; -export type INotificationRepository = RepositoryInterface; -export type IOAuthRepository = RepositoryInterface; -export type IProcessRepository = RepositoryInterface; -export type ISessionRepository = RepositoryInterface; -export type IServerInfoRepository = RepositoryInterface; -export type ISystemMetadataRepository = RepositoryInterface; -export type ITelemetryRepository = RepositoryInterface; -export type ITrashRepository = RepositoryInterface; -export type IViewRepository = RepositoryInterface; -export type IVersionHistoryRepository = RepositoryInterface; +type IActivityRepository = RepositoryInterface; +type IApiKeyRepository = RepositoryInterface; +type IMemoryRepository = RepositoryInterface; +type ISessionRepository = RepositoryInterface; export type ActivityItem = | Awaited> diff --git a/server/src/utils/config.ts b/server/src/utils/config.ts index 64a0a37c86..056064c026 100644 --- a/server/src/utils/config.ts +++ b/server/src/utils/config.ts @@ -7,15 +7,18 @@ import { SystemConfig, defaults } from 'src/config'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { SystemMetadataKey } from 'src/enum'; import { DatabaseLock } from 'src/interfaces/database.interface'; -import { DeepPartial, IConfigRepository, ILoggingRepository, ISystemMetadataRepository } from 'src/types'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; +import { DeepPartial } from 'src/types'; import { getKeysDeep, unsetDeep } from 'src/utils/misc'; export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise; type RepoDeps = { - configRepo: IConfigRepository; - metadataRepo: ISystemMetadataRepository; - logger: ILoggingRepository; + configRepo: ConfigRepository; + metadataRepo: SystemMetadataRepository; + logger: LoggingRepository; }; const asyncLock = new AsyncLock(); diff --git a/server/src/utils/file.ts b/server/src/utils/file.ts index 4f3009e39f..d9c599169d 100644 --- a/server/src/utils/file.ts +++ b/server/src/utils/file.ts @@ -5,7 +5,7 @@ import { basename, extname, isAbsolute } from 'node:path'; import { promisify } from 'node:util'; import { CacheControl } from 'src/enum'; import { ImmichReadStream } from 'src/interfaces/storage.interface'; -import { ILoggingRepository } from 'src/types'; +import { LoggingRepository } from 'src/repositories/logging.repository'; import { isConnectionAborted } from 'src/utils/misc'; export function getFileNameWithoutExtension(path: string): string { @@ -37,7 +37,7 @@ export const sendFile = async ( res: Response, next: NextFunction, handler: () => Promise, - logger: ILoggingRepository, + logger: LoggingRepository, ): Promise => { const _sendFile = (path: string, options: SendFileOptions) => promisify(res.sendFile).bind(res)(path, options); diff --git a/server/src/utils/logger.ts b/server/src/utils/logger.ts index 2fe2c618be..f2f47e0471 100644 --- a/server/src/utils/logger.ts +++ b/server/src/utils/logger.ts @@ -1,8 +1,8 @@ import { HttpException } from '@nestjs/common'; -import { ILoggingRepository } from 'src/types'; +import { LoggingRepository } from 'src/repositories/logging.repository'; import { TypeORMError } from 'typeorm'; -export const logGlobalError = (logger: ILoggingRepository, error: Error) => { +export const logGlobalError = (logger: LoggingRepository, error: Error) => { if (error instanceof HttpException) { const status = error.getStatus(); const response = error.getResponse(); diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index d53f9ecf36..13969543ef 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -13,7 +13,7 @@ import path from 'node:path'; import { SystemConfig } from 'src/config'; import { CLIP_MODEL_INFO, serverVersion } from 'src/constants'; import { ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum'; -import { ILoggingRepository } from 'src/types'; +import { LoggingRepository } from 'src/repositories/logging.repository'; export class ImmichStartupError extends Error {} export const isStartUpError = (error: unknown): error is ImmichStartupError => error instanceof ImmichStartupError; @@ -96,7 +96,7 @@ export const isFaceImportEnabled = (metadata: SystemConfig['metadata']) => metad export const isConnectionAborted = (error: Error | any) => error.code === 'ECONNABORTED'; -export const handlePromiseError = (promise: Promise, logger: ILoggingRepository): void => { +export const handlePromiseError = (promise: Promise, logger: LoggingRepository): void => { promise.catch((error: Error | any) => logger.error(`Promise error: ${error}`, error?.stack)); }; diff --git a/server/test/medium/metadata.service.spec.ts b/server/test/medium/metadata.service.spec.ts index 1750584018..4c89ce4e37 100644 --- a/server/test/medium/metadata.service.spec.ts +++ b/server/test/medium/metadata.service.spec.ts @@ -3,15 +3,11 @@ import { writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { AssetEntity } from 'src/entities/asset.entity'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MetadataService } from 'src/services/metadata.service'; -import { ILoggingRepository } from 'src/types'; -import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newRandomImage, newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newRandomImage, newTestService, ServiceMocks } from 'test/utils'; const metadataRepository = new MetadataRepository( newLoggingRepositoryMock() as ILoggingRepository as LoggingRepository, @@ -38,14 +34,12 @@ type TimeZoneTest = { describe(MetadataService.name, () => { let sut: MetadataService; - - let assetMock: Mocked; - let storageMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, storageMock } = newTestService(MetadataService, { metadataRepository })); + ({ sut, mocks } = newTestService(MetadataService, { metadataRepository })); - storageMock.stat.mockResolvedValue({ size: 123_456 } as Stats); + mocks.storage.stat.mockResolvedValue({ size: 123_456 } as Stats); delete process.env.TZ; }); @@ -120,18 +114,18 @@ describe(MetadataService.name, () => { process.env.TZ = serverTimeZone ?? undefined; const { filePath } = await createTestFile(exifData); - assetMock.getByIds.mockResolvedValue([{ id: 'asset-1', originalPath: filePath } as AssetEntity]); + mocks.asset.getByIds.mockResolvedValue([{ id: 'asset-1', originalPath: filePath } as AssetEntity]); await sut.handleMetadataExtraction({ id: 'asset-1' }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ dateTimeOriginal: new Date(expected.dateTimeOriginal), timeZone: expected.timeZone, }), ); - expect(assetMock.update).toHaveBeenCalledWith( + expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ localDateTime: new Date(expected.localDateTime), }), diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 23886e0495..ec5115b839 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -1,7 +1,12 @@ -import { IAccessRepository } from 'src/types'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export type IAccessRepositoryMock = { [K in keyof IAccessRepository]: Mocked }; +type IAccessRepository = { [K in keyof AccessRepository]: RepositoryInterface }; + +export type IAccessRepositoryMock = { + [K in keyof IAccessRepository]: Mocked; +}; export const newAccessRepositoryMock = (): IAccessRepositoryMock => { return { diff --git a/server/test/repositories/activity.repository.mock.ts b/server/test/repositories/activity.repository.mock.ts index bcc27774e3..81208b7232 100644 --- a/server/test/repositories/activity.repository.mock.ts +++ b/server/test/repositories/activity.repository.mock.ts @@ -1,7 +1,8 @@ -import { IActivityRepository } from 'src/types'; +import { ActivityRepository } from 'src/repositories/activity.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newActivityRepositoryMock = (): Mocked => { +export const newActivityRepositoryMock = (): Mocked> => { return { search: vitest.fn(), create: vitest.fn(), diff --git a/server/test/repositories/album-user.repository.mock.ts b/server/test/repositories/album-user.repository.mock.ts index aa9436e33d..e3225661a4 100644 --- a/server/test/repositories/album-user.repository.mock.ts +++ b/server/test/repositories/album-user.repository.mock.ts @@ -1,7 +1,8 @@ -import { IAlbumUserRepository } from 'src/types'; +import { AlbumUserRepository } from 'src/repositories/album-user.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked } from 'vitest'; -export const newAlbumUserRepositoryMock = (): Mocked => { +export const newAlbumUserRepositoryMock = (): Mocked> => { return { create: vitest.fn(), delete: vitest.fn(), diff --git a/server/test/repositories/api-key.repository.mock.ts b/server/test/repositories/api-key.repository.mock.ts index 8c471e520f..e8ae0bf8e2 100644 --- a/server/test/repositories/api-key.repository.mock.ts +++ b/server/test/repositories/api-key.repository.mock.ts @@ -1,7 +1,8 @@ -import { IApiKeyRepository } from 'src/types'; +import { ApiKeyRepository } from 'src/repositories/api-key.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newKeyRepositoryMock = (): Mocked => { +export const newKeyRepositoryMock = (): Mocked> => { return { create: vitest.fn(), update: vitest.fn(), diff --git a/server/test/repositories/audit.repository.mock.ts b/server/test/repositories/audit.repository.mock.ts index 96fe407c96..a76079f45d 100644 --- a/server/test/repositories/audit.repository.mock.ts +++ b/server/test/repositories/audit.repository.mock.ts @@ -1,7 +1,8 @@ -import { IAuditRepository } from 'src/types'; +import { AuditRepository } from 'src/repositories/audit.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newAuditRepositoryMock = (): Mocked => { +export const newAuditRepositoryMock = (): Mocked> => { return { getAfter: vitest.fn(), removeBefore: vitest.fn(), diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts index 2b195ae8c9..800d40642b 100644 --- a/server/test/repositories/config.repository.mock.ts +++ b/server/test/repositories/config.repository.mock.ts @@ -1,7 +1,7 @@ import { ImmichEnvironment, ImmichWorker } from 'src/enum'; import { DatabaseExtension } from 'src/interfaces/database.interface'; -import { EnvData } from 'src/repositories/config.repository'; -import { IConfigRepository } from 'src/types'; +import { ConfigRepository, EnvData } from 'src/repositories/config.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; const envData: EnvData = { @@ -97,7 +97,7 @@ const envData: EnvData = { }; export const mockEnvData = (config: Partial) => ({ ...envData, ...config }); -export const newConfigRepositoryMock = (): Mocked => { +export const newConfigRepositoryMock = (): Mocked> => { return { getEnv: vitest.fn().mockReturnValue(mockEnvData({})), getWorker: vitest.fn().mockReturnValue(ImmichWorker.API), diff --git a/server/test/repositories/cron.repository.mock.ts b/server/test/repositories/cron.repository.mock.ts index cc856909c8..5b74bd3cf5 100644 --- a/server/test/repositories/cron.repository.mock.ts +++ b/server/test/repositories/cron.repository.mock.ts @@ -1,7 +1,8 @@ -import { ICronRepository } from 'src/types'; +import { CronRepository } from 'src/repositories/cron.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newCronRepositoryMock = (): Mocked => { +export const newCronRepositoryMock = (): Mocked> => { return { create: vitest.fn(), update: vitest.fn(), diff --git a/server/test/repositories/logger.repository.mock.ts b/server/test/repositories/logger.repository.mock.ts index 0336a66090..46a81c8965 100644 --- a/server/test/repositories/logger.repository.mock.ts +++ b/server/test/repositories/logger.repository.mock.ts @@ -1,6 +1,20 @@ -import { ILoggingRepository } from 'src/types'; +import { LoggingRepository } from 'src/repositories/logging.repository'; import { Mocked, vitest } from 'vitest'; +export type ILoggingRepository = Pick< + LoggingRepository, + | 'verbose' + | 'log' + | 'debug' + | 'warn' + | 'error' + | 'fatal' + | 'isLevelEnabled' + | 'setLogLevel' + | 'setContext' + | 'setAppName' +>; + export const newLoggingRepositoryMock = (): Mocked => { return { setLogLevel: vitest.fn(), diff --git a/server/test/repositories/map.repository.mock.ts b/server/test/repositories/map.repository.mock.ts index 4b56b9443a..9e7df32252 100644 --- a/server/test/repositories/map.repository.mock.ts +++ b/server/test/repositories/map.repository.mock.ts @@ -1,7 +1,8 @@ -import { IMapRepository } from 'src/types'; +import { MapRepository } from 'src/repositories/map.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked } from 'vitest'; -export const newMapRepositoryMock = (): Mocked => { +export const newMapRepositoryMock = (): Mocked> => { return { init: vitest.fn(), reverseGeocode: vitest.fn(), diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index 238066ad9e..7c651ddef6 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -1,7 +1,8 @@ -import { IMediaRepository } from 'src/types'; +import { MediaRepository } from 'src/repositories/media.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newMediaRepositoryMock = (): Mocked => { +export const newMediaRepositoryMock = (): Mocked> => { return { generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()), generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')), diff --git a/server/test/repositories/memory.repository.mock.ts b/server/test/repositories/memory.repository.mock.ts index c818c29195..b33404f520 100644 --- a/server/test/repositories/memory.repository.mock.ts +++ b/server/test/repositories/memory.repository.mock.ts @@ -1,7 +1,8 @@ -import { IMemoryRepository } from 'src/types'; +import { MemoryRepository } from 'src/repositories/memory.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newMemoryRepositoryMock = (): Mocked => { +export const newMemoryRepositoryMock = (): Mocked> => { return { search: vitest.fn().mockResolvedValue([]), get: vitest.fn(), diff --git a/server/test/repositories/metadata.repository.mock.ts b/server/test/repositories/metadata.repository.mock.ts index e9bb68b95b..47a0471b22 100644 --- a/server/test/repositories/metadata.repository.mock.ts +++ b/server/test/repositories/metadata.repository.mock.ts @@ -1,7 +1,8 @@ -import { IMetadataRepository } from 'src/types'; +import { MetadataRepository } from 'src/repositories/metadata.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newMetadataRepositoryMock = (): Mocked => { +export const newMetadataRepositoryMock = (): Mocked> => { return { teardown: vitest.fn(), readTags: vitest.fn(), diff --git a/server/test/repositories/notification.repository.mock.ts b/server/test/repositories/notification.repository.mock.ts index 2065a0bf3e..3aa7f63cf2 100644 --- a/server/test/repositories/notification.repository.mock.ts +++ b/server/test/repositories/notification.repository.mock.ts @@ -1,7 +1,8 @@ -import { INotificationRepository } from 'src/types'; +import { NotificationRepository } from 'src/repositories/notification.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked } from 'vitest'; -export const newNotificationRepositoryMock = (): Mocked => { +export const newNotificationRepositoryMock = (): Mocked> => { return { renderEmail: vitest.fn(), sendEmail: vitest.fn().mockResolvedValue({ messageId: 'message-1' }), diff --git a/server/test/repositories/oauth.repository.mock.ts b/server/test/repositories/oauth.repository.mock.ts index 8980bfb14f..64777fa671 100644 --- a/server/test/repositories/oauth.repository.mock.ts +++ b/server/test/repositories/oauth.repository.mock.ts @@ -1,7 +1,8 @@ -import { IOAuthRepository } from 'src/types'; +import { OAuthRepository } from 'src/repositories/oauth.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked } from 'vitest'; -export const newOAuthRepositoryMock = (): Mocked => { +export const newOAuthRepositoryMock = (): Mocked> => { return { init: vitest.fn(), authorize: vitest.fn(), diff --git a/server/test/repositories/process.repository.mock.ts b/server/test/repositories/process.repository.mock.ts index 0ef1b0fdb1..f78975310b 100644 --- a/server/test/repositories/process.repository.mock.ts +++ b/server/test/repositories/process.repository.mock.ts @@ -1,7 +1,8 @@ -import { IProcessRepository } from 'src/types'; +import { ProcessRepository } from 'src/repositories/process.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newProcessRepositoryMock = (): Mocked => { +export const newProcessRepositoryMock = (): Mocked> => { return { spawn: vitest.fn(), }; diff --git a/server/test/repositories/server-info.repository.mock.ts b/server/test/repositories/server-info.repository.mock.ts index 5e9ecd1387..49f955b283 100644 --- a/server/test/repositories/server-info.repository.mock.ts +++ b/server/test/repositories/server-info.repository.mock.ts @@ -1,7 +1,8 @@ -import { IServerInfoRepository } from 'src/types'; +import { ServerInfoRepository } from 'src/repositories/server-info.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newServerInfoRepositoryMock = (): Mocked => { +export const newServerInfoRepositoryMock = (): Mocked> => { return { getGitHubRelease: vitest.fn(), getBuildVersions: vitest.fn(), diff --git a/server/test/repositories/session.repository.mock.ts b/server/test/repositories/session.repository.mock.ts index 41fa1640a2..b519b07e36 100644 --- a/server/test/repositories/session.repository.mock.ts +++ b/server/test/repositories/session.repository.mock.ts @@ -1,7 +1,8 @@ -import { ISessionRepository } from 'src/types'; +import { SessionRepository } from 'src/repositories/session.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newSessionRepositoryMock = (): Mocked => { +export const newSessionRepositoryMock = (): Mocked> => { return { search: vitest.fn(), create: vitest.fn() as any, diff --git a/server/test/repositories/system-metadata.repository.mock.ts b/server/test/repositories/system-metadata.repository.mock.ts index b96b525697..ab9e300576 100644 --- a/server/test/repositories/system-metadata.repository.mock.ts +++ b/server/test/repositories/system-metadata.repository.mock.ts @@ -1,8 +1,9 @@ -import { ISystemMetadataRepository } from 'src/types'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; +import { RepositoryInterface } from 'src/types'; import { clearConfigCache } from 'src/utils/config'; import { Mocked, vitest } from 'vitest'; -export const newSystemMetadataRepositoryMock = (): Mocked => { +export const newSystemMetadataRepositoryMock = (): Mocked> => { clearConfigCache(); return { get: vitest.fn() as any, diff --git a/server/test/repositories/telemetry.repository.mock.ts b/server/test/repositories/telemetry.repository.mock.ts index afadcea0cf..c7442052da 100644 --- a/server/test/repositories/telemetry.repository.mock.ts +++ b/server/test/repositories/telemetry.repository.mock.ts @@ -1,4 +1,5 @@ -import { ITelemetryRepository, RepositoryInterface } from 'src/types'; +import { TelemetryRepository } from 'src/repositories/telemetry.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; const newMetricGroupMock = () => { @@ -10,6 +11,8 @@ const newMetricGroupMock = () => { }; }; +type ITelemetryRepository = RepositoryInterface; + export type ITelemetryRepositoryMock = { [K in keyof ITelemetryRepository]: Mocked>; }; diff --git a/server/test/repositories/trash.repository.mock.ts b/server/test/repositories/trash.repository.mock.ts index f983afdce8..b42867213a 100644 --- a/server/test/repositories/trash.repository.mock.ts +++ b/server/test/repositories/trash.repository.mock.ts @@ -1,7 +1,8 @@ -import { ITrashRepository } from 'src/types'; +import { TrashRepository } from 'src/repositories/trash.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newTrashRepositoryMock = (): Mocked => { +export const newTrashRepositoryMock = (): Mocked> => { return { empty: vitest.fn(), restore: vitest.fn(), diff --git a/server/test/repositories/version-history.repository.mock.ts b/server/test/repositories/version-history.repository.mock.ts index 9ff7708796..98a9166487 100644 --- a/server/test/repositories/version-history.repository.mock.ts +++ b/server/test/repositories/version-history.repository.mock.ts @@ -1,7 +1,8 @@ -import { IVersionHistoryRepository } from 'src/types'; +import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newVersionHistoryRepositoryMock = (): Mocked => { +export const newVersionHistoryRepositoryMock = (): Mocked> => { return { getAll: vitest.fn().mockResolvedValue([]), getLatest: vitest.fn(), diff --git a/server/test/repositories/view.repository.mock.ts b/server/test/repositories/view.repository.mock.ts index bb58fda8a3..057d7ee28a 100644 --- a/server/test/repositories/view.repository.mock.ts +++ b/server/test/repositories/view.repository.mock.ts @@ -1,7 +1,8 @@ -import { IViewRepository } from 'src/types'; +import { ViewRepository } from 'src/repositories/view-repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newViewRepositoryMock = (): Mocked => { +export const newViewRepositoryMock = (): Mocked> => { return { getAssetsByOriginalPath: vitest.fn(), getUniqueOriginalPaths: vitest.fn(), diff --git a/server/test/utils.ts b/server/test/utils.ts index 34af8877b1..c4fee8fe93 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -2,51 +2,49 @@ import { ChildProcessWithoutNullStreams } from 'node:child_process'; import { Writable } from 'node:stream'; import { PNG } from 'pngjs'; import { ImmichWorker } from 'src/enum'; +import { IAlbumRepository } from 'src/interfaces/album.interface'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; +import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; +import { IUserRepository } from 'src/interfaces/user.interface'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AuditRepository } from 'src/repositories/audit.repository'; +import { ConfigRepository } from 'src/repositories/config.repository'; import { CronRepository } from 'src/repositories/cron.repository'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { DatabaseRepository } from 'src/repositories/database.repository'; +import { JobRepository } from 'src/repositories/job.repository'; +import { LibraryRepository } from 'src/repositories/library.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MapRepository } from 'src/repositories/map.repository'; import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; +import { MoveRepository } from 'src/repositories/move.repository'; import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; +import { PartnerRepository } from 'src/repositories/partner.repository'; +import { PersonRepository } from 'src/repositories/person.repository'; import { ProcessRepository } from 'src/repositories/process.repository'; +import { SearchRepository } from 'src/repositories/search.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; import { SessionRepository } from 'src/repositories/session.repository'; +import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; +import { StackRepository } from 'src/repositories/stack.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; +import { TagRepository } from 'src/repositories/tag.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository'; import { TrashRepository } from 'src/repositories/trash.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; import { ViewRepository } from 'src/repositories/view-repository'; import { BaseService } from 'src/services/base.service'; -import { - IAccessRepository, - IActivityRepository, - IAlbumUserRepository, - IApiKeyRepository, - IAuditRepository, - ICronRepository, - ILoggingRepository, - IMapRepository, - IMediaRepository, - IMemoryRepository, - IMetadataRepository, - INotificationRepository, - IOAuthRepository, - IProcessRepository, - IServerInfoRepository, - ISessionRepository, - ISystemMetadataRepository, - ITrashRepository, - IVersionHistoryRepository, - IViewRepository, -} from 'src/types'; -import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { RepositoryInterface } from 'src/types'; +import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { newActivityRepositoryMock } from 'test/repositories/activity.repository.mock'; import { newAlbumUserRepositoryMock } from 'test/repositories/album-user.repository.mock'; import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; @@ -60,7 +58,7 @@ import { newDatabaseRepositoryMock } from 'test/repositories/database.repository import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock'; -import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock'; import { newMapRepositoryMock } from 'test/repositories/map.repository.mock'; import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'; @@ -80,7 +78,7 @@ import { newStackRepositoryMock } from 'test/repositories/stack.repository.mock' import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock'; -import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; +import { ITelemetryRepositoryMock, newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; import { newTrashRepositoryMock } from 'test/repositories/trash.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { newVersionHistoryRepositoryMock } from 'test/repositories/version-history.repository.mock'; @@ -97,6 +95,50 @@ type Constructor> = { new (...deps: Args): Type; }; +type IAccessRepository = { [K in keyof AccessRepository]: RepositoryInterface }; + +export type ServiceMocks = { + access: IAccessRepositoryMock; + activity: Mocked>; + album: Mocked; + albumUser: Mocked>; + apiKey: Mocked>; + audit: Mocked>; + asset: Mocked; + config: Mocked>; + cron: Mocked>; + crypto: Mocked; + database: Mocked>; + event: Mocked; + job: Mocked>; + library: Mocked>; + logger: Mocked; + machineLearning: Mocked; + map: Mocked>; + media: Mocked>; + memory: Mocked>; + metadata: Mocked>; + move: Mocked>; + notification: Mocked>; + oauth: Mocked>; + partner: Mocked>; + person: Mocked>; + process: Mocked>; + search: Mocked>; + serverInfo: Mocked>; + session: Mocked>; + sharedLink: Mocked>; + stack: Mocked>; + storage: Mocked>; + systemMetadata: Mocked>; + tag: Mocked>; + telemetry: ITelemetryRepositoryMock; + trash: Mocked>; + user: Mocked; + versionHistory: Mocked>; + view: Mocked>; +}; + export const newTestService = ( Service: Constructor, overrides?: Overrides, @@ -116,13 +158,15 @@ export const newTestService = ( const databaseMock = newDatabaseRepositoryMock(); const eventMock = newEventRepositoryMock(); const jobMock = newJobRepositoryMock(); - const keyMock = newKeyRepositoryMock(); + const apiKeyMock = newKeyRepositoryMock(); const libraryMock = newLibraryRepositoryMock(); const machineLearningMock = newMachineLearningRepositoryMock(); const mapMock = newMapRepositoryMock(); const mediaMock = newMediaRepositoryMock(); const memoryMock = newMemoryRepositoryMock(); - const metadataMock = (metadataRepository || newMetadataRepositoryMock()) as Mocked; + const metadataMock = (metadataRepository || newMetadataRepositoryMock()) as Mocked< + RepositoryInterface + >; const moveMock = newMoveRepositoryMock(); const notificationMock = newNotificationRepositoryMock(); const oauthMock = newOAuthRepositoryMock(); @@ -146,86 +190,88 @@ export const newTestService = ( const sut = new Service( loggerMock as ILoggingRepository as LoggingRepository, accessMock as IAccessRepository as AccessRepository, - activityMock as IActivityRepository as ActivityRepository, - auditMock as IAuditRepository as AuditRepository, + activityMock as RepositoryInterface as ActivityRepository, + auditMock as RepositoryInterface as AuditRepository, albumMock, - albumUserMock as IAlbumUserRepository as AlbumUserRepository, + albumUserMock as RepositoryInterface as AlbumUserRepository, assetMock, configMock, - cronMock as ICronRepository as CronRepository, - cryptoMock, + cronMock as RepositoryInterface as CronRepository, + cryptoMock as RepositoryInterface as CryptoRepository, databaseMock, eventMock, jobMock, - keyMock as IApiKeyRepository as ApiKeyRepository, + apiKeyMock as RepositoryInterface as ApiKeyRepository, libraryMock, machineLearningMock, - mapMock as IMapRepository as MapRepository, - mediaMock as IMediaRepository as MediaRepository, - memoryMock as IMemoryRepository as MemoryRepository, - metadataMock as IMetadataRepository as MetadataRepository, + mapMock as RepositoryInterface as MapRepository, + mediaMock as RepositoryInterface as MediaRepository, + memoryMock as RepositoryInterface as MemoryRepository, + metadataMock as RepositoryInterface as MetadataRepository, moveMock, - notificationMock as INotificationRepository as NotificationRepository, - oauthMock as IOAuthRepository as OAuthRepository, + notificationMock as RepositoryInterface as NotificationRepository, + oauthMock as RepositoryInterface as OAuthRepository, partnerMock, personMock, - processMock as IProcessRepository as ProcessRepository, + processMock as RepositoryInterface as ProcessRepository, searchMock, - serverInfoMock as IServerInfoRepository as ServerInfoRepository, - sessionMock as ISessionRepository as SessionRepository, + serverInfoMock as RepositoryInterface as ServerInfoRepository, + sessionMock as RepositoryInterface as SessionRepository, sharedLinkMock, stackMock, storageMock, - systemMock as ISystemMetadataRepository as SystemMetadataRepository, + systemMock as RepositoryInterface as SystemMetadataRepository, tagMock, telemetryMock as unknown as TelemetryRepository, - trashMock as ITrashRepository as TrashRepository, + trashMock as RepositoryInterface as TrashRepository, userMock, - versionHistoryMock as IVersionHistoryRepository as VersionHistoryRepository, - viewMock as IViewRepository as ViewRepository, + versionHistoryMock as RepositoryInterface as VersionHistoryRepository, + viewMock as RepositoryInterface as ViewRepository, ); return { sut, - accessMock, - loggerMock, - cronMock, - cryptoMock, - activityMock, - auditMock, - albumMock, - albumUserMock, - assetMock, - configMock, - databaseMock, - eventMock, - jobMock, - keyMock, - libraryMock, - machineLearningMock, - mapMock, - mediaMock, - memoryMock, - metadataMock, - moveMock, - notificationMock, - oauthMock, - partnerMock, - personMock, - processMock, - searchMock, - serverInfoMock, - sessionMock, - sharedLinkMock, - stackMock, - storageMock, - systemMock, - tagMock, - telemetryMock, - trashMock, - userMock, - versionHistoryMock, - viewMock, + mocks: { + access: accessMock, + apiKey: apiKeyMock, + cron: cronMock, + crypto: cryptoMock, + activity: activityMock, + audit: auditMock, + album: albumMock, + albumUser: albumUserMock, + asset: assetMock, + config: configMock, + database: databaseMock, + event: eventMock, + job: jobMock, + library: libraryMock, + logger: loggerMock, + machineLearning: machineLearningMock, + map: mapMock, + media: mediaMock, + memory: memoryMock, + metadata: metadataMock, + move: moveMock, + notification: notificationMock, + oauth: oauthMock, + partner: partnerMock, + person: personMock, + process: processMock, + search: searchMock, + serverInfo: serverInfoMock, + session: sessionMock, + sharedLink: sharedLinkMock, + stack: stackMock, + storage: storageMock, + systemMetadata: systemMock, + tag: tagMock, + telemetry: telemetryMock, + trash: trashMock, + user: userMock, + versionHistory: versionHistoryMock, + view: viewMock, + } as ServiceMocks, }; };